From a7572a5ac4b249d8cb78f4ade7ebd1afdfe4e21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 13:18:51 +0200 Subject: [PATCH 1/4] fix(android): return from an app open only after the launched app is readable am start -W returns when the activity draws its first frame, which can be a splash or an empty root while a React Native app still mounts. The first capture after a cold open --relaunch then saw a content-poor tree and spent its own budget on re-captures. The open now captures the launched app through the interactor snapshot path, whose content verdict and bounded re-capture decide readiness, and reports postOpenObservation. An app that stays unreadable, or a failed capture, still opens as unobservable. Refs #1571 (iOS half: #2838). --- .../src/application-lifecycle-runtime.ts | 5 +- .../platform-android/src/lifecycle.test.ts | 147 ++++++++++++++++-- packages/platform-android/src/lifecycle.ts | 27 +++- .../android-test-suite.test.ts | 3 +- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index f13cb2347d..c476fed5c4 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -124,7 +124,10 @@ 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). */ + /** + * What the open learned about the launched app's readability before returning, set by a local + * iOS Simulator and by Android (see each platform owner). + */ postOpenObservation?: 'observable' | 'unobservable' | 'not-eligible'; }>; diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 0245284910..588cc15447 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 } 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 | undefined) => 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(`snapshot:${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,106 @@ test('preserves a runtime launch URL duration after the admitted Android follow- stateDir: '/state', runtimeHints: {}, execution: {}, - }); + ...overrides, + }; +} + +/** What the Android capture throws once its bounded re-capture still sees an unmounted app. */ +function unreadableLaunchContentError(): AppError { + return new AppError( + 'COMMAND_FAILED', + 'Android snapshot helper returned insufficient foreground app content', + { + androidSnapshotHelperFailureReason: 'content-poor-app-window', + attempts: 3, + retriable: true, + }, + ); +} - 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 a capture of the launched app is readable', async () => { + let releaseCapture: () => void = () => {}; + const captureReleased = new Promise((resolve) => { + releaseCapture = resolve; + }); + const { calls, lifecycle } = createLifecycle({ + openedAppBundleId: 'com.example.opened', + snapshot: async () => { + await captureReleased; + return { nodes: [] }; + }, + }); + + let settled = false; + const opening = lifecycle.openApplication(openInput()).then((outcome) => { + settled = true; + return outcome; + }); + await vi.waitFor(() => expect(calls).toContain('snapshot:com.example.opened')); + await Promise.resolve(); + expect(settled).toBe(false); + releaseCapture(); + const outcome = await opening; + + expect(calls).toEqual(['open:com.example.app', 'snapshot:com.example.opened']); + expect(outcome.appBundleId).toBe('com.example.opened'); + expect(outcome.timing.postOpenObservation).toBe('observable'); + expect(outcome.timing.postOpenSettleDurationMs).toEqual(expect.any(Number)); +}); + +test('an app still unreadable after the capture re-captures opens as unobservable', async () => { + const { calls, lifecycle } = createLifecycle({ + snapshot: async () => { + throw unreadableLaunchContentError(); + }, + }); + + const outcome = await lifecycle.openApplication(openInput({ relaunch: true })); + + expect(calls).toEqual(['open:com.example.app', 'snapshot:com.example.app']); + expect(outcome.appBundleId).toBe('com.example.app'); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); +}); + +test('a cancelled open does not report the launch observation as unobservable', async () => { + const controller = new AbortController(); + const { lifecycle } = createLifecycle({ + signal: controller.signal, + snapshot: async () => { + controller.abort(new Error('request cancelled')); + throw unreadableLaunchContentError(); + }, + }); + + await expect(lifecycle.openApplication(openInput())).rejects.toThrow('request cancelled'); +}); + +test('an open whose launched app package is unknown does not observe the launch', async () => { + const { calls, lifecycle } = createLifecycle({ openedAppBundleId: undefined }); + + const outcome = await lifecycle.openApplication( + openInput({ target: 'https://example.com', positionals: ['https://example.com'] }), + ); + + expect(calls).toEqual(['open:https://example.com']); + expect(outcome.timing.postOpenObservation).toBe('not-eligible'); +}); diff --git a/packages/platform-android/src/lifecycle.ts b/packages/platform-android/src/lifecycle.ts index cab32f52f6..0d20e56208 100644 --- a/packages/platform-android/src/lifecycle.ts +++ b/packages/platform-android/src/lifecycle.ts @@ -164,10 +164,35 @@ async function openAndroidApplication( if (appBundleId) { await host.androidApplications.resetFramePerfStats(binding.device, appBundleId); } - timing.postOpenSettleDurationMs = 0; + const settleStartedAtMs = Date.now(); + timing.postOpenObservation = await observeAndroidLaunch(binding, input, appBundleId); + timing.postOpenSettleDurationMs = elapsed(settleStartedAtMs); return { appBundleId, timing }; } +/** + * `am start -W` returns once the activity draws its first frame, which can be a splash or an empty + * root while the app still mounts its views, so the open itself captures the launched app. The + * capture's content verdict and its bounded re-capture decide readiness. The open still succeeds + * when the app stays unreadable or the capture fails: the capture reports why in its own + * diagnostics, and the next observation meets the same state. + */ +async function observeAndroidLaunch( + binding: ReturnType, + input: OpenApplicationInput, + appBundleId: string | undefined, +): Promise> { + if (!appBundleId) return 'not-eligible'; + const interactor = await binding.resolveInteractor(input.execution, appBundleId); + try { + await interactor.snapshot({ appBundleId, signal: binding.signal }); + return 'observable'; + } catch { + binding.signal.throwIfAborted(); + return 'unobservable'; + } +} + function elapsed(startedAtMs: number): number { return Math.max(0, Date.now() - startedAtMs); } diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 18fd2238d2..b01086063a 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -191,7 +191,8 @@ test( .length, 2, ); - assertSnapshotCountInRange(snapshots, 2, 3); + // Each launchApp open captures the launched app once before it returns. + assertSnapshotCountInRange(snapshots, 4, 5); }, ); }, From 2b092cac30ddff031aa4baf986c73dfef95f05d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 15:23:22 +0200 Subject: [PATCH 2/4] fix(android): bound and type the launch observation of an app open The open's launch capture now runs behind an injected launch observation port with a fixed 6 s window of its own, separate from the caller's cancellation. Only a content verdict, a system surface over the app, or the window running out reads as unobservable. Any other capture failure is a typed probe-failed result that the open reports and survives. The capture is transient: it borrows a running helper session and stops only a session it started, installs no helper, and does not retire the helper after a content verdict. A URL open reports no observation, and an app open whose package cannot be read reports app-unidentified. PostOpenObservation is one documented union in the lifecycle contract, shared with the Apple owner. Refs #1571 --- .../src/application-lifecycle-runtime.ts | 38 ++++- packages/contracts/src/interactor-types.ts | 5 + .../__tests__/snapshot-helper-install.test.ts | 25 +++ .../src/__tests__/snapshot.test.ts | 84 +++++++++ .../src/launch-observation.test.ts | 160 ++++++++++++++++++ .../src/launch-observation.ts | 73 ++++++++ .../platform-android/src/lifecycle.test.ts | 112 ++++++------ packages/platform-android/src/lifecycle.ts | 44 ++--- packages/platform-android/src/runtime.ts | 2 + .../src/snapshot-helper-install.ts | 14 +- .../src/snapshot-helper-types.ts | 13 +- packages/platform-android/src/snapshot.ts | 52 ++++-- .../src/snapshot-observability.ts | 14 +- src/core/interactors/android.test.ts | 18 ++ src/core/interactors/android.ts | 4 +- .../android-test-suite.test.ts | 23 ++- .../provider-scenarios/android-world.ts | 4 + 17 files changed, 577 insertions(+), 108 deletions(-) create mode 100644 packages/platform-android/src/launch-observation.test.ts create mode 100644 packages/platform-android/src/launch-observation.ts diff --git a/packages/contracts/src/application-lifecycle-runtime.ts b/packages/contracts/src/application-lifecycle-runtime.ts index c476fed5c4..98504a649b 100644 --- a/packages/contracts/src/application-lifecycle-runtime.ts +++ b/packages/contracts/src/application-lifecycle-runtime.ts @@ -124,11 +124,39 @@ export type OpenApplicationTiming = Readonly<{ openDispatchDurationMs?: number; launchUrlDurationMs?: number; postOpenSettleDurationMs?: number; - /** - * What the open learned about the launched app's readability before returning, set by a local - * iOS Simulator and by Android (see each platform 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..699e85e0c9 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -175,6 +175,11 @@ 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. + */ + transient?: boolean; }; /** 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.test.ts b/packages/platform-android/src/__tests__/snapshot.test.ts index 2930772704..e8a979a40e 100644 --- a/packages/platform-android/src/__tests__/snapshot.test.ts +++ b/packages/platform-android/src/__tests__/snapshot.test.ts @@ -37,6 +37,7 @@ import { type FakeAndroidProcess, } from './snapshot-helper-session.fixtures.ts'; import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts'; +import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; const VALID_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=', @@ -537,6 +538,60 @@ test('snapshotAndroid keeps daemon-session helper alive for reuse until session ); }); +test('a borrowed 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, + helperSessionScope: 'borrow', + }); + + 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 borrowed 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, + helperSessionScope: 'borrow', + }); + + 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 daemon-session viewport read warms the session the next snapshot reuses', async () => { // The gesture viewport and snapshot capture are different helper commands on the same device. // They may only share the live session if both derive the same session identity, which is why @@ -607,6 +662,35 @@ test('snapshotAndroid retires content-invalid daemon helper before the next requ ); }); +test('a borrowed 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, helperSessionScope: 'borrow' }), + (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('content-invalid daemon helper retirement force-stops the helper runtime', async () => { // Retirement after a content failure is a recovery path, not a release: the helper answered with // output we could not trust, so the next capture must meet a runtime that was reset. A clean quit 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..cccebaef23 --- /dev/null +++ b/packages/platform-android/src/launch-observation.test.ts @@ -0,0 +1,160 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor, SnapshotOptions } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; +import { + ANDROID_LAUNCH_OBSERVATION_WINDOW_MS, + createAndroidLaunchObservationProbe, +} from './launch-observation.ts'; +import { androidHelperContentUnavailableError } from './snapshot.ts'; + +type ProbeFixture = Readonly<{ + snapshotOptions: SnapshotOptions[]; + sleeps: number[]; + /** Ends every pending clock sleep, as elapsed time would. */ + elapse: () => void; + observe: ( + signal?: AbortSignal, + ) => ReturnType['awaitObservable']>; +}>; + +function createProbe(snapshot: (options: SnapshotOptions) => Promise): ProbeFixture { + const snapshotOptions: SnapshotOptions[] = []; + const sleeps: number[] = []; + const pendingSleeps: Array<() => void> = []; + const clock = { + now: () => Date.now(), + sleep: async (ms: number, signal?: AbortSignal) => { + sleeps.push(ms); + await new Promise((resolve, reject) => { + pendingSleeps.push(resolve); + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + } as unknown as PlatformRuntimeHost['clock']; + const interactor = { + snapshot: async (options: SnapshotOptions) => { + snapshotOptions.push(options); + return await snapshot(options); + }, + } as unknown as Pick; + const probe = createAndroidLaunchObservationProbe({ clock }); + return { + snapshotOptions, + sleeps, + elapse: () => { + for (const resolve of pendingSleeps.splice(0)) resolve(); + }, + observe: async (signal = new AbortController().signal) => + await probe.awaitObservable(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, + ); +} + +/** A capture that ends only when its signal aborts, as a stuck helper call does. */ +async function captureUntilAborted(options: SnapshotOptions): Promise { + return await new Promise((_resolve, reject) => { + if (options.signal?.aborted) reject(options.signal.reason); + options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true, + }); + }); +} + +test('a readable launched app is observable through one transient capture', async () => { + const probe = createProbe(async () => ({ nodes: [] })); + + await expect(probe.observe()).resolves.toEqual({ observation: 'observable' }); + expect(probe.snapshotOptions).toHaveLength(1); + expect(probe.snapshotOptions[0]).toMatchObject({ + appBundleId: 'com.example.app', + transient: true, + }); +}); + +test('a content verdict after the capture re-captures is unobservable', async () => { + const probe = createProbe(async () => { + throw contentVerdict(); + }); + + await expect(probe.observe()).resolves.toEqual({ observation: 'unobservable' }); +}); + +test('a system surface covering the launched app is unobservable', async () => { + const probe = createProbe(async () => ({ + nodes: [], + androidSnapshot: { backend: 'android-helper', systemSurfaceOnly: true }, + })); + + await expect(probe.observe()).resolves.toEqual({ observation: 'unobservable' }); +}); + +test('a capture mechanism failure is a failed probe with its typed reason', async () => { + const probe = createProbe(async () => { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed: accessibility timeout', { + androidSnapshotHelperFailureReason: 'Android snapshot helper failed: accessibility timeout', + androidCaptureFailureReason: 'accessibility-timeout', + }); + }); + + await expect(probe.observe()).resolves.toEqual({ + observation: 'probe-failed', + failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' }, + }); +}); + +test('a helper that is not installed at the current version is a failed probe', async () => { + const probe = createProbe(async () => { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper is not installed', { + reason: 'android-snapshot-helper-not-current', + }); + }); + + await expect(probe.observe()).resolves.toEqual({ + observation: 'probe-failed', + failure: { code: 'COMMAND_FAILED', reason: 'android-snapshot-helper-not-current' }, + }); +}); + +test('a capture that outlasts the fixed window is abandoned as unobservable', async () => { + const probe = createProbe(captureUntilAborted); + + const observing = probe.observe(); + await vi.waitFor(() => expect(probe.sleeps).toEqual([ANDROID_LAUNCH_OBSERVATION_WINDOW_MS])); + probe.elapse(); + + await expect(observing).resolves.toEqual({ observation: 'unobservable' }); + expect(probe.snapshotOptions[0]?.signal?.aborted).toBe(true); +}); + +test('a cancelled open rejects with its cancellation, not an observation', async () => { + const controller = new AbortController(); + const canceled = createRequestCanceledError(); + const probe = createProbe(async (options) => { + controller.abort(canceled); + return await captureUntilAborted(options); + }); + + await expect(probe.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..9044cd2d16 --- /dev/null +++ b/packages/platform-android/src/launch-observation.ts @@ -0,0 +1,73 @@ +import type { + PostOpenObservation, + PostOpenObservationFailure, +} from '@agent-device/contracts/application-lifecycle-runtime'; +import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; +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), + * and far shorter than the open's own timeout. The window never extends. + */ +export const ANDROID_LAUNCH_OBSERVATION_WINDOW_MS = 6_000; + +export type AndroidLaunchObservation = + | Readonly<{ observation: Extract }> + | Readonly<{ observation: 'probe-failed'; failure: PostOpenObservationFailure }>; + +export type AndroidLaunchObservationPort = Readonly<{ + awaitObservable( + interactor: Pick, + appBundleId: string, + signal: AbortSignal, + ): Promise; +}>; + +/** + * `am start -W` returns at the first frame, which can precede the app's mounted views. The probe + * captures the launched app once, transiently: it borrows the helper and installs none. The + * capture's content verdict and re-captures decide readiness within one fixed window. Only a + * content verdict or the window running out reads as `unobservable`; any other capture failure is + * reported as `probe-failed` with its typed reason. Cancelling the open still rejects. + */ +export function createAndroidLaunchObservationProbe( + deps: Readonly<{ clock: PlatformRuntimeHost['clock'] }>, +): AndroidLaunchObservationPort { + return Object.freeze({ + awaitObservable: async (interactor, appBundleId, signal) => { + const window = new AbortController(); + const windowTimer = new AbortController(); + void deps.clock.sleep(ANDROID_LAUNCH_OBSERVATION_WINDOW_MS, windowTimer.signal).then( + () => window.abort(), + () => {}, + ); + try { + const capture = await interactor.snapshot({ + appBundleId, + signal: AbortSignal.any([signal, window.signal]), + transient: true, + }); + const systemSurfaceOnly = + 'androidSnapshot' in capture && capture.androidSnapshot?.systemSurfaceOnly === true; + return { observation: systemSurfaceOnly ? 'unobservable' : 'observable' }; + } catch (error) { + signal.throwIfAborted(); + if (window.signal.aborted || isUnreadableCaptureContentError(error)) { + return { observation: 'unobservable' }; + } + return { observation: 'probe-failed', failure: typedFailure(error) }; + } finally { + windowTimer.abort(); + } + }, + }); +} + +function typedFailure(error: unknown): PostOpenObservationFailure { + const normalized = normalizeError(error); + const details = normalized.details; + const reason = details?.androidCaptureFailureReason ?? 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 588cc15447..bf3de9e9f8 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -4,9 +4,13 @@ import type { OpenApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import type { Interactor, SnapshotOptions } from '@agent-device/contracts/interactor-types'; +import type { Interactor } from '@agent-device/contracts/interactor-types'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; +import { createRequestCanceledError } from '@agent-device/kernel/errors'; +import type { + AndroidLaunchObservation, + AndroidLaunchObservationPort, +} from './launch-observation.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; const device: DeviceInfo = { @@ -29,7 +33,7 @@ type LifecycleFixture = Readonly<{ function createLifecycle( params: Readonly<{ - snapshot?: (options: SnapshotOptions | undefined) => Promise; + observe?: (appBundleId: string, signal: AbortSignal) => Promise; openedAppBundleId?: string; signal?: AbortSignal; }> = {}, @@ -44,12 +48,15 @@ function createLifecycle( openDevice: async () => {}, close: async () => {}, setSetting: async () => {}, - snapshot: async (options?: SnapshotOptions) => { - calls.push(`snapshot:${options?.appBundleId}`); - return await (params.snapshot?.(options) ?? Promise.resolve({ nodes: [] })); - }, }) as unknown as Interactor, }; + const launchObservation: AndroidLaunchObservationPort = { + awaitObservable: async (_interactor, appBundleId, signal) => { + calls.push(`observe:${appBundleId}`); + return await (params.observe?.(appBundleId, signal) ?? + Promise.resolve({ observation: 'observable' as const })); + }, + }; const host = { localInteractors, deviceReadiness: { @@ -84,6 +91,7 @@ function createLifecycle( host, device, signal: params.signal ?? new AbortController().signal, + launchObservation, }); return { calls, lifecycle }; } @@ -105,19 +113,6 @@ function openInput(overrides: Partial = {}): OpenApplicati }; } -/** What the Android capture throws once its bounded re-capture still sees an unmounted app. */ -function unreadableLaunchContentError(): AppError { - return new AppError( - 'COMMAND_FAILED', - 'Android snapshot helper returned insufficient foreground app content', - { - androidSnapshotHelperFailureReason: 'content-poor-app-window', - attempts: 3, - retriable: true, - }, - ); -} - test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => { const { calls, lifecycle } = createLifecycle(); vi.spyOn(Date, 'now') @@ -137,17 +132,14 @@ test('preserves a runtime launch URL duration after the admitted Android follow- expect(outcome.timing.launchUrlDurationMs).toBe(20); }); -test('an Android app open returns only after a capture of the launched app is readable', async () => { - let releaseCapture: () => void = () => {}; - const captureReleased = new Promise((resolve) => { - releaseCapture = resolve; - }); +test('an Android app open returns only after the launched app observation settles', async () => { + let settleObservation: (observation: AndroidLaunchObservation) => void = () => {}; const { calls, lifecycle } = createLifecycle({ openedAppBundleId: 'com.example.opened', - snapshot: async () => { - await captureReleased; - return { nodes: [] }; - }, + observe: async () => + await new Promise((resolve) => { + settleObservation = resolve; + }), }); let settled = false; @@ -155,52 +147,72 @@ test('an Android app open returns only after a capture of the launched app is re settled = true; return outcome; }); - await vi.waitFor(() => expect(calls).toContain('snapshot:com.example.opened')); + await vi.waitFor(() => expect(calls).toContain('observe:com.example.opened')); await Promise.resolve(); expect(settled).toBe(false); - releaseCapture(); + settleObservation({ observation: 'unobservable' }); const outcome = await opening; - expect(calls).toEqual(['open:com.example.app', 'snapshot:com.example.opened']); + expect(calls).toEqual(['open:com.example.app', 'observe:com.example.opened']); expect(outcome.appBundleId).toBe('com.example.opened'); - expect(outcome.timing.postOpenObservation).toBe('observable'); + expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(outcome.timing.postOpenObservationFailure).toBeUndefined(); expect(outcome.timing.postOpenSettleDurationMs).toEqual(expect.any(Number)); }); -test('an app still unreadable after the capture re-captures opens as unobservable', async () => { - const { calls, lifecycle } = createLifecycle({ - snapshot: async () => { - throw unreadableLaunchContentError(); - }, +test('a failed launch probe reports its typed failure and the open still succeeds', async () => { + const { lifecycle } = createLifecycle({ + observe: async () => ({ + observation: 'probe-failed', + failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' }, + }), }); const outcome = await lifecycle.openApplication(openInput({ relaunch: true })); - expect(calls).toEqual(['open:com.example.app', 'snapshot:com.example.app']); - expect(outcome.appBundleId).toBe('com.example.app'); - expect(outcome.timing.postOpenObservation).toBe('unobservable'); + expect(outcome.timing.postOpenObservation).toBe('probe-failed'); + expect(outcome.timing.postOpenObservationFailure).toEqual({ + code: 'COMMAND_FAILED', + reason: 'accessibility-timeout', + }); }); -test('a cancelled open does not report the launch observation as unobservable', async () => { +test('a cancelled open rejects with the cancellation the observation raised', async () => { const controller = new AbortController(); + const canceled = createRequestCanceledError(); const { lifecycle } = createLifecycle({ signal: controller.signal, - snapshot: async () => { - controller.abort(new Error('request cancelled')); - throw unreadableLaunchContentError(); + observe: async (_appBundleId, signal) => { + expect(signal).toBe(controller.signal); + throw canceled; }, }); - await expect(lifecycle.openApplication(openInput())).rejects.toThrow('request cancelled'); + await expect(lifecycle.openApplication(openInput())).rejects.toBe(canceled); }); -test('an open whose launched app package is unknown does not observe the launch', async () => { - const { calls, lifecycle } = createLifecycle({ openedAppBundleId: undefined }); +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'] }), + openInput({ + target: 'https://example.com', + positionals: ['https://example.com'], + appBundleId: undefined, + }), ); expect(calls).toEqual(['open:https://example.com']); - expect(outcome.timing.postOpenObservation).toBe('not-eligible'); + 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 0d20e56208..e818db913a 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 type { AndroidLaunchObservationPort } 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'; @@ -35,13 +38,14 @@ type AndroidLifecycleParams = Readonly<{ host: AndroidLifecycleHost; device: DeviceInfo; signal: AbortSignal; + launchObservation: AndroidLaunchObservationPort; }>; /** Android owns lifecycle sequencing, including local adb-backed hints and durable test-IME state. */ export function bindAndroidApplicationLifecycle( params: AndroidLifecycleParams, ): ApplicationLifecycleRuntimeOperations { - const { host, device, signal } = params; + const { host, device, signal, launchObservation } = params; const binding = bindLocalApplicationLifecycleInteractor({ device, signal, @@ -54,7 +58,8 @@ export function bindAndroidApplicationLifecycle( await ensureAndroidReady(host, device, { headless: false }, signal); void input; }, - openApplication: async (input) => await openAndroidApplication(host, binding, input), + openApplication: async (input) => + await openAndroidApplication(host, binding, launchObservation, input), applyRuntimeHints: async (input) => await host.androidApplications.applyRuntimeHints(device, input), clearRuntimeHints: async (input) => @@ -87,6 +92,7 @@ export function bindAndroidApplicationLifecycle( async function openAndroidApplication( host: AndroidLifecycleHost, binding: ReturnType, + launchObservation: AndroidLaunchObservationPort, input: OpenApplicationInput, ): Promise { const timing: MutableOpenTiming = {}; @@ -165,32 +171,28 @@ async function openAndroidApplication( await host.androidApplications.resetFramePerfStats(binding.device, appBundleId); } const settleStartedAtMs = Date.now(); - timing.postOpenObservation = await observeAndroidLaunch(binding, input, appBundleId); + Object.assign(timing, await observeOpenedApp(binding, launchObservation, input, appBundleId)); timing.postOpenSettleDurationMs = elapsed(settleStartedAtMs); return { appBundleId, timing }; } -/** - * `am start -W` returns once the activity draws its first frame, which can be a splash or an empty - * root while the app still mounts its views, so the open itself captures the launched app. The - * capture's content verdict and its bounded re-capture decide readiness. The open still succeeds - * when the app stays unreadable or the capture fails: the capture reports why in its own - * diagnostics, and the next observation meets the same state. - */ -async function observeAndroidLaunch( +/** A URL or deep-link open has no launched app of its own to observe, so it reports nothing. */ +async function observeOpenedApp( binding: ReturnType, + launchObservation: AndroidLaunchObservationPort, input: OpenApplicationInput, appBundleId: string | undefined, -): Promise> { - if (!appBundleId) return 'not-eligible'; - const interactor = await binding.resolveInteractor(input.execution, appBundleId); - try { - await interactor.snapshot({ appBundleId, signal: binding.signal }); - return 'observable'; - } catch { - binding.signal.throwIfAborted(); - return 'unobservable'; - } +): Promise> { + if (!input.target || isDeepLinkTarget(input.target)) return {}; + if (!appBundleId) return { postOpenObservation: 'app-unidentified' }; + const launch = await launchObservation.awaitObservable( + 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 { diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index 2a0e4c9f3b..d21777bf2f 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -55,6 +55,7 @@ import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; import { readAndroidAppStateWithExecutor } from './app-state.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; +import { createAndroidLaunchObservationProbe } from './launch-observation.ts'; import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import { androidAppDeploymentFacts, @@ -526,6 +527,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor host, device: request.device, signal: request.scope.signal, + launchObservation: createAndroidLaunchObservationProbe({ clock: host.clock }), }), facts.operations, ), diff --git a/packages/platform-android/src/snapshot-helper-install.ts b/packages/platform-android/src/snapshot-helper-install.ts index b3e7624704..fd9d6f0910 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, @@ -142,6 +142,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..0f0583e5ac 100644 --- a/packages/platform-android/src/snapshot-helper-types.ts +++ b/packages/platform-android/src/snapshot-helper-types.ts @@ -31,9 +31,11 @@ export const ANDROID_SNAPSHOT_HELPER_COMMAND_TIMEOUT_MS = 30_000; * `am instrument` start plus the UiAutomation connect wait. `daemon-session` hands that release to * session teardown (`stopSessionAndroidSnapshotHelper`), which every Android session runs, so * consecutive commands in one session share one warm helper. Device-scoped work stays `command` so - * nothing squats UiAutomation once the command returns. + * nothing squats UiAutomation once the command returns. `borrow` uses a session that is already + * running and leaves it running, but releases a session it had to start, so a one-off read never + * leaves the helper holding UiAutomation for a session that may not observe again. */ -export type AndroidHelperSessionScope = 'command' | 'daemon-session'; +export type AndroidHelperSessionScope = 'command' | 'daemon-session' | 'borrow'; /** Threaded by every helper-backed read a session command performs (capture, viewport). */ export type AndroidHelperSessionOptions = { helperSessionScope?: AndroidHelperSessionScope }; @@ -45,7 +47,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..1921eef4cb 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -43,6 +43,7 @@ import { type AndroidSnapshotHelperInstallResult, type AndroidSnapshotHelperOutput, } from './snapshot-helper.ts'; +import { getLiveAndroidSnapshotHelperSession } from './snapshot-helper-session-lifecycle.ts'; import { getAndroidSnapshotHelperSessionDeviceKey, isAndroidSnapshotHelperRuntimeOccupiedError, @@ -277,7 +278,10 @@ 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.helperSessionScope, + helperDeviceKey, + ); try { let previousContentReason: AndroidContentRecoveryReason | undefined; for (let attempt = 0; ; attempt += 1) { @@ -300,17 +304,27 @@ async function captureAndroidUiHierarchyWithHelper( artifact, adb, signal: options.signal, + retireHelper: options.helperSessionScope !== 'borrow', }); } previousContentReason = settled.decision.reason; } } finally { - if (commandScopedHelperSession) { + if (releaseHelperSession) { await stopAndroidSnapshotHelperSession(helperDeviceKey); } } } +function releasesHelperSessionAfterCapture( + scope: AndroidHelperSessionScope | undefined, + helperDeviceKey: string, +): boolean { + if (scope === 'daemon-session') return false; + if (scope === 'borrow') return getLiveAndroidSnapshotHelperSession(helperDeviceKey) === undefined; + return true; +} + async function installAndroidSnapshotHelper( options: AndroidSnapshotOptions, adb: AndroidAdbExecutor, @@ -517,6 +531,8 @@ async function rejectAndroidHelperContentUnavailable(params: { artifact: AndroidSnapshotHelperArtifact; adb: AndroidAdbExecutor; signal?: AbortSignal; + /** A borrowed 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 +544,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..b2c7b5d8c2 100644 --- a/src/core/interactors/android.test.ts +++ b/src/core/interactors/android.test.ts @@ -94,3 +94,21 @@ test('a device-only session releases the helper after fill and scroll', async () helperSessionScope: 'command', }); }); + +test('a transient snapshot borrows the helper and installs none', async () => { + snapshotAndroidMock.mockResolvedValue(makeAndroidSnapshotCapture([])); + + await createAndroidInteractor(device).snapshot({ + appBundleId: 'com.example.app', + transient: true, + }); + + expect(snapshotAndroidMock).toHaveBeenCalledWith( + device, + expect.objectContaining({ + appBundleId: 'com.example.app', + helperSessionScope: 'borrow', + helperInstallPolicy: 'current-only', + }), + ); +}); diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index fff5d3f126..2c027df0d6 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -104,7 +104,9 @@ export function createAndroidInteractor( scope: snapshotOptions.scope, raw: snapshotOptions.raw, includeHiddenContentHints: snapshotOptions.includeHiddenContentHints, - helperSessionScope: androidHelperSessionScope(snapshotOptions.appBundleId), + ...(snapshotOptions.transient + ? { helperSessionScope: 'borrow', helperInstallPolicy: 'current-only' } + : { helperSessionScope: androidHelperSessionScope(snapshotOptions.appBundleId) }), }), { 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 b01086063a..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,12 +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, ); - // Each launchApp open captures the launched app once before it returns. - assertSnapshotCountInRange(snapshots, 4, 5); + // 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'] = []; From ccd5aef4626b158b72f396ca3be4b4fb38466eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 16:13:58 +0200 Subject: [PATCH 3/4] fix(android): let the launch settle window end re-captures, never helper work The open's 6 s window reached the snapshot helper as an abort, so a window that closed during a cold helper start or a borrowed capture tore the helper down and left the next read to recover it. The window is now a settle deadline on the transient capture: the content re-capture loop starts no attempt after it, while helper start, capture and teardown keep their own budgets and only the caller's signal cancels them. The transient read replaces the injected port, the borrow session scope and the interactor-side install mapping: the capture itself keeps a session it found, releases one it started, installs no helper and does not retire it after a content verdict. Refs #1571 --- packages/contracts/src/interactor-types.ts | 5 +- .../snapshot-helper-session.fixtures.ts | 62 +++---- .../src/__tests__/snapshot.test.ts | 100 +++++++++++- .../src/launch-observation.test.ts | 151 +++++++++--------- .../src/launch-observation.ts | 83 ++++------ .../platform-android/src/lifecycle.test.ts | 52 +++--- packages/platform-android/src/lifecycle.ts | 14 +- packages/platform-android/src/runtime.ts | 2 - .../src/snapshot-helper-types.ts | 6 +- packages/platform-android/src/snapshot.ts | 38 +++-- src/core/interactors/android.test.ts | 7 +- src/core/interactors/android.ts | 5 +- 12 files changed, 304 insertions(+), 221 deletions(-) diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 699e85e0c9..15670d675b 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -177,9 +177,10 @@ export type SnapshotOptions = BaseSnapshotOptions & { 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. + * 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?: boolean; + transient?: Readonly<{ settleBy: number }>; }; /** 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..bc5c4979ad 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)); diff --git a/packages/platform-android/src/__tests__/snapshot.test.ts b/packages/platform-android/src/__tests__/snapshot.test.ts index e8a979a40e..fb5f98b4e2 100644 --- a/packages/platform-android/src/__tests__/snapshot.test.ts +++ b/packages/platform-android/src/__tests__/snapshot.test.ts @@ -538,7 +538,7 @@ test('snapshotAndroid keeps daemon-session helper alive for reuse until session ); }); -test('a borrowed capture releases a helper session it had to start', async () => { +test('a transient capture releases a helper session it had to start', async () => { const adbCalls: (readonly string[])[] = []; const spawnArgs: (readonly string[])[] = []; const processes: FakeAndroidProcess[] = []; @@ -551,7 +551,7 @@ test('a borrowed capture releases a helper session it had to start', async () => await snapshotAndroid(device, { helperAdb: provider, helperArtifact, - helperSessionScope: 'borrow', + transient: openSettleWindow(), }); assert.equal(spawnArgs.length, 1); @@ -562,7 +562,7 @@ test('a borrowed capture releases a helper session it had to start', async () => ); }); -test('a borrowed capture leaves a warm daemon-session helper running', async () => { +test('a transient capture leaves a warm daemon-session helper running', async () => { const adbCalls: (readonly string[])[] = []; const spawnArgs: (readonly string[])[] = []; const processes: FakeAndroidProcess[] = []; @@ -580,7 +580,7 @@ test('a borrowed capture leaves a warm daemon-session helper running', async () const borrowed = await snapshotAndroid(device, { helperAdb: provider, helperArtifact, - helperSessionScope: 'borrow', + transient: openSettleWindow(), }); assert.equal(borrowed.androidSnapshot.helperSessionReused, true); @@ -662,7 +662,7 @@ test('snapshotAndroid retires content-invalid daemon helper before the next requ ); }); -test('a borrowed capture reports its content verdict without retiring the warm helper', async () => { +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[] = []; @@ -682,7 +682,7 @@ test('a borrowed capture reports its content verdict without retiring the warm h }); await assert.rejects( - snapshotAndroid(device, { helperAdb: provider, helperArtifact, helperSessionScope: 'borrow' }), + snapshotAndroid(device, { helperAdb: provider, helperArtifact, transient: openSettleWindow() }), (error: unknown) => isUnreadableCaptureContentError(error), ); @@ -691,6 +691,90 @@ test('a borrowed capture reports its content verdict without retiring the warm h 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 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, + ); +}); + test('content-invalid daemon helper retirement force-stops the helper runtime', async () => { // Retirement after a content failure is a recovery path, not a release: the helper answered with // output we could not trust, so the next capture must meet a runtime that was reset. A clean quit @@ -1514,3 +1598,7 @@ test('buildUiHierarchySnapshot derives hidden content hints from can-scroll-* on assert.equal(scrollArea.hiddenContentAbove, true); assert.equal(scrollArea.hiddenContentBelow, true); }); + +function openSettleWindow(): { settleBy: number } { + return { settleBy: Date.now() + 60_000 }; +} diff --git a/packages/platform-android/src/launch-observation.test.ts b/packages/platform-android/src/launch-observation.test.ts index cccebaef23..58ae7abc1b 100644 --- a/packages/platform-android/src/launch-observation.test.ts +++ b/packages/platform-android/src/launch-observation.test.ts @@ -1,52 +1,37 @@ -import { expect, test, vi } from 'vitest'; +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 type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; -import { - ANDROID_LAUNCH_OBSERVATION_WINDOW_MS, - createAndroidLaunchObservationProbe, -} from './launch-observation.ts'; +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 ProbeFixture = Readonly<{ +type ObserveFixture = Readonly<{ snapshotOptions: SnapshotOptions[]; - sleeps: number[]; - /** Ends every pending clock sleep, as elapsed time would. */ - elapse: () => void; - observe: ( - signal?: AbortSignal, - ) => ReturnType['awaitObservable']>; + observe: (signal?: AbortSignal) => ReturnType; }>; -function createProbe(snapshot: (options: SnapshotOptions) => Promise): ProbeFixture { +function createObservation( + snapshot: (options: SnapshotOptions) => Promise, +): ObserveFixture { const snapshotOptions: SnapshotOptions[] = []; - const sleeps: number[] = []; - const pendingSleeps: Array<() => void> = []; - const clock = { - now: () => Date.now(), - sleep: async (ms: number, signal?: AbortSignal) => { - sleeps.push(ms); - await new Promise((resolve, reject) => { - pendingSleeps.push(resolve); - signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); - }); - }, - } as unknown as PlatformRuntimeHost['clock']; const interactor = { snapshot: async (options: SnapshotOptions) => { snapshotOptions.push(options); return await snapshot(options); }, } as unknown as Pick; - const probe = createAndroidLaunchObservationProbe({ clock }); return { snapshotOptions, - sleeps, - elapse: () => { - for (const resolve of pendingSleeps.splice(0)) resolve(); - }, observe: async (signal = new AbortController().signal) => - await probe.awaitObservable(interactor, 'com.example.app', signal), + await observeAndroidLaunch(interactor, 'com.example.app', signal), }; } @@ -72,89 +57,111 @@ function contentVerdict(): AppError { ); } -/** A capture that ends only when its signal aborts, as a stuck helper call does. */ -async function captureUntilAborted(options: SnapshotOptions): Promise { - return await new Promise((_resolve, reject) => { - if (options.signal?.aborted) reject(options.signal.reason); - options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { - once: true, +/** 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 probe = createProbe(async () => ({ nodes: [] })); + 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); +}); - await expect(probe.observe()).resolves.toEqual({ observation: 'observable' }); - expect(probe.snapshotOptions).toHaveLength(1); - expect(probe.snapshotOptions[0]).toMatchObject({ - appBundleId: 'com.example.app', - transient: true, - }); +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 probe = createProbe(async () => { + const observation = createObservation(async () => { throw contentVerdict(); }); - await expect(probe.observe()).resolves.toEqual({ observation: 'unobservable' }); + await expect(observation.observe()).resolves.toEqual({ observation: 'unobservable' }); }); test('a system surface covering the launched app is unobservable', async () => { - const probe = createProbe(async () => ({ + const observation = createObservation(async () => ({ nodes: [], androidSnapshot: { backend: 'android-helper', systemSurfaceOnly: true }, })); - await expect(probe.observe()).resolves.toEqual({ observation: 'unobservable' }); + await expect(observation.observe()).resolves.toEqual({ observation: 'unobservable' }); }); test('a capture mechanism failure is a failed probe with its typed reason', async () => { - const probe = createProbe(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(probe.observe()).resolves.toEqual({ + await expect(observation.observe()).resolves.toEqual({ observation: 'probe-failed', failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' }, }); }); -test('a helper that is not installed at the current version is a failed probe', async () => { - const probe = createProbe(async () => { - throw new AppError('COMMAND_FAILED', 'Android snapshot helper is not installed', { - reason: 'android-snapshot-helper-not-current', - }); +test('a device without the current helper is a failed probe', async () => { + const notCurrent = await helperNotCurrentError(); + const observation = createObservation(async () => { + throw notCurrent; }); - await expect(probe.observe()).resolves.toEqual({ + await expect(observation.observe()).resolves.toEqual({ observation: 'probe-failed', failure: { code: 'COMMAND_FAILED', reason: 'android-snapshot-helper-not-current' }, }); }); -test('a capture that outlasts the fixed window is abandoned as unobservable', async () => { - const probe = createProbe(captureUntilAborted); - - const observing = probe.observe(); - await vi.waitFor(() => expect(probe.sleeps).toEqual([ANDROID_LAUNCH_OBSERVATION_WINDOW_MS])); - probe.elapse(); - - await expect(observing).resolves.toEqual({ observation: 'unobservable' }); - expect(probe.snapshotOptions[0]?.signal?.aborted).toBe(true); -}); - test('a cancelled open rejects with its cancellation, not an observation', async () => { const controller = new AbortController(); const canceled = createRequestCanceledError(); - const probe = createProbe(async (options) => { + const observation = createObservation(async () => { controller.abort(canceled); - return await captureUntilAborted(options); + throw canceled; }); - await expect(probe.observe(controller.signal)).rejects.toBe(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 index 9044cd2d16..1004eac55e 100644 --- a/packages/platform-android/src/launch-observation.ts +++ b/packages/platform-android/src/launch-observation.ts @@ -2,72 +2,53 @@ import type { PostOpenObservation, PostOpenObservationFailure, } from '@agent-device/contracts/application-lifecycle-runtime'; -import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; +import { + isUnreadableCaptureContentError, + readAndroidCaptureFailureReason, +} from '@agent-device/contracts/android-snapshot-quality'; import type { Interactor } from '@agent-device/contracts/interactor-types'; -import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; 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), - * and far shorter than the open's own timeout. The window never extends. + * 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_OBSERVATION_WINDOW_MS = 6_000; +export const ANDROID_LAUNCH_SETTLE_WINDOW_MS = 6_000; export type AndroidLaunchObservation = | Readonly<{ observation: Extract }> | Readonly<{ observation: 'probe-failed'; failure: PostOpenObservationFailure }>; -export type AndroidLaunchObservationPort = Readonly<{ - awaitObservable( - interactor: Pick, - appBundleId: string, - signal: AbortSignal, - ): Promise; -}>; - /** - * `am start -W` returns at the first frame, which can precede the app's mounted views. The probe - * captures the launched app once, transiently: it borrows the helper and installs none. The - * capture's content verdict and re-captures decide readiness within one fixed window. Only a - * content verdict or the window running out reads as `unobservable`; any other capture failure is - * reported as `probe-failed` with its typed reason. Cancelling the open still rejects. + * `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 function createAndroidLaunchObservationProbe( - deps: Readonly<{ clock: PlatformRuntimeHost['clock'] }>, -): AndroidLaunchObservationPort { - return Object.freeze({ - awaitObservable: async (interactor, appBundleId, signal) => { - const window = new AbortController(); - const windowTimer = new AbortController(); - void deps.clock.sleep(ANDROID_LAUNCH_OBSERVATION_WINDOW_MS, windowTimer.signal).then( - () => window.abort(), - () => {}, - ); - try { - const capture = await interactor.snapshot({ - appBundleId, - signal: AbortSignal.any([signal, window.signal]), - transient: true, - }); - const systemSurfaceOnly = - 'androidSnapshot' in capture && capture.androidSnapshot?.systemSurfaceOnly === true; - return { observation: systemSurfaceOnly ? 'unobservable' : 'observable' }; - } catch (error) { - signal.throwIfAborted(); - if (window.signal.aborted || isUnreadableCaptureContentError(error)) { - return { observation: 'unobservable' }; - } - return { observation: 'probe-failed', failure: typedFailure(error) }; - } finally { - windowTimer.abort(); - } - }, - }); +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 details = normalized.details; - const reason = details?.androidCaptureFailureReason ?? details?.reason; + 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 bf3de9e9f8..d8c6aca244 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -4,13 +4,9 @@ import type { 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 { createRequestCanceledError } from '@agent-device/kernel/errors'; -import type { - AndroidLaunchObservation, - AndroidLaunchObservationPort, -} from './launch-observation.ts'; +import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; const device: DeviceInfo = { @@ -33,7 +29,7 @@ type LifecycleFixture = Readonly<{ function createLifecycle( params: Readonly<{ - observe?: (appBundleId: string, signal: AbortSignal) => Promise; + snapshot?: (options: SnapshotOptions) => Promise; openedAppBundleId?: string; signal?: AbortSignal; }> = {}, @@ -48,15 +44,12 @@ function createLifecycle( 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 launchObservation: AndroidLaunchObservationPort = { - awaitObservable: async (_interactor, appBundleId, signal) => { - calls.push(`observe:${appBundleId}`); - return await (params.observe?.(appBundleId, signal) ?? - Promise.resolve({ observation: 'observable' as const })); - }, - }; const host = { localInteractors, deviceReadiness: { @@ -91,7 +84,6 @@ function createLifecycle( host, device, signal: params.signal ?? new AbortController().signal, - launchObservation, }); return { calls, lifecycle }; } @@ -133,13 +125,15 @@ test('preserves a runtime launch URL duration after the admitted Android follow- }); test('an Android app open returns only after the launched app observation settles', async () => { - let settleObservation: (observation: AndroidLaunchObservation) => void = () => {}; + let finishCapture: () => void = () => {}; const { calls, lifecycle } = createLifecycle({ openedAppBundleId: 'com.example.opened', - observe: async () => - await new Promise((resolve) => { - settleObservation = resolve; - }), + snapshot: async () => { + await new Promise((resolve) => { + finishCapture = resolve; + }); + return { nodes: [], androidSnapshot: { backend: 'android-helper', systemSurfaceOnly: true } }; + }, }); let settled = false; @@ -150,7 +144,7 @@ test('an Android app open returns only after the launched app observation settle await vi.waitFor(() => expect(calls).toContain('observe:com.example.opened')); await Promise.resolve(); expect(settled).toBe(false); - settleObservation({ observation: 'unobservable' }); + finishCapture(); const outcome = await opening; expect(calls).toEqual(['open:com.example.app', 'observe:com.example.opened']); @@ -162,10 +156,11 @@ test('an Android app open returns only after the launched app observation settle test('a failed launch probe reports its typed failure and the open still succeeds', async () => { const { lifecycle } = createLifecycle({ - observe: async () => ({ - observation: 'probe-failed', - failure: { code: 'COMMAND_FAILED', reason: 'accessibility-timeout' }, - }), + snapshot: async () => { + throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed', { + androidCaptureFailureReason: 'accessibility-timeout', + }); + }, }); const outcome = await lifecycle.openApplication(openInput({ relaunch: true })); @@ -177,13 +172,14 @@ test('a failed launch probe reports its typed failure and the open still succeed }); }); -test('a cancelled open rejects with the cancellation the observation raised', async () => { +test('a cancelled open rejects with its cancellation', async () => { const controller = new AbortController(); const canceled = createRequestCanceledError(); const { lifecycle } = createLifecycle({ signal: controller.signal, - observe: async (_appBundleId, signal) => { - expect(signal).toBe(controller.signal); + snapshot: async (options) => { + expect(options.signal).toBe(controller.signal); + controller.abort(canceled); throw canceled; }, }); diff --git a/packages/platform-android/src/lifecycle.ts b/packages/platform-android/src/lifecycle.ts index e818db913a..d6db533324 100644 --- a/packages/platform-android/src/lifecycle.ts +++ b/packages/platform-android/src/lifecycle.ts @@ -13,7 +13,7 @@ import { invokeApplicationOpen, } from '@agent-device/contracts/application-lifecycle-interaction'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; -import type { AndroidLaunchObservationPort } from './launch-observation.ts'; +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'; @@ -38,14 +38,13 @@ type AndroidLifecycleParams = Readonly<{ host: AndroidLifecycleHost; device: DeviceInfo; signal: AbortSignal; - launchObservation: AndroidLaunchObservationPort; }>; /** Android owns lifecycle sequencing, including local adb-backed hints and durable test-IME state. */ export function bindAndroidApplicationLifecycle( params: AndroidLifecycleParams, ): ApplicationLifecycleRuntimeOperations { - const { host, device, signal, launchObservation } = params; + const { host, device, signal } = params; const binding = bindLocalApplicationLifecycleInteractor({ device, signal, @@ -58,8 +57,7 @@ export function bindAndroidApplicationLifecycle( await ensureAndroidReady(host, device, { headless: false }, signal); void input; }, - openApplication: async (input) => - await openAndroidApplication(host, binding, launchObservation, input), + openApplication: async (input) => await openAndroidApplication(host, binding, input), applyRuntimeHints: async (input) => await host.androidApplications.applyRuntimeHints(device, input), clearRuntimeHints: async (input) => @@ -92,7 +90,6 @@ export function bindAndroidApplicationLifecycle( async function openAndroidApplication( host: AndroidLifecycleHost, binding: ReturnType, - launchObservation: AndroidLaunchObservationPort, input: OpenApplicationInput, ): Promise { const timing: MutableOpenTiming = {}; @@ -171,7 +168,7 @@ async function openAndroidApplication( await host.androidApplications.resetFramePerfStats(binding.device, appBundleId); } const settleStartedAtMs = Date.now(); - Object.assign(timing, await observeOpenedApp(binding, launchObservation, input, appBundleId)); + Object.assign(timing, await observeOpenedApp(binding, input, appBundleId)); timing.postOpenSettleDurationMs = elapsed(settleStartedAtMs); return { appBundleId, timing }; } @@ -179,13 +176,12 @@ async function openAndroidApplication( /** A URL or deep-link open has no launched app of its own to observe, so it reports nothing. */ async function observeOpenedApp( binding: ReturnType, - launchObservation: AndroidLaunchObservationPort, input: OpenApplicationInput, appBundleId: string | undefined, ): Promise> { if (!input.target || isDeepLinkTarget(input.target)) return {}; if (!appBundleId) return { postOpenObservation: 'app-unidentified' }; - const launch = await launchObservation.awaitObservable( + const launch = await observeAndroidLaunch( await binding.resolveInteractor(input.execution, appBundleId), appBundleId, binding.signal, diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index d21777bf2f..2a0e4c9f3b 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -55,7 +55,6 @@ import { bindAndroidScreenRecordingRuntime } from './recording/runtime.ts'; import { ensureAndroidReady } from './readiness/runtime.ts'; import { readAndroidAppStateWithExecutor } from './app-state.ts'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; -import { createAndroidLaunchObservationProbe } from './launch-observation.ts'; import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; import { androidAppDeploymentFacts, @@ -527,7 +526,6 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor host, device: request.device, signal: request.scope.signal, - launchObservation: createAndroidLaunchObservationProbe({ clock: host.clock }), }), facts.operations, ), diff --git a/packages/platform-android/src/snapshot-helper-types.ts b/packages/platform-android/src/snapshot-helper-types.ts index 0f0583e5ac..b0581b0b02 100644 --- a/packages/platform-android/src/snapshot-helper-types.ts +++ b/packages/platform-android/src/snapshot-helper-types.ts @@ -31,11 +31,9 @@ export const ANDROID_SNAPSHOT_HELPER_COMMAND_TIMEOUT_MS = 30_000; * `am instrument` start plus the UiAutomation connect wait. `daemon-session` hands that release to * session teardown (`stopSessionAndroidSnapshotHelper`), which every Android session runs, so * consecutive commands in one session share one warm helper. Device-scoped work stays `command` so - * nothing squats UiAutomation once the command returns. `borrow` uses a session that is already - * running and leaves it running, but releases a session it had to start, so a one-off read never - * leaves the helper holding UiAutomation for a session that may not observe again. + * nothing squats UiAutomation once the command returns. */ -export type AndroidHelperSessionScope = 'command' | 'daemon-session' | 'borrow'; +export type AndroidHelperSessionScope = 'command' | 'daemon-session'; /** Threaded by every helper-backed read a session command performs (capture, viewport). */ export type AndroidHelperSessionOptions = { helperSessionScope?: AndroidHelperSessionScope }; diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index 1921eef4cb..97287858ed 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, @@ -88,6 +89,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; @@ -278,10 +281,7 @@ async function captureAndroidUiHierarchyWithHelper( ): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { const helperDeviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb); - const releaseHelperSession = releasesHelperSessionAfterCapture( - options.helperSessionScope, - helperDeviceKey, - ); + const releaseHelperSession = releasesHelperSessionAfterCapture(options, helperDeviceKey); try { let previousContentReason: AndroidContentRecoveryReason | undefined; for (let attempt = 0; ; attempt += 1) { @@ -296,7 +296,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, @@ -304,7 +307,7 @@ async function captureAndroidUiHierarchyWithHelper( artifact, adb, signal: options.signal, - retireHelper: options.helperSessionScope !== 'borrow', + retireHelper: options.transient === undefined, }); } previousContentReason = settled.decision.reason; @@ -316,13 +319,22 @@ async function captureAndroidUiHierarchyWithHelper( } } +/** A transient read keeps a session it found running and releases one it had to start. */ function releasesHelperSessionAfterCapture( - scope: AndroidHelperSessionScope | undefined, + options: AndroidSnapshotOptions, helperDeviceKey: string, ): boolean { - if (scope === 'daemon-session') return false; - if (scope === 'borrow') return getLiveAndroidSnapshotHelperSession(helperDeviceKey) === undefined; - return true; + 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( @@ -340,14 +352,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({ @@ -531,7 +543,7 @@ async function rejectAndroidHelperContentUnavailable(params: { artifact: AndroidSnapshotHelperArtifact; adb: AndroidAdbExecutor; signal?: AbortSignal; - /** A borrowed capture does not own the helper, so its content verdict leaves the helper alone. */ + /** A transient capture does not own the helper, so its content verdict leaves the helper alone. */ retireHelper: boolean; }): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { emitDiagnostic({ diff --git a/src/core/interactors/android.test.ts b/src/core/interactors/android.test.ts index b2c7b5d8c2..fa58f526a8 100644 --- a/src/core/interactors/android.test.ts +++ b/src/core/interactors/android.test.ts @@ -95,20 +95,19 @@ test('a device-only session releases the helper after fill and scroll', async () }); }); -test('a transient snapshot borrows the helper and installs none', async () => { +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: true, + transient: { settleBy: 1_000 }, }); expect(snapshotAndroidMock).toHaveBeenCalledWith( device, expect.objectContaining({ appBundleId: 'com.example.app', - helperSessionScope: 'borrow', - helperInstallPolicy: 'current-only', + transient: { settleBy: 1_000 }, }), ); }); diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 2c027df0d6..84e1684ac5 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -104,9 +104,8 @@ export function createAndroidInteractor( scope: snapshotOptions.scope, raw: snapshotOptions.raw, includeHiddenContentHints: snapshotOptions.includeHiddenContentHints, - ...(snapshotOptions.transient - ? { helperSessionScope: 'borrow', helperInstallPolicy: 'current-only' } - : { helperSessionScope: androidHelperSessionScope(snapshotOptions.appBundleId) }), + helperSessionScope: androidHelperSessionScope(snapshotOptions.appBundleId), + transient: snapshotOptions.transient, }), { backend: 'android' }, ); From 78214d2ba843fbc1c042f2a6890872699c6124f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 24 Sep 2026 17:48:22 +0200 Subject: [PATCH 4/4] fix(android): keep a missing-helper refusal out of capture failure recovery A transient capture on a device without the current helper refused at the install check, and that refusal went through the capture failure handler, which logged an error and force-stopped the helper runtime on every new device's first open. The refusal now reaches the caller directly. The transient-capture tests move to their own file, so snapshot.test.ts stays under the test-file size ratchet. Refs #1571 --- .../snapshot-helper-session.fixtures.ts | 13 ++ .../snapshot-transient-capture.test.ts | 217 ++++++++++++++++++ .../src/__tests__/snapshot.test.ts | 185 +-------------- .../src/snapshot-helper-install.ts | 9 +- packages/platform-android/src/snapshot.ts | 2 + 5 files changed, 241 insertions(+), 185 deletions(-) create mode 100644 packages/platform-android/src/__tests__/snapshot-transient-capture.test.ts 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 bc5c4979ad..181f5846e2 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-session.fixtures.ts @@ -521,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 fb5f98b4e2..c1c05f7f07 100644 --- a/packages/platform-android/src/__tests__/snapshot.test.ts +++ b/packages/platform-android/src/__tests__/snapshot.test.ts @@ -34,10 +34,10 @@ 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'; -import { isUnreadableCaptureContentError } from '@agent-device/contracts/android-snapshot-quality'; const VALID_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+b9xkAAAAASUVORK5CYII=', @@ -189,18 +189,6 @@ test('screenshotAndroid throws when PNG payload is truncated', async () => { }); }); -function androidSystemWindowOnlyXml(): string { - return [ - '', - '', - ' ', - ' ', - ' ', - ' ', - '', - ].join('\n'); -} - function androidContentPoorFabricAppWindowXml(): string { return [ '', @@ -538,60 +526,6 @@ test('snapshotAndroid keeps daemon-session helper alive for reuse until session ); }); -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 daemon-session viewport read warms the session the next snapshot reuses', async () => { // The gesture viewport and snapshot capture are different helper commands on the same device. // They may only share the live session if both derive the same session identity, which is why @@ -662,119 +596,6 @@ test('snapshotAndroid retires content-invalid daemon helper before the next requ ); }); -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 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, - ); -}); - test('content-invalid daemon helper retirement force-stops the helper runtime', async () => { // Retirement after a content failure is a recovery path, not a release: the helper answered with // output we could not trust, so the next capture must meet a runtime that was reset. A clean quit @@ -1598,7 +1419,3 @@ test('buildUiHierarchySnapshot derives hidden content hints from can-scroll-* on assert.equal(scrollArea.hiddenContentAbove, true); assert.equal(scrollArea.hiddenContentBelow, true); }); - -function openSettleWindow(): { settleBy: number } { - return { settleBy: Date.now() + 60_000 }; -} diff --git a/packages/platform-android/src/snapshot-helper-install.ts b/packages/platform-android/src/snapshot-helper-install.ts index fd9d6f0910..c12df35185 100644 --- a/packages/platform-android/src/snapshot-helper-install.ts +++ b/packages/platform-android/src/snapshot-helper-install.ts @@ -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(); } @@ -147,7 +154,7 @@ export async function ensureAndroidSnapshotHelper(options: { 'COMMAND_FAILED', 'Android snapshot helper is not installed at the current version', { - reason: 'android-snapshot-helper-not-current', + reason: ANDROID_SNAPSHOT_HELPER_NOT_CURRENT, packageName, versionCode, installedVersionCode, diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index 97287858ed..4d25cedbb5 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -45,6 +45,7 @@ import { type AndroidSnapshotHelperOutput, } from './snapshot-helper.ts'; import { getLiveAndroidSnapshotHelperSession } from './snapshot-helper-session-lifecycle.ts'; +import { isAndroidSnapshotHelperNotCurrentError } from './snapshot-helper-install.ts'; import { getAndroidSnapshotHelperSessionDeviceKey, isAndroidSnapshotHelperRuntimeOccupiedError, @@ -492,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({