refactor: tighten capture ownership boundaries (#1736)

This commit is contained in:
Michał Pierzchała
2026-08-11 13:55:28 +02:00
committed by GitHub
parent 167f93ba8c
commit b8dd6a5854
18 changed files with 397 additions and 188 deletions
@@ -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);
@@ -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<string | undefined> {
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<boolean>): Promise<boolean> {
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,
-29
View File
@@ -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);
}
}
-37
View File
@@ -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,
};
}
@@ -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();
});
@@ -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<void> {
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,
@@ -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<boolean> {
const result = await runXcrun(['devicectl', '--version'], { allowFailure: true });
return result.exitCode === 0;
}
export async function checkCoreDeviceConsoleCaptureSupport(): Promise<CoreDeviceConsoleCaptureSupport> {
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 };
}
@@ -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 },
]),
});
@@ -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<string>;
@@ -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: '',
@@ -12,7 +12,7 @@ import {
import {
createAppleRunnerProviderFromTranscript,
createRecordingAppleToolProvider,
simctlListDevicesHandler,
simctlDeviceLifecycleHandler,
} from './providers.ts';
import {
createProviderTranscript,
@@ -111,7 +111,7 @@ async function withDirectSelectorScenario(
): Promise<void> {
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 },
]),
});
@@ -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 },
]),
});
@@ -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 },
]),
});
@@ -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');
});
@@ -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,
@@ -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 },
]),
});
@@ -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 },
]),
});
@@ -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' },
]),
});
@@ -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 },
]),
});