diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index f13cb2347d..98504a649b 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -124,8 +124,39 @@ export type OpenApplicationTiming = Readonly<{ openDispatchDurationMs?: number; launchUrlDurationMs?: number; postOpenSettleDurationMs?: number; - /** What a Simulator open learned about the launched app before returning (see the Apple owner). */ - postOpenObservation?: 'observable' | 'unobservable' | 'not-eligible'; + /** Unset when the open had no launched app to observe, such as a URL or deep-link target. */ + postOpenObservation?: PostOpenObservation; + /** Why the observation could not run; present exactly when it is `probe-failed`. */ + postOpenObservationFailure?: PostOpenObservationFailure; +}>; + +/** + * What an app open learned about the launched app before it returned. A local iOS Simulator asks + * its host AX bridge; a local Android device captures the app through the snapshot helper. Each + * owner bounds the observation, and the open succeeds whatever the value is. + * + * - `observable`: the launched app's tree was readable. + * - `unobservable`: the app stayed unreadable within the owner's bounded window: a launch transition + * or AX-server state that did not clear, a system surface over the app, a content verdict after + * the capture's own re-captures, or the window ran out. + * - `probe-failed`: the observation could not run (Android: the helper is not installed at the + * current version, or adb or the accessibility service failed). `postOpenObservationFailure` + * carries the typed failure. + * - `app-unidentified`: the open targeted an app, but the owner could not read which package it + * launched, so nothing was observed. + * - `not-eligible`: the device has no observation path. + */ +export type PostOpenObservation = + | 'observable' + | 'unobservable' + | 'probe-failed' + | 'app-unidentified' + | 'not-eligible'; + +/** The typed failure of a `probe-failed` observation: the error code and its typed reason. */ +export type PostOpenObservationFailure = Readonly<{ + code: string; + reason?: string; }>; export type OpenApplicationOutcome = Readonly<{ diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index d6905eee8f..15670d675b 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -175,6 +175,12 @@ export type SnapshotOptions = BaseSnapshotOptions & { surface?: SessionSurface; /** Internal capture purpose; action outcomes always require the full tree. */ acquisitionIntent?: 'full' | 'surface-observation'; + /** + * A one-off read, such as an open's launch observation. It may use a capture host the session + * already keeps warm, but it never installs one or leaves running one that it started. It starts + * no re-capture after `settleBy` (epoch ms); only `signal` cancels work already started. + */ + transient?: Readonly<{ settleBy: number }>; }; /** diff --git a/packages/platform-android/src/__tests__/snapshot-helper-install.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-install.test.ts index 2f7a66405f..d3f31e17a9 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-install.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-install.test.ts @@ -86,6 +86,31 @@ test('an exec-shaped helper install timeout names the dialog, not a wedged adb s ); }); +test('a current-only helper check refuses a missing helper without installing it', async () => { + const apkPath = await writeHelperApk('snapshot-helper-current-only-'); + let installs = 0; + const adbProvider = missingPackageProvider(async () => { + installs += 1; + return { exitCode: 0, stdout: 'Success', stderr: '' }; + }); + + await assert.rejects( + () => + ensureAndroidSnapshotHelper({ + adb: adbProvider.exec, + adbProvider, + artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } }, + deviceKey: 'android:emulator-5554', + installPolicy: 'current-only', + }), + (error) => { + assert.equal((error as AppError).details?.reason, 'android-snapshot-helper-not-current'); + return true; + }, + ); + assert.equal(installs, 0); +}); + function adbInstallTimeout(): AppError { // An unattended first install on ColorOS: adb blocks on the system install-confirmation dialog // and the exec layer kills the command, so stdout/stderr stay empty. diff --git a/packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts b/packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts index ac00d736f8..181f5846e2 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts @@ -62,6 +62,10 @@ export type PersistentSnapshotHelperProviderOptions = { oneShotXml?: string; /** Make the device-side stop fail the way an unhealthy transport answers. */ runtimeStopFailure?: boolean; + /** How long a started session takes to report ready. */ + sessionReadyDelayMs?: number; + /** How long a session takes to answer its `snapshotCount`th capture. */ + captureResponseDelayMs?: (snapshotCount: number) => number; }; export function createPersistentSnapshotHelperProvider( @@ -104,36 +108,40 @@ export function createPersistentSnapshotHelperProvider( const body = options.sessionXml ? options.sessionXml(sessionIndex, snapshotCount) : ``; - socket.end( - sessionResponse({ - requestId, - body, - metadata: { - waitForIdleTimeoutMs: '500', - waitForIdleQuietMs: '100', - timeoutMs: '5000', - maxDepth: '128', - maxNodes: '5000', - rootPresent: 'true', - captureMode: 'interactive-windows', - windowCount: '1', - nodeCount: '1', - truncated: 'false', - elapsedMs: '8', - }, - }), - ); + const answer = () => + socket.end( + sessionResponse({ + requestId, + body, + metadata: { + waitForIdleTimeoutMs: '500', + waitForIdleQuietMs: '100', + timeoutMs: '5000', + maxDepth: '128', + maxNodes: '5000', + rootPresent: 'true', + captureMode: 'interactive-windows', + windowCount: '1', + nodeCount: '1', + truncated: 'false', + elapsedMs: '8', + }, + }), + ); + setTimeout(answer, options.captureResponseDelayMs?.(snapshotCount) ?? 0); }); }); server.listen(port, '127.0.0.1', () => { - process.stdout.write( - [ - 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', - 'INSTRUMENTATION_STATUS: sessionReady=true', - 'INSTRUMENTATION_STATUS_CODE: 2', - '', - ].join('\n'), - ); + setTimeout(() => { + process.stdout.write( + [ + 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_STATUS: sessionReady=true', + 'INSTRUMENTATION_STATUS_CODE: 2', + '', + ].join('\n'), + ); + }, options.sessionReadyDelayMs ?? 0); }); process.onKill = () => { server.close(() => process.emitExit(0, null)); @@ -513,3 +521,16 @@ function readSessionPort(args: readonly string[]): number { assert.notEqual(index, -1); return Number(args[index + 1]); } + +/** A capture holding only system-UI chrome: the helper answered, but no app content. */ +export function androidSystemWindowOnlyXml(): string { + return [ + '', + '', + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); +} diff --git a/packages/platform-android/src/__tests__/snapshot-transient-capture.test.ts b/packages/platform-android/src/__tests__/snapshot-transient-capture.test.ts new file mode 100644 index 0000000000..b76e1d8f5e --- /dev/null +++ b/packages/platform-android/src/__tests__/snapshot-transient-capture.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; + +vi.mock('../adb.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sleep: vi.fn(async () => {}) }; +}); + +import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppError } from '@agent-device/kernel/errors'; +import { snapshotAndroid } from '../snapshot.ts'; +import { resetAndroidSnapshotHelperInstallCache } from '../snapshot-helper-install.ts'; +import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; +import type { AndroidAdbExecutor } from '../snapshot-helper.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; +import { + androidSystemWindowOnlyXml, + createPersistentSnapshotHelperProvider, + isAndroidHelperRuntimeForceStop as isHelperRuntimeReset, + type FakeAndroidProcess, +} from './snapshot-helper-session.fixtures.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +const helperArtifact = ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT; + +beforeEach(async () => { + await resetAndroidSnapshotHelperSessions(); + resetAndroidSnapshotHelperInstallCache(); +}); + +afterEach(async () => { + await resetAndroidSnapshotHelperSessions(); +}); + +function openSettleWindow(): { settleBy: number } { + return { settleBy: Date.now() + 60_000 }; +} + +function isHelperVersionProbe(args: readonly string[]): boolean { + return args.includes('--show-versioncode'); +} + +test('a transient capture releases a helper session it had to start', async () => { + const adbCalls: (readonly string[])[] = []; + const spawnArgs: (readonly string[])[] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs, + processes, + }); + + await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + transient: openSettleWindow(), + }); + + assert.equal(spawnArgs.length, 1); + assert.equal(processes[0]?.exitCode, 0); + assert.equal( + adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'), + true, + ); +}); + +test('a transient capture leaves a warm daemon-session helper running', async () => { + const adbCalls: (readonly string[])[] = []; + const spawnArgs: (readonly string[])[] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs, + processes, + }); + + await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + helperSessionScope: 'daemon-session', + }); + const borrowed = await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + transient: openSettleWindow(), + }); + + assert.equal(borrowed.androidSnapshot.helperSessionReused, true); + assert.equal(spawnArgs.length, 1); + assert.equal(processes[0]?.exitCode, null); + assert.equal( + adbCalls.some((args) => args[0] === 'forward' && args[1] === '--remove'), + false, + ); +}); + +test('a transient capture reports its content verdict without retiring the warm helper', async () => { + const adbCalls: (readonly string[])[] = []; + const spawnArgs: (readonly string[])[] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs, + processes, + sessionXml: (_sessionIndex, snapshotCount) => + snapshotCount === 1 + ? '' + : androidSystemWindowOnlyXml(), + }); + await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + helperSessionScope: 'daemon-session', + }); + + await assert.rejects( + snapshotAndroid(device, { helperAdb: provider, helperArtifact, transient: openSettleWindow() }), + (error: unknown) => isUnreadableCaptureContentError(error), + ); + + assert.equal(processes[0]?.exitCode, null, 'the session helper is still running'); + assert.equal(adbCalls.some(isHelperRuntimeReset), false); + assert.equal(spawnArgs.length, 1); +}); + +test('a settle window that passes during the helper start never cancels the start', async () => { + const adbCalls: (readonly string[])[] = []; + const spawnArgs: (readonly string[])[] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs, + processes, + sessionXml: () => androidSystemWindowOnlyXml(), + sessionReadyDelayMs: 150, + }); + + await assert.rejects( + snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + transient: { settleBy: Date.now() + 30 }, + }), + (error: unknown) => + isUnreadableCaptureContentError(error) && (error as AppError).details?.attempts === 1, + ); + + assert.equal(spawnArgs.length, 1); + assert.equal(processes[0]?.killed, false, 'the started helper was not signalled'); + assert.equal(processes[0]?.exitCode, 0, 'the started helper quit on its own'); + assert.equal(adbCalls.some(isHelperRuntimeReset), false); +}); + +test('a settle window that passes during a warm session capture leaves the session running', async () => { + const adbCalls: (readonly string[])[] = []; + const spawnArgs: (readonly string[])[] = []; + const processes: FakeAndroidProcess[] = []; + const provider = createPersistentSnapshotHelperProvider({ + calls: adbCalls, + spawnArgs, + processes, + sessionXml: (_sessionIndex, snapshotCount) => + snapshotCount === 1 + ? '' + : androidSystemWindowOnlyXml(), + captureResponseDelayMs: (snapshotCount) => (snapshotCount === 1 ? 0 : 150), + }); + await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + helperSessionScope: 'daemon-session', + }); + + await assert.rejects( + snapshotAndroid(device, { + helperAdb: provider, + helperArtifact, + transient: { settleBy: Date.now() + 30 }, + }), + (error: unknown) => + isUnreadableCaptureContentError(error) && (error as AppError).details?.attempts === 1, + ); + + assert.equal(spawnArgs.length, 1); + assert.equal(processes[0]?.killed, false); + assert.equal(processes[0]?.exitCode, null, 'the session helper is still running'); + assert.equal(adbCalls.some(isHelperRuntimeReset), false); +}); + +test('a transient capture on a device without the current helper installs and resets nothing', async () => { + const adbCalls: (readonly string[])[] = []; + const helperAdb: AndroidAdbExecutor = async (args) => { + adbCalls.push(args); + if (isHelperVersionProbe(args)) return { exitCode: 1, stdout: '', stderr: 'not found' }; + return { exitCode: 0, stdout: '', stderr: '' }; + }; + + await assert.rejects( + snapshotAndroid(device, { helperAdb, helperArtifact, transient: openSettleWindow() }), + (error: unknown) => + (error as AppError).details?.reason === 'android-snapshot-helper-not-current', + ); + + assert.equal( + adbCalls.some((args) => args.includes('install') || args.includes('instrument')), + false, + ); + assert.equal(adbCalls.some(isHelperRuntimeReset), false, 'a refusal is not a helper failure'); +}); diff --git a/packages/platform-android/src/__tests__/snapshot.test.ts b/packages/platform-android/src/__tests__/snapshot.test.ts index 2930772704..c1c05f7f07 100644 --- a/packages/platform-android/src/__tests__/snapshot.test.ts +++ b/packages/platform-android/src/__tests__/snapshot.test.ts @@ -34,6 +34,7 @@ import { createPersistentSnapshotHelperProvider, isAndroidHelperRuntimeForceStop as isHelperRuntimeReset, ANDROID_HELPER_INSTALLED_VERSION_PROBE as installedHelperProbe, + androidSystemWindowOnlyXml, type FakeAndroidProcess, } from './snapshot-helper-session.fixtures.ts'; import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts'; @@ -188,18 +189,6 @@ test('screenshotAndroid throws when PNG payload is truncated', async () => { }); }); -function androidSystemWindowOnlyXml(): string { - return [ - '', - '', - ' ', - ' ', - ' ', - ' ', - '', - ].join('\n'); -} - function androidContentPoorFabricAppWindowXml(): string { return [ '', diff --git a/packages/platform-android/src/launch-observation.test.ts b/packages/platform-android/src/launch-observation.test.ts new file mode 100644 index 0000000000..58ae7abc1b --- /dev/null +++ b/packages/platform-android/src/launch-observation.test.ts @@ -0,0 +1,167 @@ +import crypto from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import './__tests__/test-utils/android-host-test-setup.ts'; +import type { Interactor, SnapshotOptions } from '@agent-device/contracts/interactor-types'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { mkdtempForTest } from './__tests__/test-utils/tmp-dir.ts'; +import { ANDROID_LAUNCH_SETTLE_WINDOW_MS, observeAndroidLaunch } from './launch-observation.ts'; +import { androidHelperContentUnavailableError } from './snapshot.ts'; +import { + ensureAndroidSnapshotHelper, + resetAndroidSnapshotHelperInstallCache, +} from './snapshot-helper-install.ts'; + +type ObserveFixture = Readonly<{ + snapshotOptions: SnapshotOptions[]; + observe: (signal?: AbortSignal) => ReturnType; +}>; + +function createObservation( + snapshot: (options: SnapshotOptions) => Promise, +): ObserveFixture { + const snapshotOptions: SnapshotOptions[] = []; + const interactor = { + snapshot: async (options: SnapshotOptions) => { + snapshotOptions.push(options); + return await snapshot(options); + }, + } as unknown as Pick; + return { + snapshotOptions, + observe: async (signal = new AbortController().signal) => + await observeAndroidLaunch(interactor, 'com.example.app', signal), + }; +} + +/** What the capture throws once its re-captures still see an unmounted app. */ +function contentVerdict(): AppError { + return androidHelperContentUnavailableError( + { + reason: 'content-poor-app-window', + failureReason: 'Android snapshot helper returned insufficient foreground app content', + diagnostics: { + helperNodeCount: 3, + helperSystemUiNodeCount: 0, + helperWindowRootCount: 1, + helperApplicationWindowRootCount: 1, + helperMeaningfulNodeCount: 0, + helperApplicationMeaningfulNodeCount: 0, + helperNonSystemMeaningfulNodeCount: 0, + helperInputMethodMeaningfulNodeCount: 0, + helperWindowTypes: [1], + }, + }, + 3, + ); +} + +/** What a transient capture's install step throws on a device without the current helper. */ +async function helperNotCurrentError(): Promise { + resetAndroidSnapshotHelperInstallCache(); + const apkPath = path.join(await mkdtempForTest('launch-observation-helper-'), 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const sha256 = crypto.createHash('sha256').update('helper-apk').digest('hex'); + try { + await ensureAndroidSnapshotHelper({ + adb: async () => ({ exitCode: 1, stdout: '', stderr: 'not found' }), + artifact: { + apkPath, + manifest: { + name: 'android-snapshot-helper', + version: '0.13.3', + apkUrl: null, + sha256, + packageName: 'com.callstack.agentdevice.snapshothelper', + versionCode: 13003, + instrumentationRunner: + 'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation', + minSdk: 23, + targetSdk: 36, + outputFormat: 'uiautomator-xml', + statusProtocol: 'android-snapshot-helper-v1', + }, + }, + deviceKey: 'android:emulator-5554', + installPolicy: 'current-only', + }); + } catch (error) { + return error; + } + throw new Error('a current-only check on a device without the helper must reject'); +} + +test('a readable launched app is observable through one transient capture', async () => { + const observation = createObservation(async () => ({ nodes: [] })); + const startedAt = Date.now(); + + await expect(observation.observe()).resolves.toEqual({ observation: 'observable' }); + expect(observation.snapshotOptions).toHaveLength(1); + const settleBy = observation.snapshotOptions[0]?.transient?.settleBy ?? 0; + expect(settleBy - startedAt).toBeGreaterThanOrEqual(ANDROID_LAUNCH_SETTLE_WINDOW_MS); + expect(settleBy - Date.now()).toBeLessThanOrEqual(ANDROID_LAUNCH_SETTLE_WINDOW_MS); +}); + +test('the capture runs under the caller signal alone, so the window never cancels it', async () => { + const observation = createObservation(async () => ({ nodes: [] })); + const signal = new AbortController().signal; + + await observation.observe(signal); + + expect(observation.snapshotOptions[0]?.signal).toBe(signal); +}); + +test('a content verdict after the capture re-captures is unobservable', async () => { + const observation = createObservation(async () => { + throw contentVerdict(); + }); + + await expect(observation.observe()).resolves.toEqual({ observation: 'unobservable' }); +}); + +test('a system surface covering the launched app is unobservable', async () => { + const observation = createObservation(async () => ({ + nodes: [], + androidSnapshot: { backend: 'android-helper', systemSurfaceOnly: true }, + })); + + await expect(observation.observe()).resolves.toEqual({ observation: 'unobservable' }); +}); + +test('a capture mechanism failure is a failed probe with its typed reason', async () => { + const observation = createObservation(async () => { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed: accessibility timeout', { + androidSnapshotHelperFailureReason: 'Android snapshot helper failed: accessibility timeout', + androidCaptureFailureReason: 'accessibility-timeout', + }); + }); + + await expect(observation.observe()).resolves.toEqual({ + observation: 'probe-failed', + failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' }, + }); +}); + +test('a device without the current helper is a failed probe', async () => { + const notCurrent = await helperNotCurrentError(); + const observation = createObservation(async () => { + throw notCurrent; + }); + + await expect(observation.observe()).resolves.toEqual({ + observation: 'probe-failed', + failure: { code: 'COMMAND_FAILED', reason: 'android-snapshot-helper-not-current' }, + }); +}); + +test('a cancelled open rejects with its cancellation, not an observation', async () => { + const controller = new AbortController(); + const canceled = createRequestCanceledError(); + const observation = createObservation(async () => { + controller.abort(canceled); + throw canceled; + }); + + await expect(observation.observe(controller.signal)).rejects.toBe(canceled); +}); diff --git a/packages/platform-android/src/launch-observation.ts b/packages/platform-android/src/launch-observation.ts new file mode 100644 index 0000000000..1004eac55e --- /dev/null +++ b/packages/platform-android/src/launch-observation.ts @@ -0,0 +1,54 @@ +import type { + PostOpenObservation, + PostOpenObservationFailure, +} from '@agent-device/contracts/application-lifecycle-runtime'; +import { + isUnreadableCaptureContentError, + readAndroidCaptureFailureReason, +} from '@agent-device/contracts/android-snapshot-quality'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { normalizeError } from '@agent-device/kernel/errors'; + +/** + * Longer than the unmounted window measured after `am start -W` on a loaded emulator (up to 5.4 s). + * It only stops further re-captures; helper start and capture keep their own budgets. + */ +export const ANDROID_LAUNCH_SETTLE_WINDOW_MS = 6_000; + +export type AndroidLaunchObservation = + | Readonly<{ observation: Extract }> + | Readonly<{ observation: 'probe-failed'; failure: PostOpenObservationFailure }>; + +/** + * `am start -W` returns at the first frame, which can precede the app's mounted views. This + * captures the launched app transiently and lets the capture's content verdict and re-captures + * decide readiness. Only a content verdict or a system surface over the app reads as + * `unobservable`; any other capture failure is `probe-failed` with its typed reason. Only the + * caller's signal cancels the capture. + */ +export async function observeAndroidLaunch( + interactor: Pick, + appBundleId: string, + signal: AbortSignal, +): Promise { + try { + const capture = await interactor.snapshot({ + appBundleId, + signal, + transient: { settleBy: Date.now() + ANDROID_LAUNCH_SETTLE_WINDOW_MS }, + }); + const systemSurfaceOnly = + 'androidSnapshot' in capture && capture.androidSnapshot?.systemSurfaceOnly === true; + return { observation: systemSurfaceOnly ? 'unobservable' : 'observable' }; + } catch (error) { + signal.throwIfAborted(); + if (isUnreadableCaptureContentError(error)) return { observation: 'unobservable' }; + return { observation: 'probe-failed', failure: typedFailure(error) }; + } +} + +function typedFailure(error: unknown): PostOpenObservationFailure { + const normalized = normalizeError(error); + const reason = readAndroidCaptureFailureReason(normalized) ?? normalized.details?.reason; + return typeof reason === 'string' ? { code: normalized.code, reason } : { code: normalized.code }; +} diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 0245284910..d8c6aca244 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -1,8 +1,12 @@ import { afterEach, expect, test, vi } from 'vitest'; -import type { LocalApplicationInteractorHost } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { + LocalApplicationInteractorHost, + OpenApplicationInput, +} from '@agent-device/contracts/application-lifecycle-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { Interactor, SnapshotOptions } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; const device: DeviceInfo = { @@ -18,17 +22,32 @@ afterEach(() => { vi.restoreAllMocks(); }); -test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => { - const opens: string[] = []; +type LifecycleFixture = Readonly<{ + calls: string[]; + lifecycle: ReturnType; +}>; + +function createLifecycle( + params: Readonly<{ + snapshot?: (options: SnapshotOptions) => Promise; + openedAppBundleId?: string; + signal?: AbortSignal; + }> = {}, +): LifecycleFixture { + const calls: string[] = []; const localInteractors: LocalApplicationInteractorHost = { resolve: async () => ({ open: async (app: string) => { - opens.push(app); + calls.push(`open:${app}`); }, openDevice: async () => {}, close: async () => {}, setSetting: async () => {}, + snapshot: async (options: SnapshotOptions) => { + calls.push(`observe:${options.appBundleId}`); + return await (params.snapshot?.(options) ?? Promise.resolve({ nodes: [] })); + }, }) as unknown as Interactor, }; const host = { @@ -41,7 +60,8 @@ test('preserves a runtime launch URL duration after the admitted Android follow- }, androidApplications: { resolveOpenTarget: async () => ({}), - inferOpenedAppBundleId: async () => 'com.example.app', + inferOpenedAppBundleId: async () => + 'openedAppBundleId' in params ? params.openedAppBundleId : 'com.example.app', resetFramePerfStats: async () => {}, applyRuntimeHints: async () => {}, clearRuntimeHints: async () => {}, @@ -60,21 +80,18 @@ test('preserves a runtime launch URL duration after the admitted Android follow- | 'localInteractors' | 'toolchains' >; - vi.spyOn(Date, 'now') - .mockReturnValueOnce(10) - .mockReturnValueOnce(20) - .mockReturnValueOnce(30) - .mockReturnValueOnce(50); const lifecycle = bindAndroidApplicationLifecycle({ host, device, - signal: new AbortController().signal, + signal: params.signal ?? new AbortController().signal, }); + return { calls, lifecycle }; +} - const outcome = await lifecycle.openApplication({ +function openInput(overrides: Partial = {}): OpenApplicationInput { + return { target: 'com.example.app', positionals: ['com.example.app'], - runtimeLaunchUrl: 'example://after-open', appBundleId: 'com.example.app', surface: 'app', hasExistingSession: false, @@ -84,8 +101,114 @@ test('preserves a runtime launch URL duration after the admitted Android follow- stateDir: '/state', runtimeHints: {}, execution: {}, - }); + ...overrides, + }; +} - expect(opens).toEqual(['com.example.app', 'example://after-open']); +test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => { + const { calls, lifecycle } = createLifecycle(); + vi.spyOn(Date, 'now') + .mockReturnValueOnce(10) + .mockReturnValueOnce(20) + .mockReturnValueOnce(30) + .mockReturnValueOnce(50); + + const outcome = await lifecycle.openApplication( + openInput({ runtimeLaunchUrl: 'example://after-open' }), + ); + + expect(calls.filter((call) => call.startsWith('open:'))).toEqual([ + 'open:com.example.app', + 'open:example://after-open', + ]); expect(outcome.timing.launchUrlDurationMs).toBe(20); }); + +test('an Android app open returns only after the launched app observation settles', async () => { + let finishCapture: () => void = () => {}; + const { calls, lifecycle } = createLifecycle({ + openedAppBundleId: 'com.example.opened', + snapshot: async () => { + await new Promise((resolve) => { + finishCapture = resolve; + }); + return { nodes: [], androidSnapshot: { backend: 'android-helper', systemSurfaceOnly: true } }; + }, + }); + + let settled = false; + const opening = lifecycle.openApplication(openInput()).then((outcome) => { + settled = true; + return outcome; + }); + await vi.waitFor(() => expect(calls).toContain('observe:com.example.opened')); + await Promise.resolve(); + expect(settled).toBe(false); + finishCapture(); + const outcome = await opening; + + expect(calls).toEqual(['open:com.example.app', 'observe:com.example.opened']); + expect(outcome.appBundleId).toBe('com.example.opened'); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(outcome.timing.postOpenObservationFailure).toBeUndefined(); + expect(outcome.timing.postOpenSettleDurationMs).toEqual(expect.any(Number)); +}); + +test('a failed launch probe reports its typed failure and the open still succeeds', async () => { + const { lifecycle } = createLifecycle({ + snapshot: async () => { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed', { + androidCaptureFailureReason: 'accessibility-timeout', + }); + }, + }); + + const outcome = await lifecycle.openApplication(openInput({ relaunch: true })); + + expect(outcome.timing.postOpenObservation).toBe('probe-failed'); + expect(outcome.timing.postOpenObservationFailure).toEqual({ + code: 'COMMAND_FAILED', + reason: 'accessibility-timeout', + }); +}); + +test('a cancelled open rejects with its cancellation', async () => { + const controller = new AbortController(); + const canceled = createRequestCanceledError(); + const { lifecycle } = createLifecycle({ + signal: controller.signal, + snapshot: async (options) => { + expect(options.signal).toBe(controller.signal); + controller.abort(canceled); + throw canceled; + }, + }); + + await expect(lifecycle.openApplication(openInput())).rejects.toBe(canceled); +}); + +test('a URL open has no launched app to observe and leaves the observation unset', async () => { + const { calls, lifecycle } = createLifecycle({ openedAppBundleId: 'com.example.browser' }); + + const outcome = await lifecycle.openApplication( + openInput({ + target: 'https://example.com', + positionals: ['https://example.com'], + appBundleId: undefined, + }), + ); + + expect(calls).toEqual(['open:https://example.com']); + expect(outcome.timing.postOpenObservation).toBeUndefined(); +}); + +test('an app open whose launched package cannot be identified reports it unidentified', async () => { + const { calls, lifecycle } = createLifecycle({ openedAppBundleId: undefined }); + + const outcome = await lifecycle.openApplication( + openInput({ target: 'Example', positionals: ['Example'], appBundleId: undefined }), + ); + + expect(calls).toEqual(['open:Example']); + expect(outcome.timing.postOpenObservation).toBe('app-unidentified'); +}); diff --git a/packages/platform-android/src/lifecycle.ts b/packages/platform-android/src/lifecycle.ts index cab32f52f6..d6db533324 100644 --- a/packages/platform-android/src/lifecycle.ts +++ b/packages/platform-android/src/lifecycle.ts @@ -2,6 +2,7 @@ import { type ApplicationLifecycleRuntimeOperations, type OpenApplicationInput, type OpenApplicationOutcome, + type OpenApplicationTiming, hasRuntimeTransportHintValues, } from '@agent-device/contracts/application-lifecycle-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; @@ -11,6 +12,8 @@ import { invokeApplicationClose, invokeApplicationOpen, } from '@agent-device/contracts/application-lifecycle-interaction'; +import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import { observeAndroidLaunch } from './launch-observation.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; @@ -164,10 +167,30 @@ async function openAndroidApplication( if (appBundleId) { await host.androidApplications.resetFramePerfStats(binding.device, appBundleId); } - timing.postOpenSettleDurationMs = 0; + const settleStartedAtMs = Date.now(); + Object.assign(timing, await observeOpenedApp(binding, input, appBundleId)); + timing.postOpenSettleDurationMs = elapsed(settleStartedAtMs); return { appBundleId, timing }; } +/** A URL or deep-link open has no launched app of its own to observe, so it reports nothing. */ +async function observeOpenedApp( + binding: ReturnType, + input: OpenApplicationInput, + appBundleId: string | undefined, +): Promise> { + if (!input.target || isDeepLinkTarget(input.target)) return {}; + if (!appBundleId) return { postOpenObservation: 'app-unidentified' }; + const launch = await observeAndroidLaunch( + await binding.resolveInteractor(input.execution, appBundleId), + appBundleId, + binding.signal, + ); + return launch.observation === 'probe-failed' + ? { postOpenObservation: 'probe-failed', postOpenObservationFailure: launch.failure } + : { postOpenObservation: launch.observation }; +} + function elapsed(startedAtMs: number): number { return Math.max(0, Date.now() - startedAtMs); } diff --git a/packages/platform-android/src/snapshot-helper-install.ts b/packages/platform-android/src/snapshot-helper-install.ts index b3e7624704..c12df35185 100644 --- a/packages/platform-android/src/snapshot-helper-install.ts +++ b/packages/platform-android/src/snapshot-helper-install.ts @@ -1,4 +1,4 @@ -import { asAppError, type AppError } from '@agent-device/kernel/errors'; +import { AppError, asAppError } from '@agent-device/kernel/errors'; import { inspectInstalledAndroidHelper, installAndroidHelperPackage, @@ -32,6 +32,13 @@ export function forgetAndroidSnapshotHelperInstall(options: { /** * @internal Test isolation hook for process-global snapshot helper install cache. */ +const ANDROID_SNAPSHOT_HELPER_NOT_CURRENT = 'android-snapshot-helper-not-current'; + +/** A `current-only` check found no current helper: nothing ran, so there is nothing to recover. */ +export function isAndroidSnapshotHelperNotCurrentError(error: unknown): boolean { + return asAppError(error).details?.reason === ANDROID_SNAPSHOT_HELPER_NOT_CURRENT; +} + export function resetAndroidSnapshotHelperInstallCache(): void { installedSnapshotHelpers.clear(); } @@ -142,6 +149,18 @@ export async function ensureAndroidSnapshotHelper(options: { reason, }; } + if (installPolicy === 'current-only') { + throw new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper is not installed at the current version', + { + reason: ANDROID_SNAPSHOT_HELPER_NOT_CURRENT, + packageName, + versionCode, + installedVersionCode, + }, + ); + } let result: Awaited>; try { diff --git a/packages/platform-android/src/snapshot-helper-types.ts b/packages/platform-android/src/snapshot-helper-types.ts index cee1b6bf6d..b0581b0b02 100644 --- a/packages/platform-android/src/snapshot-helper-types.ts +++ b/packages/platform-android/src/snapshot-helper-types.ts @@ -45,7 +45,12 @@ export type { AndroidSnapshotHelperManifest, } from './helper-artifacts.ts'; -export type AndroidSnapshotHelperInstallPolicy = 'missing-or-outdated' | 'always' | 'never'; +/** `current-only` uses an installed helper at the artifact's version and refuses to install one. */ +export type AndroidSnapshotHelperInstallPolicy = + | 'missing-or-outdated' + | 'always' + | 'never' + | 'current-only'; export type AndroidSnapshotHelperInstallResult = { packageName: string; diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index 008e30913a..4d25cedbb5 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -6,6 +6,7 @@ import { } from '@agent-device/kernel/errors'; import path from 'node:path'; import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; +import type { SnapshotOptions as InteractorSnapshotOptions } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { attachRefs, @@ -43,6 +44,8 @@ import { type AndroidSnapshotHelperInstallResult, type AndroidSnapshotHelperOutput, } from './snapshot-helper.ts'; +import { getLiveAndroidSnapshotHelperSession } from './snapshot-helper-session-lifecycle.ts'; +import { isAndroidSnapshotHelperNotCurrentError } from './snapshot-helper-install.ts'; import { getAndroidSnapshotHelperSessionDeviceKey, isAndroidSnapshotHelperRuntimeOccupiedError, @@ -87,6 +90,8 @@ export type AndroidSnapshotOptions = SnapshotOptions & { helperArtifact?: AndroidSnapshotHelperArtifact; helperInstallPolicy?: AndroidSnapshotHelperInstallPolicy; helperSessionScope?: AndroidHelperSessionScope; + /** The interactor contract's one-off read: no install, no kept session, no re-capture after `settleBy`. */ + transient?: InteractorSnapshotOptions['transient']; helperAdb?: AndroidAdbExecutor | AndroidAdbProvider; includeHiddenContentHints?: boolean; androidPresentation?: AndroidSnapshotPresentationOptions; @@ -277,7 +282,7 @@ async function captureAndroidUiHierarchyWithHelper( ): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { const helperDeviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb); - const commandScopedHelperSession = options.helperSessionScope !== 'daemon-session'; + const releaseHelperSession = releasesHelperSessionAfterCapture(options, helperDeviceKey); try { let previousContentReason: AndroidContentRecoveryReason | undefined; for (let attempt = 0; ; attempt += 1) { @@ -292,7 +297,10 @@ async function captureAndroidUiHierarchyWithHelper( previousContentReason, }); if (settled.outcome === 'captured') return settled.capture; - if (attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS) { + if ( + attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS || + (options.transient !== undefined && Date.now() >= options.transient.settleBy) + ) { return await rejectAndroidHelperContentUnavailable({ contentRecovery: settled.decision, attempts: attempt + 1, @@ -300,17 +308,36 @@ async function captureAndroidUiHierarchyWithHelper( artifact, adb, signal: options.signal, + retireHelper: options.transient === undefined, }); } previousContentReason = settled.decision.reason; } } finally { - if (commandScopedHelperSession) { + if (releaseHelperSession) { await stopAndroidSnapshotHelperSession(helperDeviceKey); } } } +/** A transient read keeps a session it found running and releases one it had to start. */ +function releasesHelperSessionAfterCapture( + options: AndroidSnapshotOptions, + helperDeviceKey: string, +): boolean { + if (options.transient) return getLiveAndroidSnapshotHelperSession(helperDeviceKey) === undefined; + return options.helperSessionScope !== 'daemon-session'; +} + +/** A transient read never installs the helper; it uses one that is already current. */ +function resolveHelperInstallPolicy( + options: AndroidSnapshotOptions, +): AndroidSnapshotHelperInstallPolicy { + return options.transient + ? 'current-only' + : (options.helperInstallPolicy ?? 'missing-or-outdated'); +} + async function installAndroidSnapshotHelper( options: AndroidSnapshotOptions, adb: AndroidAdbExecutor, @@ -326,14 +353,14 @@ async function installAndroidSnapshotHelper( adbProvider, artifact, deviceKey, - installPolicy: options.helperInstallPolicy, + installPolicy: resolveHelperInstallPolicy(options), timeoutMs: HELPER_INSTALL_TIMEOUT_MS, signal: options.signal, }), { packageName: artifact.manifest.packageName, versionCode: artifact.manifest.versionCode, - installPolicy: options.helperInstallPolicy ?? 'missing-or-outdated', + installPolicy: resolveHelperInstallPolicy(options), }, ); emitDiagnostic({ @@ -466,6 +493,7 @@ async function captureAndroidHelperContentAttempt(params: { helperCapture = formatAndroidHelperCaptureResult(capture, artifact, install.reason); } catch (error) { options.signal?.throwIfAborted(); + if (isAndroidSnapshotHelperNotCurrentError(error)) throw error; return { outcome: 'captured', capture: await rejectAndroidHelperCaptureFailure({ @@ -517,6 +545,8 @@ async function rejectAndroidHelperContentUnavailable(params: { artifact: AndroidSnapshotHelperArtifact; adb: AndroidAdbExecutor; signal?: AbortSignal; + /** A transient capture does not own the helper, so its content verdict leaves the helper alone. */ + retireHelper: boolean; }): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { emitDiagnostic({ level: 'error', @@ -528,17 +558,27 @@ async function rejectAndroidHelperContentUnavailable(params: { ...params.contentRecovery.diagnostics, }, }); - await retireAndroidSnapshotHelperAfterContentFailure({ - adb: params.adb, - deviceKey: params.helperDeviceKey, - packageName: params.artifact.manifest.packageName, - signal: params.signal, - cause: params.contentRecovery.failureReason, - }); - throw new AppError('COMMAND_FAILED', params.contentRecovery.failureReason, { - ...params.contentRecovery.diagnostics, - androidSnapshotHelperFailureReason: params.contentRecovery.reason, - attempts: params.attempts, + if (params.retireHelper) { + await retireAndroidSnapshotHelperAfterContentFailure({ + adb: params.adb, + deviceKey: params.helperDeviceKey, + packageName: params.artifact.manifest.packageName, + signal: params.signal, + cause: params.contentRecovery.failureReason, + }); + } + throw androidHelperContentUnavailableError(params.contentRecovery, params.attempts); +} + +/** The retriable content verdict a capture reports once its re-captures still see no content. */ +export function androidHelperContentUnavailableError( + contentRecovery: AndroidHelperContentRecoveryDecision, + attempts: number, +): AppError { + return new AppError('COMMAND_FAILED', contentRecovery.failureReason, { + ...contentRecovery.diagnostics, + androidSnapshotHelperFailureReason: contentRecovery.reason, + attempts, retriable: true, hint: 'Retry after the app UI stabilizes. If this persists, capture a screenshot and report the helper diagnostics; agent-device does not substitute a second snapshot engine.', }); diff --git a/packages/platform-apple/src/snapshot-observability.ts b/packages/platform-apple/src/snapshot-observability.ts index 91ff821b3d..117218b2d4 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -3,6 +3,7 @@ import { deriveIosCaptureHint, } from '@agent-device/capture-kit/ios-snapshot-planning'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import type { PostOpenObservation } from '@agent-device/contracts/application-lifecycle-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts'; @@ -12,14 +13,11 @@ import { type SimulatorSnapshotTargetResolver, } from './snapshot-target.ts'; -/** - * What an `open` learned about the app it just launched on a local Simulator: `observable` means - * the host AX bridge published its tree, so the first observation will not pay a launch - * transition; `unobservable` means the bridge reported a state that will not clear within its - * grace (a system dialog, a lost AX server) or the grace ran out; `not-eligible` means the device - * has no bridge to ask. - */ -export type LaunchObservation = 'observable' | 'unobservable' | 'not-eligible'; +/** The observations a local Simulator's host AX bridge can report for a launched app. */ +export type LaunchObservation = Extract< + PostOpenObservation, + 'observable' | 'unobservable' | 'not-eligible' +>; export type LaunchObservationPort = Readonly<{ awaitObservable( diff --git a/src/core/interactors/android.test.ts b/src/core/interactors/android.test.ts index a5707432fa..fa58f526a8 100644 --- a/src/core/interactors/android.test.ts +++ b/src/core/interactors/android.test.ts @@ -94,3 +94,20 @@ test('a device-only session releases the helper after fill and scroll', async () helperSessionScope: 'command', }); }); + +test('a transient snapshot reaches the Android capture with its settle deadline', async () => { + snapshotAndroidMock.mockResolvedValue(makeAndroidSnapshotCapture([])); + + await createAndroidInteractor(device).snapshot({ + appBundleId: 'com.example.app', + transient: { settleBy: 1_000 }, + }); + + expect(snapshotAndroidMock).toHaveBeenCalledWith( + device, + expect.objectContaining({ + appBundleId: 'com.example.app', + transient: { settleBy: 1_000 }, + }), + ); +}); diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index fff5d3f126..84e1684ac5 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -105,6 +105,7 @@ export function createAndroidInteractor( raw: snapshotOptions.raw, includeHiddenContentHints: snapshotOptions.includeHiddenContentHints, helperSessionScope: androidHelperSessionScope(snapshotOptions.appBundleId), + transient: snapshotOptions.transient, }), { backend: 'android' }, ); diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 18fd2238d2..53b5c2d84a 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -81,13 +81,21 @@ test( 'Provider-backed integration Android Maestro refreshes action geometry and preserves authored swipe points', async () => { let snapshots = 0; + let capturesSinceLaunch = 0; await withProviderScenarioResource( async () => await createAndroidSettingsWorld({ + onAdbExec: (args) => { + if (args.slice(0, 3).join(' ') === 'shell am start') capturesSinceLaunch = 0; + }, + // The helper is not installed yet when the flow opens the app, so the open's launch + // probe fails before it captures. The flow's assertion reads the first capture after + // `am start`, which carries the geometry the tap must not reuse. snapshotXml: () => { snapshots += 1; + capturesSinceLaunch += 1; return androidMaestroReplayXml( - snapshots === 1 ? '[16,24][374,80]' : '[100,300][260,360]', + capturesSinceLaunch === 1 ? '[16,24][374,80]' : '[100,300][260,360]', ); }, }), @@ -135,7 +143,8 @@ test( assert.deepEqual(swipePlan.pointers[0]?.samples[0]?.point, { x: 351, y: 300 }); assert.deepEqual(swipePlan.pointers[0]?.samples.at(-1)?.point, { x: 39, y: 300 }); assert.equal(world.gestureViewportCalls, 1); - // Assertion, launch/tap stability comparisons, and fresh tap geometry share retained baselines. + // Assertion, launch/tap stability comparisons, and fresh tap geometry share retained + // baselines; the open's launch probe adds no capture here. assert.equal(snapshots, 4); }, ); @@ -187,11 +196,14 @@ test( ['shell', 'input', 'tap', '180', '330'], ); assert.equal( - world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell am force-stop') - .length, + world.adbCalls.filter( + (call) => call.join(' ') === 'shell am force-stop com.android.settings', + ).length, 2, ); - assertSnapshotCountInRange(snapshots, 2, 3); + // The second flow's open finds the helper the first flow installed, so its launch probe + // adds one capture. + assertSnapshotCountInRange(snapshots, 3, 4); }, ); }, diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index 5032259e23..ae6f242eb4 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -15,6 +15,7 @@ import { androidSnapshotHelperOutput, } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; import { runCmd, runCmdBackground } from '@agent-device/host-kit/command'; +import { resetAndroidSnapshotHelperInstallCache } from '@agent-device/platform-android/mechanics'; import { validPng } from './assertions.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; import { @@ -65,6 +66,9 @@ export async function createAndroidSettingsWorld(options?: { dumpsysWindow?: () => string; onAdbExec?: (args: readonly string[]) => void; }): Promise { + // The world's helper version probe always reports no helper, so no install may be remembered + // from an earlier world on the same serial. + resetAndroidSnapshotHelperInstallCache(); const hostAdbGuard = installFakeHostAdbGuard(); const adbCalls: string[][] = []; const textInjectionCalls: AndroidSettingsWorld['textInjectionCalls'] = [];