mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
refactor: tighten capture ownership boundaries (#1736)
This commit is contained in:
committed by
GitHub
parent
167f93ba8c
commit
b8dd6a5854
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user