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..f078c479e 100644 --- a/src/core/interactors/apple.ts +++ b/src/core/interactors/apple.ts @@ -47,7 +47,12 @@ export function createAppleInteractor( }); return; } - await screenshotIos(device, outPath, options?.appBundleId, options?.fullscreen, runnerOpts); + await screenshotIos(device, outPath, { + appBundleId: options?.appBundleId, + fullscreen: options?.fullscreen, + runnerOptions: runnerOpts, + skipBootCheck: 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..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,15 +781,96 @@ 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); }); +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', { + appBundleId: 'com.example.app', + 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 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', { + appBundleId: 'com.example.app', + skipBootCheck: true, + deps: { + ensureBooted, + prepareStatusBarForScreenshot, + captureWithRetry, + captureWithRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + }, + }); + + 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', { + appBundleId: 'com.example.app', + skipBootCheck: true, + deps: { + ensureBooted, + prepareStatusBarForScreenshot, + captureWithRetry, + captureWithRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + }, + }); + + 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'); @@ -791,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); }); @@ -833,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, }, - ); + }); }, ); @@ -885,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 e8a0827ed..c161db554 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 = { + appBundleId?: string; + fullscreen?: boolean; + runnerOptions?: AppleRunnerCommandOptions; + skipBootCheck?: boolean; + deps?: SimulatorScreenshotFlowDeps; +}; + const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { ensureBooted: ensureBootedSimulator, prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, @@ -50,25 +58,22 @@ const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { export async function screenshotIos( device: DeviceInfo, outPath: string, - appBundleId?: string, - fullscreen?: boolean, - runnerOptions?: AppleRunnerCommandOptions, + 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, + 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], { @@ -83,16 +88,19 @@ 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') { throw new AppError( @@ -101,28 +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; - await effectiveDeps.ensureBooted(device); + if (!options.skipBootCheck) { + 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) { - if (!effectiveDeps.shouldFallbackToRunner(error)) { - throw error; + let screenshotError = error; + if (options.skipBootCheck && shouldEnsureBootedAfterSimulatorScreenshotFailure(error)) { + await deps.ensureBooted(device); + try { + await deps.captureWithRetry(device, outPath); + return; + } catch (retryError) { + screenshotError = retryError; + } + } + if (!deps.shouldFallbackToRunner(screenshotError)) { + throw screenshotError; } - emitScreenshotFallbackDiagnostic(device, 'simctl_screenshot', error); + 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), @@ -380,10 +404,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')) || @@ -394,13 +415,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') && @@ -410,4 +425,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';