From b8dd6a5854116eda5ea2c21c5ba7e6b2cb60bd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 11 Aug 2026 13:55:28 +0200 Subject: [PATCH] refactor: tighten capture ownership boundaries (#1736) --- .../src/recording/runtime.test.ts | 22 ++ .../src/recording/runtime.ts | 61 ++++- scripts/lib/run-as-main.ts | 29 --- src/daemon/record-trace-errors.ts | 37 --- ...n-recording-apple-runner-transport.test.ts | 48 +++- ...screen-recording-apple-runner-transport.ts | 12 +- .../apple/core/physical-device-console.ts | 61 ----- .../interaction-contract/daemon-harness.ts | 4 +- .../provider-scenarios/android-world.ts | 232 ++++++++++++++++-- ...teraction-direct-selector-fallback.test.ts | 4 +- .../interaction-verify.test.ts | 4 +- .../ios-record-trace.test.ts | 4 +- .../provider-command-dispatch.test.ts | 22 ++ .../provider-scenarios/providers.ts | 21 +- .../settle-observation.test.ts | 10 +- .../stale-ref-warning.test.ts | 6 +- .../provider-scenarios/tvos-remote.test.ts | 4 +- .../provider-scenarios/versioned-refs.test.ts | 4 +- 18 files changed, 397 insertions(+), 188 deletions(-) delete mode 100644 scripts/lib/run-as-main.ts delete mode 100644 src/daemon/record-trace-errors.ts delete mode 100644 src/platforms/apple/core/physical-device-console.ts create mode 100644 test/integration/provider-scenarios/provider-command-dispatch.test.ts diff --git a/packages/platform-harmonyos/src/recording/runtime.test.ts b/packages/platform-harmonyos/src/recording/runtime.test.ts index d652ae4af..92ca12830 100644 --- a/packages/platform-harmonyos/src/recording/runtime.test.ts +++ b/packages/platform-harmonyos/src/recording/runtime.test.ts @@ -179,6 +179,28 @@ test('retries zero-byte Harmony staging and retains media when finalization fail expect(removeMedia).not.toHaveBeenCalled(); }); +test('reports unconfirmed completed-artifact cleanup without clobbering finalizer warnings', async () => { + const operations = createHarmonyScreenRecordingOperations({ + host: harmonyHost({ + remove: async () => false, + removeMedia: async () => false, + complete: async () => ({ warning: 'finalizer warning.' }), + }), + device, + owner: localRuntimeOwner('harmonyos'), + signal: new AbortController().signal, + }); + const started = await operations.screenRecordingStart(input()); + + await expect(started.pendingHandle.transfer().finish()).resolves.toMatchObject({ + status: 'completed', + result: { + warning: + 'finalizer warning. HarmonyOS recording completed, but cleanup was not confirmed for the staging artifact and media-library artifact.', + }, + }); +}); + test('compensates finalizer failure without stopping the native recorder twice', async () => { const stop = vi.fn(async () => success()); const removeMedia = vi.fn(async () => true); diff --git a/packages/platform-harmonyos/src/recording/runtime.ts b/packages/platform-harmonyos/src/recording/runtime.ts index 5e8a68bc2..c08abc68a 100644 --- a/packages/platform-harmonyos/src/recording/runtime.ts +++ b/packages/platform-harmonyos/src/recording/runtime.ts @@ -154,7 +154,6 @@ async function finishHarmonyRecording( `failed to finalize HarmonyOS recording: ${descriptor.fileName} did not produce a non-empty media file`, ); } - let completed = false; try { const pulled = await host.screenRecording.harmony.pull(device, { remotePath: descriptor.remotePath, @@ -167,16 +166,64 @@ async function finishHarmonyRecording( gestureEvents: snapshot.gestureEvents, targetLabel: 'HarmonyOS recording', }); - completed = true; - return createScreenRecordingCompletion(snapshot, finalization, false); - } finally { + const cleanupWarning = await cleanupCompletedHarmonyArtifacts( + host, + device, + descriptor, + mediaUri, + ); + return createScreenRecordingCompletion( + snapshot, + { + ...finalization, + ...appendCompletionWarning(finalization.warning, cleanupWarning), + }, + false, + ); + } catch (error) { await host.screenRecording.harmony.remove(device, descriptor.remotePath).catch(() => {}); - if (completed) { - await host.screenRecording.harmony.removeMedia(device, mediaUri).catch(() => {}); - } + throw error; } } +async function cleanupCompletedHarmonyArtifacts( + host: HarmonyScreenRecordingOperationHost, + device: DeviceInfo, + descriptor: HarmonyRecordingDescriptor, + mediaUri: string, +): Promise { + const unconfirmed: string[] = []; + if ( + !(await confirmedRemoval(() => + host.screenRecording.harmony.remove(device, descriptor.remotePath), + )) + ) + unconfirmed.push('staging artifact'); + if (!(await confirmedRemoval(() => host.screenRecording.harmony.removeMedia(device, mediaUri)))) + unconfirmed.push('media-library artifact'); + return unconfirmed.length === 0 + ? undefined + : `HarmonyOS recording completed, but cleanup was not confirmed for the ${unconfirmed.join(' and ')}.`; +} + +async function confirmedRemoval(remove: () => Promise): Promise { + try { + return await remove(); + } catch { + return false; + } +} + +function appendCompletionWarning( + existing: string | undefined, + cleanup: string | undefined, +): { warning?: string } { + const warning = [existing, cleanup] + .filter((value): value is string => value !== undefined) + .join(' '); + return warning.length === 0 ? {} : { warning }; +} + async function stageHarmonyMedia( host: HarmonyScreenRecordingOperationHost, device: DeviceInfo, diff --git a/scripts/lib/run-as-main.ts b/scripts/lib/run-as-main.ts deleted file mode 100644 index a5b4dd5e9..000000000 --- a/scripts/lib/run-as-main.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { pathToFileURL } from 'node:url'; - -/** - * Run `main` as the process entrypoint, but only when `moduleUrl` (pass `import.meta.url`) is the - * script node was actually invoked with — so importing the module for its exports never runs it. - * Exits with `main`'s status and turns a throw into a `label`-prefixed non-zero exit. Keeps the - * `import.meta.url === pathToFileURL(process.argv[1])` guard from being copy-pasted per script. - */ -function isEntrypoint(moduleUrl: string): boolean { - return moduleUrl === pathToFileURL(process.argv[1] ?? '').href; -} - -function messageOf(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function runAsMain( - moduleUrl: string, - label: string, - main: (argv: string[]) => number, -): void { - if (!isEntrypoint(moduleUrl)) return; - try { - process.exit(main(process.argv.slice(2))); - } catch (error: unknown) { - process.stderr.write(`${label}: ${messageOf(error)}\n`); - process.exit(1); - } -} diff --git a/src/daemon/record-trace-errors.ts b/src/daemon/record-trace-errors.ts deleted file mode 100644 index f3c87464e..000000000 --- a/src/daemon/record-trace-errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ExecResult } from '../utils/exec.ts'; - -export function formatRecordTraceError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -const MIN_PLAYABLE_RECORDING_DURATION_MS = 1_000; - -type RecordingStartedAt = { - startedAt: number; -}; - -type RecordStopFailure = { - message: string; - tooShort: boolean; -}; - -export function formatRecordTraceExecFailure(result: ExecResult, command: string): string { - return ( - result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.exitCode}` - ); -} - -export function buildRecordStopFailure( - message: string, - recording: RecordingStartedAt, - now = Date.now(), -): RecordStopFailure { - const elapsedMs = Math.max(0, now - recording.startedAt); - if (elapsedMs >= MIN_PLAYABLE_RECORDING_DURATION_MS) { - return { message, tooShort: false }; - } - return { - message: `${message}. Recording stopped after ${Math.round(elapsedMs)}ms; wait at least ${MIN_PLAYABLE_RECORDING_DURATION_MS}ms between record start and record stop so the recorder can finalize a playable MP4`, - tooShort: true, - }; -} diff --git a/src/platform-runtime-screen-recording-apple-runner-transport.test.ts b/src/platform-runtime-screen-recording-apple-runner-transport.test.ts index 4e840231f..e37abd2c7 100644 --- a/src/platform-runtime-screen-recording-apple-runner-transport.test.ts +++ b/src/platform-runtime-screen-recording-apple-runner-transport.test.ts @@ -1,4 +1,4 @@ -import { expect, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import { resolveAppleRunnerScreenRecordingTransport, withAppleRunnerScreenRecordingTransport, @@ -24,6 +24,10 @@ const device = { booted: true, }; +beforeEach(() => { + vi.clearAllMocks(); +}); + test('scopes an unavailable runner authority instead of falling back to a local lease', async () => { await withAppleRunnerScreenRecordingTransport(undefined, async () => { const transport = resolveAppleRunnerScreenRecordingTransport(); @@ -51,3 +55,45 @@ test('passes the recorded session identity into the runner stop dispatch boundar { signal: undefined, expectedRunnerSessionId: 'runner-session-1' }, ); }); + +test('cancellation after runner acquisition stops only the acquired session', async () => { + const controller = new AbortController(); + const reason = new Error('cancel after runner acquisition'); + runner.run.mockResolvedValue({}); + runner.snapshot.mockImplementation(() => { + controller.abort(reason); + return { sessionId: 'runner-session-2', alive: true }; + }); + const transport = resolveAppleRunnerScreenRecordingTransport(); + + await expect( + transport.start({ + device, + appBundleId: 'com.example.app', + outputPath: '/tmp/capture.mp4', + signal: controller.signal, + }), + ).rejects.toBe(reason); + + expect(runner.run).toHaveBeenLastCalledWith( + device, + { command: 'recordStop', appBundleId: 'com.example.app' }, + { expectedRunnerSessionId: 'runner-session-2' }, + ); +}); + +test('does not issue an unowned stop when runner acquisition exposes no session identity', async () => { + runner.run.mockResolvedValue({}); + runner.snapshot.mockReturnValue(undefined); + const transport = resolveAppleRunnerScreenRecordingTransport(); + + await expect( + transport.start({ + device, + appBundleId: 'com.example.app', + outputPath: '/tmp/capture.mp4', + }), + ).rejects.toThrow('did not expose a durable runner session identity'); + + expect(runner.run).toHaveBeenCalledOnce(); +}); diff --git a/src/platform-runtime-screen-recording-apple-runner-transport.ts b/src/platform-runtime-screen-recording-apple-runner-transport.ts index 46321cbfd..631ed1139 100644 --- a/src/platform-runtime-screen-recording-apple-runner-transport.ts +++ b/src/platform-runtime-screen-recording-apple-runner-transport.ts @@ -75,13 +75,16 @@ async function startLocalAppleRunnerRecording({ ); const session = getRunnerSessionSnapshot(device.id); if (!session?.alive) { - await stopAcquiredRunner(device, appBundleId); throw new Error('Apple runner recording did not expose a durable runner session identity'); } try { signal?.throwIfAborted(); } catch (error) { - await stopAcquiredRunner(device, appBundleId); + await runAppleRunnerCommand( + device, + { command: 'recordStop', appBundleId }, + { expectedRunnerSessionId: session.sessionId }, + ).catch(() => {}); throw error; } return freezeRunnerStartResult(session.sessionId, remotePath, result); @@ -114,11 +117,6 @@ function freezeRunnerStartResult( }); } -async function stopAcquiredRunner(device: DeviceInfo, appBundleId: string): Promise { - const { runAppleRunnerCommand } = await import('./platforms/apple/core/runner/runner-client.ts'); - await runAppleRunnerCommand(device, { command: 'recordStop', appBundleId }, {}).catch(() => {}); -} - const unavailableScopedTransport: AppleRunnerScreenRecordingTransport = Object.freeze({ authority: 'scoped-provider', available: false, diff --git a/src/platforms/apple/core/physical-device-console.ts b/src/platforms/apple/core/physical-device-console.ts deleted file mode 100644 index c29c37677..000000000 --- a/src/platforms/apple/core/physical-device-console.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ExecResult } from '../../../utils/exec.ts'; -import { runXcrun } from './tool-provider.ts'; - -export type CoreDeviceConsoleCaptureSupport = - | { supported: true; stderr?: string } - | { supported: false; reason: 'unsupported' | 'probe-failed'; stderr?: string }; - -let cachedConsoleCaptureSupport: CoreDeviceConsoleCaptureSupport | undefined; - -export function buildCoreDeviceConsoleLaunchArgs(deviceId: string, appBundleId: string): string[] { - return [ - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - '--console', - '--terminate-existing', - appBundleId, - ]; -} - -export async function checkCoreDeviceAvailable(): Promise { - const result = await runXcrun(['devicectl', '--version'], { allowFailure: true }); - return result.exitCode === 0; -} - -export async function checkCoreDeviceConsoleCaptureSupport(): Promise { - if (cachedConsoleCaptureSupport) return cachedConsoleCaptureSupport; - try { - const result = await runXcrun(['devicectl', 'device', 'process', 'launch', '--help'], { - allowFailure: true, - timeoutMs: 5_000, - }); - const support = readCoreDeviceConsoleCaptureSupport(result); - if (support.supported) cachedConsoleCaptureSupport = support; - return support; - } catch (error) { - return { - supported: false, - reason: 'probe-failed', - stderr: error instanceof Error ? error.message : undefined, - }; - } -} - -function readCoreDeviceConsoleCaptureSupport(result: ExecResult): CoreDeviceConsoleCaptureSupport { - const stderr = result.stderr.trim() || undefined; - if (result.exitCode !== 0) { - return { supported: false, reason: 'probe-failed', stderr }; - } - const help = `${result.stdout}\n${result.stderr}`; - const supported = - /\bUSAGE:\s+devicectl device process launch\b/i.test(help) && - /--console\b/.test(help) && - /--terminate-existing\b/.test(help); - return supported - ? { supported: true, stderr } - : { supported: false, reason: 'unsupported', stderr }; -} diff --git a/test/integration/interaction-contract/daemon-harness.ts b/test/integration/interaction-contract/daemon-harness.ts index 99487ac0f..002ce1d2b 100644 --- a/test/integration/interaction-contract/daemon-harness.ts +++ b/test/integration/interaction-contract/daemon-harness.ts @@ -8,7 +8,7 @@ import { import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from '../provider-scenarios/providers.ts'; import { createProviderTranscript, @@ -35,7 +35,7 @@ export async function withIosContractDaemon( const transcript = createProviderTranscript(entries); const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner'); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: CONTRACT_DEVICE_ID }, ]), }); diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 0c3bafe99..d1bc4bd95 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -16,6 +16,7 @@ import { import { runCmd, runCmdBackground } from '../../../src/utils/exec.ts'; import { validPng } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { unexpectedProviderCall } from './providers.ts'; import { createProviderScenarioHarness, restoreEnv, @@ -95,14 +96,21 @@ export async function createAndroidSettingsWorld(options?: { updateAndroidProviderShellState(args, shellState); const stateResult = updateAndroidProviderAppState(args, appState); if (stateResult) return stateResult; + const mutationResult = androidDeviceMutationAdbResult(args); + if (mutationResult) return mutationResult; const heapResult = androidHeapDumpAdbResult(args); if (heapResult) return heapResult; - return androidAdbResult(args, shellState.searchText, shellState.clipboardText, { - snapshotXml: options?.snapshotXml, - dumpsysWindow: - options?.dumpsysWindow ?? (() => androidForegroundWindowDump(appState.foreground)), - pidof: (packageName) => androidPidofResult(appState, packageName), - }); + return respondToAndroidSettingsAdbCommand( + args, + shellState.searchText, + shellState.clipboardText, + { + snapshotXml: options?.snapshotXml, + dumpsysWindow: + options?.dumpsysWindow ?? (() => androidForegroundWindowDump(appState.foreground)), + pidof: (packageName) => androidPidofResult(appState, packageName), + }, + ); }, touch: async (request) => { touchInjectionCalls.push({ ...request }); @@ -205,7 +213,7 @@ async function createAndroidManifestApk( return apkPath; } -function androidAdbResult( +export function respondToAndroidSettingsAdbCommand( args: string[], searchText: string, clipboardText: string, @@ -216,16 +224,13 @@ function androidAdbResult( }, ): { stdout: string; stderr: string; exitCode: number; stdoutBuffer?: Buffer } { const key = args.join(' '); - return ( - androidDeviceStateAdbResult(key, args, clipboardText, options.pidof) ?? + const result = + androidDeviceAvailabilityAdbResult(key, args, options.pidof) ?? + androidClipboardAdbResult(key, clipboardText) ?? androidMetricsAdbResult(key) ?? androidPackageAdbResult(key, args, options.dumpsysWindow) ?? - androidCaptureAdbResult(key, searchText, options.snapshotXml) ?? { - stdout: '', - stderr: '', - exitCode: 0, - } - ); + androidCaptureAdbResult(key, searchText, options.snapshotXml); + return result ?? unexpectedProviderCall('Android', args); } type AndroidAdbResult = { @@ -271,17 +276,16 @@ function unquoteAndroidShellArg(value: string): string { return value.slice(1, -1).replaceAll("'\\''", "'"); } -function androidDeviceStateAdbResult( +function androidDeviceAvailabilityAdbResult( key: string, args: string[], - clipboardText: string, pidof?: (packageName: string) => AndroidAdbResult | undefined, ): AndroidAdbResult | undefined { if (key === 'shell getprop sys.boot_completed') { return { stdout: '1\n', stderr: '', exitCode: 0 }; } - if (key === 'shell cmd clipboard get text') { - return { stdout: `clipboard text: ${clipboardText}\n`, stderr: '', exitCode: 0 }; + if (key === 'emu kill') { + return { stdout: '', stderr: '', exitCode: 0 }; } if (key === 'shell dumpsys input_method') { return { stdout: 'mInputShown=false inputType=0x1\n', stderr: '', exitCode: 0 }; @@ -292,6 +296,16 @@ function androidDeviceStateAdbResult( return undefined; } +function androidClipboardAdbResult( + key: string, + clipboardText: string, +): AndroidAdbResult | undefined { + if (key === 'shell cmd clipboard get text') { + return { stdout: `clipboard text: ${clipboardText}\n`, stderr: '', exitCode: 0 }; + } + return undefined; +} + type AndroidProviderAppState = { foreground: string | null; runningPackages: Set; @@ -325,21 +339,157 @@ function stopAndroidProviderApp( return { stdout: '', stderr: '', exitCode: 0 }; } -function startAndroidProviderApp(args: string[], state: AndroidProviderAppState): undefined { +function startAndroidProviderApp( + args: string[], + state: AndroidProviderAppState, +): AndroidAdbResult | undefined { if (args[2] !== 'start' && args[2] !== 'start-activity') return undefined; const componentIndex = args.indexOf('-n'); const component = componentIndex >= 0 ? args[componentIndex + 1] : undefined; if (component) { foregroundAndroidComponent(state, component); - return undefined; + return { stdout: '', stderr: '', exitCode: 0 }; } if (args.includes('android.settings.SETTINGS')) { foregroundAndroidComponent(state, 'com.android.settings/.Settings'); } + return { stdout: '', stderr: '', exitCode: 0 }; +} + +function androidDeviceMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + return ( + androidAppMutationAdbResult(args) ?? + androidInputMutationAdbResult(args) ?? + androidScreenshotDemoAdbResult(args) ?? + androidSettingsMutationAdbResult(args) + ); +} + +function androidAppMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + if (args[0] === 'uninstall' && args.length === 2) { + return { stdout: 'Success\n', stderr: '', exitCode: 0 }; + } + if ( + args[0] === 'shell' && + args[1] === 'am' && + args[2] === 'broadcast' && + args.includes('-a') && + args.includes('-p') + ) { + return { stdout: 'Broadcast completed: result=0\n', stderr: '', exitCode: 0 }; + } return undefined; } +function androidInputMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + return ( + androidShellInputAdbResult(args) ?? + androidClipboardMutationAdbResult(args) ?? + androidDoctorProbeAdbResult(args) + ); +} + +function androidShellInputAdbResult(args: string[]): AndroidAdbResult | undefined { + if ( + args[0] === 'shell' && + args[1] === 'input' && + (args[2] === 'text' || args[2] === 'keyevent' || args[2] === 'tap' || args[2] === 'swipe') + ) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidClipboardMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + if (argsStartWith(args, ANDROID_CLIPBOARD_SET_TEXT_PREFIX)) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidDoctorProbeAdbResult(args: string[]): AndroidAdbResult | undefined { + if (args.length === 3 && argsStartWith(args, ['shell', 'echo', 'ok'])) { + return { stdout: 'ok\n', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidScreenshotDemoAdbResult(args: string[]): AndroidAdbResult | undefined { + if (args.length === 2 && ANDROID_SCREENSHOT_DEMO_SHELL_COMMANDS.has(args[1] ?? '')) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidSettingsMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + return ( + androidAppearanceMutationAdbResult(args) ?? + androidLocationMutationAdbResult(args) ?? + androidFingerprintMutationAdbResult(args) ?? + androidPermissionMutationAdbResult(args) ?? + androidSettingsPutAdbResult(args) + ); +} + +function androidAppearanceMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + if ( + args.length === 5 && + argsStartWith(args, ['shell', 'cmd', 'uimode', 'night']) && + (args[4] === 'yes' || args[4] === 'no') + ) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidLocationMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + if (args.length === 5 && argsStartWith(args, ['emu', 'geo', 'fix'])) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidFingerprintMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + if ( + args.length === 5 && + argsStartWith(args, ['shell', 'cmd', 'fingerprint']) && + (args[3] === 'touch' || args[3] === 'finger') + ) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidPermissionMutationAdbResult(args: string[]): AndroidAdbResult | undefined { + if ( + args.length === 5 && + argsStartWith(args, ['shell', 'pm']) && + (args[2] === 'grant' || args[2] === 'revoke') + ) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +function androidSettingsPutAdbResult(args: string[]): AndroidAdbResult | undefined { + if ( + args.length === 6 && + argsStartWith(args, ['shell', 'settings', 'put']) && + (args[3] === 'global' || args[3] === 'secure' || args[3] === 'system') + ) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return undefined; +} + +const ANDROID_SCREENSHOT_DEMO_SHELL_COMMANDS = new Set([ + 'settings put global sysui_demo_allowed 1', + 'am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941', + 'am broadcast -a com.android.systemui.demo -e command notifications -e visible false', + 'am broadcast -a com.android.systemui.demo -e command exit', +]); + function foregroundAndroidComponent(state: AndroidProviderAppState, component: string): void { const packageName = component.split('/')[0]; if (!packageName) return; @@ -376,6 +526,9 @@ function androidMetricsAdbResult(key: string): AndroidAdbResult | undefined { exitCode: 0, }; } + if (key.endsWith(' reset') && key.startsWith('shell dumpsys gfxinfo ')) { + return { stdout: '', stderr: '', exitCode: 0 }; + } if (key === 'shell dumpsys meminfo com.example.demo') { return { stdout: [ @@ -411,6 +564,25 @@ function androidPackageAdbResult( args: string[], dumpsysWindow?: () => string, ): AndroidAdbResult | undefined { + return ( + androidSnapshotHelperProbeAdbResult(key) ?? + androidLaunchablePackagesAdbResult(args) ?? + androidInstalledPackagesAdbResult(key) ?? + androidForegroundReadAdbResult(key, dumpsysWindow) + ); +} + +function androidSnapshotHelperProbeAdbResult(key: string): AndroidAdbResult | undefined { + if ( + key === + 'shell cmd package list packages --show-versioncode com.callstack.agentdevice.snapshothelper' + ) { + return { stdout: '', stderr: '', exitCode: 1 }; + } + return undefined; +} + +function androidLaunchablePackagesAdbResult(args: string[]): AndroidAdbResult | undefined { if ( args.slice(0, 7).join(' ') === 'shell cmd package query-activities --brief -a android.intent.action.MAIN' @@ -421,6 +593,10 @@ function androidPackageAdbResult( exitCode: 0, }; } + return undefined; +} + +function androidInstalledPackagesAdbResult(key: string): AndroidAdbResult | undefined { if (key === 'shell pm list packages -3') { return { stdout: 'package:com.example.demo\npackage:com.example.serviceonly\n', @@ -428,7 +604,19 @@ function androidPackageAdbResult( exitCode: 0, }; } - if (key === 'shell dumpsys window windows' || key === 'shell dumpsys window') { + return undefined; +} + +function androidForegroundReadAdbResult( + key: string, + dumpsysWindow?: () => string, +): AndroidAdbResult | undefined { + if ( + key === 'shell dumpsys window windows' || + key === 'shell dumpsys window' || + key === 'shell dumpsys activity activities' || + key === 'shell dumpsys activity' + ) { return { stdout: dumpsysWindow?.() ?? 'mCurrentFocus=Window{42 u0 com.android.settings/.Settings}\n', stderr: '', diff --git a/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts b/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts index 08815c525..3e1e45663 100644 --- a/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts +++ b/test/integration/provider-scenarios/interaction-direct-selector-fallback.test.ts @@ -12,7 +12,7 @@ import { import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, @@ -111,7 +111,7 @@ async function withDirectSelectorScenario( ): Promise { const appleRunnerProvider = createAppleRunnerProviderFromTranscript(transcript, 'ios.runner'); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); diff --git a/test/integration/provider-scenarios/interaction-verify.test.ts b/test/integration/provider-scenarios/interaction-verify.test.ts index 78fa9c995..788c7db78 100644 --- a/test/integration/provider-scenarios/interaction-verify.test.ts +++ b/test/integration/provider-scenarios/interaction-verify.test.ts @@ -6,7 +6,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; @@ -73,7 +73,7 @@ test('Provider-backed integration press --verify returns post-action evidence di 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index 51851ebd8..c5043198a 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -18,7 +18,7 @@ import { import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript } from './transcript.ts'; @@ -71,7 +71,7 @@ test('Provider-backed integration iOS simulator recording flow uses the focused 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: 'iPhone 15', udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id }, ]), }); diff --git a/test/integration/provider-scenarios/provider-command-dispatch.test.ts b/test/integration/provider-scenarios/provider-command-dispatch.test.ts new file mode 100644 index 000000000..f5e95ad32 --- /dev/null +++ b/test/integration/provider-scenarios/provider-command-dispatch.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; +import { respondToAndroidSettingsAdbCommand } from './android-world.ts'; +import { simctlDeviceLifecycleHandler } from './providers.ts'; + +test('simulator inventory fixture rejects unmodeled simctl commands', async () => { + const run = simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', []); + + await expect(run(['delete', 'unexpected-device'], {})).rejects.toThrow( + 'Unscripted Apple provider call: simctl delete unexpected-device', + ); +}); + +test('Android settings fixture rejects unmodeled adb commands', () => { + expect(() => + respondToAndroidSettingsAdbCommand( + ['shell', 'echo', 'unexpected-provider-command'], + '', + '', + {}, + ), + ).toThrow('Unscripted Android provider call: shell echo unexpected-provider-command'); +}); diff --git a/test/integration/provider-scenarios/providers.ts b/test/integration/provider-scenarios/providers.ts index 92d682fb1..e6ed5bf8e 100644 --- a/test/integration/provider-scenarios/providers.ts +++ b/test/integration/provider-scenarios/providers.ts @@ -218,17 +218,30 @@ function simctlListDevicesJson( }; } -export function simctlListDevicesHandler( +export function simctlDeviceLifecycleHandler( runtime: string, devices: Array<{ name: string; udid: string; state?: string; isAvailable?: boolean }>, ): AppleToolSubcommandExecutor { return async (args) => { - return ( - simctlListDevicesResult(args, runtime, devices) ?? { stdout: '', stderr: '', exitCode: 0 } - ); + const result = simctlListDevicesResult(args, runtime, devices); + if (result) return result; + if (isModeledSimulatorLifecycleCommand(args)) { + return { stdout: '', stderr: '', exitCode: 0 }; + } + return unexpectedProviderCall('Apple', ['simctl', ...args]); }; } +function isModeledSimulatorLifecycleCommand(args: readonly string[]): boolean { + if (args[0] === 'boot' || args[0] === 'shutdown') return args.length === 2; + if (args[0] === 'launch' || args[0] === 'terminate') return args.length === 3; + return false; +} + +export function unexpectedProviderCall(platform: string, command: readonly string[]): never { + throw new Error(`Unscripted ${platform} provider call: ${command.join(' ')}`); +} + export function simctlListDevicesResult( args: string[], runtime: string, diff --git a/test/integration/provider-scenarios/settle-observation.test.ts b/test/integration/provider-scenarios/settle-observation.test.ts index 7adeeb6b0..d2a52aa2f 100644 --- a/test/integration/provider-scenarios/settle-observation.test.ts +++ b/test/integration/provider-scenarios/settle-observation.test.ts @@ -6,7 +6,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; @@ -188,7 +188,7 @@ test('Provider-backed integration press --settle returns the settled diff and fr 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); @@ -307,7 +307,7 @@ test('Provider-backed integration never-settled press --settle does not issue di 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); @@ -372,7 +372,7 @@ test('Provider-backed integration modal-dismiss press --settle attaches the unch 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); @@ -704,7 +704,7 @@ test('Provider-backed integration fill --settle summoning the keyboard still att 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); diff --git a/test/integration/provider-scenarios/stale-ref-warning.test.ts b/test/integration/provider-scenarios/stale-ref-warning.test.ts index 8aed00e08..ae19cd807 100644 --- a/test/integration/provider-scenarios/stale-ref-warning.test.ts +++ b/test/integration/provider-scenarios/stale-ref-warning.test.ts @@ -6,7 +6,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; @@ -98,7 +98,7 @@ test('Provider-backed integration iOS @refs reject after a selector press replac 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); @@ -161,7 +161,7 @@ test('Provider-backed iOS press rejects a stale ref after navigation', async () 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), }); diff --git a/test/integration/provider-scenarios/tvos-remote.test.ts b/test/integration/provider-scenarios/tvos-remote.test.ts index 0f1d83257..36df03621 100644 --- a/test/integration/provider-scenarios/tvos-remote.test.ts +++ b/test/integration/provider-scenarios/tvos-remote.test.ts @@ -6,7 +6,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript } from './transcript.ts'; @@ -51,7 +51,7 @@ test('Provider-backed integration tvOS remote flow maps navigation commands to r 'tvos.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.tvOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.tvOS-18-0', [ { name: 'Apple TV', udid: 'tv-sim-1' }, ]), }); diff --git a/test/integration/provider-scenarios/versioned-refs.test.ts b/test/integration/provider-scenarios/versioned-refs.test.ts index 4400941f7..27a9d9f9b 100644 --- a/test/integration/provider-scenarios/versioned-refs.test.ts +++ b/test/integration/provider-scenarios/versioned-refs.test.ts @@ -6,7 +6,7 @@ import { createProviderScenarioHarness, withProviderScenarioResource } from './h import { createAppleRunnerProviderFromTranscript, createRecordingAppleToolProvider, - simctlListDevicesHandler, + simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; @@ -83,7 +83,7 @@ test('Provider-backed integration rejects stale pinned @refs and accepts current 'ios.runner', ); const appleTool = createRecordingAppleToolProvider({ - simctl: simctlListDevicesHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, ]), });