diff --git a/src/client-types.ts b/src/client-types.ts index d22c4ed5e..1b25b363f 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -306,6 +306,7 @@ export type CaptureSnapshotOptions = AgentDeviceRequestOverrides & scope?: string; raw?: boolean; forceFull?: boolean; + timeoutMs?: number; }; export type CaptureSnapshotResult = { @@ -478,6 +479,11 @@ export type ReactNativeCommandOptions = ClientCommandBaseOptions & { action: 'dismiss-overlay'; }; +export type PrepareCommandOptions = ClientCommandBaseOptions & { + action: 'ios-runner'; + timeoutMs?: number; +}; + export type AgentDeviceCommandClient = { wait: (options: WaitCommandOptions) => Promise; alert: (options?: AlertCommandOptions) => Promise; @@ -489,6 +495,7 @@ export type AgentDeviceCommandClient = { keyboard: (options?: KeyboardCommandOptions) => Promise; clipboard: (options: ClipboardCommandOptions) => Promise; reactNative: (options: ReactNativeCommandOptions) => Promise; + prepare: (options: PrepareCommandOptions) => Promise; }; type SelectorSnapshotCommandOptions = Pick; diff --git a/src/client.ts b/src/client.ts index 1bf2abe79..539a8c93e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -96,6 +96,7 @@ export function createAgentDeviceClient( keyboard: async (options = {}) => await executeCommand('keyboard', options), clipboard: async (options) => await executeCommand('clipboard', options), reactNative: async (options) => await executeCommand('react-native', options), + prepare: async (options) => await executeCommand('prepare', options), }, devices: { list: async (options = {}) => { diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 3d2dd42f8..fece45cd7 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -26,6 +26,7 @@ export const PUBLIC_COMMANDS = { network: 'network', open: 'open', perf: 'perf', + prepare: 'prepare', press: 'press', push: 'push', record: 'record', @@ -129,6 +130,7 @@ const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.disconnect, LOCAL_CLI_COMMANDS.mcp, LOCAL_CLI_COMMANDS.reactDevtools, + PUBLIC_COMMANDS.prepare, ); const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( @@ -141,6 +143,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( LOCAL_CLI_COMMANDS.reactDevtools, LOCAL_CLI_COMMANDS.session, PUBLIC_COMMANDS.appState, + PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.gesture, @@ -249,6 +252,7 @@ export const DAEMON_COMMAND_GROUPS = { PUBLIC_COMMANDS.network, PUBLIC_COMMANDS.open, PUBLIC_COMMANDS.perf, + PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.push, PUBLIC_COMMANDS.reinstall, PUBLIC_COMMANDS.replay, diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index c4feaefc5..bf6714fea 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -19,3 +19,7 @@ test('MCP exposed command names have metadata and executable command definitions assert.ok(executableNames.has(name), `${name} must have an executable command definition`); } }); + +test('CI-only prepare command stays out of MCP tool surface', () => { + assert.equal(listMcpExposedCommandNames().includes('prepare'), false); +}); diff --git a/src/commands/cli-grammar/apps.ts b/src/commands/cli-grammar/apps.ts index aa1784f8a..37a1f5ca3 100644 --- a/src/commands/cli-grammar/apps.ts +++ b/src/commands/cli-grammar/apps.ts @@ -29,6 +29,11 @@ export const appCliReaders = { ...commonInputFromFlags(flags), headless: flags.headless, }), + prepare: (positionals, flags) => ({ + ...commonInputFromFlags(flags), + action: requiredString(positionals[0], 'prepare requires subcommand'), + timeoutMs: flags.timeoutMs, + }), open: (positionals, flags) => ({ ...commonInputFromFlags(flags), app: positionals[0], @@ -72,6 +77,9 @@ export const appCliReaders = { export const appDaemonWriters = { devices: direct(PUBLIC_COMMANDS.devices), boot: direct(PUBLIC_COMMANDS.boot), + prepare: direct(PUBLIC_COMMANDS.prepare, (input) => [ + requiredDaemonString(input.action, 'prepare requires subcommand'), + ]), apps: direct(PUBLIC_COMMANDS.apps), open: direct(PUBLIC_COMMANDS.open, openPositionals), close: direct(PUBLIC_COMMANDS.close, (input) => optionalString(input.app)), diff --git a/src/commands/cli-grammar/capture.ts b/src/commands/cli-grammar/capture.ts index 2da3e1e54..3f5200bca 100644 --- a/src/commands/cli-grammar/capture.ts +++ b/src/commands/cli-grammar/capture.ts @@ -39,6 +39,7 @@ export const captureCliReaders = { scope: flags.snapshotScope, raw: flags.snapshotRaw, forceFull: flags.snapshotForceFull, + timeoutMs: flags.timeoutMs, }), screenshot: (positionals, flags) => ({ ...commonInputFromFlags(flags), diff --git a/src/commands/cli-output.ts b/src/commands/cli-output.ts index aac4361c8..a5a32963b 100644 --- a/src/commands/cli-output.ts +++ b/src/commands/cli-output.ts @@ -82,6 +82,7 @@ const cliOutputFormatters: Partial> = { is: resultOutput(isCliOutput), find: resultOutput(findCliOutput), perf: resultOutput(perfCliOutput), + prepare: messageOutput, logs: resultOutput(logsCliOutput), network: resultOutput(networkCliOutput), record: resultOutput(recordCliOutput), diff --git a/src/commands/client-command-contracts.ts b/src/commands/client-command-contracts.ts index d1912bbe5..8aa81a06c 100644 --- a/src/commands/client-command-contracts.ts +++ b/src/commands/client-command-contracts.ts @@ -62,6 +62,7 @@ export const clientCommandDefinitions = [ defineExecutableCommand(metadata('react-native'), (client, input) => client.command.reactNative(input), ), + defineExecutableCommand(metadata('prepare'), (client, input) => client.command.prepare(input)), defineExecutableCommand(metadata('replay'), (client, input) => client.replay.run(input)), defineExecutableCommand(metadata('test'), (client, input) => client.replay.test(input)), defineExecutableCommand(metadata('perf'), (client, input) => client.observability.perf(input)), diff --git a/src/commands/client-command-metadata.ts b/src/commands/client-command-metadata.ts index 262f9119b..c22c270f3 100644 --- a/src/commands/client-command-metadata.ts +++ b/src/commands/client-command-metadata.ts @@ -36,12 +36,17 @@ const NETWORK_INCLUDE_VALUES = ['summary', 'headers', 'body', 'all'] as const; const START_STOP_VALUES = ['start', 'stop'] as const; const REACT_NATIVE_ACTION_VALUES = ['dismiss-overlay'] as const; const METRO_ACTION_VALUES = ['prepare', 'reload'] as const; +const PREPARE_ACTION_VALUES = ['ios-runner'] as const; export const clientCommandMetadata = [ defineClientCommandMetadata('devices', {}), defineClientCommandMetadata('boot', { headless: booleanField('Boot without showing simulator UI when supported.'), }), + defineClientCommandMetadata('prepare', { + action: requiredField(enumField(PREPARE_ACTION_VALUES)), + timeoutMs: integerField('Maximum wall-clock time for the prepare command.'), + }), defineClientCommandMetadata('apps', { appsFilter: enumField(['user-installed', 'all']), }), @@ -100,6 +105,7 @@ export const clientCommandMetadata = [ scope: stringField(), raw: booleanField(), forceFull: booleanField(), + timeoutMs: integerField('Maximum wall-clock time for the snapshot command.'), }), defineClientCommandMetadata('screenshot', { path: stringField('Output path.'), diff --git a/src/commands/command-descriptions.ts b/src/commands/command-descriptions.ts index 23be6e089..e87efa167 100644 --- a/src/commands/command-descriptions.ts +++ b/src/commands/command-descriptions.ts @@ -4,6 +4,7 @@ const COMMAND_DESCRIPTIONS = { apps: 'List installed apps.', session: 'List active sessions.', open: 'Open an app, deep link, URL, or platform surface.', + prepare: 'Prepare platform helper infrastructure.', close: 'Close an app or end the active session.', install: 'Install an app binary.', reinstall: 'Reinstall an app binary.', diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 0af33e5f0..cbffc86a4 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -116,6 +116,8 @@ type EnsuredDaemon = { type ResolvedDaemonTransport = 'socket' | 'http'; const REQUEST_TIMEOUT_MS = 90_000; +const SNAPSHOT_REQUEST_TIMEOUT_MS = 30_000; +const PREPARE_REQUEST_TIMEOUT_MS = 240_000; const DAEMON_STARTUP_TIMEOUT_MS = 15_000; const DAEMON_STARTUP_ATTEMPTS = 2; const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000; @@ -192,14 +194,26 @@ export async function sendToDaemon(req: Omit): Promise): number | undefined { +export function resolveDaemonRequestTimeoutMs( + req: Omit, +): number | undefined { if (req.command === PUBLIC_COMMANDS.test) return undefined; - if (req.command === PUBLIC_COMMANDS.replay && typeof req.flags?.timeoutMs === 'number') { + if (typeof req.flags?.timeoutMs === 'number' && isExplicitTimeoutCommand(req.command)) { return req.flags.timeoutMs; } + if (req.command === PUBLIC_COMMANDS.prepare) return PREPARE_REQUEST_TIMEOUT_MS; + if (req.command === PUBLIC_COMMANDS.snapshot) return SNAPSHOT_REQUEST_TIMEOUT_MS; return REQUEST_TIMEOUT_MS; } +function isExplicitTimeoutCommand(command: string | undefined): boolean { + return ( + command === PUBLIC_COMMANDS.prepare || + command === PUBLIC_COMMANDS.replay || + command === PUBLIC_COMMANDS.snapshot + ); +} + export async function openApp(options: OpenAppOptions = {}): Promise { const { session = 'default', @@ -1143,7 +1157,11 @@ function resolveRequestTimeoutHint(params: { return 'Retry with --debug and verify the remote daemon URL, auth token, and remote host logs.'; } if (!resetDaemon) { - return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and iOS runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.`; + const iosPrepareHint = + command === PUBLIC_COMMANDS.snapshot + ? ' If this was the first iOS snapshot on the device, run agent-device prepare ios-runner --platform ios before snapshot/test so runner startup is handled explicitly.' + : ''; + return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and iOS runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`; } return 'Retry with --debug and check daemon diagnostics logs. Timed-out iOS runner xcodebuild processes were terminated when detected.'; } diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 0a2dbade5..1d3d0a3c9 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -18,6 +18,7 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => { return { ...actual, prewarmIosRunnerSession: vi.fn(), + runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })), stopIosRunnerSession: vi.fn(async () => {}), }; }); @@ -96,6 +97,7 @@ import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts'; import { prewarmIosRunnerSession, + runIosRunnerCommand, stopIosRunnerSession, } from '../../../platforms/ios/runner-client.ts'; import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts'; @@ -118,6 +120,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp); const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp); const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); +const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand); const mockStopIosRunner = vi.mocked(stopIosRunnerSession); const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); const mockSettleSimulator = vi.mocked(settleIosSimulator); @@ -148,6 +151,8 @@ beforeEach(() => { mockClearRuntimeHints.mockReset(); mockClearRuntimeHints.mockResolvedValue(undefined); mockPrewarmIosRunnerSession.mockReset(); + mockRunIosRunnerCommand.mockReset(); + mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 }); mockStopIosRunner.mockReset(); mockStopIosRunner.mockResolvedValue(undefined); mockDismissMacOsAlert.mockReset(); @@ -2094,6 +2099,118 @@ test('open iOS URL without app bundle id skips runner prewarm', async () => { expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled(); }); +test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'prepare-ios-runner'; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'ios', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'ios', udid: 'sim-1', timeoutMs: 240000 }, + meta: { requestId: 'prepare-request' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'ios', id: 'sim-1' }), + ); + expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1); + expect(mockRunIosRunnerCommand).toHaveBeenCalledWith( + expect.objectContaining({ platform: 'ios', id: 'sim-1' }), + { command: 'uptime' }, + expect.objectContaining({ + logPath: expect.stringMatching(/daemon\.log$/), + requestId: 'prepare-request', + }), + ); + expect((response as any).data).toMatchObject({ + action: 'ios-runner', + platform: 'ios', + deviceId: 'sim-1', + deviceName: 'iPhone 17 Pro', + kind: 'simulator', + runner: { currentUptimeMs: 42 }, + message: 'Prepared iOS runner: iPhone 17 Pro', + }); + expect(sessionStore.get(sessionName)).toBeUndefined(); +}); + +test('prepare ios-runner rejects non-iOS devices', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel 9 Pro XL', + kind: 'emulator', + booted: true, + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'prepare-android', + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'android', serial: 'emulator-5554' }, + }, + sessionName: 'prepare-android', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe('prepare ios-runner is only supported on iOS'); + } + expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); +}); + +test('prepare requires the ios-runner subcommand', async () => { + const sessionStore = makeSessionStore(); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'prepare-invalid', + command: 'prepare', + positionals: [], + flags: { platform: 'ios' }, + }, + sessionName: 'prepare-invalid', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toBe('prepare requires a subcommand: ios-runner'); + } + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); + expect(mockRunIosRunnerCommand).not.toHaveBeenCalled(); +}); + test('open web URL on iOS device session without active app falls back to Safari', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 1f96836fa..f58632ae7 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -7,6 +7,7 @@ import { } from '../../command-catalog.ts'; import { resolvePayloadInput } from '../../utils/payload-input.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; +import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { normalizePlatformSelector } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -56,10 +57,81 @@ export const SESSION_COMMAND_HANDLERS = { [PUBLIC_COMMANDS.push]: true, [PUBLIC_COMMANDS.triggerAppEvent]: true, [PUBLIC_COMMANDS.open]: true, + [PUBLIC_COMMANDS.prepare]: true, [PUBLIC_COMMANDS.batch]: true, [PUBLIC_COMMANDS.close]: true, } as const satisfies Record; +async function handlePrepareCommand(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; +}): Promise { + const { req, sessionName, logPath, sessionStore } = params; + const action = req.positionals?.[0] ?? ''; + if (action !== 'ios-runner') { + return errorResponse('INVALID_ARGS', 'prepare requires a subcommand: ios-runner'); + } + + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.prepare, session, flags); + if (guard) return guard; + + const device = await resolveCommandDevice({ + session, + flags, + ensureReady: true, + }); + if (device.platform !== 'ios') { + return errorResponse('UNSUPPORTED_OPERATION', 'prepare ios-runner is only supported on iOS'); + } + + const startedAtMs = Date.now(); + const result = await runIosRunnerCommand( + device, + { command: 'uptime' }, + buildPrepareIosRunnerOptions(req, session, logPath), + ); + const durationMs = Math.max(0, Date.now() - startedAtMs); + return { + ok: true, + data: prepareIosRunnerResponseData(action, device, durationMs, result), + }; +} + +function buildPrepareIosRunnerOptions( + req: DaemonRequest, + session: SessionState | undefined, + logPath: string, +): Parameters[2] { + return { + verbose: req.flags?.verbose, + logPath, + traceLogPath: session?.trace?.outPath, + requestId: req.meta?.requestId, + }; +} + +function prepareIosRunnerResponseData( + action: string, + device: DeviceInfo, + durationMs: number, + runner: Awaited>, +): Record { + return { + action, + platform: device.platform, + deviceId: device.id, + deviceName: device.name, + kind: device.kind, + durationMs, + runner, + message: `Prepared iOS runner: ${device.name}`, + }; +} + // fallow-ignore-next-line complexity async function runSessionOrSelectorDispatch(params: { req: DaemonRequest; @@ -253,6 +325,15 @@ export async function handleSessionCommands(params: { }); } + if (req.command === PUBLIC_COMMANDS.prepare) { + return await handlePrepareCommand({ + req, + sessionName, + logPath, + sessionStore, + }); + } + if (req.command === PUBLIC_COMMANDS.install || req.command === PUBLIC_COMMANDS.reinstall) { return await handleAppDeployCommand({ req, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 569b5c0ec..f9390925d 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -43,6 +43,17 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.headless, true); }, }, + { + label: 'prepare ios-runner', + argv: ['prepare', 'ios-runner', '--platform', 'ios', '--timeout', '240000'], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'prepare'); + assert.deepEqual(parsed.positionals, ['ios-runner']); + assert.equal(parsed.flags.platform, 'ios'); + assert.equal(parsed.flags.timeoutMs, 240000); + }, + }, { label: 'back --in-app', argv: ['back', '--in-app'], @@ -894,6 +905,7 @@ test('usage includes concise top-level commands', () => { usageText, /install-from-source \| install-from-source --github-actions-artifact/, ); + assert.match(usageText, /prepare ios-runner --platform ios/); assert.match(usageText, /metro prepare --public-base-url /); assert.match(usageText, /batch --steps \| --steps-file /); assert.match(usageText, /network dump/); @@ -1020,6 +1032,15 @@ test('usageForCommand includes Maestro test suite flag', () => { assert.match(help, /Replay\/Test: inject or override/); }); +test('usageForCommand documents prepare ios-runner', () => { + const help = usageForCommand('prepare'); + if (help === null) throw new Error('Expected prepare help text'); + assert.match(help, /Usage:\s+agent-device prepare ios-runner --platform ios/); + assert.match(help, /Prepare platform helper infrastructure/); + assert.match(help, /--timeout /); + assert.match(help, /XCTest runner/); +}); + test('usageForCommand resolves workflow help topic', () => { const help = usageForCommand('workflow'); if (help === null) throw new Error('Expected workflow help text'); @@ -1082,6 +1103,8 @@ test('usageForCommand resolves workflow help topic', () => { assert.match(help, /agent-device open exp:\/\/127\.0\.0\.1:8081 --platform android/); assert.match(help, /apps lookup misses the project but shows Expo Go\/dev-client/); assert.match(help, /metro prepare --kind expo/); + assert.match(help, /agent-device prepare ios-runner --platform ios --timeout 240000/); + assert.match(help, /prepare ios-runner builds\/reuses the XCTest runner/); assert.match(help, /help react-devtools/); assert.match(help, /help react-native/); assert.doesNotMatch(help, /agent-device react-devtools profile/); @@ -1297,14 +1320,18 @@ test('strict mode rejects click-only button flag on press', () => { }); test('snapshot command accepts command-specific flags', () => { - const parsed = parseArgs(['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login'], { - strictFlags: true, - }); + const parsed = parseArgs( + ['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login', '--timeout', '120000'], + { + strictFlags: true, + }, + ); assert.equal(parsed.command, 'snapshot'); assert.equal(parsed.flags.snapshotInteractiveOnly, true); assert.equal(parsed.flags.snapshotCompact, true); assert.equal(parsed.flags.snapshotDepth, 3); assert.equal(parsed.flags.snapshotScope, 'Login'); + assert.equal(parsed.flags.timeoutMs, 120000); }); test('snapshot command accepts diff alias flag', () => { @@ -1420,6 +1447,7 @@ test('usage includes swipe and press series options', () => { test('usage renders concise commands inline with descriptions', () => { const help = usage(); assert.match(help, /Commands:[\s\S]*\n boot\s{2,}Boot target device\/simulator/); + assert.match(help, / prepare ios-runner --platform ios\s{2,}Prepare platform helpers/); assert.match( help, / metro prepare --public-base-url \| --proxy-base-url ; metro reload\s{2,}Prepare Metro or reload apps/, @@ -1459,6 +1487,7 @@ test('snapshot command usage documents diff alias', () => { const help = usageForCommand('snapshot'); if (help === null) throw new Error('Expected command help text'); assert.match(help, /agent-device snapshot \[--diff\]/); + assert.match(help, /--timeout /); assert.match(help, /Capture accessibility tree or diff against the previous session baseline/); }); diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index a3bff4a3a..50fa04fed 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -18,6 +18,7 @@ import { computeDaemonCodeSignature, downloadRemoteArtifact, openApp, + resolveDaemonRequestTimeoutMs, resolveDaemonStartupHint, sendToDaemon, shouldResetDaemonAfterRequestTimeout, @@ -165,6 +166,44 @@ test('snapshot request timeout preserves daemon metadata for follow-up evidence assert.equal(shouldResetDaemonAfterRequestTimeout(undefined), true); }); +test('snapshot uses a shorter daemon request timeout with an explicit override', () => { + const base = { + session: 'default', + positionals: [], + flags: {}, + meta: {}, + }; + + assert.equal(resolveDaemonRequestTimeoutMs({ ...base, command: 'snapshot' }), 30_000); + assert.equal( + resolveDaemonRequestTimeoutMs({ + ...base, + command: 'snapshot', + flags: { timeoutMs: 120_000 }, + }), + 120_000, + ); + assert.equal(resolveDaemonRequestTimeoutMs({ ...base, command: 'screenshot' }), 90_000); + assert.equal( + resolveDaemonRequestTimeoutMs({ + ...base, + command: 'prepare', + positionals: ['ios-runner'], + }), + 240_000, + ); + assert.equal( + resolveDaemonRequestTimeoutMs({ + ...base, + command: 'prepare', + positionals: ['ios-runner'], + flags: { timeoutMs: 240_000 }, + }), + 240_000, + ); + assert.equal(resolveDaemonRequestTimeoutMs({ ...base, command: 'test' }), undefined); +}); + test('cleanupFailedDaemonStartupMetadata removes partial startup metadata', async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-cleanup-')); const paths = resolveDaemonPaths(stateDir); diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 020523fe2..a191b08a9 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -65,6 +65,15 @@ const CLI_COMMAND_OVERRIDES = { summary: 'Boot target device/simulator', allowedFlags: ['headless'], }, + prepare: { + usageOverride: 'prepare ios-runner --platform ios [--timeout ]', + listUsageOverride: 'prepare ios-runner --platform ios', + helpDescription: + 'Prepare platform helper infrastructure. ios-runner builds/reuses and starts the XCTest runner so later iOS snapshots and interactions do not pay first-use startup cost.', + summary: 'Prepare platform helpers', + positionalArgs: ['ios-runner'], + allowedFlags: ['timeoutMs'], + }, open: { helpDescription: 'Boot device/simulator; optionally launch app or deep link URL (macOS also supports --surface app|frontmost-app|desktop|menubar)', @@ -107,9 +116,10 @@ const CLI_COMMAND_OVERRIDES = { positionalArgs: ['bundleOrPackage', 'payloadOrJson'], }, snapshot: { - usageOverride: 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full]', + usageOverride: + 'snapshot [--diff] [-i] [-c] [-d ] [-s ] [--raw] [--force-full] [--timeout ]', helpDescription: 'Capture accessibility tree or diff against the previous session baseline', - allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull'], + allowedFlags: ['snapshotDiff', ...SNAPSHOT_FLAGS, 'snapshotForceFull', 'timeoutMs'], }, diff: { usageOverride: diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index d3bbfb710..3a42b706c 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -800,7 +800,7 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'int', min: 1, usageLabel: '--timeout ', - usageDescription: 'Test: maximum wall-clock time per script attempt', + usageDescription: 'Prepare/Replay/Snapshot/Test: maximum wall-clock time for the command or attempt', }, { key: 'retries', diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index f80003232..6827c7392 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -115,8 +115,10 @@ Bootstrap: agent-device reinstall com.example.app ./build/MyApp.app --platform ios agent-device install-from-source --github-actions-artifact org/repo:app-debug --platform android agent-device open com.example.app --platform android --relaunch + agent-device prepare ios-runner --platform ios --timeout 240000 If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. + In iOS CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner and proves it can answer a lightweight command before the first snapshot pays that setup cost. Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop. Snapshots and refs: diff --git a/test/integration/provider-scenarios/ios-lifecycle.test.ts b/test/integration/provider-scenarios/ios-lifecycle.test.ts index 7dcdb67a8..4f838241c 100644 --- a/test/integration/provider-scenarios/ios-lifecycle.test.ts +++ b/test/integration/provider-scenarios/ios-lifecycle.test.ts @@ -44,6 +44,18 @@ test('Provider-backed integration iOS Settings flow uses scripted simctl and run ios_simulator_device_set: null, }, }, + { + name: 'prepare iOS runner', + command: 'prepare', + positionals: ['ios-runner'], + flags: { platform: 'ios', udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id }, + expectData: { + action: 'ios-runner', + platform: 'ios', + deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + runner: { uptimeMs: 42 }, + }, + }, { name: 'capture settings snapshot', command: 'snapshot', diff --git a/test/integration/provider-scenarios/ios-world.ts b/test/integration/provider-scenarios/ios-world.ts index 4dd2a34aa..8072427ce 100644 --- a/test/integration/provider-scenarios/ios-world.ts +++ b/test/integration/provider-scenarios/ios-world.ts @@ -29,6 +29,13 @@ export async function createIosSettingsWorld(): Promise { const { tempRoot, appPath } = createDemoIosApp('agent-device-provider-scenario-ios-deploy-'); const inventoryRequests: DeviceInventoryRequest[] = []; const runnerTranscript = createProviderTranscript([ + { + command: 'ios.runner.uptime', + deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + platform: 'ios', + request: { command: 'uptime' }, + result: { uptimeMs: 42 }, + }, runnerSnapshot(), runnerSnapshot(), {