Files
callstack__agent-device/test/integration/provider-scenarios/android-lifecycle.test.ts
Michał Pierzchała d29dc22861 fix(android): warn when a permission revoke kills the session app (#1856)
* fix(android): warn when a permission revoke kills the session app

settings permission deny|reset maps to pm revoke, and Android kills the
app's process whenever a runtime permission it currently holds is revoked,
so a grant -> deny/reset sequence silently left the session on the launcher
and the next selector failed with no hint. The revoke path now reads the
prior grant state from dumpsys package first and, when it was granted,
returns wasGranted: true plus a warning naming open <app> --relaunch; the
settings CLI output renders response warnings, and commands.md documents
the behavior next to the pm revoke mapping.

Closes #1796

* fix(android): state the revoke-kill consequence conditionally

Review finding: the warning asserted the app had been killed, inferred only
from the prior grant state. dumpsys reports granted=true for any user
profile while pm revoke acts on the current one, and the app need not have
been running, so the claim could be false. State the platform rule and make
the consequence conditional; the relaunch guidance is unchanged.

* fix(android): model the prior grant state as granted/not_granted/unknown

A failed or unparseable dumpsys read as "nothing granted", so the response
asserted the app was untouched when the state was simply unknown, and the
grant scan matched every granted=true line — install permissions and other
users' blocks included — so another profile's grant could claim a kill that
never happened. Both directions of the same defect.

The read now resolves the acting user (am get-current-user) and walks the
dump's nesting (Packages: > User <id>: > runtime permissions:), and reports
priorGrantState: granted | not_granted | unknown. unknown carries the same
relaunch guidance without claiming what the state was; only not_granted is
silent.

* fix(android): address the foreground user in every permission mutation

The tri-state read scoped state to am get-current-user, but the mutations
ran bare pm grant/revoke and clear-permission-flags.
PackageManagerShellCommand defaults those to UserHandle.USER_SYSTEM, so on
a device whose foreground user is nonzero the command read one user's state
and edited user 0 — leaving the running app's permission untouched while
reporting on a user it did not change.

Proven on a Pixel 7 / API 36 emulator with the foreground user switched to
10: a bare pm revoke flipped User 0 to granted=false and left User 10
granted=true.

The foreground user is now resolved once and passed as --user to pm
grant/revoke, pm clear-permission-flags, and appops set, and the state read
takes that same id. When it cannot be resolved the mutation keeps the
platform default and the state is reported unknown rather than guessed.

* test(android): pin the user-scoped permission argv in the provider scenario

The scripted ADB provider answered only the unscoped pm grant/revoke form,
and the Settings contract asserted the unscoped transcript entry, so the
provider lane could not see which user a permission mutation addressed.

* test(android): extract the settings contract out of the lifecycle monolith

The user-scoped argv assertions pushed android-lifecycle.test.ts past its
size ratchet, whose instruction is to extract rather than grow a file over
the tripwire. assertAndroidSettingsContract moves to a sibling module and
the pin drops 1597 -> 1559.

* refactor(android): shrink the permission path to one concept per file

Size/design pass on the #1796 change:
- settings.ts was 505 lines (past the 500 extract-before-adding tripwire);
  the permission family moves to settings-permission.ts and the dispatcher
  drops to 265.
- permission-grant-state.ts loses topLevelSection (a nestedBlock with an
  indent-0 header), its single-use line reader, and androidPriorGrantState
  (one map lookup at its only production call site).
- the grants map narrows to 'granted' | 'not_granted': unknown was never a
  value, absence is what carries it, so the tests read the map directly.
- the permission tests move to settings-permission.test.ts and consolidate
  into argv/tri-state/photos/rejection tables; the parser tests fold seven
  cases into two.

Every red-proof re-run after the consolidation: dropping --user reds 7
argv/photos cases, and the pre-fix state model reds 13 across both files.

* fix(android): refuse permission mutations that cannot name their user

The fallback issued bare pm/appops commands when am get-current-user did
not answer, which is the #1796 defect itself: those default to
UserHandle.USER_SYSTEM, so a session running as user 10 had user 0 edited
while the response reported only priorGrantState: unknown. It was also a
fallback added without approval, and the docs' claim that every mutation
names its user was false on that path.

Resolving the acting user is now a prerequisite: setAndroidSetting
permission fails with COMMAND_FAILED and a recovery hint, issuing no pm,
appops or clear-permission-flags call at all. The test that locked the
fallback in is replaced by one asserting the empty mutation call list for
grant, deny and reset.
2026-08-19 17:35:47 +02:00

1560 lines
55 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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 alert handles runtime permission dialog', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ snapshotXml: androidRuntimePermissionXml }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
assert.equal(alertGet.kind, 'alertStatus');
assert.deepEqual(alertGet.alert, {
title: 'Allow Demo to send you notifications?',
buttons: ['Don’t allow', 'Allow'],
platform: 'android',
source: 'permission',
packageName: 'com.google.android.permissioncontroller',
});
const alertAccept = await client.command.alert({ action: 'accept', ...world.selection });
assert.equal(alertAccept.kind, 'alertHandled');
assert.equal(alertAccept.button, 'Allow');
assert.deepEqual(
world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 274 638'),
[['shell', 'input', 'tap', '274', '638']],
);
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
assert.equal(alertDismiss.kind, 'alertHandled');
assert.equal(alertDismiss.button, 'Don’t allow');
assert.deepEqual(
world.adbCalls.filter((call) => call.join(' ') === 'shell input tap 116 638'),
[['shell', 'input', 'tap', '116', '638']],
);
},
);
});
test('Provider-backed integration Android alert handles native AlertDialog actions', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ snapshotXml: androidNativeAlertXml }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
assert.deepEqual(alertGet.alert, {
title: 'Unsaved changes',
message: 'Leave without saving?',
buttons: ['Cancel', 'Discard'],
platform: 'android',
source: 'native-dialog',
packageName: 'com.example.demo',
});
const alertAccept = await client.command.alert({ action: 'accept', ...world.selection });
assert.equal(alertAccept.button, 'Discard');
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
assert.equal(alertDismiss.button, 'Cancel');
assert.deepEqual(
world.adbCalls.filter((call) =>
['shell input tap 274 638', 'shell input tap 116 638'].includes(call.join(' ')),
),
[
['shell', 'input', 'tap', '274', '638'],
['shell', 'input', 'tap', '116', '638'],
],
);
},
);
});
test('Provider-backed integration Android alert handles system dialogs', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ snapshotXml: androidSystemDialogXml }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
assert.deepEqual(alertGet.alert, {
title: "Demo isn't responding",
message: 'Do you want to close it?',
buttons: ['Close app', 'Wait'],
platform: 'android',
source: 'system-dialog',
packageName: 'com.android.systemui',
});
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
assert.equal(alertDismiss.button, 'Close app');
assertCommandCall(world.adbCalls, ['shell', 'input', 'tap', '116', '638']);
},
);
});
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,
);
},
);
});
test('Provider-backed integration Android alert dismiss falls back to Back without a dismiss button', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ snapshotXml: androidButtonlessAlertXml }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const alertDismiss = await client.command.alert({ action: 'dismiss', ...world.selection });
assert.equal(alertDismiss.kind, 'alertHandled');
assert.equal(alertDismiss.button, 'Back');
assertCommandCall(world.adbCalls, ['shell', 'input', 'keyevent', '4']);
},
);
});
test('Provider-backed integration Android alert wait polls until a dialog appears', async () => {
let snapshotCount = 0;
await withProviderScenarioResource(
async () =>
await createAndroidSettingsWorld({
snapshotXml: () => {
snapshotCount += 1;
return snapshotCount === 1 ? androidAppOwnedSheetXml() : androidRuntimePermissionXml();
},
}),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const alertWait = await client.command.alert({
action: 'wait',
timeoutMs: 1000,
...world.selection,
});
assert.equal(alertWait.kind, 'alertWait');
// alert now returns the untyped CommandRequestResult bag (its iOS path is a
// dynamic runner Record, so the public type is no longer a closed shape).
const alertInfo = alertWait.alert as { source?: string } | null | undefined;
assert.equal(alertInfo?.source, 'permission');
assert.ok(snapshotCount >= 2);
},
);
});
test('Provider-backed integration Android alert ignores app-owned sheets', async () => {
await withProviderScenarioResource(
async () => await createAndroidSettingsWorld({ snapshotXml: androidAppOwnedSheetXml }),
async (world) => {
const client = world.daemon.client();
await client.apps.open({ app: 'com.example.demo', ...world.selection });
const alertGet = await client.command.alert({ action: 'get', ...world.selection });
assert.equal(alertGet.kind, 'alertStatus');
assert.equal(alertGet.alert, null);
},
);
});
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);
}
function androidRuntimePermissionXml(): string {
const packageName = 'com.google.android.permissioncontroller';
return androidXml([
rootNode(packageName),
textNode(
1,
'Allow Demo to send you notifications?',
'com.android.permissioncontroller:id/permission_message',
packageName,
'[24,300][366,352]',
),
buttonNode(
2,
'Don’t allow',
'com.android.permissioncontroller:id/permission_deny_button',
'[52,612][180,664]',
packageName,
),
buttonNode(
3,
'Allow',
'com.android.permissioncontroller:id/permission_allow_button',
'[210,612][338,664]',
packageName,
),
' </node>',
]);
}
function androidNativeAlertXml(): string {
return androidDialogXml([
textNode(2, 'Unsaved changes', 'android:id/alertTitle'),
textNode(3, 'Leave without saving?', 'android:id/message'),
buttonNode(4, 'Cancel', 'android:id/button2', '[52,612][180,664]'),
buttonNode(5, 'Discard', 'android:id/button1', '[210,612][338,664]'),
]);
}
function androidSystemDialogXml(): string {
const packageName = 'com.android.systemui';
return androidXml([
rootNode(packageName),
textNode(1, 'Demo isn&apos;t responding', 'android:id/alertTitle', packageName),
textNode(2, 'Do you want to close it?', 'android:id/message', packageName),
buttonNode(3, 'Close app', 'android:id/button2', '[52,612][180,664]', packageName),
buttonNode(4, 'Wait', 'android:id/button1', '[210,612][338,664]', packageName),
' </node>',
]);
}
function androidButtonlessAlertXml(): string {
return androidDialogXml([
textNode(2, 'Unsaved changes', 'android:id/alertTitle'),
textNode(3, 'Leave without saving?', 'android:id/message'),
]);
}
function androidAppOwnedSheetXml(): string {
return androidXml([
rootNode('com.example.demo', 'com.example.demo:id/root'),
textNode(1, 'Choose an option', 'com.example.demo:id/title'),
buttonNode(2, 'Allow', 'com.example.demo:id/allow_button', '[210,612][338,664]'),
' </node>',
]);
}
function androidDialogXml(children: string[]): string {
return androidXml([
rootNode(),
androidNode({
index: 1,
id: 'android:id/parentPanel',
type: 'android.app.AlertDialog',
bounds: '[24,240][366,680]',
selfClosing: false,
}),
...children,
' </node>',
' </node>',
]);
}
function androidXml(body: string[]): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<hierarchy rotation="0">',
...body,
'</hierarchy>',
].join('\n');
}
function rootNode(packageName = 'com.example.demo', id = 'android:id/content'): string {
return androidNode({ index: 0, id, type: 'FrameLayout', packageName, selfClosing: false });
}
function textNode(
index: number,
text: string,
id: string,
packageName = 'com.example.demo',
bounds?: string,
): string {
return androidNode({ index, text, id, packageName, ...(bounds ? { bounds } : {}) });
}
function buttonNode(
index: number,
text: string,
id: string,
bounds: string,
packageName = 'com.example.demo',
): string {
return androidNode({ index, text, id, type: 'Button', packageName, bounds, clickable: true });
}
function androidNode(options: {
index: number;
id: string;
text?: string;
type?: string;
packageName?: string;
bounds?: string;
clickable?: boolean;
selfClosing?: boolean;
}): string {
const type = options.type ?? 'TextView';
const className = type.includes('.') ? type : `android.widget.${type}`;
const tagEnd = options.selfClosing === false ? '>' : ' />';
return [
` <node index="${options.index}"`,
`text="${options.text ?? ''}"`,
`resource-id="${options.id}"`,
`class="${className}"`,
`package="${options.packageName ?? 'com.example.demo'}"`,
'content-desc=""',
`bounds="${options.bounds ?? '[48,340][342,392]'}"`,
`clickable="${options.clickable ? 'true' : 'false'}"`,
'enabled="true"',
options.clickable ? 'focusable="true"' : '',
]
.filter(Boolean)
.join(' ')
.concat(tagEnd);
}
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>).sort(), [
'memory',
'snapshot',
]);
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', ['metrics', 'poll'], {
platform: 'android',
serial: PROVIDER_SCENARIO_ANDROID.id,
});
assertRpcError(invalidPerfAction, 'INVALID_ARGS', /perf action must be sample/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();
}