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);