diff --git a/packages/command-surface/src/capture/screenshot.ts b/packages/command-surface/src/capture/screenshot.ts index ed6cc0c393..2fd871e96d 100644 --- a/packages/command-surface/src/capture/screenshot.ts +++ b/packages/command-surface/src/capture/screenshot.ts @@ -74,7 +74,7 @@ export const screenshotCommandFacet = defineCommandFacet({ text: { summary: 'Capture a screenshot', cliDetail: - "Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent.", + "Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. On iOS simulators a screenshot waits for that simulator's status bar to be free, and a request its caller cancelled while it waits reports the cancellation instead of capturing.", }, metadata: screenshotCommandMetadata, run: (client, input) => client.capture.screenshot(input), diff --git a/packages/command-surface/src/capture/settings.ts b/packages/command-surface/src/capture/settings.ts index 83d258f352..b444cd9d1e 100644 --- a/packages/command-surface/src/capture/settings.ts +++ b/packages/command-surface/src/capture/settings.ts @@ -103,6 +103,8 @@ export const settingsCommandFacet = defineCommandFacet({ cliDetail: [ 'A bare readable setting reads the value the target holds. A target that does not serve a setting refuses it with UNSUPPORTED_OPERATION before anything changes.', '', + 'On an iOS simulator, settings wifi and settings airplane wait for screenshots and other status bar writes. The request timeout includes this wait (90 seconds for a standalone CLI request). Once the write starts, its status bar command has a separate 10-second timeout. A request or write that times out may already have changed the bar; check the bar before relying on it. Cancelling while still queued guarantees nothing was written.', + '', 'Settings:', ...settingsHelpLines().map((line) => ` ${line}`), ].join('\n'), diff --git a/packages/kernel/src/keyed-lock.test.ts b/packages/kernel/src/keyed-lock.test.ts index 9b3a5cad29..dc40292f87 100644 --- a/packages/kernel/src/keyed-lock.test.ts +++ b/packages/kernel/src/keyed-lock.test.ts @@ -312,6 +312,40 @@ test('heldKeyedLockKeys lists only the locks of the map held by the current cont }); }); +test('heldKeyedLockKeys reports exactly the keys withKeyedLock would re-enter for that context', async () => { + // A caller may decide behaviour from `heldKeyedLockKeys` before calling `withKeyedLock`, as the + // iOS status bar owner does. Both must answer from the same scan of the current context, or a + // caller that reads "held" could be queued behind, or reject at the boundary of, its own lock. + // Each reported key re-enters here while the frames that report it are still open, which a queued + // task could not do. + const locks = new Map>(); + const reEntered: string[] = []; + await withKeyedLock(locks, 'host', async () => { + await withKeyedLock(locks, 'host:app', async () => { + for (const key of heldKeyedLockKeys(locks)) { + await withKeyedLock(locks, key, async () => { + reEntered.push(key); + }); + } + }); + }); + assert.deepEqual(reEntered.sort(), ['host', 'host:app']); + + const holderReEntered: string[] = []; + await withKeyedLockHolder(async (holder) => { + const taking = holder.hold(locks, 'device:app'); + // While the hold is still being taken it already reads as held, and a nested step must not queue + // behind it, so the report and the re-entry are both checked before anything is awaited. + assert.deepEqual(heldKeyedLockKeys(locks), ['device:app']); + await withKeyedLock(locks, 'device:app', async () => { + holderReEntered.push('device:app'); + }); + await taking; + }); + assert.deepEqual(holderReEntered, ['device:app']); + assert.equal(locks.size, 0); +}); + test('a scope wanting a key its enclosing holder is still taking re-enters it once taken', async () => { const locks = new Map>(); let releaseOwner: () => void = () => {}; diff --git a/packages/platform-apple/src/core/__tests__/fake-status-bar-simctl.ts b/packages/platform-apple/src/core/__tests__/fake-status-bar-simctl.ts new file mode 100644 index 0000000000..a9df742b08 --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/fake-status-bar-simctl.ts @@ -0,0 +1,175 @@ +import type { FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts'; + +/** + * `simctl status_bar` with the simulator's own override state behind it. The production reader asks + * the tool what the status bar currently shows, so a test can only prove what a capture would + * restore to when the tool answers from state rather than from one fixed listing. + */ + +export type StatusBarOverrideState = { + time?: string; + dataNetwork?: string; + wifiMode?: string; + wifiBars?: string; + cellularMode?: string; + cellularBars?: string; + operatorName?: string; + batteryState?: string; + batteryLevel?: string; +}; + +export type FakeStatusBarSimctl = { + /** Acts as the `withFakeAppleTool` script. */ + script(args: string[]): FakeAppleToolResponse; + /** The override values the simulator holds now. */ + overrides(): StatusBarOverrideState; + /** The state each `list` served, in order: one entry per snapshot a capture took. */ + servedSnapshots(): StatusBarOverrideState[]; +}; + +const DATA_NETWORK_CODE: Record = { + hide: 0, + wifi: 1, + '3g': 6, + '4g': 7, + lte: 8, + 'lte-a': 9, + 'lte+': 10, + '5g': 11, + '5g+': 12, + '5g-uwb': 13, + '5g-uc': 14, +}; + +const WIFI_MODE_CODE: Record = { + searching: 1, + failed: 2, + active: 3, +}; + +const CELLULAR_MODE_CODE: Record = { + notSupported: 0, + searching: 1, + failed: 2, + active: 3, +}; + +const OVERRIDE_OPTION_FIELDS = { + '--time': 'time', + '--dataNetwork': 'dataNetwork', + '--wifiMode': 'wifiMode', + '--wifiBars': 'wifiBars', + '--cellularMode': 'cellularMode', + '--cellularBars': 'cellularBars', + '--operatorName': 'operatorName', + '--batteryState': 'batteryState', + '--batteryLevel': 'batteryLevel', +} as const satisfies Record; + +function parseOverrideOptions(args: string[]): Record { + const options: Record = {}; + for (let index = 0; index < args.length; index += 2) { + const option = args[index]; + if (option === undefined) break; + options[option] = args[index + 1] ?? ''; + } + return options; +} + +/** One `list` line, rendered from the state fields it reads, or nothing when the state omits them. */ +type StatusBarOverrideLine = { + reads: (keyof StatusBarOverrideState)[]; + render: (state: StatusBarOverrideState) => string; +}; + +/** The listing code for one override value; an unmapped value is the fake's bug, not a silent `hide`. */ +function codeOf(codes: Record, value: string | undefined, field: string): number { + if (value === undefined) return 0; + const code = codes[value]; + if (code === undefined) { + throw new Error(`fake simctl status_bar cannot list ${field}: ${value}`); + } + return code; +} + +const STATUS_BAR_OVERRIDES_LISTING: StatusBarOverrideLine[] = [ + { + reads: ['time'], + render: (state) => `Time: ${state.time}`, + }, + { + reads: ['dataNetwork'], + render: (state) => + `DataNetworkType: ${codeOf(DATA_NETWORK_CODE, state.dataNetwork, 'dataNetwork')}`, + }, + { + reads: ['wifiMode', 'wifiBars'], + render: (state) => + `WiFi Mode: ${codeOf(WIFI_MODE_CODE, state.wifiMode, 'wifiMode')}, WiFi Bars: ${state.wifiBars ?? 0}`, + }, + { + reads: ['cellularMode', 'cellularBars'], + render: (state) => + `Cell Mode: ${codeOf(CELLULAR_MODE_CODE, state.cellularMode, 'cellularMode')}, Cell Bars: ${state.cellularBars ?? 0}`, + }, + { + reads: ['operatorName'], + render: (state) => `Operator Name: ${state.operatorName}`, + }, + { + reads: ['batteryState', 'batteryLevel'], + render: (state) => + `Battery State: ${state.batteryState ? Number(state.batteryState === 'unplugged') : 0}, Battery Level: ${state.batteryLevel ?? 100}, Not Charging: 0`, + }, +]; + +/** Renders state the way `simctl status_bar list` does, codes and all. */ +function renderStatusBarOverrides(state: StatusBarOverrideState): string { + const lines = ['Current Status Bar Overrides:', '=============================']; + for (const entry of STATUS_BAR_OVERRIDES_LISTING) { + if (entry.reads.every((field) => state[field] === undefined)) continue; + lines.push(entry.render(state)); + } + return lines.join('\n'); +} + +export function createFakeStatusBarSimctl( + udid: string, + initialOverrides: StatusBarOverrideState, +): FakeStatusBarSimctl { + let state: StatusBarOverrideState = { ...initialOverrides }; + const servedSnapshots: StatusBarOverrideState[] = []; + + return { + script: (args): FakeAppleToolResponse => { + const unexpected = (detail: string): FakeAppleToolResponse => ({ + stderr: `unexpected status bar call: ${detail}`, + exitCode: 1, + }); + if (args[0] !== 'simctl' || args[1] !== 'status_bar' || args[2] !== udid) { + return unexpected(args.join(' ')); + } + const verb = args[3]; + if (verb === 'list') { + servedSnapshots.push({ ...state }); + return renderStatusBarOverrides(state); + } + if (verb === 'clear') { + state = {}; + return ''; + } + if (verb !== 'override') return unexpected(args.join(' ')); + + const applied: StatusBarOverrideState = {}; + for (const [option, value] of Object.entries(parseOverrideOptions(args.slice(4)))) { + const field = OVERRIDE_OPTION_FIELDS[option as keyof typeof OVERRIDE_OPTION_FIELDS]; + if (field === undefined) return unexpected(`unknown option ${option}`); + applied[field] = value; + } + state = { ...state, ...applied }; + return ''; + }, + overrides: () => ({ ...state }), + servedSnapshots: () => servedSnapshots.map((snapshot) => ({ ...snapshot })), + }; +} diff --git a/packages/platform-apple/src/core/__tests__/screenshot-status-bar-concurrency.test.ts b/packages/platform-apple/src/core/__tests__/screenshot-status-bar-concurrency.test.ts new file mode 100644 index 0000000000..f165873c1e --- /dev/null +++ b/packages/platform-apple/src/core/__tests__/screenshot-status-bar-concurrency.test.ts @@ -0,0 +1,609 @@ +import { beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { withFakeAppleTool } from '../../__tests__/fake-apple-tool.ts'; +import { createLocalAppleToolProvider, withAppleToolProvider } from '../tool-provider.ts'; + +// `setIosSetting` boots through this owner; the capture route under test skips it, so the settings +// case below needs it quiet without touching the status bar owner. +vi.mock('../simulator.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ensureBootedSimulator: async () => {} }; +}); + +import { setIosSetting } from '../app-settings.ts'; +import { captureSimulatorScreenshotWithFallback } from '../screenshot.ts'; +import { + invalidateSimulatorStatusBarOverrideCache, + STATUS_BAR_COMMAND_TIMEOUT_MS, +} from '../screenshot-status-bar.ts'; +import { + createFakeStatusBarSimctl, + type FakeStatusBarSimctl, + type StatusBarOverrideState, +} from './fake-status-bar-simctl.ts'; +import { IOS_TEST_SIMULATOR } from './apple-core-stub-helpers.ts'; + +/** + * Two captures that overlap on one simulator. The normalization a capture applies is temporary, so + * a capture must never read it back as the state to restore to, and it must never leave it behind. + * + * These run through the capture route with the status bar owner's real implementation: the only + * seam they replace is the capture itself, so a gate can hold one capture open while another starts. + */ + +/** The caller's own override, the state both captures must leave behind. */ +const CALLER_OVERRIDES: StatusBarOverrideState = { + dataNetwork: 'hide', + wifiMode: 'failed', + wifiBars: '0', + cellularMode: 'failed', + cellularBars: '0', + operatorName: 'No Service', +}; + +/** The normalization, which is what the tool reports while a capture is in flight. */ +const NORMALIZED_OVERRIDES: StatusBarOverrideState = { + time: '9:41', + dataNetwork: 'wifi', + wifiMode: 'active', + wifiBars: '3', + batteryState: 'charged', + batteryLevel: '100', +}; + +/** Holds one capture open until the test releases it, so the overlap is the test's, not the host's. */ +function createCaptureGate() { + let release!: () => void; + let markEntered!: () => void; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + return { + entered, + release, + capture: async (): Promise => { + markEntered(); + await released; + }, + }; +} + +type CaptureGate = ReturnType; + +/** Everything a simulator capture needs except the status bar, which stays the real owner so these + * cases exercise the same status bar commands the route sends on a device. */ +function captureDeps(captureWithRetry: (device: DeviceInfo) => Promise) { + return { + resolveCaptureTarget: async () => ({}) as const, + captureWithRetry, + normalizeDensity: async () => {}, + captureWithRunner: async () => undefined, + shouldFallbackToRunner: () => false, + }; +} + +function screenshotFailure(): AppError { + return new AppError('COMMAND_FAILED', 'simctl screenshot failed', { + stderr: 'Unable to capture screenshot', + exitCode: 60, + }); +} + +/** Lets the second capture reach the point where it would read the first capture's state. */ +async function yieldToQueuedWork(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +/** What `simctl status_bar list` answers when the simulator holds no overrides. */ +const CLEARED_STATUS_BAR_LISTING = 'Current Status Bar Overrides:\n============================='; + +function screenshotOptions( + gate: CaptureGate, + normalizeStatusBar: boolean, + options: { readDuringCapture?: () => void; failsAfterGate?: boolean } = {}, +) { + return { + env: process.env, + normalizeStatusBar, + skipIosSimulatorBootCheck: true, + deps: captureDeps(async () => { + // The read happens at the top of the capture step: that is the state this capture's image + // is taken with, and it is the only moment the status bar owner has it. + options.readDuringCapture?.(); + await gate.capture(); + if (options.failsAfterGate === true) { + throw screenshotFailure(); + } + }), + } as const; +} + +/** + * Runs a body that starts two captures on one simulator and hands back both settled. Gates are + * released and both captures are observed whatever the body threw: a capture left holding the + * simulator's lock would hang the later cases in this file, and a rejection nobody awaited would + * fail one of them instead of the case that caused it. + */ +async function withOverlappingCaptures( + start: () => Promise<{ captures: Array>; gates: CaptureGate[] }>, + run: (settled: PromiseSettledResult[]) => void, +): Promise { + let captures: Array> = []; + let gates: CaptureGate[] = []; + let settled: PromiseSettledResult[] = []; + try { + const started = await start(); + captures = started.captures; + gates = started.gates; + await yieldToQueuedWork(); + for (const gate of gates) gate.release(); + settled = await Promise.allSettled(captures); + } finally { + for (const gate of gates) gate.release(); + settled = await Promise.allSettled(captures); + } + run(settled); +} + +function fulfilledCount(settled: PromiseSettledResult[]): number { + return settled.filter((result) => result.status === 'fulfilled').length; +} + +function rejectionReason(settled: PromiseSettledResult[], index: number): unknown { + const result = settled[index]; + if (result?.status !== 'rejected') { + throw new assert.AssertionError({ message: `expected capture ${index} to reject` }); + } + return result.reason; +} + +beforeEach(() => { + invalidateSimulatorStatusBarOverrideCache(IOS_TEST_SIMULATOR); +}); + +test('overlapping simulator captures both restore the caller status bar overrides', async () => { + const statusBar: FakeStatusBarSimctl = createFakeStatusBarSimctl( + IOS_TEST_SIMULATOR.id, + CALLER_OVERRIDES, + ); + + await withFakeAppleTool(statusBar.script, async () => { + await withOverlappingCaptures( + async () => { + const firstGate = createCaptureGate(); + const secondGate = createCaptureGate(); + const first = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/first.png', + screenshotOptions(firstGate, true), + ); + await firstGate.entered; + const second = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/second.png', + screenshotOptions(secondGate, true), + ); + return { captures: [first, second], gates: [firstGate, secondGate] }; + }, + (settled) => { + assert.equal(fulfilledCount(settled), 2, 'both captures must finish'); + assert.deepEqual( + statusBar.overrides(), + CALLER_OVERRIDES, + 'the second capture must restore the caller overrides, not the normalization it snapshotted', + ); + assert.deepEqual( + statusBar.servedSnapshots(), + [CALLER_OVERRIDES, CALLER_OVERRIDES], + "the second capture must snapshot the caller overrides, not the first capture's normalization", + ); + }, + ); + }); +}); + +test('a failed simulator capture restores the caller overrides and lets the queued capture finish', async () => { + const statusBar = createFakeStatusBarSimctl(IOS_TEST_SIMULATOR.id, CALLER_OVERRIDES); + + await withFakeAppleTool(statusBar.script, async () => { + await withOverlappingCaptures( + async () => { + const firstGate = createCaptureGate(); + const secondGate = createCaptureGate(); + const first = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/first.png', + screenshotOptions(firstGate, true, { failsAfterGate: true }), + ); + await firstGate.entered; + const second = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/second.png', + screenshotOptions(secondGate, true), + ); + return { captures: [first, second], gates: [firstGate, secondGate] }; + }, + (settled) => { + const error = rejectionReason(settled, 0); + assert.ok(error instanceof AppError, `expected an AppError, received: ${String(error)}`); + assert.equal((error as AppError).code, 'COMMAND_FAILED'); + assert.equal((error as AppError).message, 'simctl screenshot failed'); + assert.equal(settled[1]?.status, 'fulfilled', 'the queued capture must still finish'); + + assert.deepEqual(statusBar.overrides(), CALLER_OVERRIDES); + assert.deepEqual( + statusBar.servedSnapshots(), + [CALLER_OVERRIDES, CALLER_OVERRIDES], + 'the capture queued behind a failing capture snapshots the caller overrides as well', + ); + }, + ); + }); +}); + +test('a single simulator capture normalizes while it captures and restores afterwards', async () => { + const statusBar = createFakeStatusBarSimctl(IOS_TEST_SIMULATOR.id, CALLER_OVERRIDES); + const seenDuringCapture: StatusBarOverrideState[] = []; + + await withFakeAppleTool(statusBar.script, async () => { + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { + env: process.env, + normalizeStatusBar: true, + skipIosSimulatorBootCheck: true, + deps: captureDeps(async () => { + seenDuringCapture.push(statusBar.overrides()); + }), + }); + + assert.deepEqual(seenDuringCapture, [NORMALIZED_OVERRIDES]); + assert.deepEqual(statusBar.overrides(), CALLER_OVERRIDES); + }); +}); + +test('an already-aborted request does not start a plain simulator capture', async () => { + // Since the plain path takes the window it also honours the request's cancellation: a request the + // caller gave up on stops before it does device work nobody will read. + const statusBar = createFakeStatusBarSimctl(IOS_TEST_SIMULATOR.id, CALLER_OVERRIDES); + const abortController = new AbortController(); + abortController.abort(new Error('request canceled')); + let captureStarted = false; + + await withFakeAppleTool(statusBar.script, async () => { + const options = screenshotOptions(createCaptureGate(), false); + const settled = await captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/aborted.png', + { + ...options, + deps: { + ...options.deps, + captureWithRetry: async () => { + captureStarted = true; + }, + }, + runnerOptions: { signal: abortController.signal }, + }, + ).then( + () => 'fulfilled', + (error: unknown) => (error instanceof Error ? error.message : String(error)), + ); + + assert.equal(settled, 'request canceled'); + assert.equal(captureStarted, false); + }); +}); + +test('an aborted request leaves the queue behind another request on the same simulator', async () => { + // Dropping the signal for a holder's own nested leg must stay caller-scoped: another request that + // merely shares the simulator keeps its cancellation, so it leaves the queue when its own caller + // gives up instead of waiting for the holder to finish. + const statusBar = createFakeStatusBarSimctl(IOS_TEST_SIMULATOR.id, CALLER_OVERRIDES); + const holderGate = createCaptureGate(); + const abortController = new AbortController(); + + await withFakeAppleTool(statusBar.script, async () => { + const holder = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/holder.png', + screenshotOptions(holderGate, true), + ); + await holderGate.entered; + + let queuedCaptureStarted = false; + const queued = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/aborted-queue.png', + { + env: process.env, + normalizeStatusBar: false, + skipIosSimulatorBootCheck: true, + deps: captureDeps(async () => { + queuedCaptureStarted = true; + }), + runnerOptions: { signal: abortController.signal }, + }, + ).catch((error: unknown) => error); + + await yieldToQueuedWork(); + abortController.abort(new Error('request canceled')); + const outcome = await queued; + holderGate.release(); + await holder; + + assert.ok( + outcome instanceof Error && outcome.message === 'request canceled', + `expected the queued capture to reject with the abort reason, received: ${String(outcome)}`, + ); + assert.equal(queuedCaptureStarted, false, 'the aborted capture must not reach the device'); + }); +}); + +test('a settings write to the status bar waits for the capture window', async () => { + // Its own simulator id: a lock this case leaves held must never make a later case wait for it. + const device: DeviceInfo = { ...IOS_TEST_SIMULATOR, id: 'sim-bar-writer' }; + invalidateSimulatorStatusBarOverrideCache(device); + const statusBar = createFakeStatusBarSimctl(device.id, { wifiMode: 'failed', wifiBars: '0' }); + + await withFakeAppleTool( + (args) => statusBar.script(args), + async ({ calls }) => { + const gate = createCaptureGate(); + const capturing = captureSimulatorScreenshotWithFallback( + device, + '/tmp/normalized.png', + screenshotOptions(gate, true), + ); + let settingDone = false; + let setting: Promise = Promise.resolve(); + try { + await gate.entered; + setting = setIosSetting(device, 'wifi', 'on', undefined, undefined, process.env).then( + () => { + settingDone = true; + }, + ); + await yieldToQueuedWork(); + assert.equal(settingDone, false, 'a status bar write must wait for the capture window'); + } finally { + gate.release(); + await Promise.allSettled([capturing, setting]); + } + + // The write lands after the capture restored the bar the caller had, so that restore cannot + // revert a setting the caller asked for while the capture was running. + assert.deepEqual( + calls.map((args) => args.join(' ')).at(-1), + 'simctl status_bar sim-bar-writer override --wifiMode active', + ); + assert.deepEqual(statusBar.overrides(), { wifiMode: 'active', wifiBars: '0' }); + }, + ); +}); + +test('a settings write queued behind a capture window can still be cancelled', async () => { + const device: DeviceInfo = { ...IOS_TEST_SIMULATOR, id: 'sim-bar-cancel' }; + invalidateSimulatorStatusBarOverrideCache(device); + const statusBar = createFakeStatusBarSimctl(device.id, { wifiMode: 'failed', wifiBars: '0' }); + const abortController = new AbortController(); + + await withFakeAppleTool(statusBar.script, async () => { + const gate = createCaptureGate(); + const capturing = captureSimulatorScreenshotWithFallback( + device, + '/tmp/normalized.png', + screenshotOptions(gate, true), + ); + let outcome: unknown = 'still running'; + await gate.entered; + const setting = setIosSetting( + device, + 'wifi', + 'on', + undefined, + undefined, + process.env, + abortController.signal, + ).catch((error: unknown) => error); + await yieldToQueuedWork(); + abortController.abort(new Error('request canceled')); + // Released before the wait: if the cancellation were ignored, the write finishes and the + // assertion below reports that, instead of the case hanging on the gate until its timeout. + gate.release(); + outcome = await setting; + await capturing; + + assert.ok( + outcome instanceof Error && outcome.message === 'request canceled', + `expected the queued write to reject, received: ${String(outcome)}`, + ); + assert.deepEqual(statusBar.overrides(), { wifiMode: 'failed', wifiBars: '0' }); + }); +}); + +test('a settings write bounds its status bar command like a capture does', async () => { + // The write holds the simulator's window, so a wedged `simctl status_bar` inside it would hold + // every later capture of that simulator. It keeps the same per-command budget the capture's own + // status bar commands use. + const budgets: Array = []; + const device: DeviceInfo = { ...IOS_TEST_SIMULATOR, id: 'sim-bar-budget' }; + invalidateSimulatorStatusBarOverrideCache(device); + + await withAppleToolProvider( + createLocalAppleToolProvider({ + simctl: { + run: async (args, options) => { + if (args.includes('status_bar')) budgets.push(options?.timeoutMs); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + }, + }), + async () => { + await setIosSetting(device, 'wifi', 'on', undefined, undefined, process.env); + await setIosSetting(device, 'airplane', 'on', undefined, undefined, process.env); + await setIosSetting(device, 'airplane', 'off', undefined, undefined, process.env); + }, + ); + + for (const budget of budgets) { + assert.equal(typeof budget, 'number', 'a status bar write must bound its own command'); + } + assert.deepEqual(budgets, [ + STATUS_BAR_COMMAND_TIMEOUT_MS, + STATUS_BAR_COMMAND_TIMEOUT_MS, + STATUS_BAR_COMMAND_TIMEOUT_MS, + ]); +}); + +test('a timed-out settings write forces the next capture to read the bar again', async () => { + // A command that hits its deadline may still have reached `simctl`, so a write that times out + // cannot leave a cached answer about the bar behind: the next capture has to read what the write + // applied, or it would normalize over a setting the caller asked for and restore without it. + const device: DeviceInfo = { ...IOS_TEST_SIMULATOR, id: 'sim-bar-timeout' }; + invalidateSimulatorStatusBarOverrideCache(device); + const statusBar = createFakeStatusBarSimctl(device.id, {}); + let timeoutApplied = false; + + await withFakeAppleTool( + (args) => { + const response = statusBar.script(args); + // The settings write is the override that names only `--wifiMode`: a capture's normalization + // and its restore both name the other fields. + const lastOption = args.at(-2); + if (args.includes('--wifiMode') && lastOption === '--wifiMode' && !timeoutApplied) { + timeoutApplied = true; + return new AppError('COMMAND_FAILED', 'xcrun timed out after 10000ms', { exitCode: 1 }); + } + return response; + }, + async ({ calls }) => { + // A capture with nothing on the bar earns the answer that the bar is cleared. + await captureSimulatorScreenshotWithFallback(device, '/tmp/cleared.png', { + env: process.env, + normalizeStatusBar: true, + skipIosSimulatorBootCheck: true, + deps: captureDeps(async () => {}), + }); + calls.length = 0; + + await setIosSetting(device, 'wifi', 'on', undefined, undefined, process.env).then( + () => assert.fail('expected the write to time out'), + (error: unknown) => + assert.match(String((error as Error).message), /timed out/, 'the timeout must surface'), + ); + assert.deepEqual( + statusBar.overrides(), + { wifiMode: 'active' }, + 'the write reached the simulator before its deadline', + ); + calls.length = 0; + + await captureSimulatorScreenshotWithFallback(device, '/tmp/after-timeout.png', { + env: process.env, + normalizeStatusBar: true, + skipIosSimulatorBootCheck: true, + deps: captureDeps(async () => {}), + }); + + assert.equal( + calls[0]?.join(' '), + `simctl status_bar ${device.id} list`, + 'a capture after a timed-out write must read the bar, not trust a cached answer', + ); + assert.deepEqual( + statusBar.overrides(), + // `simctl` reports a Wi-Fi mode together with a bars count, so the snapshot the capture + // restores from names both once the timed-out write has applied the mode. + { wifiMode: 'active', wifiBars: '0' }, + 'the capture must restore the setting the timed-out write applied', + ); + }, + ); +}); + +test('two device records with the same udid and different set paths share one lock', async () => { + const entered: string[] = []; + const gates = { one: createCaptureGate(), two: createCaptureGate() }; + const deviceFor = (tag: keyof typeof gates): DeviceInfo => ({ + ...IOS_TEST_SIMULATOR, + simulatorSetPath: `/sets/${tag}`, + }); + + await withFakeAppleTool( + (args) => { + // The `--set` scope lands before `status_bar`, so locate the verb rather than a fixed index. + const index = args.indexOf('status_bar'); + if (index === -1) return { stderr: `unexpected args: ${args.join(' ')}`, exitCode: 1 }; + const verb = args[index + 2]; + return verb === 'list' ? CLEARED_STATUS_BAR_LISTING : ''; + }, + async () => { + const captureFor = (tag: keyof typeof gates) => + captureSimulatorScreenshotWithFallback(deviceFor(tag), `/tmp/${tag}.png`, { + env: process.env, + normalizeStatusBar: true, + skipIosSimulatorBootCheck: true, + deps: captureDeps(async () => { + entered.push(tag); + await gates[tag].capture(); + }), + }); + + const first = captureFor('one'); + await gates.one.entered; + const second = captureFor('two'); + try { + await yieldToQueuedWork(); + assert.deepEqual(entered, ['one']); + } finally { + gates.one.release(); + gates.two.release(); + const results = await Promise.allSettled([first, second]); + assert.equal(fulfilledCount(results), 2); + } + assert.deepEqual(entered, ['one', 'two']); + }, + ); +}); + +test('a plain simulator capture waits for the normalization window instead of capturing it', async () => { + const statusBar = createFakeStatusBarSimctl(IOS_TEST_SIMULATOR.id, CALLER_OVERRIDES); + const seenDuringPlainCapture: StatusBarOverrideState[] = []; + + await withFakeAppleTool(statusBar.script, async () => { + await withOverlappingCaptures( + async () => { + const normalizingGate = createCaptureGate(); + const plainGate = createCaptureGate(); + const normalizing = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/normalized.png', + screenshotOptions(normalizingGate, true), + ); + await normalizingGate.entered; + const plain = captureSimulatorScreenshotWithFallback( + IOS_TEST_SIMULATOR, + '/tmp/plain.png', + screenshotOptions(plainGate, false, { + readDuringCapture: () => { + seenDuringPlainCapture.push(statusBar.overrides()); + }, + }), + ); + return { captures: [normalizing, plain], gates: [normalizingGate, plainGate] }; + }, + (settled) => { + assert.equal(fulfilledCount(settled), 2, 'both captures must finish'); + assert.deepEqual( + seenDuringPlainCapture, + [CALLER_OVERRIDES], + 'a capture without --normalize-status-bar must not take its screenshot of the temporary normalized bar', + ); + }, + ); + }); +}); diff --git a/packages/platform-apple/src/core/__tests__/screenshot-status-bar.test.ts b/packages/platform-apple/src/core/__tests__/screenshot-status-bar.test.ts index 2af570634c..15d3880c80 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot-status-bar.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot-status-bar.test.ts @@ -3,9 +3,10 @@ import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { invalidateSimulatorStatusBarOverrideCache, - prepareSimulatorStatusBarForScreenshot, + withNormalizedSimulatorStatusBar, + withSimulatorStatusBarHeld, } from '../screenshot-status-bar.ts'; -import { withFakeAppleTool } from '../../__tests__/fake-apple-tool.ts'; +import { withFakeAppleTool, type FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts'; import { IOS_TEST_SIMULATOR } from './apple-core-stub-helpers.ts'; import { statusBarDiagnosticEvents } from './screenshot-diagnostic-log.ts'; @@ -21,8 +22,14 @@ Cell Mode: 2, Cell Bars: 0 Operator Name: No Service Battery State: 1, Battery Level: 42, Not Charging: 0`; +const NORMALIZE_ARGS = + '--time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100'; + +const RESTORE_ARGS = + '--dataNetwork hide --wifiMode failed --wifiBars 0 --cellularMode failed --cellularBars 0 --operatorName No Service'; + function statusBarScript(listResponse: () => string | { stderr: string; exitCode: number }) { - return (args: string[]) => { + return (args: string[]): FakeAppleToolResponse | undefined => { if (args[0] !== 'simctl' || args[1] !== 'status_bar') { return { stderr: `unexpected xcrun args: ${args.join(' ')}`, exitCode: 1 }; } @@ -32,67 +39,105 @@ function statusBarScript(listResponse: () => string | { stderr: string; exitCode }; } +/** The caller's step that the status bar surrounds; the status bar tests do not exercise it. */ +async function capture(): Promise { + return 'captured'; +} + beforeEach(() => { invalidateSimulatorStatusBarOverrideCache(IOS_TEST_SIMULATOR); }); -test('prepareSimulatorStatusBarForScreenshot restores prior visible overrides', async () => { +test('withNormalizedSimulatorStatusBar restores prior visible overrides', async () => { await withFakeAppleTool( statusBarScript(() => STATUS_BAR_OVERRIDES_LISTING), async ({ calls }) => { - const restore = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR); - await restore(); + const captured = await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + assert.equal(captured, 'captured'); assert.deepEqual( calls.map((args) => args.join(' ')), [ 'simctl status_bar sim-1 list', 'simctl status_bar sim-1 clear', - 'simctl status_bar sim-1 override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100', + `simctl status_bar sim-1 override ${NORMALIZE_ARGS}`, 'simctl status_bar sim-1 clear', - 'simctl status_bar sim-1 override --dataNetwork hide --wifiMode failed --wifiBars 0 --cellularMode failed --cellularBars 0 --operatorName No Service', + `simctl status_bar sim-1 override ${RESTORE_ARGS}`, ], ); }, ); }); -test('prepareSimulatorStatusBarForScreenshot skips known redundant status bar commands', async () => { +test('a capture leg nested in its own status bar window keeps it after the request aborts', async () => { + // The sequence this pins: a normalized capture whose simctl leg fails falls back to the runner + // leg while the window is still held. The request may abort in between; the leg the caller is + // already inside must still run, and must not queue behind, or be rejected by, the window that + // caller holds. + const abortController = new AbortController(); await withFakeAppleTool( statusBarScript(() => 'Current Status Bar Overrides:\n============================='), async ({ calls }) => { - const restoreFirst = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR); - await restoreFirst(); - const restoreSecond = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR); - await restoreSecond(); + const captured = await withNormalizedSimulatorStatusBar( + IOS_TEST_SIMULATOR, + async () => { + abortController.abort(new Error('caller gave up')); + return await withSimulatorStatusBarHeld( + IOS_TEST_SIMULATOR, + capture, + abortController.signal, + ); + }, + abortController.signal, + ); + assert.equal(captured, 'captured', 'the leg inside the window must run'); assert.deepEqual( calls.map((args) => args.join(' ')), [ 'simctl status_bar sim-1 list', - 'simctl status_bar sim-1 override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100', - 'simctl status_bar sim-1 clear', - 'simctl status_bar sim-1 override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100', + `simctl status_bar sim-1 override ${NORMALIZE_ARGS}`, 'simctl status_bar sim-1 clear', ], + 'the window must still restore once the aborted leg finishes', ); }, ); }); -test('prepareSimulatorStatusBarForScreenshot still normalizes when snapshotting current overrides fails', async () => { +test('withNormalizedSimulatorStatusBar still normalizes when snapshotting current overrides fails', async () => { await withFakeAppleTool( statusBarScript(() => ({ stderr: 'list failed', exitCode: 1 })), async ({ calls }) => { - const restore = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR); - await restore(); + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); assert.deepEqual( calls.map((args) => args.join(' ')), [ 'simctl status_bar sim-1 list', 'simctl status_bar sim-1 clear', - 'simctl status_bar sim-1 override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100', + `simctl status_bar sim-1 override ${NORMALIZE_ARGS}`, + 'simctl status_bar sim-1 clear', + ], + ); + }, + ); +}); + +test('withNormalizedSimulatorStatusBar skips the snapshot read for back-to-back captures of a cleared status bar', async () => { + await withFakeAppleTool( + statusBarScript(() => 'Current Status Bar Overrides:\n============================='), + async ({ calls }) => { + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + + assert.deepEqual( + calls.map((args) => args.join(' ')), + [ + 'simctl status_bar sim-1 list', + `simctl status_bar sim-1 override ${NORMALIZE_ARGS}`, + 'simctl status_bar sim-1 clear', + `simctl status_bar sim-1 override ${NORMALIZE_ARGS}`, 'simctl status_bar sim-1 clear', ], ); @@ -105,8 +150,7 @@ test('a failed status bar snapshot logs snapshot_failed with the tool failure', await withFakeAppleTool( statusBarScript(() => ({ stderr: 'list failed', exitCode: 1 })), async () => { - const restore = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR); - await restore(); + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); }, ); }); @@ -128,7 +172,104 @@ test('a failed status bar snapshot logs snapshot_failed with the tool failure', ]); }); -test('a failed status bar override logs prepare_failed and the step stays best-effort', async () => { +test('a failed status bar restore logs restore_failed and keeps the capture result', async () => { + const events = await statusBarDiagnosticEvents(async () => { + await withFakeAppleTool( + (args) => { + if (args[3] === 'list') return STATUS_BAR_OVERRIDES_LISTING; + if (args[3] === 'clear') { + return new AppError('COMMAND_FAILED', 'clear rejected', { + exitCode: 8, + stderr: 'clear rejected', + }); + } + return ''; + }, + async () => { + assert.equal( + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture), + 'captured', + ); + }, + ); + }); + + assert.deepEqual(events, [ + { + level: 'warn', + phase: 'ios_screenshot_status_bar_prepare_failed', + data: { + platform: 'apple', + deviceKind: 'simulator', + deviceId: 'sim-1', + errorCode: 'COMMAND_FAILED', + reason: 'clear rejected', + exitCode: 8, + stderr: 'clear rejected', + }, + }, + { + level: 'warn', + phase: 'ios_screenshot_status_bar_restore_failed', + data: { + platform: 'apple', + deviceKind: 'simulator', + deviceId: 'sim-1', + errorCode: 'COMMAND_FAILED', + reason: 'clear rejected', + exitCode: 8, + stderr: 'clear rejected', + }, + }, + ]); +}); + +test('a failed status bar restore keeps the known-cleared answer so the next capture re-clears', async () => { + // A restore whose `clear` fails leaves the deterministic bar on the simulator. Reading the bar + // then would make the next capture adopt that normalization as the caller's state and hand it back + // forever, so the answer that says "this bar is cleared" survives: the next capture skips the read + // and its own restore clears the leftover. + const CLEARED_LISTING = 'Current Status Bar Overrides:\n============================='; + let clearAttempts = 0; + await withFakeAppleTool( + (args) => { + if (args[3] === 'list') return CLEARED_LISTING; + if (args[3] === 'clear') { + clearAttempts += 1; + if (clearAttempts === 1) return ''; + return new AppError('COMMAND_FAILED', 'clear timed out', { + exitCode: 1, + stderr: 'clear timed out', + }); + } + return ''; + }, + async ({ calls }) => { + // First capture: a clean restore earns the known-cleared answer. + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + calls.length = 0; + // Second capture: its restore fails and leaves the deterministic bar on the simulator. + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + assert.match(calls[0]?.join(' ') ?? '', /^simctl status_bar sim-1 override/); + assert.equal( + calls.some((args) => args[3] === 'list'), + false, + 'a failed restore must not send the next capture to read the leftover back', + ); + calls.length = 0; + + await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + + assert.deepEqual( + calls.map((args) => args.join(' ')), + [`simctl status_bar sim-1 override ${NORMALIZE_ARGS}`, 'simctl status_bar sim-1 clear'], + 'the next capture must trust the cleared answer and clear the leftover, never read it back', + ); + }, + ); +}); + +test('a failed status bar override logs prepare_failed and the capture still runs', async () => { const events = await statusBarDiagnosticEvents(async () => { await withFakeAppleTool( (args) => { @@ -143,8 +284,8 @@ test('a failed status bar override logs prepare_failed and the step stays best-e return ''; }, async ({ calls }) => { - const restore = await prepareSimulatorStatusBarForScreenshot(IOS_TEST_SIMULATOR); - await restore(); + const captured = await withNormalizedSimulatorStatusBar(IOS_TEST_SIMULATOR, capture); + assert.equal(captured, 'captured'); assert.equal(calls.at(-1)?.join(' '), 'simctl status_bar sim-1 clear'); }, ); diff --git a/packages/platform-apple/src/core/__tests__/screenshot.test.ts b/packages/platform-apple/src/core/__tests__/screenshot.test.ts index 0cb8c35b88..bc559ef93c 100644 --- a/packages/platform-apple/src/core/__tests__/screenshot.test.ts +++ b/packages/platform-apple/src/core/__tests__/screenshot.test.ts @@ -32,7 +32,7 @@ vi.mock('../screenshot-status-bar.ts', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - prepareSimulatorStatusBarForScreenshot: vi.fn(actual.prepareSimulatorStatusBarForScreenshot), + withNormalizedSimulatorStatusBar: vi.fn(actual.withNormalizedSimulatorStatusBar), }; }); @@ -45,10 +45,6 @@ const retryActual = await vi.importActual('../runner-client.ts'); const simulatorActual = await vi.importActual('../simulator.ts'); -const screenshotStatusBarActual = await vi.importActual< - typeof import('../screenshot-status-bar.ts') ->('../screenshot-status-bar.ts'); - import { captureSimulatorScreenshotWithFallback, captureSimulatorScreenshotWithRetry, @@ -57,7 +53,11 @@ import { shouldRetryIosSimulatorScreenshot, } from '../screenshot.ts'; import { ensureBootedSimulator, openIosSimulatorApp } from '../simulator.ts'; -import { prepareSimulatorStatusBarForScreenshot } from '../screenshot-status-bar.ts'; +import { + invalidateSimulatorStatusBarOverrideCache, + withNormalizedSimulatorStatusBar, + withSimulatorStatusBarHeld, +} from '../screenshot-status-bar.ts'; import { runAppleRunnerCommand } from '../runner-client.ts'; import { runCmd } from '@agent-device/host-kit/command'; import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; @@ -65,14 +65,13 @@ import { retryWithPolicy } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts'; -import { statusBarDiagnosticEvents } from './screenshot-diagnostic-log.ts'; const mockRunCmd = vi.mocked(runCmd); const mockRetryWithPolicy = vi.mocked(retryWithPolicy); const mockRunAppleRunnerCommand = vi.mocked(runAppleRunnerCommand); const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator); const mockOpenIosSimulatorApp = vi.mocked(openIosSimulatorApp); -const mockPrepareStatusBarForScreenshot = vi.mocked(prepareSimulatorStatusBarForScreenshot); +const mockWithNormalizedStatusBar = vi.mocked(withNormalizedSimulatorStatusBar); // The wire key and a real measured capture come from the cross-language golden table, so this // consumer suite cannot spell a shape the Swift encoder disagrees with (#2728). @@ -92,9 +91,7 @@ beforeEach(() => { mockRunAppleRunnerCommand.mockImplementation(runnerActual.runAppleRunnerCommand); mockEnsureBootedSimulator.mockImplementation(simulatorActual.ensureBootedSimulator); mockOpenIosSimulatorApp.mockImplementation(simulatorActual.openIosSimulatorApp); - mockPrepareStatusBarForScreenshot.mockImplementation( - screenshotStatusBarActual.prepareSimulatorStatusBarForScreenshot, - ); + mockWithNormalizedStatusBar.mockImplementation(async (_device, capture) => await capture()); }); async function waitForFileText(filePath: string, attempts = 20): Promise { @@ -145,7 +142,6 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after retry ex ensureBootedCalls += 1; }); mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); mockRetryWithPolicy.mockRejectedValue( new AppError('COMMAND_FAILED', 'Detected file type from extension: PNG', { stderr: 'Timeout waiting for screen surfaces', @@ -168,7 +164,7 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after retry ex appBundleId: 'com.example.app', deps: { ensureBooted: ensureBootedSimulator, - prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, + withNormalizedStatusBar: withNormalizedSimulatorStatusBar, captureWithRetry: captureSimulatorScreenshotWithRetry, normalizeDensity: async () => {}, captureWithRunner: captureScreenshotViaRunner, @@ -193,7 +189,6 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s await fs.writeFile(runnerImage, 'runner-timeout', 'utf8'); mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); mockRetryWithPolicy.mockRejectedValue( new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', { args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], @@ -216,7 +211,7 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s appBundleId: 'com.example.app', deps: { ensureBooted: ensureBootedSimulator, - prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, + withNormalizedStatusBar: withNormalizedSimulatorStatusBar, captureWithRetry: captureSimulatorScreenshotWithRetry, normalizeDensity: async () => {}, captureWithRunner: captureScreenshotViaRunner, @@ -231,13 +226,19 @@ test('captureSimulatorScreenshotWithFallback falls back to runner after simctl s } }); -test('captureSimulatorScreenshotWithFallback continues when status bar preparation fails', async () => { - mockPrepareStatusBarForScreenshot.mockRejectedValue( - new AppError('COMMAND_FAILED', 'status_bar override failed'), - ); +test('captureSimulatorScreenshotWithFallback runs the capture inside status bar normalization', async () => { + const steps: string[] = []; + mockWithNormalizedStatusBar.mockImplementation(async (_device, capture) => { + steps.push('normalize'); + const captured = await capture(); + steps.push('restore'); + return captured; + }); mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockRetryWithPolicy.mockResolvedValue(undefined); + mockRetryWithPolicy.mockImplementation(async () => { + steps.push('capture'); + }); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { env: process.env, @@ -245,14 +246,12 @@ test('captureSimulatorScreenshotWithFallback continues when status bar preparati normalizeStatusBar: true, deps: { normalizeDensity: async () => {} }, }); - assert.equal(mockPrepareStatusBarForScreenshot.mock.calls.length, 1); - assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); + assert.deepEqual(steps, ['normalize', 'capture', 'restore']); assert.equal(mockRunAppleRunnerCommand.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', { @@ -270,7 +269,6 @@ test('captureSimulatorScreenshotWithFallback can skip session-backed simulator b 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; @@ -290,7 +288,6 @@ test('captureSimulatorScreenshotWithFallback boots skipped-check simulator after skipIosSimulatorBootCheck: true, deps: { ensureBooted, - prepareStatusBarForScreenshot, captureWithRetry, normalizeDensity: async () => {}, captureWithRunner, @@ -306,7 +303,6 @@ test('captureSimulatorScreenshotWithFallback boots skipped-check simulator after 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; @@ -330,7 +326,6 @@ test('captureSimulatorScreenshotWithFallback keeps runner fallback after skipped skipIosSimulatorBootCheck: true, deps: { ensureBooted, - prepareStatusBarForScreenshot, captureWithRetry, normalizeDensity: async () => {}, captureWithRunner, @@ -344,108 +339,56 @@ test('captureSimulatorScreenshotWithFallback keeps runner fallback after skipped 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'); - }); +test('captureSimulatorScreenshotWithFallback skips status bar normalization by default', async () => { mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); mockRetryWithPolicy.mockResolvedValue(undefined); + await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { env: process.env, appBundleId: 'com.example.app', - normalizeStatusBar: true, deps: { normalizeDensity: async () => {} }, }); - assert.equal(mockPrepareStatusBarForScreenshot.mock.calls.length, 1); + + assert.equal(mockWithNormalizedStatusBar.mock.calls.length, 0); assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); }); -function statusBarItemSummary(event: { - level: string; - phase: string; - data?: Record; -}) { - return { - level: event.level, - phase: event.phase, - deviceId: event.data?.deviceId, - exitCode: event.data?.exitCode, - }; -} - -test('captureSimulatorScreenshotWithFallback logs a status bar preparation failure', async () => { - mockEnsureBootedSimulator.mockResolvedValue(undefined); - mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockRetryWithPolicy.mockResolvedValue(undefined); - mockPrepareStatusBarForScreenshot.mockRejectedValue( - new AppError('COMMAND_FAILED', 'status_bar override failed', { exitCode: 69 }), - ); - - const events = await statusBarDiagnosticEvents(async () => { - await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { - env: process.env, - appBundleId: 'com.example.app', - normalizeStatusBar: true, - deps: { normalizeDensity: async () => {}, resolveCaptureTarget: async () => ({}) }, - }); - }); - - assert.deepEqual(events.map(statusBarItemSummary), [ - { - level: 'warn', - phase: 'ios_screenshot_status_bar_prepare_failed', - deviceId: 'sim-1', - exitCode: 69, - }, - ]); -}); - -test('captureSimulatorScreenshotWithFallback logs a status bar restoration failure', async () => { - mockEnsureBootedSimulator.mockResolvedValue(undefined); - mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockRetryWithPolicy.mockResolvedValue(undefined); - mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => { - throw new AppError('COMMAND_FAILED', 'status_bar clear failed', { exitCode: 70 }); - }); - - const events = await statusBarDiagnosticEvents(async () => { - await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { - env: process.env, - appBundleId: 'com.example.app', - normalizeStatusBar: true, - deps: { normalizeDensity: async () => {}, resolveCaptureTarget: async () => ({}) }, - }); +test('captureSimulatorScreenshotWithFallback keeps the runner fallback inside the normalization window', async () => { + const steps: string[] = []; + mockWithNormalizedStatusBar.mockImplementation(async (_device, capture) => { + steps.push('normalize'); + const captured = await capture(); + steps.push('restore'); + return captured; }); - - assert.deepEqual(events.map(statusBarItemSummary), [ - { - level: 'warn', - phase: 'ios_screenshot_status_bar_restore_failed', - deviceId: 'sim-1', - exitCode: 70, - }, - ]); -}); - -test('captureSimulatorScreenshotWithFallback skips status bar normalization by default', async () => { - mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockRetryWithPolicy.mockResolvedValue(undefined); + mockRetryWithPolicy.mockRejectedValue( + new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', { + args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], + timeoutMs: 20_000, + }), + ); await captureSimulatorScreenshotWithFallback(IOS_TEST_SIMULATOR, '/tmp/out.png', { env: process.env, - appBundleId: 'com.example.app', - deps: { normalizeDensity: async () => {} }, + normalizeStatusBar: true, + deps: { + normalizeDensity: async () => {}, + resolveCaptureTarget: async () => ({}), + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + captureWithRunner: async () => { + steps.push('runner fallback capture'); + return undefined; + }, + }, }); - assert.equal(mockPrepareStatusBarForScreenshot.mock.calls.length, 0); - assert.equal(mockRetryWithPolicy.mock.calls.length > 0, true); - assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 0); + assert.deepEqual(steps, ['normalize', 'runner fallback capture', 'restore']); }); test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before using runner', async () => { @@ -467,7 +410,7 @@ test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before us await fs.writeFile(runnerImage, 'diag-fallback', 'utf8'); mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); + mockWithNormalizedStatusBar.mockImplementation(async (_device, capture) => await capture()); mockRetryWithPolicy.mockRejectedValue( new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', { args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], @@ -487,7 +430,7 @@ test('captureSimulatorScreenshotWithFallback emits fallback diagnostic before us appBundleId: 'com.example.app', deps: { ensureBooted: ensureBootedSimulator, - prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, + withNormalizedStatusBar: withNormalizedSimulatorStatusBar, captureWithRetry: captureSimulatorScreenshotWithRetry, normalizeDensity: async () => {}, captureWithRunner: captureScreenshotViaRunner, @@ -518,7 +461,6 @@ test('captureSimulatorScreenshotWithFallback uses simulator runner fallback by d await fs.writeFile(runnerImage, 'default-fallback', 'utf8'); mockEnsureBootedSimulator.mockResolvedValue(undefined); mockOpenIosSimulatorApp.mockResolvedValue(undefined); - mockPrepareStatusBarForScreenshot.mockResolvedValue(async () => {}); mockRetryWithPolicy.mockRejectedValue( new AppError('COMMAND_FAILED', 'xcrun timed out after 20000ms', { args: ['simctl', 'io', 'sim-1', 'screenshot', '/tmp/out.png'], @@ -680,6 +622,149 @@ test('captureScreenshotViaRunner reports the display facts a runner capture meas } }); +// Both capture legs of one request run inside the status bar window, and the runner leg takes that +// same window for itself. This drives the real normalization around the real runner fallback, with +// only the tools faked, so the nesting is exercised rather than assumed. +test('captureSimulatorScreenshotWithFallback keeps the real runner fallback inside its own status bar window', async () => { + const tmpDir = await mkdtempForTest('agent-device-normalized-runner-fallback-'); + const device = { ...IOS_TEST_SIMULATOR, id: 'sim-1' }; + const containerPath = path.join(tmpDir, 'container'); + const runnerImage = path.join(containerPath, 'tmp', 'fallback-window.png'); + await fs.mkdir(path.dirname(runnerImage), { recursive: true }); + await fs.writeFile(runnerImage, 'runner-window-image', 'utf8'); + const timeline: string[] = []; + const steps: string[] = []; + + // The file's mock of the status bar owner stands in for the plain pass-through by default; this + // case needs the real transaction, so it restores the implementation `vi.fn` was created with. + mockWithNormalizedStatusBar.mockReset(); + invalidateSimulatorStatusBarOverrideCache(device); + mockRetryWithPolicy.mockRejectedValue( + new AppError('COMMAND_FAILED', 'Detected file type from extension: PNG', { + stderr: 'Timeout waiting for screen surfaces', + exitCode: 60, + }), + ); + mockRunAppleRunnerCommand.mockImplementation(async () => { + steps.push('runner'); + timeline.push('runner'); + return { message: 'tmp/fallback-window.png' }; + }); + mockRunCmd.mockImplementation(async (cmd, args) => { + const argv = [cmd, ...(args ?? [])].join(' '); + if (argv.includes('status_bar')) { + timeline.push(`status_bar:${/status_bar\s+\S+\s+(\S+)/.exec(argv)?.[1]}`); + return { + exitCode: 0, + stdout: 'Current Status Bar Overrides:\n=============================\n', + stderr: '', + }; + } + if (argv.includes('get_app_container')) { + return { exitCode: 0, stdout: `${containerPath}\n`, stderr: '' }; + } + throw new Error(`Unexpected command: ${argv}`); + }); + + try { + const outPath = path.join(tmpDir, 'out.png'); + await captureSimulatorScreenshotWithFallback(device, outPath, { + env: process.env, + normalizeStatusBar: true, + skipIosSimulatorBootCheck: true, + deps: { + captureWithRetry: captureSimulatorScreenshotWithRetry, + normalizeDensity: async () => {}, + captureWithRunner: captureScreenshotViaRunner, + shouldFallbackToRunner: shouldRetryIosSimulatorScreenshot, + resolveCaptureTarget: async () => ({}), + }, + }); + + assert.deepEqual(steps, ['runner']); + assert.equal(await fs.readFile(outPath, 'utf8'), 'runner-window-image'); + // The window belongs to the request: it opens before either capture and closes after the + // runner capture, and the runner leg re-enters that window instead of opening its own. + assert.deepEqual(timeline, [ + 'status_bar:list', + 'status_bar:override', + 'runner', + 'status_bar:clear', + ]); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('captureScreenshotViaRunner waits for a held simulator status bar', async () => { + const tmpDir = await mkdtempForTest('agent-device-runner-bar-window-'); + const containerPath = path.join(tmpDir, 'container'); + const runnerImage = path.join(containerPath, 'tmp', 'held.png'); + const device = { ...IOS_TEST_SIMULATOR, id: 'sim-runner-held' }; + await fs.mkdir(path.dirname(runnerImage), { recursive: true }); + await fs.writeFile(runnerImage, 'runner-image', 'utf8'); + + let releaseHolder!: () => void; + const held = new Promise((resolve) => { + releaseHolder = resolve; + }); + let releaseRunnerCommand!: () => void; + const runnerCommandReleased = new Promise((resolve) => { + releaseRunnerCommand = resolve; + }); + let releaseHolderGate!: () => void; + const holderEntered = new Promise((resolve) => { + releaseHolderGate = resolve; + }); + + mockRunAppleRunnerCommand.mockImplementation(async () => { + await runnerCommandReleased; + return { message: 'tmp/held.png' }; + }); + mockRunCmd.mockImplementation(async (_cmd, args) => { + if (args.includes('get_app_container')) { + return { exitCode: 0, stdout: `${containerPath}\n`, stderr: '' }; + } + throw new Error(`Unexpected xcrun args: ${args.join(' ')}`); + }); + + const holder = withSimulatorStatusBarHeld(device, async () => { + releaseHolderGate(); + await held; + }); + + try { + await holderEntered; + const outPath = path.join(tmpDir, 'out.png'); + const runnerCapture = captureScreenshotViaRunner( + device, + outPath, + undefined, + undefined, + undefined, + process.env, + ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + mockRunAppleRunnerCommand.mock.calls.length, + 0, + 'a runner capture must wait while another capture holds the status bar', + ); + + releaseHolder(); + await holder; + await new Promise((resolve) => setImmediate(resolve)); + releaseRunnerCommand(); + await runnerCapture; + assert.equal(mockRunAppleRunnerCommand.mock.calls.length, 1); + assert.equal(await fs.readFile(outPath, 'utf8'), 'runner-image'); + } finally { + releaseHolder(); + releaseRunnerCommand(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('captureScreenshotViaRunner reports no display facts for a pre-panel runner', async () => { const tmpDir = await mkdtempForTest('agent-device-runner-no-metadata-'); const containerPath = path.join(tmpDir, 'container'); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 01c3f98768..7e97f5ed80 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -35,6 +35,8 @@ import { buildSimctlArgsForDevice, readSimctlContainerPath, runSimctlForDevice } import { invalidateSimulatorStatusBarOverrideCache, rememberClearedStatusBarOverrides, + STATUS_BAR_COMMAND_TIMEOUT_MS, + withSimulatorStatusBarHeld, } from './screenshot-status-bar.ts'; import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { runXcrun } from './tool-provider.ts'; @@ -66,6 +68,7 @@ export async function setIosSetting( appBundleId: string | undefined, options: SettingOptions | undefined, env: Readonly, + signal?: AbortSignal, ): Promise | void> { if (isMacOs(device)) return await setMacOsHostSetting(setting, state, options, env); requireSimulatorDevice(device, 'settings'); @@ -104,35 +107,65 @@ export async function setIosSetting( case 'wifi': { const enabled = parseSettingState(state); const mode = enabled ? 'active' : 'failed'; - await runSimctlForDevice(device, ['status_bar', device.id, 'override', '--wifiMode', mode]); - invalidateSimulatorStatusBarOverrideCache(device); + // The write waits for the simulator's status bar window: a capture restores the bar it + // snapshotted, so a write that landed inside that window would be reverted by that restore. + await withSimulatorStatusBarHeld( + device, + async () => { + // Dropped before the command, not after it: a write that hits its deadline may still have + // reached `simctl`, so no answer about the bar survives the attempt either way. + invalidateSimulatorStatusBarOverrideCache(device); + await runSimctlForDevice( + device, + ['status_bar', device.id, 'override', '--wifiMode', mode], + { timeoutMs: STATUS_BAR_COMMAND_TIMEOUT_MS }, + ); + }, + signal, + ); return; } case 'airplane': { const enabled = parseSettingState(state); - if (enabled) { - await runSimctlForDevice(device, [ - 'status_bar', - device.id, - 'override', - '--dataNetwork', - 'hide', - '--wifiMode', - 'failed', - '--wifiBars', - '0', - '--cellularMode', - 'failed', - '--cellularBars', - '0', - '--operatorName', - '', - ]); - invalidateSimulatorStatusBarOverrideCache(device); - } else { - await runSimctlForDevice(device, ['status_bar', device.id, 'clear']); - rememberClearedStatusBarOverrides(device); - } + // Same reason as `wifi`: the write waits for the capture window so a restore cannot undo it, + // and the command is bounded so a wedged `simctl` cannot hold the simulator's captures. + await withSimulatorStatusBarHeld( + device, + async () => { + // Dropped before the command, as for `wifi`: a write that hits its deadline may still have + // reached `simctl`, so no answer about the bar survives the attempt. + invalidateSimulatorStatusBarOverrideCache(device); + if (enabled) { + await runSimctlForDevice( + device, + [ + 'status_bar', + device.id, + 'override', + '--dataNetwork', + 'hide', + '--wifiMode', + 'failed', + '--wifiBars', + '0', + '--cellularMode', + 'failed', + '--cellularBars', + '0', + '--operatorName', + '', + ], + { timeoutMs: STATUS_BAR_COMMAND_TIMEOUT_MS }, + ); + } else { + await runSimctlForDevice(device, ['status_bar', device.id, 'clear'], { + timeoutMs: STATUS_BAR_COMMAND_TIMEOUT_MS, + }); + rememberClearedStatusBarOverrides(device); + } + }, + signal, + ); return; } case 'faceid': diff --git a/packages/platform-apple/src/core/screenshot-status-bar.ts b/packages/platform-apple/src/core/screenshot-status-bar.ts index 03fd273c0e..2a1729f730 100644 --- a/packages/platform-apple/src/core/screenshot-status-bar.ts +++ b/packages/platform-apple/src/core/screenshot-status-bar.ts @@ -2,6 +2,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { requireExecSuccess } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError } from '@agent-device/kernel/errors'; +import { heldKeyedLockKeys, withKeyedLock } from '@agent-device/kernel/keyed-lock'; import { runSimctlForDevice } from './simctl.ts'; import { extractAppleToolErrorMeta } from './tool-diagnostics.ts'; @@ -57,44 +58,130 @@ const CELLULAR_MODE_BY_CODE: Record = { const devicesKnownWithoutStatusBarOverrides = new Set(); -export async function prepareSimulatorStatusBarForScreenshot( +/** One entry per simulator whose status bar a capture is holding right now. */ +const simulatorStatusBarTransactions = new Map>(); + +/** Bounds one `simctl status_bar` step so a wedged tool cannot hold a simulator's captures. */ +export const STATUS_BAR_COMMAND_TIMEOUT_MS = 10_000; + +/** + * Budget for the restore specifically. Leaving the normalization on the simulator is the failure + * this owner exists to prevent, so the restore is given the longer deadline; it is still bounded, + * because an untimed wedged tool would hold the simulator's captures forever. It applies to each + * restore command, so one stuck `clear` followed by one stuck `override` can hold this simulator's + * captures for twice this budget. + */ +const STATUS_BAR_RESTORE_TIMEOUT_MS = 30_000; + +/** What the simulator's status bar held before a capture normalized it. */ +type StatusBarOverrideSnapshot = { + /** False when the tool refused to answer, so nothing here is safe to restore to. */ + canRestore: boolean; + overrides: RestorableStatusBarOverrides | null; +}; + +/** + * Runs `capture` with the simulator status bar normalized, then restores the overrides the + * simulator held before this call. One simulator handles one of these at a time: the normalization + * is temporary state, so a capture that overlapped another would snapshot it and later hand it back + * as though the caller had set it. A status bar problem never decides whether `capture` runs or + * what it returns. + * + * `signal` cancels the wait for the simulator only. It is not handed to the status bar commands: a + * request that gives up must not leave the normalization behind, so the restore runs to its budget + * instead — {@link STATUS_BAR_COMMAND_TIMEOUT_MS} per setup command and the longer + * {@link STATUS_BAR_RESTORE_TIMEOUT_MS} per restore command, so even a wedged `simctl` cannot hold + * the simulator forever. + */ +export async function withNormalizedSimulatorStatusBar( + device: DeviceInfo, + capture: () => Promise, + signal?: AbortSignal, +): Promise { + return await withKeyedLock( + simulatorStatusBarTransactions, + statusBarSimulatorKey(device), + async () => { + const snapshot = await snapshotSimulatorStatusBarOverrides(device); + await normalizeSimulatorStatusBar(device, snapshot); + try { + return await capture(); + } finally { + await restoreSimulatorStatusBarOverrides( + device, + snapshot.canRestore ? snapshot.overrides : null, + ).catch((error) => emitSimulatorStatusBarDiagnostic(device, 'restore_failed', error)); + } + }, + signal, + ); +} + +/** + * Reserves one simulator's status bar while `capture` runs, without changing the bar. A capture that + * does not normalize still has to wait: while another capture normalizes the bar, this one would + * otherwise take its screenshot of the temporary 9:41 overrides. + * + * `signal` cancels waiting for another capture. It is dropped when the caller already holds this + * simulator's window: that caller is the one who gave up, not an obstacle, so a leg of its own work + * is not rejected at the lock boundary and the window still restores what it normalized. The leg + * keeps the request's signal, so a request that has given up still stops as early as it can. + */ +export async function withSimulatorStatusBarHeld( + device: DeviceInfo, + capture: () => Promise, + signal?: AbortSignal, +): Promise { + const key = statusBarSimulatorKey(device); + const holdsOwnWindow = heldKeyedLockKeys(simulatorStatusBarTransactions).includes(key); + return await withKeyedLock( + simulatorStatusBarTransactions, + key, + capture, + holdsOwnWindow ? undefined : signal, + ); +} + +async function snapshotSimulatorStatusBarOverrides( device: DeviceInfo, -): Promise<() => Promise> { - let previousOverrides: RestorableStatusBarOverrides | null = null; - let canRestorePreviousOverrides = false; +): Promise { if (hasKnownClearedStatusBarOverrides(device)) { - canRestorePreviousOverrides = true; - } else { - try { - previousOverrides = await readSimulatorStatusBarOverrides(device); - canRestorePreviousOverrides = true; - } catch (error) { - emitSimulatorStatusBarDiagnostic(device, 'snapshot_failed', error); - } + return { canRestore: true, overrides: null }; + } + try { + return { canRestore: true, overrides: await readSimulatorStatusBarOverrides(device) }; + } catch (error) { + emitSimulatorStatusBarDiagnostic(device, 'snapshot_failed', error); + return { canRestore: false, overrides: null }; } +} +async function normalizeSimulatorStatusBar( + device: DeviceInfo, + snapshot: StatusBarOverrideSnapshot, +): Promise { try { - if (!canRestorePreviousOverrides || previousOverrides) { + if (!snapshot.canRestore || snapshot.overrides) { await clearSimulatorStatusBarOverride(device); } await applySimulatorStatusBarOverrideArgs(device, DETERMINISTIC_SCREENSHOT_STATUS_BAR_ARGS); } catch (error) { emitSimulatorStatusBarDiagnostic(device, 'prepare_failed', error); } - - return async () => { - await restoreSimulatorStatusBarOverrides( - device, - canRestorePreviousOverrides ? previousOverrides : null, - ); - }; } async function restoreSimulatorStatusBarOverrides( device: DeviceInfo, overrides: RestorableStatusBarOverrides | null, ): Promise { - await clearSimulatorStatusBarOverride(device); + // Only a restore that ran to the end may change the known-cleared answer, and this covers the + // simulator the answer already says is cleared. A `clear` that fails leaves the normalization on + // the bar, and that answer is the one that fixes it: the next capture skips the read, so it cannot + // adopt the leftover as the caller's state, and its own restore clears the bar. Reading the bar + // after a failed clear would do the opposite — adopt the leftover and hand it back on every later + // capture. When the snapshot held the caller's own overrides, a failed `clear` cannot return them: + // that recovery needs the snapshot itself and is tracked separately. + await clearSimulatorStatusBarOverride(device, STATUS_BAR_RESTORE_TIMEOUT_MS); if (!overrides) { rememberClearedStatusBarOverrides(device); return; @@ -102,23 +189,24 @@ async function restoreSimulatorStatusBarOverrides( await applySimulatorStatusBarOverrideArgs( device, buildRestorableStatusBarOverrideArgs(overrides), + STATUS_BAR_RESTORE_TIMEOUT_MS, ); invalidateSimulatorStatusBarOverrideCache(device); } export function invalidateSimulatorStatusBarOverrideCache(device: DeviceInfo): void { - devicesKnownWithoutStatusBarOverrides.delete(statusBarOverrideCacheKey(device)); + devicesKnownWithoutStatusBarOverrides.delete(statusBarSimulatorKey(device)); } export function rememberClearedStatusBarOverrides(device: DeviceInfo): void { - devicesKnownWithoutStatusBarOverrides.add(statusBarOverrideCacheKey(device)); + devicesKnownWithoutStatusBarOverrides.add(statusBarSimulatorKey(device)); } function hasKnownClearedStatusBarOverrides(device: DeviceInfo): boolean { - return devicesKnownWithoutStatusBarOverrides.has(statusBarOverrideCacheKey(device)); + return devicesKnownWithoutStatusBarOverrides.has(statusBarSimulatorKey(device)); } -function statusBarOverrideCacheKey(device: DeviceInfo): string { +function statusBarSimulatorKey(device: DeviceInfo): string { return `${device.platform}:${device.kind}:${device.id}`; } @@ -128,22 +216,27 @@ async function readSimulatorStatusBarOverrides( const result = requireExecSuccess( await runSimctlForDevice(device, ['status_bar', device.id, 'list'], { allowFailure: true, + timeoutMs: STATUS_BAR_COMMAND_TIMEOUT_MS, }), 'Failed to read simulator status bar overrides', ); return parseSimulatorStatusBarOverrides(result.stdout); } -async function clearSimulatorStatusBarOverride(device: DeviceInfo): Promise { - await runSimctlForDevice(device, ['status_bar', device.id, 'clear']); +async function clearSimulatorStatusBarOverride( + device: DeviceInfo, + timeoutMs = STATUS_BAR_COMMAND_TIMEOUT_MS, +): Promise { + await runSimctlForDevice(device, ['status_bar', device.id, 'clear'], { timeoutMs }); } async function applySimulatorStatusBarOverrideArgs( device: DeviceInfo, args: string[], + timeoutMs = STATUS_BAR_COMMAND_TIMEOUT_MS, ): Promise { if (args.length === 0) return; - await runSimctlForDevice(device, ['status_bar', device.id, 'override', ...args]); + await runSimctlForDevice(device, ['status_bar', device.id, 'override', ...args], { timeoutMs }); } function parseSimulatorStatusBarOverrides(output: string): RestorableStatusBarOverrides | null { @@ -236,9 +329,10 @@ function isCellularDataNetworkType(value: string | undefined): boolean { /** * Records one best-effort status bar step that failed, under `ios_screenshot_status_bar_`. - * A status bar problem never decides a screenshot's outcome, so callers swallow the error. + * A status bar problem never decides a screenshot's outcome, so every caller of this swallows the + * error and the transaction still runs the capture. */ -export function emitSimulatorStatusBarDiagnostic( +function emitSimulatorStatusBarDiagnostic( device: DeviceInfo, phase: 'snapshot_failed' | 'prepare_failed' | 'restore_failed', error: unknown, diff --git a/packages/platform-apple/src/core/screenshot.ts b/packages/platform-apple/src/core/screenshot.ts index 7d1353b484..a564ee0ecc 100644 --- a/packages/platform-apple/src/core/screenshot.ts +++ b/packages/platform-apple/src/core/screenshot.ts @@ -28,8 +28,8 @@ import { type AppleRunnerCommandOptions, } from '@agent-device/apple-runner'; import { - emitSimulatorStatusBarDiagnostic, - prepareSimulatorStatusBarForScreenshot, + withNormalizedSimulatorStatusBar, + withSimulatorStatusBarHeld, } from './screenshot-status-bar.ts'; import { appleSimulatorDisplayArgvFragment, @@ -44,7 +44,24 @@ import { resolveIosPhysicalDeviceControl } from './physical-device-control.ts'; type SimulatorScreenshotFlowDeps = { ensureBooted: (device: DeviceInfo) => Promise; - prepareStatusBarForScreenshot: (device: DeviceInfo) => Promise<() => Promise>; + /** + * Runs `capture` while it owns the simulator's status bar: normalized to the deterministic state, + * with the caller's overrides restored afterwards. One capture per simulator at a time. + */ + withNormalizedStatusBar: ( + device: DeviceInfo, + capture: () => Promise, + signal?: AbortSignal, + ) => Promise; + /** + * Runs `capture` while it owns the simulator's status bar, without changing the bar. It still + * waits: a capture that normalizes shows the deterministic state while it is in flight. + */ + withStatusBarHeld: ( + device: DeviceInfo, + capture: () => Promise, + signal?: AbortSignal, + ) => Promise; resolveCaptureTarget: (device: DeviceInfo) => Promise; captureWithRetry: ( device: DeviceInfo, @@ -89,7 +106,8 @@ type SimulatorScreenshotFlowOptions = { const defaultSimulatorScreenshotFlowDeps: SimulatorScreenshotFlowDeps = { ensureBooted: ensureBootedSimulator, - prepareStatusBarForScreenshot: prepareSimulatorStatusBarForScreenshot, + withNormalizedStatusBar: withNormalizedSimulatorStatusBar, + withStatusBarHeld: withSimulatorStatusBarHeld, resolveCaptureTarget: resolveAppleCaptureTarget, captureWithRetry: captureSimulatorScreenshotWithRetry, normalizeDensity: normalizeIosSimulatorScreenshotDensity, @@ -154,15 +172,7 @@ export async function captureSimulatorScreenshotWithFallback( await deps.captureWithRetry(device, outPath, display); await deps.normalizeDensity(device, outPath, options.pixelDensity, display?.pointScale); }; - let restoreStatusBar = async () => {}; - if (options.normalizeStatusBar === true) { - try { - restoreStatusBar = await deps.prepareStatusBarForScreenshot(device); - } catch (error) { - emitSimulatorStatusBarDiagnostic(device, 'prepare_failed', error); - } - } - try { + const captureWithFallback = async (): Promise => { try { await captureAndNormalize(); return captureFacts; @@ -199,11 +209,11 @@ export async function captureSimulatorScreenshotWithFallback( // than borrowing a scale from a panel nobody captured (#2728). await deps.normalizeDensity(device, outPath, options.pixelDensity, captured?.pixelsPerPoint); return captureFacts; - } finally { - await restoreStatusBar().catch((error) => - emitSimulatorStatusBarDiagnostic(device, 'restore_failed', error), - ); - } + }; + + const holdStatusBar = + options.normalizeStatusBar === true ? deps.withNormalizedStatusBar : deps.withStatusBarHeld; + return await holdStatusBar(device, captureWithFallback, options.runnerOptions?.signal); } export async function captureSimulatorScreenshotWithRetry( @@ -239,6 +249,26 @@ export async function captureSimulatorScreenshotWithRetry( ); } +/** The runner renders this target's screen the way `simctl io screenshot` does, through the simulator. */ +function capturesThroughSimulatorStatusBar(device: DeviceInfo): boolean { + return device.kind === 'simulator' && !isMacOs(device); +} + +/** The runner reaches this target through the physical-device control rather than the runner app. */ +function capturesThroughPhysicalDeviceControl(device: DeviceInfo): boolean { + return device.kind === 'device' && !isMacOs(device); +} + +/** + * Captures through the runner backend. An iOS simulator's runner capture renders the same screen a + * simctl capture does, so it takes the same status bar reservation: while another capture + * normalizes the bar, this one would otherwise copy an image of the temporary 9:41 overrides. This + * reservation covers the runner capture alone; the density normalization the fallback flow applies + * afterwards is covered by that flow's own hold, and a direct call here performs no density step. + * It re-enters when the caller already holds it, as the simctl fallback does, and an already-aborted + * request does not reject that re-entry at the lock boundary — see `withSimulatorStatusBarHeld`. The + * runner command keeps the request's signal. + */ export async function captureScreenshotViaRunner( device: DeviceInfo, outPath: string, @@ -247,7 +277,33 @@ export async function captureScreenshotViaRunner( runnerOptions: AppleRunnerCommandOptions | undefined, env: Readonly, ): Promise { - if (device.kind === 'device' && !isMacOs(device)) { + if (!capturesThroughSimulatorStatusBar(device)) { + return await captureRunnerScreenshot( + device, + outPath, + appBundleId, + fullscreen, + runnerOptions, + env, + ); + } + return await withSimulatorStatusBarHeld( + device, + async () => + await captureRunnerScreenshot(device, outPath, appBundleId, fullscreen, runnerOptions, env), + runnerOptions?.signal, + ); +} + +async function captureRunnerScreenshot( + device: DeviceInfo, + outPath: string, + appBundleId: string | undefined, + fullscreen: boolean | undefined, + runnerOptions: AppleRunnerCommandOptions | undefined, + env: Readonly, +): Promise { + if (capturesThroughPhysicalDeviceControl(device)) { await resolveIosPhysicalDeviceControl(device).captureScreenshot(device, outPath, { appBundleId, fullscreen, diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index c35fcd70c9..87b3e40013 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -231,7 +231,7 @@ export function createAppleInteractor( readClipboard: () => readIosClipboardText(device), writeClipboard: (text) => writeIosClipboardText(device, text, runnerOpts), setSetting: (setting, state, appId, options) => - setIosSetting(device, setting, state, appId, options, env), + setIosSetting(device, setting, state, appId, options, env, runnerOpts.signal), readSetting: (setting) => readIosSetting(device, setting), readAlert: (options) => readAppleAlert(device, runnerOpts, options, env), awaitAlert: (options) => awaitAppleAlert(device, runnerOpts, options, env), @@ -513,8 +513,9 @@ async function runAppleScreenshot( return {}; } if (options.captureBackend === 'runner') { - // Runner capture returns the XCTest surface as-is; density and simulator - // status-bar normalization belong only to the simctl capture pipeline. + // Runner capture returns the XCTest surface as-is; density normalization belongs only to the + // simctl capture pipeline. Of this branch's targets, only an iOS simulator capture waits for a + // status bar window: physical-device and macOS captures take none. await captureScreenshotViaRunner( device, outPath, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 2bf8d26ec2..369fee7c14 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -758,6 +758,7 @@ Platform support: - `settings appearance` sets macOS appearance, iOS simulator appearance, or Android night mode. - `settings location set ` sets coordinates on iOS simulators and Android emulators. - A target that does not serve a setting refuses it with `UNSUPPORTED_OPERATION` and `error.details.reason` (for example `unsupported-platform-leaf`) before anything changes. A request that names an app the change cannot take is refused first, with `INVALID_ARGS` and `setting_app_not_consumed`. `settings --help` describes where each setting runs. +- On an iOS simulator, `settings wifi` and `settings airplane` wait for screenshots and other status bar writes. The request timeout includes this wait (90 seconds for a standalone CLI request). Once the write starts, its status bar command has a separate 10-second timeout. A request or write that times out may already have changed the bar; check the bar before relying on it. Cancelling while still queued guarantees nothing was written. Device settings: @@ -1041,7 +1042,8 @@ agent-device record stop # Stop active recording - `--overlay-refs` takes a fresh snapshot and burns visible `@eN` refs and their rectangles into the PNG. With `--scale`, refs are drawn at the final size, so avoid scales where labels become unreadable. In `--json` mode each ref also has a screenshot-space `center` for `press ` fallback. - `--crop-on ` crops the capture to the frame the selector resolves on the same screen. It works only on iOS simulators, Android emulators, and physical iPhones. A physical iPhone crop is verified only in portrait using CoreDevice/devicectl; the XCTest backend and runner fallback are not accepted. Physical iPads remain rejected for crop, and landscape iPhone crop remains unverified. It needs exactly one framed node, cannot be combined with `--overlay-refs` or `--fullscreen`, and warns when the frame is clipped. The crop is re-encoded, so do not byte-compare it with an older crop. - `--fullscreen` on macOS applies only to app sessions. Other surfaces (`desktop`, `menubar`, `frontmost-app`) always capture the main display and refuse `--fullscreen` with `INVALID_ARGS`. -- `--normalize-status-bar` temporarily normalizes iOS simulator status-bar chrome for reusable baselines. +- `--normalize-status-bar` temporarily normalizes iOS simulator status-bar chrome for reusable baselines. Each status-bar read or preparation command has a 10-second timeout. Each restore command has a 30-second timeout, so clearing and reapplying overrides can take up to 60 seconds. The request timeout includes the wait and can expire before these steps finish. +- On an iOS simulator, a screenshot waits until no other screenshot or `settings wifi` / `settings airplane` write is using that simulator's status bar, so overlapping captures cannot see each other's temporary chrome. A request you cancel while it is waiting fails with the cancellation instead of capturing. - On single-display iOS simulators and Android devices, the result may report `displayRotation` (`portrait`, `portrait-upside-down`, `landscape-left`, or `landscape-right`). It is absent when the device returns no reading in time, on other targets, and on multi-display devices such as foldables. The saved image is always upright. **Screenshot diffs**