From 0711734a70686ddafb48788634865adda1bd8301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 28 Jun 2026 19:37:17 +0200 Subject: [PATCH 1/3] perf: skip boot probe for iOS simulator screenshots --- src/core/dispatch-context.ts | 1 + src/core/dispatch.ts | 1 + src/core/interactor-types.ts | 1 + src/core/interactors/apple.ts | 4 +++- .../request-router-screenshot.test.ts | 21 +++++++++++++++++++ src/daemon/screenshot-runtime.ts | 3 +++ src/platforms/ios/__tests__/index.test.ts | 20 ++++++++++++++++++ src/platforms/ios/screenshot.ts | 15 ++++++++++++- 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 7c829e28a..01c1f29be 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -50,6 +50,7 @@ export type DispatchContext = ScreenshotDispatchFlags & { snapshotScope?: string; snapshotRaw?: boolean; snapshotIncludeRects?: boolean; + skipIosSimulatorBootCheck?: boolean; count?: number; intervalMs?: number; delayMs?: number; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index a9a50b418..3c5ca554c 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -340,6 +340,7 @@ async function handleScreenshotCommand( fullscreen: screenshotOptions.fullscreen, stabilize: screenshotOptions.stabilize, surface: context?.surface, + skipIosSimulatorBootCheck: context?.skipIosSimulatorBootCheck, }); return { path: screenshotPath, ...successText(`Saved screenshot: ${screenshotPath}`) }; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index d26e0cdfa..8319ca986 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -43,6 +43,7 @@ export type ScreenshotOptions = { fullscreen?: boolean; stabilize?: boolean; surface?: SessionSurface; + skipIosSimulatorBootCheck?: boolean; }; export type ElementSelectorKey = 'id' | 'label' | 'text' | 'value'; diff --git a/src/core/interactors/apple.ts b/src/core/interactors/apple.ts index 18bfe4e5b..a9b07bfc8 100644 --- a/src/core/interactors/apple.ts +++ b/src/core/interactors/apple.ts @@ -47,7 +47,9 @@ export function createAppleInteractor( }); return; } - await screenshotIos(device, outPath, options?.appBundleId, options?.fullscreen, runnerOpts); + await screenshotIos(device, outPath, options?.appBundleId, options?.fullscreen, runnerOpts, { + skipSimulatorBootCheck: options?.skipIosSimulatorBootCheck, + }); }, snapshot: async (options) => { const result = readAppleSnapshotResult( diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index d6df4912b..4f3767b11 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -129,6 +129,27 @@ test('default screenshot temp directory is cleaned when capture fails', async () expect(fs.existsSync(path.dirname(capturedPath!))).toBe(false); }); +test('session-backed iOS simulator screenshots skip redundant boot probe', async () => { + const session = makeIosSession('ios'); + const outPath = path.join(os.tmpdir(), 'agent-device-ios-session-screenshot.png'); + let capturedContext: Parameters[4]; + + mockDispatch.mockImplementation(async (_device, _command, _positionals, _outPath, context) => { + capturedContext = context; + return { path: outPath }; + }); + + await dispatchScreenshotViaRuntime({ + session, + sessionName: session.name, + outPath, + outputPlacement: 'positional', + dispatchContext: {}, + }); + + expect(capturedContext?.skipIosSimulatorBootCheck).toBe(true); +}); + test('router serializes concurrent commands for the same device across sessions', async () => { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); sessionStore.set('session-a', makeSession('session-a')); diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index 4cb950e09..ee5b64b10 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -57,6 +57,9 @@ function createDispatchScreenshotBackend(params: { ...dispatchContext, ...screenshotFlagsFromOptions(options), surface: options?.surface, + skipIosSimulatorBootCheck: + dispatchContext.skipIosSimulatorBootCheck ?? + (session.device.platform === 'ios' && session.device.kind === 'simulator'), }; if (outputPlacement === 'out') { return readScreenshotResultData( diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 12b563c0e..c499f9cbe 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -784,6 +784,26 @@ test('captureSimulatorScreenshotWithFallback continues when status bar preparati assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); }); +test('captureSimulatorScreenshotWithFallback can skip session-backed simulator boot probe', async () => { + mockEnsureBootedSimulator.mockRejectedValue(new Error('should not probe boot state')); + mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); + mockRetryWithPolicy.mockResolvedValue(undefined); + + await captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/out.png', + 'com.example.app', + undefined, + undefined, + undefined, + { skipBootCheck: true }, + ); + + assert.equal(mockEnsureBootedSimulator.mock.calls.length, 0); + assert.equal(mockRetryWithPolicy.mock.calls.length, 1); + assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); +}); + test('captureSimulatorScreenshotWithFallback ignores status bar restore failures', async () => { mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => { throw new AppError('COMMAND_FAILED', 'status_bar clear failed'); diff --git a/src/platforms/ios/screenshot.ts b/src/platforms/ios/screenshot.ts index e8a0827ed..b6ffbef3d 100644 --- a/src/platforms/ios/screenshot.ts +++ b/src/platforms/ios/screenshot.ts @@ -40,6 +40,14 @@ type SimulatorScreenshotFlowDeps = { shouldFallbackToRunner: (error: unknown) => boolean; }; +type SimulatorScreenshotFlowOptions = { + skipBootCheck?: boolean; +}; + +type IosScreenshotOptions = { + skipSimulatorBootCheck?: boolean; +}; + const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, @@ -53,6 +61,7 @@ export async function screenshotIos( appBundleId?: string, fullscreen?: boolean, runnerOptions?: AppleRunnerCommandOptions, + options: IosScreenshotOptions = {}, ): Promise { if (device.platform === 'macos') { await captureScreenshotViaRunner(device, outPath, appBundleId, fullscreen, runnerOptions); @@ -66,6 +75,7 @@ export async function screenshotIos( fullscreen, undefined, runnerOptions, + { skipBootCheck: options.skipSimulatorBootCheck }, ); return; } @@ -93,6 +103,7 @@ export async function captureSimulatorScreenshotWithFallback( fullscreenOrDeps?: boolean | SimulatorScreenshotFlowDeps, deps: SimulatorScreenshotFlowDeps = defaultSimulatorScreenshotFlowDeps, runnerOptions?: AppleRunnerCommandOptions, + options: SimulatorScreenshotFlowOptions = {}, ): Promise { if (device.kind !== 'simulator') { throw new AppError( @@ -105,7 +116,9 @@ export async function captureSimulatorScreenshotWithFallback( const effectiveDeps = typeof fullscreenOrDeps === 'object' && fullscreenOrDeps !== null ? fullscreenOrDeps : deps; - await effectiveDeps.ensureBooted(device); + if (!options.skipBootCheck) { + await effectiveDeps.ensureBooted(device); + } let restoreStatusBar = async () => {}; try { restoreStatusBar = await effectiveDeps.prepareStatusBarForScreenshot(device); From 2c75b2b16c317ca39ed659dd1db6668dd9b4f2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 29 Jun 2026 13:37:13 +0200 Subject: [PATCH 2/3] fix: recover skipped simulator screenshot boot checks --- src/platforms/ios/__tests__/index.test.ts | 76 +++++++++++++++++++++++ src/platforms/ios/screenshot.ts | 53 +++++++++++----- 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index c499f9cbe..99316d77a 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -804,6 +804,82 @@ test('captureSimulatorScreenshotWithFallback can skip session-backed simulator b assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); }); +test('captureSimulatorScreenshotWithFallback boots skipped-check simulator after shutdown screenshot failure', async () => { + const ensureBooted = vi.fn(async () => {}); + const prepareStatusBarForScreenshot = vi.fn(async () => async () => {}); + let captureAttempts = 0; + const captureWithRetry = vi.fn(async () => { + captureAttempts += 1; + if (captureAttempts === 1) { + throw new AppError('COMMAND_FAILED', 'simctl screenshot failed', { + stderr: 'Unable to boot device in current state: Shutdown', + args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], + }); + } + }); + const captureWithRunner = vi.fn(async () => {}); + + await captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/out.png', + 'com.example.app', + undefined, + { + ensureBooted, + prepareStatusBarForScreenshot, + captureWithRetry, + captureWithRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + }, + undefined, + { skipBootCheck: true }, + ); + + assert.equal(ensureBooted.mock.calls.length, 1); + assert.equal(captureWithRetry.mock.calls.length, 2); + assert.equal(captureWithRunner.mock.calls.length, 0); +}); + +test('captureSimulatorScreenshotWithFallback keeps runner fallback after skipped-check boot recovery', async () => { + const ensureBooted = vi.fn(async () => {}); + const prepareStatusBarForScreenshot = vi.fn(async () => async () => {}); + let captureAttempts = 0; + const captureWithRetry = vi.fn(async () => { + captureAttempts += 1; + if (captureAttempts === 1) { + throw new AppError('COMMAND_FAILED', 'simctl screenshot failed', { + stderr: 'Unable to boot device in current state: Shutdown', + args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], + }); + } + throw new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', { + args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], + timeoutMs: 20_000, + }); + }); + const captureWithRunner = vi.fn(async () => {}); + + await captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/out.png', + 'com.example.app', + undefined, + { + ensureBooted, + prepareStatusBarForScreenshot, + captureWithRetry, + captureWithRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + }, + undefined, + { skipBootCheck: true }, + ); + + assert.equal(ensureBooted.mock.calls.length, 1); + assert.equal(captureWithRetry.mock.calls.length, 2); + assert.equal(captureWithRunner.mock.calls.length, 1); +}); + test('captureSimulatorScreenshotWithFallback ignores status bar restore failures', async () => { mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => { throw new AppError('COMMAND_FAILED', 'status_bar clear failed'); diff --git a/src/platforms/ios/screenshot.ts b/src/platforms/ios/screenshot.ts index b6ffbef3d..e735310ee 100644 --- a/src/platforms/ios/screenshot.ts +++ b/src/platforms/ios/screenshot.ts @@ -130,10 +130,20 @@ export async function captureSimulatorScreenshotWithFallback( await effectiveDeps.captureWithRetry(device, outPath); return; } catch (error) { - if (!effectiveDeps.shouldFallbackToRunner(error)) { - throw error; + let screenshotError = error; + if (options.skipBootCheck && shouldEnsureBootedAfterSimulatorScreenshotFailure(error)) { + await effectiveDeps.ensureBooted(device); + try { + await effectiveDeps.captureWithRetry(device, outPath); + return; + } catch (retryError) { + screenshotError = retryError; + } } - emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', error); + if (!effectiveDeps.shouldFallbackToRunner(screenshotError)) { + throw screenshotError; + } + emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', screenshotError); } await effectiveDeps.captureWithRunner(device, outPath, appBundleId, fullscreen, runnerOptions); } finally { @@ -393,10 +403,7 @@ export function resolveSimulatorRunnerScreenshotCandidatePaths( export function shouldFallbackToRunnerForIosScreenshot(error: unknown): boolean { if (!(error instanceof AppError)) return false; if (error.code !== 'COMMAND_FAILED') return false; - const details = (error.details ?? {}) as { stdout?: unknown; stderr?: unknown }; - const stdout = typeof details.stdout === 'string' ? details.stdout : ''; - const stderr = typeof details.stderr === 'string' ? details.stderr : ''; - const combined = `${error.message}\n${stdout}\n${stderr}`.toLowerCase(); + const combined = commandFailureText(error); return ( combined.includes("unknown option '--device'") || (combined.includes('unknown subcommand') && combined.includes('screenshot')) || @@ -407,13 +414,7 @@ export function shouldFallbackToRunnerForIosScreenshot(error: unknown): boolean export function shouldRetryIosSimulatorScreenshot(error: unknown): boolean { if (!(error instanceof AppError)) return false; if (error.code !== 'COMMAND_FAILED') return false; - const details = (error.details ?? {}) as { stdout?: unknown; stderr?: unknown; args?: unknown }; - const stdout = typeof details.stdout === 'string' ? details.stdout : ''; - const stderr = typeof details.stderr === 'string' ? details.stderr : ''; - const args = Array.isArray(details.args) - ? details.args.filter((value): value is string => typeof value === 'string').join(' ') - : ''; - const combined = `${error.message}\n${stdout}\n${stderr}\n${args}`.toLowerCase(); + const combined = commandFailureText(error); return ( combined.includes('timeout waiting for screen surfaces') || (combined.includes('nsposixerrordomain') && @@ -423,4 +424,28 @@ export function shouldRetryIosSimulatorScreenshot(error: unknown): boolean { ); } +function shouldEnsureBootedAfterSimulatorScreenshotFailure(error: unknown): boolean { + if (!(error instanceof AppError)) return false; + if (error.code !== 'COMMAND_FAILED') return false; + const combined = commandFailureText(error); + return ( + combined.includes('not booted') || + combined.includes('current state: shutdown') || + combined.includes('current state is shutdown') || + combined.includes('current state=shutdown') || + combined.includes('state: shutdown') || + combined.includes('state=shutdown') + ); +} + +function commandFailureText(error: AppError): string { + const details = (error.details ?? {}) as { stdout?: unknown; stderr?: unknown; args?: unknown }; + const stdout = typeof details.stdout === 'string' ? details.stdout : ''; + const stderr = typeof details.stderr === 'string' ? details.stderr : ''; + const args = Array.isArray(details.args) + ? details.args.filter((value): value is string => typeof value === 'string').join(' ') + : ''; + return `${error.message}\n${stdout}\n${stderr}\n${args}`.toLowerCase(); +} + export { prepareSimulatorStatusBarForScreenshot } from './screenshot-status-bar.ts'; From 56a2f55788e6325512df4bd90b6fbcf63d0e3560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 29 Jun 2026 13:43:08 +0200 Subject: [PATCH 3/3] refactor: simplify ios screenshot options --- src/core/interactors/apple.ts | 7 +- src/platforms/ios/__tests__/index.test.ts | 101 ++++++++++------------ src/platforms/ios/screenshot.ts | 67 +++++++------- 3 files changed, 84 insertions(+), 91 deletions(-) diff --git a/src/core/interactors/apple.ts b/src/core/interactors/apple.ts index a9b07bfc8..f078c479e 100644 --- a/src/core/interactors/apple.ts +++ b/src/core/interactors/apple.ts @@ -47,8 +47,11 @@ export function createAppleInteractor( }); return; } - await screenshotIos(device, outPath, options?.appBundleId, options?.fullscreen, runnerOpts, { - skipSimulatorBootCheck: options?.skipIosSimulatorBootCheck, + await screenshotIos(device, outPath, { + appBundleId: options?.appBundleId, + fullscreen: options?.fullscreen, + runnerOptions: runnerOpts, + skipBootCheck: options?.skipIosSimulatorBootCheck, }); }, snapshot: async (options) => { diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 99316d77a..9f55e7cde 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -713,12 +713,15 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after retry ex try { const outPath = path.join(tmpDir, 'out.png'); - await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, 'com.example.app', { - ensureBooted: ensureBootedSimulator, - prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, - captureWithRetry: captureSimulatorScreenshotWithRetry, - captureWithRunner: captureScreenshotViaRunner, - shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { + appBundleId: 'com.example.app', + deps: { + ensureBooted: ensureBootedSimulator, + prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, + captureWithRetry: captureSimulatorScreenshotWithRetry, + captureWithRunner: captureScreenshotViaRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + }, }); assert.equal(ensureBootedCalls, 1); assert.equal(mockRetryWithPolicy.mock.calls.length, 1); @@ -754,12 +757,15 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s try { const outPath = path.join(tmpDir, 'out.png'); - await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, 'com.example.app', { - ensureBooted: ensureBootedSimulator, - prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, - captureWithRetry: captureSimulatorScreenshotWithRetry, - captureWithRunner: captureScreenshotViaRunner, - shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { + appBundleId: 'com.example.app', + deps: { + ensureBooted: ensureBootedSimulator, + prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, + captureWithRetry: captureSimulatorScreenshotWithRetry, + captureWithRunner: captureScreenshotViaRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + }, }); assert.equal(mockRunIosRunnerCommand.mock.calls.length, 1); assert.equal(await fs.readFile(outPath, 'utf8'), 'runner-timeout'); @@ -775,11 +781,9 @@ test('captureSimulatorScreenshotWithFallback continues when status bar preparati mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); mockRetryWithPolicy.mockResolvedValue(undefined); - await captureSimulatorScreenshotWithFallback( - IOS_TEST_SIMULATOR, - '/tmp/out.png', - 'com.example.app', - ); + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + appBundleId: 'com.example.app', + }); assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); }); @@ -789,15 +793,10 @@ test('captureSimulatorScreenshotWithFallback can skip session-backed simulator b mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); mockRetryWithPolicy.mockResolvedValue(undefined); - await captureSimulatorScreenshotWithFallback( - IOS_TEST_SIMULATOR, - '/tmp/out.png', - 'com.example.app', - undefined, - undefined, - undefined, - { skipBootCheck: true }, - ); + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + appBundleId: 'com.example.app', + skipBootCheck: true, + }); assert.equal(mockEnsureBootedSimulator.mock.calls.length, 0); assert.equal(mockRetryWithPolicy.mock.calls.length, 1); @@ -819,21 +818,17 @@ test('captureSimulatorScreenshotWithFallback boots skipped-check simulator after }); const captureWithRunner = vi.fn(async () => {}); - await captureSimulatorScreenshotWithFallback( - IOS_TEST_SIMULATOR, - '/tmp/out.png', - 'com.example.app', - undefined, - { + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + appBundleId: 'com.example.app', + skipBootCheck: true, + deps: { ensureBooted, prepareStatusBarForScreenshot, captureWithRetry, captureWithRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, - undefined, - { skipBootCheck: true }, - ); + }); assert.equal(ensureBooted.mock.calls.length, 1); assert.equal(captureWithRetry.mock.calls.length, 2); @@ -859,21 +854,17 @@ test('captureSimulatorScreenshotWithFallback keeps runner fallback after skipped }); const captureWithRunner = vi.fn(async () => {}); - await captureSimulatorScreenshotWithFallback( - IOS_TEST_SIMULATOR, - '/tmp/out.png', - 'com.example.app', - undefined, - { + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + appBundleId: 'com.example.app', + skipBootCheck: true, + deps: { ensureBooted, prepareStatusBarForScreenshot, captureWithRetry, captureWithRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, - undefined, - { skipBootCheck: true }, - ); + }); assert.equal(ensureBooted.mock.calls.length, 1); assert.equal(captureWithRetry.mock.calls.length, 2); @@ -887,11 +878,9 @@ test('captureSimulatorScreenshotWithFallback ignores status bar restore failures mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); mockRetryWithPolicy.mockResolvedValue(undefined); - await captureSimulatorScreenshotWithFallback( - IOS_TEST_SIMULATOR, - '/tmp/out.png', - 'com.example.app', - ); + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + appBundleId: 'com.example.app', + }); assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); assert.equal(mockRunIosRunnerCommand.mock.calls.length, 0); }); @@ -929,18 +918,16 @@ test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before us } throw new Error(`Unexpected xcrun args: ${args.join(' ')}`); }); - await captureSimulatorScreenshotWithFallback( - IOS_TEST_SIMULATOR, - '/tmp/out.png', - 'com.example.app', - { + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + appBundleId: 'com.example.app', + deps: { ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareStatusBarForScreenshot, captureWithRetry: captureSimulatorScreenshotWithRetry, captureWithRunner: captureScreenshotViaRunner, shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, }, - ); + }); }, ); @@ -981,7 +968,9 @@ test('captureSimulatorScreenshotWithFallback uses simulator runner fallback by d try { const outPath = path.join(tmpDir, 'out.png'); - await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, 'com.example.app'); + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, outPath, { + appBundleId: 'com.example.app', + }); assert.equal(mockRunIosRunnerCommand.mock.calls.length, 1); assert.equal(await fs.readFile(outPath, 'utf8'), 'default-fallback'); } finally { diff --git a/src/platforms/ios/screenshot.ts b/src/platforms/ios/screenshot.ts index e735310ee..c161db554 100644 --- a/src/platforms/ios/screenshot.ts +++ b/src/platforms/ios/screenshot.ts @@ -41,11 +41,11 @@ type SimulatorScreenshotFlowDeps = { }; type SimulatorScreenshotFlowOptions = { + appBundleId?: string; + fullscreen?: boolean; + runnerOptions?: AppleRunnerCommandOptions; skipBootCheck?: boolean; -}; - -type IosScreenshotOptions = { - skipSimulatorBootCheck?: boolean; + deps?: SimulatorScreenshotFlowDeps; }; const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { @@ -58,27 +58,22 @@ const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { export async function screenshotIos( device: DeviceInfo, outPath: string, - appBundleId?: string, - fullscreen?: boolean, - runnerOptions?: AppleRunnerCommandOptions, - options: IosScreenshotOptions = {}, + options: Omit = {}, ): Promise { if (device.platform === 'macos') { - await captureScreenshotViaRunner(device, outPath, appBundleId, fullscreen, runnerOptions); - return; - } - if (device.kind === 'simulator') { - await captureSimulatorScreenshotWithFallback( + await captureScreenshotViaRunner( device, outPath, - appBundleId, - fullscreen, - undefined, - runnerOptions, - { skipBootCheck: options.skipSimulatorBootCheck }, + options.appBundleId, + options.fullscreen, + options.runnerOptions, ); return; } + if (device.kind === 'simulator') { + await captureSimulatorScreenshotWithFallback(device, outPath, options); + return; + } try { await runIosDevicectl(['device', 'screenshot', '--device', device.id, outPath], { @@ -93,16 +88,18 @@ export async function screenshotIos( emitScreenshotFallbackDiagnostic(device, 'devicectl_screenshot', error); } - await captureScreenshotViaRunner(device, outPath, appBundleId, fullscreen, runnerOptions); + await captureScreenshotViaRunner( + device, + outPath, + options.appBundleId, + options.fullscreen, + options.runnerOptions, + ); } export async function captureSimulatorScreenshotWithFallback( device: DeviceInfo, outPath: string, - appBundleId?: string, - fullscreenOrDeps?: boolean | SimulatorScreenshotFlowDeps, - deps: SimulatorScreenshotFlowDeps = defaultSimulatorScreenshotFlowDeps, - runnerOptions?: AppleRunnerCommandOptions, options: SimulatorScreenshotFlowOptions = {}, ): Promise { if (device.kind !== 'simulator') { @@ -112,40 +109,44 @@ export async function captureSimulatorScreenshotWithFallback( ); } - const fullscreen = typeof fullscreenOrDeps === 'boolean' ? fullscreenOrDeps : undefined; - const effectiveDeps = - typeof fullscreenOrDeps === 'object' && fullscreenOrDeps !== null ? fullscreenOrDeps : deps; + const deps = options.deps ?? defaultSimulatorScreenshotFlowDeps; if (!options.skipBootCheck) { - await effectiveDeps.ensureBooted(device); + await deps.ensureBooted(device); } let restoreStatusBar = async () => {}; try { - restoreStatusBar = await effectiveDeps.prepareStatusBarForScreenshot(device); + restoreStatusBar = await deps.prepareStatusBarForScreenshot(device); } catch (error) { emitStatusBarDiagnostic(device, 'prepare_failed', error); } try { try { - await effectiveDeps.captureWithRetry(device, outPath); + await deps.captureWithRetry(device, outPath); return; } catch (error) { let screenshotError = error; if (options.skipBootCheck && shouldEnsureBootedAfterSimulatorScreenshotFailure(error)) { - await effectiveDeps.ensureBooted(device); + await deps.ensureBooted(device); try { - await effectiveDeps.captureWithRetry(device, outPath); + await deps.captureWithRetry(device, outPath); return; } catch (retryError) { screenshotError = retryError; } } - if (!effectiveDeps.shouldFallbackToRunner(screenshotError)) { + if (!deps.shouldFallbackToRunner(screenshotError)) { throw screenshotError; } emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', screenshotError); } - await effectiveDeps.captureWithRunner(device, outPath, appBundleId, fullscreen, runnerOptions); + await deps.captureWithRunner( + device, + outPath, + options.appBundleId, + options.fullscreen, + options.runnerOptions, + ); } finally { await restoreStatusBar().catch((error) => emitStatusBarDiagnostic(device, 'restore_failed', error),