mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
c84b449ecc
* test(settle): pin that an expanded quick-settings shade stays visible to --settle (#1319)
#1319 asked whether the systemui run-condemnation rule leaves `--settle` blind
to a fully expanded quick-settings shade, the way it blinded the replay
divergence in #1318. It does not, and this pins the reason.
The settle loop captures `interactiveOnly: true` (`stable-capture.ts`). That
walk drops the structural systemui window spine (`legacy_window_root`,
`notification_panel`, `qs_frame`, the quick-settings ComposeView chain) — and
those are exactly the nodes that merge the shade into ONE contiguous run in the
`--raw` / non-raw shape #1318 measured. Under settle's shape the same capture
arrives as five runs, only `split_shade_status_bar` carries a marker, and the
29 quick-settings nodes survive into both diff sides.
So settle and divergence do not read one tree differently, as #1318 framed it:
they consume different capture shapes, and settle already gets the outcome that
layer wants — shade content diffs, status-bar churn does not.
Separately, the quiet-detection loop digests the UNFILTERED capture
(`digestSnapshotNodes` in `stable-capture.ts`), so the shade would reset the
quiet window even in the hypothetical where chrome stripping had emptied the
diff. Both halves of the question come out clean.
No product change. The behavior is correct but rested on an untested structural
coincidence: retaining the systemui spine in the interactive walk would re-merge
the runs and make `--settle` report a full-cover shade as bare removals with no
added content and no hint. The test fails if that happens (verified by flipping
the helper to `interactiveOnly: false`).
Verified live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock), both
directions #1319 asked about and both halves the test asserts:
- shade OPENING mid-settle: settled after 6917ms: +28 -25
(added: brightness seekbar, Wi-Fi/Bluetooth/Mobile data/Quick Share/
Modes/Wallet tiles)
- shade CLOSING mid-settle: settled after 6919ms: +25 -28 (mirror image)
- the shade's own status bar ("Tue, Jul 21, Wifi signal full., T-Mobile") is
absent from both settle diffs while `snapshot -i` of the identical screen
still lists it — the run rule filters the churn rather than sitting inert.
The archived #1318 capture run through the real interactive-only walk reproduces
the live tree node-for-node (35 nodes, 29 kept, 6 stripped).
* docs(android): correct the chrome-classifier TODO — window-type keying is ruled out on device
Follow-up to the #1319 investigation, acting on review feedback that a
paragraph justifying fragile behavior means the code is wrong.
Two candidate fixes for the capture-shape-dependent chrome classification were
tested, and BOTH are dead. Recording that here so the next person does not
re-walk them:
1. The window-type approach this file's own TODO proposed. Measured against the
live helper XML (emulator-5554, Pixel 9 Pro XL API 37): systemui reports
`window-type=3` (TYPE_SYSTEM) both collapsed and expanded, `TYPE_STATUS_BAR`
(2000) never appears, the helper stamps window metadata on window ROOTS only
(1 of 169 nodes in an expanded-shade capture), and an expanded shade is ONE
window hosting the status icons AND the quick-settings tiles. No window-level
signal separates them. The TODO promised a fix the device data rules out.
2. Replacing run-condemnation with "condemn each marked node's subtree plus
fully-condemned ancestors". Shape-independent as intended, and it keeps the
tiles in both walks — but on the COLLAPSED status bar it leaks the unmarked
chrome the walk re-parents next to the markers ("Battery 100 percent.", the
notification-icon summary, neither carrying any resource-id). That is the
ticking-clock regression #1319 explicitly warned against.
So run-condemnation is not a lazy workaround: it reconstructs identity the walk
already discarded when it drops the `status_bar*` wrappers and re-parents chrome
leaves next to content. That upstream information loss is the actual root cause,
and a provenance-preserving walk is the real fix — larger than this PR, and it
would let #1318's divergence fallback be revisited.
Also trims the #1319 test's doc comment from a long justification of the current
behavior down to the finding, the defect, and what the test holds in place.
* fix(android): stamp status-bar chrome during the walk instead of reconstructing it downstream
Chrome classification gave opposite answers about the same screen depending on
capture shape: an expanded quick-settings shade was 100% chrome under the
`--raw`/non-raw walk (#1318 — every tile condemned, needing a divergence-local
fallback) and ~17% chrome under the interactive-only walk (#1319). Two layers
were papering over one broken classifier.
Root cause: `shouldIncludeStructuralAndroidNode` drops the `status_bar*` /
`navigation_bar*` containers — the only nodes that identify the region — and
re-parents their leaves next to real content. Everything downstream was
reconstructing identity the walk had already discarded, and reconstruction is
what depended on shape.
Fix: record it while it still exists. `walkUiHierarchyNode` threads
`ancestorSystemChrome` exactly like the `ancestorHittable` it already carries,
and stamps `systemChrome` on the emitted node; `androidUiNodes` tracks the same
subtree for the streaming content-recovery pass. Classification is then per node
and intrinsic, so `--raw` and non-raw agree by construction.
This deletes what was compensating for the loss:
- the 18 hand-picked marker leaf ids and their justification comment (what
enumerates them is "descendant of a status/nav-bar container" — now stamped);
- `collectAndroidSystemChromeRunIndexes`, the run-condemnation rule that let one
`clock` node condemn 95 unrelated ones;
- the leaf-id/prefix split, replaced by one container predicate that matches
`status_bar`/`navigation_bar` as an id SEGMENT so the shade's own
`split_shade_status_bar` counts.
Net −53 lines across 8 files, almost all of it classifier and prose.
Two alternatives were measured and rejected before this one (both recorded in
#1319 so they are not re-attempted):
- keying off AOSP window types, which this file's own TODO proposed:
impossible. On a live device systemui reports `window-type=3` (TYPE_SYSTEM)
collapsed AND expanded, `TYPE_STATUS_BAR` never appears, metadata is stamped
on window roots only (1 of 169 nodes), and an expanded shade is ONE window
holding the status icons and the tiles.
- condemning marked subtrees plus fully-condemned ancestors: leaks the unmarked
chrome the walk re-parents beside the markers ("Battery 100 percent.", the
notification-icon summary) — the ticking-clock regression #1319 warned about.
Test fixtures that modelled the old mechanism now model the device instead: the
synthetic status bar gets the `status_bar_launch_animation_container` root the
real capture has, and the content-recovery XML nests its chrome leaves inside
their container. Assertions were not weakened — settle.test.ts stamps via the
production predicate, and the #1319 test now asserts both walks classify
IDENTICALLY, which is the property that was missing.
Verified live on emulator-5554 (Pixel 9 Pro XL API 37, deskclock):
- shade opening mid-settle: settled after 7655ms: +28 -25, tiles present,
zero status-bar chrome (unchanged from before — settle was already right)
- ordinary action, no shade: +1 -1, no chrome leak
- real captures: collapsed status bar 9/9 chrome; expanded shade keeps every
tile under BOTH walks, which is the behavior that changed
* fix(android): keep systemChrome provenance out of published nodes; simplify androidUiNodes
Review blockers on 8615771c6:
1. `systemChrome` reached CLI/SDK/MCP payloads: `serializeSnapshotResult`
passed `result.nodes` straight through, so an internal classification became
a de-facto public contract and per-node bytes. Added `publicSnapshotNodes`
next to the field it strips, mirroring the existing
`publicSnapshotCaptureAnnotations` projection, and applied it at that
boundary (the single serializer both the CLI and capture output route
through). The regression test asserts the published nodes deep-equal their
input minus the field AND that the serialized JSON never contains the string;
it fails if the projection is removed.
2. Fallow complexity on `androidUiNodes` (10 cyclomatic / 19 cognitive):
extracted `createAndroidChromeSubtreeTracker`, which owns the depth and
chrome-subtree state, leaving the generator a token loop. Not baselined.
Note the tracker keeps open/close as distinct operations on purpose: a
self-closing tag leaves at the depth it entered, a container descends and is
left by its `</node>`, so collapsing them silently breaks nesting.
* refactor(android): let the chrome classifier read the tree, deleting the test-side stamping helper
Review: the remaining long comments read as workarounds. Three were, and each
pointed at code worth changing rather than explaining.
1. `collectAndroidSystemChromeIndexes` required the walk-stamped `systemChrome`
even when the tree still held the container that defines it. Hand-built trees
(every settle unit test) therefore classified as "no chrome at all", which is
why they needed `withAndroidSystemChrome` — a test helper re-implementing the
production descent, with a comment explaining why it had to exist.
The classifier now reads the container from whichever source still has it:
the parent chain when the capture kept it, the stamped flag when the walk
dropped it. The helper, its type and its comment are deleted; no test
re-states production logic, and any caller holding an intact tree is now
classified correctly regardless of how it was produced.
2. `createAndroidChromeSubtreeTracker` tracked depth with arithmetic subtle
enough to need a comment about self-closing tags — and subtle enough that an
earlier revision of it was wrong. Replaced with a stack of open elements:
push on descent, pop on `</node>`, nothing to explain.
3. `walkInteractiveOnlyAndroidFixture`'s 16-line comment still described the
run-classification this branch deleted — stale, not merely long. Cut to what
the helper does.
Also trimmed the contract, kernel, classifier and serialization-test comments to
the fact each states. Comment lines in the branch drop from 116 to 51.
No behavior change beyond (1), which strictly widens correct classification.
4318/4318 unit tests, lint, typecheck, build and smoke pass.
* fix(android): keep chrome provenance internal
* refactor(android): tighten chrome provenance seam
* fix(android): close chrome provenance response leaks
* test(provider): enforce chrome invariant across clients
1571 lines
55 KiB
TypeScript
1571 lines
55 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 { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts';
|
||
import { createProviderScenarioTempPath, withProviderScenarioResource } from './harness.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('Provider-backed integration Android Settings flow uses scripted ADB provider', 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);
|
||
});
|
||
}, 15_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('Provider-backed integration Android touch provider handles multi-touch gestures', 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'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),
|
||
);
|
||
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 perf = await client.observability.perf(selection);
|
||
assert.equal(perf.platform, 'android');
|
||
assert.equal(perf.deviceId, PROVIDER_SCENARIO_ANDROID.id);
|
||
const metrics = perf.metrics as Record<string, any>;
|
||
assert.equal(metrics.startup?.available, true, JSON.stringify(perf));
|
||
assert.equal(metrics.startup?.method, 'open-command-roundtrip');
|
||
assert.ok(metrics.startup?.sampleCount >= 2, JSON.stringify(metrics.startup));
|
||
const startupSamples = Array.isArray(metrics.startup?.samples) ? metrics.startup.samples : [];
|
||
assert.equal(startupSamples.at(-1)?.appTarget, 'com.example.demo');
|
||
assert.equal(startupSamples.at(-1)?.appBundleId, 'com.example.demo');
|
||
assert.equal(metrics.memory?.available, true, JSON.stringify(perf));
|
||
assert.equal(metrics.memory?.totalPssKb, 216524);
|
||
assert.equal(metrics.memory?.totalRssKb, 340112);
|
||
assert.equal(metrics.cpu?.available, true, JSON.stringify(perf));
|
||
assert.equal(metrics.cpu?.usagePercent, 9);
|
||
assert.deepEqual(metrics.cpu?.matchedProcesses, ['com.example.demo', 'com.example.demo:sync']);
|
||
assert.equal(metrics.fps?.available, true, JSON.stringify(perf));
|
||
assert.equal(metrics.fps?.droppedFramePercent, 25);
|
||
const relatedActions = Array.isArray(metrics.fps?.relatedActions)
|
||
? metrics.fps.relatedActions
|
||
: [];
|
||
assert.ok(
|
||
relatedActions.some(
|
||
(action: Record<string, unknown>) =>
|
||
action.command === 'open' && action.target === 'com.example.demo',
|
||
),
|
||
JSON.stringify(metrics.fps),
|
||
);
|
||
|
||
const explicitMetrics = await client.observability.perf({ area: 'metrics', ...selection });
|
||
assert.deepEqual(Object.keys(explicitMetrics.metrics as Record<string, unknown>).sort(), [
|
||
'cpu',
|
||
'fps',
|
||
'memory',
|
||
'startup',
|
||
]);
|
||
|
||
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);
|
||
const savedReplayPath = path.join(tempRoot, 'saved-session.ad');
|
||
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', 'cpuinfo']);
|
||
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 assertAndroidSettingsContract(world: AndroidSettingsWorld): void {
|
||
const { adbCalls } = world;
|
||
assertCommandCall(adbCalls, ['shell', 'cmd', 'uimode', 'night', 'yes']);
|
||
assertCommandCall(adbCalls, ['emu', 'geo', 'fix', '-122.009', '37.3349']);
|
||
assertCommandCall(adbCalls, ['shell', 'cmd', 'fingerprint', 'touch', '1']);
|
||
assertCommandCall(adbCalls, [
|
||
'shell',
|
||
'pm',
|
||
'grant',
|
||
'com.example.demo',
|
||
'android.permission.CAMERA',
|
||
]);
|
||
assertCommandCall(adbCalls, [
|
||
'shell',
|
||
'settings',
|
||
'put',
|
||
'global',
|
||
'window_animation_scale',
|
||
'0',
|
||
]);
|
||
assertCommandCall(adbCalls, [
|
||
'shell',
|
||
'settings',
|
||
'put',
|
||
'global',
|
||
'transition_animation_scale',
|
||
'0',
|
||
]);
|
||
assertCommandCall(adbCalls, [
|
||
'shell',
|
||
'settings',
|
||
'put',
|
||
'global',
|
||
'animator_duration_scale',
|
||
'0',
|
||
]);
|
||
assertCommandCall(adbCalls, ['shell', 'echo', 'ok']);
|
||
}
|
||
|
||
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, ['shell', 'input', 'text', 'Display']);
|
||
assertCommandCall(adbCalls, ['shell', 'input', 'text', 'Network']);
|
||
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();
|
||
}
|