Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/dispatch-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type DispatchContext = ScreenshotDispatchFlags & {
snapshotScope?: string;
snapshotRaw?: boolean;
snapshotIncludeRects?: boolean;
skipIosSimulatorBootCheck?: boolean;
count?: number;
intervalMs?: number;
delayMs?: number;
Expand Down
1 change: 1 addition & 0 deletions src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) };
}
Expand Down
1 change: 1 addition & 0 deletions src/core/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type ScreenshotOptions = {
fullscreen?: boolean;
stabilize?: boolean;
surface?: SessionSurface;
skipIosSimulatorBootCheck?: boolean;
};

export type ElementSelectorKey = 'id' | 'label' | 'text' | 'value';
Expand Down
7 changes: 6 additions & 1 deletion src/core/interactors/apple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/daemon/__tests__/request-router-screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof dispatchCommand>[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'));
Expand Down
3 changes: 3 additions & 0 deletions src/daemon/screenshot-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
143 changes: 114 additions & 29 deletions src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -775,27 +781,106 @@ 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');
});
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);
});
Expand Down Expand Up @@ -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,
},
);
});
},
);

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading