Files
callstack__agent-device/test/integration/provider-scenarios/android-lifecycle.test.ts

1261 lines
45 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import type { AgentDeviceClient } from '../../../src/client/client-types.ts';
import {
arrayEqual,
assertCommandCall,
assertPngFile,
assertRpcError,
assertRpcOk,
} from './assertions.ts';
import { createAndroidSettingsWorld, waitForFileContent } from './android-world.ts';
import { androidAppOwnedSheetXml, androidSystemDialogXml } from './android-dialog-fixtures.ts';
import { assertAndroidSettingsContract } from './android-settings-contract.ts';
import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts';
import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.ts';
import {
androidImeClearTextBroadcast,
androidImeInputTextBroadcast,
} from './android-ime-lifecycle-world.ts';
import {
ANDROID_LIFECYCLE_CONTRACT_EVIDENCE,
ANDROID_TOUCH_CONTRACT_EVIDENCE,
} from './android-lifecycle.coverage.ts';
type AndroidSettingsWorld = Awaited<ReturnType<typeof createAndroidSettingsWorld>>;
const ANDROID_SYSTEM_SURFACE_XML = `<hierarchy>
<node class="android.widget.FrameLayout" package="com.android.systemui" window-type="3" window-active="true" window-focused="true" bounds="[0,0][390,844]">
<node class="android.widget.FrameLayout" package="com.android.systemui" resource-id="com.android.systemui:id/status_bar_launch_animation_container" bounds="[0,0][390,80]">
<node class="android.widget.TextView" package="com.android.systemui" text="7:03" bounds="[10,10][80,60]"/>
</node>
<node class="android.widget.SeekBar" package="com.android.systemui" text="Display brightness" clickable="true" bounds="[20,120][370,170]"/>
<node class="android.widget.Button" package="com.android.systemui" text="Wi-Fi" clickable="true" bounds="[20,200][180,280]"/>
<node class="android.widget.Button" package="com.android.systemui" text="Bluetooth" clickable="true" bounds="[200,200][370,280]"/>
</node>
</hierarchy>`;
test(
ANDROID_LIFECYCLE_CONTRACT_EVIDENCE.testName,
async () => {
await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => {
const client = world.daemon.client();
await runAndroidSetupAndInstallWorkflow(world, client);
await runAndroidAppControlAndObservabilityWorkflow(world, client);
await runAndroidCaptureInteractionAndReplayWorkflow(world, client);
assertAndroidProviderContract(world);
});
},
25_000,
);
test('Provider-backed Android reads keep chrome provenance internal across public node payloads', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ snapshotXml: () => ANDROID_SYSTEM_SURFACE_XML }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'settings', ...world.selection });
const response = await world.daemon.callCommand('snapshot', [], world.selection);
const snapshot = assertRpcOk<{ nodes: Array<{ ref: string; label?: string }> }>(response);
assert.equal(
snapshot.nodes.some((node) => node.label === 'Display brightness'),
true,
);
const clock = snapshot.nodes.find((node) => node.label === '7:03');
assert.ok(clock?.ref);
assertRpcOk(
await world.daemon.callCommand('get', ['attrs', `@${clock.ref}`], world.selection),
);
assertRpcOk(
await world.daemon.callCommand('find', ['label', '7:03', 'get', 'attrs'], world.selection),
);
},
);
});
test('Provider-backed integration Android text provider handles Unicode without shell input text', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ nativeTextInjection: true }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'settings', ...world.selection });
const snapshot = await client.capture.snapshot({
interactiveOnly: true,
...world.selection,
});
const search = snapshot.nodes.find((node) => node.label === 'Search');
assert.ok(search, JSON.stringify(snapshot.nodes));
const fill = await client.interactions.fill({
ref: `@${search.ref}`,
text: 'Łódź café',
delayMs: 2,
...world.selection,
});
assert.equal(fill.text, 'Łódź café');
const typed = await client.interactions.type({
text: 'naïve résumé',
delayMs: 3,
...world.selection,
});
assert.equal(typed.text, 'naïve résumé');
assert.deepEqual(world.textInjectionCalls, [
{
action: 'fill',
target: { x: 195, y: 52 },
text: 'Łódź café',
delayMs: 2,
},
{
action: 'type',
text: 'naïve résumé',
delayMs: 3,
},
]);
assert.equal(
world.adbCalls.some(
(call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text',
),
false,
JSON.stringify(world.adbCalls),
);
},
);
});
test(ANDROID_TOUCH_CONTRACT_EVIDENCE.testName, async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld(),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'settings', ...world.selection });
await client.interactions.longPress({
x: 195,
y: 320,
durationMs: 750,
...world.selection,
});
await client.interactions.scroll({
direction: 'down',
pixels: 120,
durationMs: 350,
...world.selection,
});
await client.interactions.pan({
x: 340,
y: 400,
dx: -280,
dy: 0,
durationMs: 300,
...world.selection,
});
const oneFingerPan = await client.interactions.pan({
x: 195,
y: 320,
dx: 20,
dy: 0,
durationMs: 500,
...world.selection,
});
assert.equal(oneFingerPan.pointerCount, 1);
assert.equal(world.daemon.session()?.actions.at(-1)?.flags.pointerCount, undefined);
const twoFingerPan = await client.interactions.pan({
x: 195,
y: 320,
dx: 40,
dy: -20,
pointerCount: 2,
durationMs: 500,
...world.selection,
});
assert.equal(twoFingerPan.pointerCount, 2);
assert.equal(twoFingerPan.backend, 'provider-native-touch');
assert.equal(world.daemon.session()?.actions.at(-1)?.flags.pointerCount, 2);
const pinch = await client.interactions.pinch({
scale: 2,
x: 195,
y: 320,
...world.selection,
});
assert.equal(pinch.kind, 'pinch');
assert.equal(pinch.pointerCount, 2);
assert.equal(pinch.backend, 'provider-native-touch');
const rotate = await client.interactions.rotateGesture({
degrees: 145,
x: 195,
y: 320,
...world.selection,
});
assert.equal(rotate.kind, 'rotate');
assert.equal(rotate.pointerCount, 2);
assert.equal(rotate.backend, 'provider-native-touch');
const transform = await client.interactions.transformGesture({
x: 195,
y: 320,
dx: 40,
dy: -20,
scale: 1.5,
degrees: 35,
durationMs: 700,
...world.selection,
});
assert.equal(transform.kind, 'transform');
assert.equal(transform.pointerCount, 2);
assert.equal(transform.backend, 'provider-native-touch');
const touchCalls = world.touchInjectionCalls.map((plan) => ({
topology: plan.topology,
intent: plan.intent,
pointerCount: plan.pointers.length,
durationMs: plan.durationMs,
}));
assert.deepEqual(touchCalls, [
{ topology: 'single', intent: 'longPress', pointerCount: 1, durationMs: 750 },
{ topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 350 },
{ topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 300 },
{ topology: 'single', intent: 'pan', pointerCount: 1, durationMs: 500 },
{ topology: 'two', intent: 'pan', pointerCount: 2, durationMs: 500 },
{ topology: 'two', intent: 'pinch', pointerCount: 2, durationMs: 300 },
{ topology: 'two', intent: 'rotate', pointerCount: 2, durationMs: 784 },
{ topology: 'two', intent: 'transform', pointerCount: 2, durationMs: 700 },
]);
assert.equal(world.gestureViewportCalls, 8);
},
);
});
test('Provider-backed integration Android app-owned ANR recovers before action commands', async () => {
let anrFocused = true;
await withProviderScenarioResource(
async () =>
await createAndroidSettingsWorld({
snapshotXml: () => (anrFocused ? androidSystemDialogXml() : androidAppOwnedSheetXml()),
dumpsysWindow: () =>
anrFocused
? 'mCurrentFocus=Window{7f8 u0 Application Not Responding: com.example.demo}\n'
: 'mCurrentFocus=Window{42 u0 com.example.demo/.MainActivity}\n',
onAdbExec: (args) => {
if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'tap' && anrFocused) {
anrFocused = false;
}
},
}),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const press = await world.daemon.callCommand('press', ['50', '60'], world.selection);
if (press.json?.error) {
assert.fail(JSON.stringify({ response: press.json, adbCalls: world.adbCalls }, null, 2));
}
const pressData = assertRpcOk(press);
assert.equal(pressData.x, 50);
assert.equal(pressData.y, 60);
assert.match(String(pressData.warning ?? ''), /Recovered Android app ANR before press/);
assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '116', '638']);
assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '50', '60']);
assert.ok(
world.adbCalls.filter((call) => call.slice(0, 4).join(' ') === 'shell am start -W')
.length >= 2,
JSON.stringify(world.adbCalls),
);
},
);
});
test('Provider-backed integration Android external ANR fails with actionable context', async () => {
await withProviderScenarioResource(
async () =>
await createAndroidSettingsWorld({
snapshotXml: androidSystemDialogXml,
dumpsysWindow: () =>
'mCurrentFocus=Window{7f8 u0 Application Not Responding: com.android.systemui}\n',
}),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const openCalls = world.adbCalls.filter(
(call) => call.slice(0, 4).join(' ') === 'shell am start -W',
).length;
const press = await world.daemon.callCommand('press', ['50', '60'], world.selection);
const error = assertRpcError(press, 'COMMAND_FAILED', /com\.android\.systemui/);
const details = error.details as Record<string, unknown>;
assert.equal(details.focusedPackage, 'com.android.systemui');
assert.equal(details.expectedPackage, 'com.example.demo');
assert.equal(
world.adbCalls.some((call) => call.join(' ') === 'shell input tap 116 638'),
false,
JSON.stringify(world.adbCalls),
);
assert.equal(
world.adbCalls.filter((call) => call.slice(0, 4).join(' ') === 'shell am start -W').length,
openCalls,
);
},
);
});
async function runAndroidSetupAndInstallWorkflow(
world: AndroidSettingsWorld,
client: AgentDeviceClient,
): Promise<void> {
const { daemon, apkPath, aabPath, manifestApkPath, selection, tempRoot } = world;
const devices = await client.devices.list({ platform: 'android' });
assert.equal(devices.length, 1);
assert.equal(devices[0]?.platform, 'android');
assert.equal(devices[0]?.id, PROVIDER_SCENARIO_ANDROID.id);
assert.equal(devices[0]?.name, PROVIDER_SCENARIO_ANDROID.name);
assert.equal(devices[0]?.target, PROVIDER_SCENARIO_ANDROID.target);
assert.equal(devices[0]?.booted, true);
const allowlistedDevices = await client.devices.list({
platform: 'android',
androidDeviceAllowlist: PROVIDER_SCENARIO_ANDROID.id,
});
assert.equal(allowlistedDevices.length, 1);
assert.equal(allowlistedDevices[0]?.id, PROVIDER_SCENARIO_ANDROID.id);
const boot = await client.devices.boot(selection);
assert.equal(boot.platform, 'android');
assert.equal(boot.id, PROVIDER_SCENARIO_ANDROID.id);
assert.equal(boot.booted, true);
const shutdown = await client.devices.shutdown(selection);
assert.equal(shutdown.platform, 'android');
assert.equal(shutdown.id, PROVIDER_SCENARIO_ANDROID.id);
assert.equal((shutdown.shutdown as { success?: unknown } | undefined)?.success, true);
const selectorTriggeredEvent = await client.apps.triggerEvent({
event: 'pre_open_ping',
payload: { stage: 'explicit-selector' },
...selection,
});
assert.equal(selectorTriggeredEvent.event, 'pre_open_ping');
assert.equal(selectorTriggeredEvent.transport, 'deep-link');
assert.equal(
selectorTriggeredEvent.eventUrl,
'demo://agent-device/event?name=pre_open_ping&payload=%7B%22stage%22%3A%22explicit-selector%22%7D&platform=android',
);
assert.equal(daemon.session(), undefined);
const keyboardDismiss = await client.command.keyboard({ action: 'dismiss', ...selection });
assert.equal(keyboardDismiss.platform, 'android');
assert.equal(keyboardDismiss.action, 'dismiss');
assert.equal(keyboardDismiss.visible, false);
assert.equal(keyboardDismiss.dismissed, false);
const open = await client.apps.open({ app: 'settings', ...selection });
assert.equal(open.device?.id, PROVIDER_SCENARIO_ANDROID.id);
const sessionBoot = await client.devices.boot();
assert.equal(sessionBoot.platform, 'android');
assert.equal(sessionBoot.id, PROVIDER_SCENARIO_ANDROID.id);
assert.equal(sessionBoot.booted, true);
const listedApps = await client.apps.list(selection);
assert.deepEqual(listedApps, ['Demo (com.example.demo)']);
const rawListedApps = await world.daemon.callCommand('apps', [], selection);
assert.deepEqual(rawListedApps.json.result.data.apps, ['Demo (com.example.demo)']);
const allApps = await client.apps.list({ ...selection, appsFilter: 'all' });
assert.deepEqual(allApps, ['Settings (com.android.settings)', 'Demo (com.example.demo)']);
const appstate = await client.command.appState(selection);
assert.equal(appstate.platform, 'android');
assert.equal(appstate.package, 'com.android.settings');
assert.equal(appstate.activity, '.Settings');
const reinstall = await client.apps.reinstall({
app: 'com.example.demo',
appPath: apkPath,
...selection,
});
assert.equal(reinstall.platform, 'android');
assert.equal(reinstall.appId, 'com.example.demo');
assert.equal(path.basename(reinstall.appPath), 'Demo.apk');
const reinstallBundle = await client.apps.reinstall({
app: 'com.example.demo',
appPath: aabPath,
...selection,
});
assert.equal(reinstallBundle.platform, 'android');
assert.equal(reinstallBundle.appId, 'com.example.demo');
assert.equal(path.basename(reinstallBundle.appPath), 'Demo.aab');
const installFromManifest = await client.apps.installFromSource({
source: { kind: 'path', path: manifestApkPath },
retainPaths: true,
retentionMs: 60_000,
...selection,
});
assert.equal(installFromManifest.packageName, 'io.example.demo_manifest');
assert.equal(installFromManifest.appName, 'Manifest');
assert.equal(installFromManifest.launchTarget, 'io.example.demo_manifest');
assert.ok(installFromManifest.installablePath?.endsWith('ManifestDemo.apk'));
assert.equal(typeof installFromManifest.materializationId, 'string');
const releaseManifestInstall = await client.materializations.release({
materializationId: String(installFromManifest.materializationId),
...selection,
});
assert.equal(releaseManifestInstall.released, true);
const push = await client.apps.push({
app: 'com.example.demo',
payload: {
action: 'com.example.demo.PUSH',
extras: { message: 'hello', unread: 2, foreground: true },
},
...selection,
});
assert.equal(push.platform, 'android');
assert.equal(push.package, 'com.example.demo');
assert.equal(push.action, 'com.example.demo.PUSH');
assert.equal(push.extrasCount, 3);
const pushPayloadPath = path.join(tempRoot, 'payload.json');
fs.writeFileSync(
pushPayloadPath,
JSON.stringify({
action: 'com.example.demo.FILE_PUSH',
extras: { source: 'relative-file' },
}),
'utf8',
);
const filePush = await daemon.callCommand(
'push',
['com.example.demo', './payload.json'],
selection,
{
meta: { cwd: tempRoot },
},
);
assert.equal(filePush.json.result.data.package, 'com.example.demo');
assert.equal(filePush.json.result.data.action, 'com.example.demo.FILE_PUSH');
assert.equal(filePush.json.result.data.extrasCount, 1);
const bracePayloadPath = path.join(tempRoot, '{payload}.json');
fs.writeFileSync(
bracePayloadPath,
JSON.stringify({
action: 'com.example.demo.BRACE_PUSH',
extras: { source: 'brace-file' },
}),
'utf8',
);
const braceFilePush = await daemon.callCommand(
'push',
['com.example.demo', './{payload}.json'],
selection,
{ meta: { cwd: tempRoot } },
);
assert.equal(braceFilePush.json.result.data.package, 'com.example.demo');
assert.equal(braceFilePush.json.result.data.action, 'com.example.demo.BRACE_PUSH');
assert.equal(braceFilePush.json.result.data.extrasCount, 1);
const clipboard = await client.command.clipboard({ action: 'read', ...selection });
if (clipboard.action !== 'read') throw new Error('expected clipboard read result');
assert.equal(clipboard.text, 'hello');
const clipboardWrite = await client.command.clipboard({
action: 'write',
text: 'android otp',
...selection,
});
if (clipboardWrite.action !== 'write') throw new Error('expected clipboard write result');
assert.equal(clipboardWrite.textLength, 11);
const clipboardAfterWrite = await client.command.clipboard({
action: 'read',
...selection,
});
if (clipboardAfterWrite.action !== 'read') throw new Error('expected clipboard read result');
assert.equal(clipboardAfterWrite.text, 'android otp');
const keyboard = await client.command.keyboard({ action: 'status', ...selection });
assert.equal(keyboard.visible, false);
}
async function runAndroidAppControlAndObservabilityWorkflow(
world: AndroidSettingsWorld,
client: AgentDeviceClient,
): Promise<void> {
const { daemon, selection, spawnedLogcat } = world;
await client.settings.update({ setting: 'appearance', state: 'dark', ...selection });
await client.settings.update({
setting: 'location',
state: 'set',
latitude: 37.3349,
longitude: -122.009,
...selection,
});
await client.settings.update({ setting: 'fingerprint', state: 'match', ...selection });
const demoOpen = await client.apps.open({
app: 'com.example.demo',
activity: 'com.example.demo/.MainActivity',
relaunch: true,
...selection,
});
assert.equal(demoOpen.appBundleId, 'com.example.demo');
const triggeredEvent = await client.apps.triggerEvent({
event: 'screenshot_taken',
payload: { source: 'provider-scenario', foreground: true },
...selection,
});
assert.equal(triggeredEvent.event, 'screenshot_taken');
assert.equal(triggeredEvent.transport, 'deep-link');
assert.equal(
triggeredEvent.eventUrl,
'demo://agent-device/event?name=screenshot_taken&payload=%7B%22source%22%3A%22provider-scenario%22%2C%22foreground%22%3Atrue%7D&platform=android',
);
const sessionAfterTriggeredEvent = daemon.session();
assert.equal(sessionAfterTriggeredEvent?.appBundleId, 'com.example.demo');
assert.ok(
sessionAfterTriggeredEvent?.actions.some(
(action) =>
action.command === 'trigger-app-event' && action.positionals[0] === 'screenshot_taken',
),
JSON.stringify(sessionAfterTriggeredEvent?.actions),
);
const tracePath = path.join(world.tempRoot, 'android-provider.adtrace');
const finalTracePath = path.join(world.tempRoot, 'android-provider-final.adtrace');
const traceStart = await daemon.callCommand('trace', ['start', tracePath], selection);
assert.equal(traceStart.json?.result?.data?.trace, 'started');
await client.settings.update({
setting: 'permission',
state: 'grant',
permission: 'camera',
...selection,
});
const logsStart = await client.observability.logs({ action: 'start', ...selection });
assert.equal(logsStart.started, true);
const logsPath = await client.observability.logs({ action: 'path', ...selection });
assert.equal(logsPath.active, true);
assert.equal(logsPath.backend, 'android');
assert.equal(typeof logsPath.path, 'string');
const appLogPath = logsPath.path as string;
await waitForFileContent(appLogPath, 'https://api.example.com/v1/login');
fs.writeFileSync(appLogPath, 'before-restart', 'utf8');
fs.writeFileSync(`${appLogPath}.1`, 'older', 'utf8');
const logsRestart = await client.observability.logs({
action: 'clear',
restart: true,
...selection,
});
assert.equal(logsRestart.path, appLogPath);
assert.equal(logsRestart.cleared, true);
assert.equal(logsRestart.restarted, true);
assert.equal(fs.existsSync(`${appLogPath}.1`), false);
assert.ok(
spawnedLogcat.some((child) => child.killed),
'Expected logs clear --restart to stop the first scripted logcat stream',
);
await waitForFileContent(appLogPath, 'https://api.example.com/v1/login');
const network = await client.observability.network({
action: 'dump',
limit: 5,
include: 'all',
...selection,
});
assert.equal(network.active, true);
assert.equal(network.backend, 'android');
assert.equal(network.include, 'all');
const networkEntries = Array.isArray(network.entries) ? network.entries : [];
assert.equal(networkEntries.length, 1, JSON.stringify(network));
const latestNetworkEntry = networkEntries[0] as Record<string, unknown>;
assert.equal(latestNetworkEntry.method, 'POST');
assert.equal(latestNetworkEntry.url, 'https://api.example.com/v1/login');
assert.equal(latestNetworkEntry.status, 401);
assert.equal(latestNetworkEntry.headers, '{"x-id":"abc"}');
assert.equal(latestNetworkEntry.requestBody, '{"email":"test@example.com"}');
assert.equal(latestNetworkEntry.responseBody, '{"error":"bad_credentials"}');
const frameSample = await client.observability.perf({ area: 'frames', ...selection });
assert.equal(frameSample.platform, 'android');
assert.equal(frameSample.deviceId, PROVIDER_SCENARIO_ANDROID.id);
const initialFrameMetrics = frameSample.metrics as Record<string, any>;
assert.equal(initialFrameMetrics.fps?.available, true, JSON.stringify(frameSample));
assert.equal(initialFrameMetrics.fps?.droppedFramePercent, 25);
const relatedActions = Array.isArray(initialFrameMetrics.fps?.relatedActions)
? initialFrameMetrics.fps.relatedActions
: [];
assert.ok(
relatedActions.some(
(action: Record<string, unknown>) =>
action.command === 'open' && action.target === 'com.example.demo',
),
JSON.stringify(initialFrameMetrics.fps),
);
const events = await client.observability.events({ limit: 100, ...selection });
const eventEntries = Array.isArray(events.events)
? (events.events as Array<{ command?: string }>)
: [];
assert.ok(eventEntries.some((event) => event.command === 'trigger-app-event'));
assert.ok(eventEntries.some((event) => event.command === 'perf'));
const traceStop = await daemon.callCommand('trace', ['stop', finalTracePath], selection);
assert.equal(traceStop.json?.result?.data?.trace, 'stopped');
assert.equal(traceStop.json?.result?.data?.outPath, finalTracePath);
assert.equal(fs.existsSync(finalTracePath), true);
const memorySample = await client.observability.perf({
area: 'memory',
action: 'sample',
...selection,
});
const memoryMetrics = memorySample.metrics as Record<string, any>;
assert.deepEqual(Object.keys(memoryMetrics), ['memory']);
assert.equal(memoryMetrics.memory?.available, true, JSON.stringify(memorySample));
assert.equal(memoryMetrics.memory?.totalPssKb, 216524);
assert.deepEqual(Object.keys(memorySample.sampling as Record<string, unknown>), ['memory']);
const heapPath = path.join(world.tempRoot, 'demo.hprof');
const memorySnapshot = await client.observability.perf({
area: 'memory',
action: 'snapshot',
kind: 'android-hprof',
out: heapPath,
...selection,
});
const heapArtifact = memorySnapshot.artifact as Record<string, any>;
assert.equal(heapArtifact.available, true, JSON.stringify(memorySnapshot));
assert.equal(heapArtifact.kind, 'android-hprof');
assert.equal(heapArtifact.path, heapPath);
assert.equal(heapArtifact.sizeBytes, 'provider-hprof-bytes'.length);
assert.equal(fs.existsSync(heapPath), true);
assertCommandCall(world.adbCalls, ['shell', 'pidof', 'com.example.demo']);
assert.ok(
world.adbCalls.some(
(call) => call.slice(0, 4).join(' ') === 'shell am dumpheap com.example.demo',
),
JSON.stringify(world.adbCalls),
);
assert.ok(
world.adbCalls.some((call) => call[0] === 'pull' && call[2] === heapPath),
JSON.stringify(world.adbCalls),
);
assert.ok(
world.adbCalls.some((call) => call.slice(0, 3).join(' ') === 'shell rm -f'),
JSON.stringify(world.adbCalls),
);
const frameCallStart = world.adbCalls.length;
const frames = await client.observability.perf({
area: 'frames',
action: 'sample',
...selection,
});
const frameMetrics = frames.metrics as Record<string, any>;
assert.deepEqual(Object.keys(frameMetrics), ['fps']);
assert.equal(frameMetrics.fps?.available, true, JSON.stringify(frames));
assert.equal(frameMetrics.fps?.droppedFramePercent, 25);
assert.deepEqual(Object.keys(frames.sampling as Record<string, unknown>), ['fps']);
assert.deepEqual(world.adbCalls.slice(frameCallStart), [
['shell', 'dumpsys', 'gfxinfo', 'com.example.demo', 'framestats'],
['shell', 'dumpsys', 'gfxinfo', 'com.example.demo', 'reset'],
]);
const invalidPerfAction = await world.daemon.callCommand('perf', ['frames', 'poll'], {
platform: 'android',
serial: PROVIDER_SCENARIO_ANDROID.id,
});
assertRpcError(invalidPerfAction, 'INVALID_ARGS', /perf action must be/i);
const logsStop = await client.observability.logs({ action: 'stop', ...selection });
assert.equal(logsStop.stopped, true);
fs.writeFileSync(appLogPath, 'before-clear', 'utf8');
fs.writeFileSync(`${appLogPath}.1`, 'older', 'utf8');
const logsClear = await client.observability.logs({ action: 'clear', ...selection });
assert.equal(logsClear.path, appLogPath);
assert.equal(logsClear.cleared, true);
assert.equal(fs.readFileSync(appLogPath, 'utf8'), '');
assert.equal(fs.existsSync(`${appLogPath}.1`), false);
const animations = await client.settings.update({
setting: 'animations',
state: 'off',
...selection,
});
assert.equal(animations.scale, '0');
assert.deepEqual(animations.keys, [
'window_animation_scale',
'transition_animation_scale',
'animator_duration_scale',
]);
await client.apps.open({ app: 'settings', ...selection });
const logsDoctor = await client.observability.logs({ action: 'doctor', ...selection });
assert.equal((logsDoctor.checks as { adbAvailable?: boolean }).adbAvailable, true);
}
async function runAndroidCaptureInteractionAndReplayWorkflow(
world: AndroidSettingsWorld,
client: AgentDeviceClient,
): Promise<void> {
const { daemon, selection, tempRoot } = world;
const screenshotPath = createProviderScenarioTempPath(
'agent-device-provider-scenario-android',
'png',
);
const fastScreenshotPath = createProviderScenarioTempPath(
'agent-device-provider-scenario-android-fast',
'png',
);
const baselineDiff = await client.capture.diff({
kind: 'snapshot',
interactiveOnly: true,
...selection,
});
assert.equal(baselineDiff.mode, 'snapshot');
assert.equal(baselineDiff.baselineInitialized, true);
assert.deepEqual(baselineDiff.summary, { additions: 0, removals: 0, unchanged: 3 });
assert.deepEqual(baselineDiff.lines, []);
const snapshot = await client.capture.snapshot({
interactiveOnly: true,
...selection,
});
const apps = snapshot.nodes.find((node) => node.label === 'Apps');
const search = snapshot.nodes.find((node) => node.label === 'Search');
assert.ok(apps, JSON.stringify(snapshot.nodes));
assert.ok(search, JSON.stringify(snapshot.nodes));
assert.equal(apps.ref, 'e2', JSON.stringify(snapshot.nodes));
assert.equal(search.ref, 'e3', JSON.stringify(snapshot.nodes));
const reactNativeDismiss = await daemon.callCommand('react-native', ['dismiss-overlay'], {
...selection,
});
assert.equal(reactNativeDismiss.statusCode, 200, JSON.stringify(reactNativeDismiss.json));
assert.equal(reactNativeDismiss.json?.result?.data?.detected, false);
assert.equal(reactNativeDismiss.json?.result?.data?.dismissed, false);
const rawSnapshot = await daemon.callCommand('snapshot', [], {
snapshotRaw: true,
...selection,
});
assert.equal(rawSnapshot.statusCode, 200, JSON.stringify(rawSnapshot.json));
assert.equal(rawSnapshot.json?.result?.data?.nodes?.[0]?.type, 'android.widget.ScrollView');
const diff = await client.capture.diff({
kind: 'snapshot',
interactiveOnly: true,
...selection,
});
assert.equal(diff.mode, 'snapshot');
assert.equal(diff.baselineInitialized, false);
assert.deepEqual(diff.summary, { additions: 0, removals: 0, unchanged: 3 });
const rotate = await client.command.orientation({
orientation: 'landscape-left',
...selection,
});
assert.equal(rotate.action, 'orientation');
assert.equal(rotate.orientation, 'landscape-left');
const appSwitcher = await client.command.appSwitcher(selection);
assert.equal(appSwitcher.action, 'app-switcher');
// ADR 0014: rotate + app-switcher expired the frame, so re-observe before
// acting through a ref again (the mock re-captures the same tree).
await client.capture.snapshot({ interactiveOnly: true, ...selection });
const press = await client.interactions.press({ ref: `@${apps.ref}`, ...selection });
assert.equal(press.x, 88);
assert.equal(press.y, 151);
const heldPress = await client.interactions.press({
x: 30,
y: 40,
count: 2,
holdMs: 5,
jitterPx: 1,
...selection,
});
assert.equal(heldPress.count, 2);
assert.equal(heldPress.holdMs, 5);
assert.equal(heldPress.jitterPx, 1);
// ADR 0014: the press + held coordinate press expired the frame — re-observe.
await client.capture.snapshot({ interactiveOnly: true, ...selection });
const click = await client.interactions.click({ ref: `@${apps.ref}`, ...selection });
assert.equal(click.x, 88);
assert.equal(click.y, 151);
const unrecordedPress = await daemon.callCommand('press', ['50', '60'], {
...selection,
noRecord: true,
});
assert.equal(unrecordedPress.json?.result?.data?.x, 50);
assert.equal(
daemon
.session()
?.actions.some(
(action) =>
action.command === 'press' &&
action.positionals[0] === '50' &&
action.positionals[1] === '60',
),
false,
);
// ADR 0014: the ref click expired the frame — re-observe before filling.
await client.capture.snapshot({ interactiveOnly: true, ...selection });
const fill = await client.interactions.fill({
ref: `@${search.ref}`,
text: 'Display',
...selection,
});
assert.equal(fill.text, 'Display');
const getText = await client.interactions.get({
format: 'text',
selector: 'id=com.android.settings:id/search',
...selection,
});
assert.equal(getText.text, 'Display');
const isVisible = await client.interactions.is({
predicate: 'visible',
selector: 'label=Apps',
...selection,
});
assert.equal(isVisible.pass, true);
const waitText = await client.command.wait({ text: 'Apps', timeoutMs: 100, ...selection });
assert.equal(waitText.text, 'Apps');
const swipe = await client.interactions.swipe({
from: { x: 20, y: 200 },
to: { x: 20, y: 100 },
count: 2,
pauseMs: 1,
pattern: 'ping-pong',
...selection,
});
assert.deepEqual(swipe.from, { x: 20, y: 200 });
assert.deepEqual(swipe.to, { x: 20, y: 100 });
assert.equal(swipe.x1, 20);
assert.equal(swipe.y1, 200);
assert.equal(swipe.x2, 20);
assert.equal(swipe.y2, 100);
assert.equal(swipe.durationMs, 100);
assert.equal(swipe.effectiveDurationMs, 100);
assert.equal(swipe.timingMode, 'direct');
assert.equal(swipe.count, 2);
assert.equal(swipe.pauseMs, 1);
assert.equal(swipe.pattern, 'ping-pong');
const pan = await client.interactions.pan({
x: 100,
y: 200,
dx: 50,
dy: -20,
durationMs: 400,
...selection,
});
assert.equal(pan.kind, 'pan');
assert.equal(pan.pointerCount, 1);
assert.deepEqual(pan.from, { x: 100, y: 200 });
assert.deepEqual(pan.to, { x: 150, y: 180 });
assert.equal(pan.durationMs, 400);
const fling = await client.interactions.fling({
direction: 'right',
x: 100,
y: 200,
distance: 180,
...selection,
});
assert.equal(fling.kind, 'fling');
assert.equal(fling.pointerCount, 1);
assert.deepEqual(fling.from, { x: 100, y: 200 });
assert.deepEqual(fling.to, { x: 280, y: 200 });
assert.equal(fling.durationMs, 100);
const batch = await client.batch.run({
steps: [
{
command: 'press',
input: { target: { kind: 'point', x: 10, y: 20 }, count: 2, intervalMs: 1 },
},
],
onError: 'stop',
maxSteps: 1,
...selection,
});
assert.equal(batch.executed, 1);
assert.equal(batch.results[0]?.data.count, 2);
const replayPath = path.join(tempRoot, 'settings-search.ad');
fs.writeFileSync(
replayPath,
[
'snapshot -i',
'press @e2 Apps --count 2 --interval-ms 1',
// ADR 0014: the ref press expired the frame, so the script re-observes
// before mutating through another ref (a legacy multi-ref-from-one-snapshot
// script would now fail closed).
'snapshot -i',
'fill @e3 Search "Network"',
'get text @e3 Search',
'',
].join('\n'),
);
const updateReplay = await client.replay.run({
path: replayPath,
update: true,
...selection,
});
assert.equal(updateReplay.replayed, 5);
assert.equal(updateReplay.healed, 0);
const replayEnvPath = path.join(tempRoot, 'settings-env.ad');
fs.writeFileSync(
replayEnvPath,
['snapshot -i', 'press @e2 "${APP_LABEL}"', 'get text @e3 Search', ''].join('\n'),
);
const replayEnv = await client.replay.run({
path: replayEnvPath,
env: ['APP_LABEL=Apps'],
...selection,
});
assert.equal(replayEnv.replayed, 3);
// ADR 0012 step 5: replay resume. A full run diverges at step 2 (a
// selector matching nothing); the report's resume object carries the plan
// digest. Resuming at the next index after the failed step (the documented
// "completed the failed action manually" loop) skips steps 1-2 without
// executing them and replays only the tail.
const resumePath = path.join(tempRoot, 'settings-resume.ad');
fs.writeFileSync(
resumePath,
['snapshot -i', 'press label="NoSuchControl"', 'get text @e3 Search', ''].join('\n'),
);
const divergenceError = await client.replay.run({ path: resumePath, ...selection }).then(
() => null,
(error: unknown) => error as { code?: string; details?: Record<string, unknown> },
);
assert.ok(divergenceError, 'expected the replay to diverge on the missing selector');
assert.equal(divergenceError.code, 'REPLAY_DIVERGENCE');
const divergenceReport = divergenceError.details?.divergence as {
resume: { allowed: boolean; from: number; planDigest: string };
};
assert.equal(divergenceReport.resume.allowed, true);
assert.equal(divergenceReport.resume.from, 2);
assert.match(divergenceReport.resume.planDigest, /^[0-9a-f]{64}$/);
const resumedReplay = await client.replay.run({
path: resumePath,
resumeFrom: divergenceReport.resume.from + 1,
resumePlanDigest: divergenceReport.resume.planDigest,
...selection,
});
assert.equal(resumedReplay.replayed, 1);
const screenshot = await client.capture.screenshot({
path: screenshotPath,
...selection,
});
assert.equal(screenshot.path, screenshotPath);
assertPngFile(screenshotPath);
const fastScreenshot = await client.capture.screenshot({
path: fastScreenshotPath,
overlayRefs: true,
stabilize: false,
...selection,
});
assert.equal(fastScreenshot.path, fastScreenshotPath);
assert.ok(
fastScreenshot.overlayRefs && fastScreenshot.overlayRefs.length > 0,
JSON.stringify(fastScreenshot),
);
assertPngFile(fastScreenshotPath);
const screenshotOutPath = path.join(tempRoot, 'screenshot-out-flag.png');
const screenshotWithOut = await daemon.callCommand('screenshot', [], {
out: screenshotOutPath,
...selection,
});
assert.equal(screenshotWithOut.statusCode, 200, JSON.stringify(screenshotWithOut.json));
assert.equal(screenshotWithOut.json?.result?.data?.path, screenshotOutPath);
assertPngFile(screenshotOutPath);
const beforeCloseOpen = await client.apps.open({ app: 'com.example.demo', ...selection });
assert.equal(beforeCloseOpen.appBundleId, 'com.example.demo');
const logsBeforeClose = await client.observability.logs({ action: 'start', ...selection });
assert.equal(logsBeforeClose.started, true);
// close --save-script now requires the session to have been armed at open (recording-time
// target-v1 evidence cannot be reconstructed retroactively for a session that never recorded
// it). End this long-lived unarmed session plainly, then arm a fresh one before exercising
// close --save-script + shutdown below.
const plainCloseBeforeArm = await daemon.callCommand('close');
assert.equal(plainCloseBeforeArm.statusCode, 200, JSON.stringify(plainCloseBeforeArm.json));
assert.equal(daemon.session(), undefined);
const savedReplayPath = path.join(tempRoot, 'saved-session.ad');
const armedOpen = await client.apps.open({
app: 'com.example.demo',
saveScript: savedReplayPath,
...selection,
});
assert.equal(armedOpen.appBundleId, 'com.example.demo');
const close = await daemon.callCommand('close', [], {
saveScript: savedReplayPath,
shutdown: true,
});
assert.equal(close.json?.result?.data?.shutdown?.success, true);
assert.equal(fs.existsSync(savedReplayPath), true);
assert.equal(daemon.session(), undefined);
const testReplayPath = path.join(tempRoot, 'settings-smoke.ad');
fs.writeFileSync(
testReplayPath,
['context platform=android', 'open settings', 'snapshot -i', 'is visible label=Apps', ''].join(
'\n',
),
);
const testSuite = await client.replay.test({
paths: [testReplayPath],
artifactsDir: path.join(tempRoot, 'artifacts'),
...selection,
});
assert.equal(testSuite.total, 1);
assert.equal(testSuite.passed, 1, JSON.stringify(testSuite));
assert.equal(testSuite.failed, 0, JSON.stringify(testSuite));
}
function assertAndroidProviderContract(world: AndroidSettingsWorld): void {
assertAndroidInventoryContract(world);
assertAndroidInstallAndLaunchContract(world);
assertAndroidPushAndEventContract(world);
assertAndroidObservabilityContract(world);
assertAndroidSettingsContract(world);
assertAndroidInteractionContract(world);
assertAndroidShutdownContract(world);
}
function assertAndroidInventoryContract(world: AndroidSettingsWorld): void {
assert.ok(
world.inventoryRequests.some((request) =>
request.androidSerialAllowlist?.includes(PROVIDER_SCENARIO_ANDROID.id),
),
JSON.stringify(world.inventoryRequests),
);
}
function assertAndroidInstallAndLaunchContract(world: AndroidSettingsWorld): void {
const { adbCalls, apkInstallCalls, bundleInstallCalls } = world;
const appApkInstallCalls = apkInstallCalls.filter((call) =>
['Demo.apk', 'ManifestDemo.apk'].includes(path.basename(call.apkPath)),
);
assertCommandCall(adbCalls, ['shell', 'am', 'start', '-W', '-a', 'android.settings.SETTINGS']);
assertCommandCall(adbCalls, ['shell', 'am', 'force-stop', 'com.example.demo']);
assertCommandCall(adbCalls, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.MAIN',
'-c',
'android.intent.category.DEFAULT',
'-c',
'android.intent.category.LAUNCHER',
'-n',
'com.example.demo/.MainActivity',
]);
assertCommandCall(adbCalls, ['uninstall', 'com.example.demo']);
assert.equal(appApkInstallCalls.length, 2);
assert.equal(path.basename(appApkInstallCalls[0]?.apkPath ?? ''), 'Demo.apk');
assert.equal(appApkInstallCalls[0]?.replace, true);
assert.equal(path.basename(appApkInstallCalls[1]?.apkPath ?? ''), 'ManifestDemo.apk');
assert.equal(appApkInstallCalls[1]?.replace, true);
assert.deepEqual(bundleInstallCalls, [{ bundlePath: world.aabPath, mode: 'universal' }]);
}
function assertAndroidPushAndEventContract(world: AndroidSettingsWorld): void {
const { adbCalls } = world;
assertCommandCall(adbCalls, [
'shell',
'am',
'broadcast',
'-a',
'com.example.demo.PUSH',
'-p',
'com.example.demo',
'--es',
'message',
'hello',
'--ei',
'unread',
'2',
'--ez',
'foreground',
'true',
]);
assertCommandCall(adbCalls, [
'shell',
'am',
'broadcast',
'-a',
'com.example.demo.FILE_PUSH',
'-p',
'com.example.demo',
'--es',
'source',
'relative-file',
]);
assertCommandCall(adbCalls, [
'shell',
'am',
'broadcast',
'-a',
'com.example.demo.BRACE_PUSH',
'-p',
'com.example.demo',
'--es',
'source',
'brace-file',
]);
assertCommandCall(adbCalls, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.VIEW',
'-d',
"'demo://agent-device/event?name=pre_open_ping&payload=%7B%22stage%22%3A%22explicit-selector%22%7D&platform=android'",
]);
assertCommandCall(adbCalls, [
'shell',
'am',
'start',
'-W',
'-a',
'android.intent.action.VIEW',
'-d',
"'demo://agent-device/event?name=screenshot_taken&payload=%7B%22source%22%3A%22provider-scenario%22%2C%22foreground%22%3Atrue%7D&platform=android'",
'-p',
'com.example.demo',
]);
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'get', 'text']);
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', "'android otp'"]);
assertCommandCall(adbCalls, ['shell', 'dumpsys', 'input_method']);
}
function assertAndroidObservabilityContract(world: AndroidSettingsWorld): void {
const { adbCalls, spawnedLogcat } = world;
assertCommandCall(adbCalls, ['shell', 'pidof', 'com.example.demo']);
assertCommandCall(adbCalls, ['shell', 'dumpsys', 'meminfo', 'com.example.demo']);
assertCommandCall(adbCalls, ['shell', 'dumpsys', 'gfxinfo', 'com.example.demo', 'framestats']);
assertCommandCall(adbCalls, ['shell', 'dumpsys', 'gfxinfo', 'com.example.demo', 'reset']);
assert.ok(
spawnedLogcat.some((child) => child.killed),
'Expected logs stop to terminate the scripted logcat stream',
);
assert.ok(
spawnedLogcat.filter((child) => child.killed).length >= 2,
'Expected close to auto-stop the active scripted logcat stream',
);
}
function assertAndroidInteractionContract(world: AndroidSettingsWorld): void {
const { adbCalls } = world;
assert.ok(
adbCalls.some(
(call) =>
call[0] === 'shell' &&
call[1] === 'am' &&
call[2] === 'instrument' &&
call.includes('com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation'),
),
JSON.stringify(adbCalls),
);
assert.deepEqual(
world.touchInjectionCalls.map((plan) => ({
intent: plan.intent,
durationMs: plan.durationMs,
})),
[
{ intent: 'longPress', durationMs: 5 },
{ intent: 'longPress', durationMs: 5 },
{ intent: 'fling', durationMs: 100 },
{ intent: 'fling', durationMs: 100 },
{ intent: 'pan', durationMs: 400 },
{ intent: 'fling', durationMs: 100 },
],
);
assertCommandCall(adbCalls, ['shell', 'input', 'tap', '88', '151']);
assertCommandCall(adbCalls, [
'shell',
'settings',
'put',
'system',
'accelerometer_rotation',
'0',
]);
assertCommandCall(adbCalls, ['shell', 'settings', 'put', 'system', 'user_rotation', '1']);
assertCommandCall(adbCalls, ['shell', 'input', 'keyevent', '187']);
assert.equal(
adbCalls.filter((call) => arrayEqual(call, ['shell', 'input', 'tap', '10', '20'])).length,
2,
);
assertCommandCall(adbCalls, ['shell', 'input', 'tap', '50', '60']);
assert.equal(
adbCalls.filter((call) => arrayEqual(call, ['shell', 'input', 'tap', '88', '151'])).length,
5,
);
assertCommandCall(adbCalls, androidImeClearTextBroadcast);
assertCommandCall(adbCalls, androidImeInputTextBroadcast('Display'));
assertCommandCall(adbCalls, androidImeInputTextBroadcast('Network'));
assert.equal(
adbCalls.some((call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text'),
false,
);
assert.equal(
adbCalls.filter((call) => arrayEqual(call, ['exec-out', 'screencap', '-p'])).length,
3,
);
assert.equal(
adbCalls.filter((call) =>
arrayEqual(call, ['shell', 'settings put global sysui_demo_allowed 1']),
).length,
2,
);
}
function assertAndroidShutdownContract(world: AndroidSettingsWorld): void {
const { adbCalls } = world;
assertCommandCall(adbCalls, ['emu', 'kill']);
world.assertNoHostAdbCalls();
}