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