mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
fix(snapshot): compare unchanged presentations by value (#2442)
* fix(snapshot): compare unchanged presentations by value * test(android): verify unchanged snapshot output live --------- Co-authored-by: PLASMA-FR <173463847+PLASMA-FR@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
backend acquired, so every backend serves the request; the private AX declaration is now
|
||||
`regular-depth=presentation-cut` and an acquisition that stopped short of the cut keeps
|
||||
disclosing that through `truncated`/`effectiveDepth` as it does unscoped.
|
||||
- Fixed: repeated unfiltered Android snapshots stay compact when identical element bounds arrive
|
||||
with a different property order. Changes to the bounds still re-emit the tree.
|
||||
- Added: `replay export` supports flows that switch apps and return, preserving each
|
||||
`open <appId>` target as an explicit Maestro `launchApp.appId`.
|
||||
- Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
buildUnchangedSnapshotMetadata,
|
||||
ensureSnapshotPresentationKey,
|
||||
} from './snapshot-unchanged.ts';
|
||||
import type { SnapshotState, SnapshotStateProvenance } from '@agent-device/kernel/snapshot';
|
||||
import type {
|
||||
SnapshotNode,
|
||||
SnapshotState,
|
||||
SnapshotStateProvenance,
|
||||
} from '@agent-device/kernel/snapshot';
|
||||
import {
|
||||
distinctRectPairArb,
|
||||
PROPERTY_RUNS,
|
||||
} from '@agent-device/selectors/snapshot-geometry-fixtures';
|
||||
|
||||
function snapshot(
|
||||
label: string,
|
||||
@@ -52,6 +61,133 @@ test('unchanged metadata detects visible label changes', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each<Partial<SnapshotNode>>([
|
||||
{ index: 1 },
|
||||
{ depth: 1 },
|
||||
{ parentIndex: 1 },
|
||||
{ type: 'TextField' },
|
||||
{ role: 'button' },
|
||||
{ subrole: 'AXCloseButton' },
|
||||
{ value: 'Draft' },
|
||||
{ identifier: 'create' },
|
||||
{ enabled: false },
|
||||
{ selected: true },
|
||||
{ focused: true },
|
||||
{ hittable: false },
|
||||
{ bundleId: 'com.example.app' },
|
||||
{ appName: 'Example' },
|
||||
{ windowTitle: 'Compose' },
|
||||
{ surface: 'app' },
|
||||
{ hiddenContentAbove: true },
|
||||
{ hiddenContentBelow: true },
|
||||
{ interactionBlocked: 'covered' },
|
||||
])('unchanged metadata detects presentation changes: %j', (change) => {
|
||||
const previous = snapshot('Create');
|
||||
const current = snapshot('Create', { nodes: [{ ...previous.nodes[0]!, ...change }] });
|
||||
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each<Partial<SnapshotNode>>([
|
||||
{ editable: true },
|
||||
{ password: true },
|
||||
{ hintShowing: true },
|
||||
{ selectionStart: 2 },
|
||||
{ selectionEnd: 3 },
|
||||
{ visibleToUser: true },
|
||||
{ inheritsLabel: true },
|
||||
{ inheritsIdentifier: true },
|
||||
])('unchanged metadata ignores non-presentation fields: %j', (change) => {
|
||||
const previous = snapshot('Create');
|
||||
const current = snapshot('Create', { nodes: [{ ...previous.nodes[0]!, ...change }] });
|
||||
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toMatchObject({
|
||||
nodeCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('unchanged metadata detects node count, order and truncation changes', () => {
|
||||
const first = snapshot('Create').nodes[0]!;
|
||||
const second = { ...first, index: 1, ref: 'e2', label: 'Cancel' };
|
||||
const previous = snapshot('Create', { nodes: [first, second], truncated: false });
|
||||
|
||||
for (const current of [
|
||||
{ ...previous, nodes: [first] },
|
||||
{ ...previous, nodes: [second, first] },
|
||||
{ ...previous, truncated: true },
|
||||
]) {
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('unchanged metadata compares rectangle values independently of property order', () => {
|
||||
fc.assert(
|
||||
fc.property(distinctRectPairArb, ({ ancestor, target }) => {
|
||||
const previous = snapshot('Create');
|
||||
previous.nodes[0]!.rect = ancestor;
|
||||
const current = snapshot('Create', {
|
||||
nodes: [
|
||||
{
|
||||
...previous.nodes[0]!,
|
||||
rect: {
|
||||
height: ancestor.height,
|
||||
width: ancestor.width,
|
||||
y: ancestor.y,
|
||||
x: ancestor.x,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toMatchObject({
|
||||
nodeCount: 1,
|
||||
});
|
||||
|
||||
current.nodes[0]!.rect = target;
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
|
||||
current.nodes[0]!.rect = undefined;
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
expect(
|
||||
buildUnchangedSnapshotMetadata({ previous: current, current: previous, options: {} }),
|
||||
).toBeUndefined();
|
||||
}),
|
||||
{ numRuns: PROPERTY_RUNS },
|
||||
);
|
||||
});
|
||||
|
||||
test('unchanged metadata compares action and presentation hint arrays by value', () => {
|
||||
const previous = snapshot('Create');
|
||||
previous.nodes[0]!.actions = ['Reply', 'Share'];
|
||||
previous.nodes[0]!.presentationHints = ['offscreen'];
|
||||
const current = snapshot('Create', {
|
||||
nodes: [
|
||||
{
|
||||
...previous.nodes[0]!,
|
||||
actions: ['Reply', 'Share'],
|
||||
presentationHints: ['offscreen'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toMatchObject({
|
||||
nodeCount: 1,
|
||||
});
|
||||
|
||||
current.nodes[0]!.actions = ['Share', 'Reply'];
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
|
||||
current.nodes[0]!.actions = ['Reply'];
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
|
||||
current.nodes[0]!.actions = ['Reply', 'Share'];
|
||||
current.nodes[0]!.presentationHints = [];
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
|
||||
current.nodes[0]!.presentationHints = undefined;
|
||||
expect(buildUnchangedSnapshotMetadata({ previous, current, options: {} })).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unchanged metadata requires comparison-safe snapshots', () => {
|
||||
expect(
|
||||
buildUnchangedSnapshotMetadata({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SnapshotCommandOptions } from '../../runtime-types.ts';
|
||||
import {
|
||||
buildSnapshotPresentationKey,
|
||||
type Rect,
|
||||
type SnapshotNode,
|
||||
type SnapshotState,
|
||||
type SnapshotUnchanged,
|
||||
@@ -69,41 +70,85 @@ function areSnapshotPresentationsEquivalent(
|
||||
current: SnapshotState,
|
||||
): boolean {
|
||||
if (previous.truncated !== current.truncated) return false;
|
||||
// TODO: replace stringify with a field-by-field comparison or stable presentation hash.
|
||||
return (
|
||||
JSON.stringify(buildComparableSnapshotPresentation(previous.nodes)) ===
|
||||
JSON.stringify(buildComparableSnapshotPresentation(current.nodes))
|
||||
previous.nodes.length === current.nodes.length &&
|
||||
previous.nodes.every((node, index) => areSnapshotNodesEquivalent(node, current.nodes[index]!))
|
||||
);
|
||||
}
|
||||
|
||||
function buildComparableSnapshotPresentation(
|
||||
nodes: readonly SnapshotNode[],
|
||||
): ComparableSnapshotNode[] {
|
||||
return nodes.map((node) => ({
|
||||
index: node.index,
|
||||
depth: node.depth,
|
||||
parentIndex: node.parentIndex,
|
||||
type: node.type,
|
||||
role: node.role,
|
||||
subrole: node.subrole,
|
||||
label: node.label,
|
||||
value: node.value,
|
||||
identifier: node.identifier,
|
||||
enabled: node.enabled,
|
||||
selected: node.selected,
|
||||
focused: node.focused,
|
||||
hittable: node.hittable,
|
||||
rect: node.rect,
|
||||
bundleId: node.bundleId,
|
||||
appName: node.appName,
|
||||
windowTitle: node.windowTitle,
|
||||
surface: node.surface,
|
||||
hiddenContentAbove: node.hiddenContentAbove,
|
||||
hiddenContentBelow: node.hiddenContentBelow,
|
||||
interactionBlocked: node.interactionBlocked,
|
||||
presentationHints: node.presentationHints,
|
||||
actions: node.actions,
|
||||
}));
|
||||
// Native text-entry/visibility facts are not rendered; refs and process ids are volatile.
|
||||
// Inheritance markers are output-only: this comparison runs before label deduplication.
|
||||
type ComparableSnapshotNode = Omit<
|
||||
SnapshotNode,
|
||||
| 'ref'
|
||||
| 'pid'
|
||||
| 'editable'
|
||||
| 'password'
|
||||
| 'hintShowing'
|
||||
| 'selectionStart'
|
||||
| 'selectionEnd'
|
||||
| 'visibleToUser'
|
||||
| 'inheritsLabel'
|
||||
| 'inheritsIdentifier'
|
||||
>;
|
||||
|
||||
type ScalarPresentationField = Exclude<
|
||||
keyof ComparableSnapshotNode,
|
||||
'rect' | 'presentationHints' | 'actions'
|
||||
>;
|
||||
|
||||
const PRESENTATION_SCALAR_FIELDS = {
|
||||
index: true,
|
||||
depth: true,
|
||||
parentIndex: true,
|
||||
type: true,
|
||||
role: true,
|
||||
subrole: true,
|
||||
label: true,
|
||||
value: true,
|
||||
identifier: true,
|
||||
enabled: true,
|
||||
selected: true,
|
||||
focused: true,
|
||||
hittable: true,
|
||||
bundleId: true,
|
||||
appName: true,
|
||||
windowTitle: true,
|
||||
surface: true,
|
||||
hiddenContentAbove: true,
|
||||
hiddenContentBelow: true,
|
||||
interactionBlocked: true,
|
||||
} satisfies Record<ScalarPresentationField, true>;
|
||||
|
||||
const PRESENTATION_SCALAR_KEYS = Object.keys(
|
||||
PRESENTATION_SCALAR_FIELDS,
|
||||
) as ScalarPresentationField[];
|
||||
|
||||
function areSnapshotNodesEquivalent(previous: SnapshotNode, current: SnapshotNode): boolean {
|
||||
return (
|
||||
PRESENTATION_SCALAR_KEYS.every((field) => previous[field] === current[field]) &&
|
||||
areRectsEquivalent(previous.rect, current.rect) &&
|
||||
areStringArraysEquivalent(previous.presentationHints, current.presentationHints) &&
|
||||
areStringArraysEquivalent(previous.actions, current.actions)
|
||||
);
|
||||
}
|
||||
|
||||
type ComparableSnapshotNode = Omit<SnapshotNode, 'ref' | 'pid'>;
|
||||
function areRectsEquivalent(previous: Rect | undefined, current: Rect | undefined): boolean {
|
||||
if (!previous || !current) return previous === current;
|
||||
return (
|
||||
previous.x === current.x &&
|
||||
previous.y === current.y &&
|
||||
previous.width === current.width &&
|
||||
previous.height === current.height
|
||||
);
|
||||
}
|
||||
|
||||
function areStringArraysEquivalent(
|
||||
previous: readonly string[] | undefined,
|
||||
current: readonly string[] | undefined,
|
||||
): boolean {
|
||||
if (!previous || !current) return previous === current;
|
||||
return (
|
||||
previous.length === current.length && previous.every((value, index) => value === current[index])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,41 @@ test('runtime snapshot upgrades an absent truncation flag only for producers tha
|
||||
}
|
||||
});
|
||||
|
||||
test('runtime snapshot recognizes equivalent bounds and still updates its baseline', async () => {
|
||||
let stored: Parameters<CommandSessionStore['set']>[0] | undefined;
|
||||
let rect = { x: 10, y: 20, width: 100, height: 40 };
|
||||
const device = createAgentDevice({
|
||||
backend: createSnapshotBackend(() => ({
|
||||
snapshot: makeSnapshotState([{ index: 0, depth: 0, type: 'Button', label: 'Save', rect }], {
|
||||
comparisonSafe: true,
|
||||
}),
|
||||
})),
|
||||
artifacts: createLocalArtifactAdapter(),
|
||||
sessions: {
|
||||
get: () => stored,
|
||||
set: (record) => {
|
||||
stored = record;
|
||||
},
|
||||
},
|
||||
policy: localCommandPolicy(),
|
||||
});
|
||||
|
||||
await device.capture.snapshot({ session: 'default' });
|
||||
rect = { height: 40, width: 100, y: 20, x: 10 };
|
||||
const repeated = await device.capture.snapshot({ session: 'default' });
|
||||
|
||||
assert.equal(repeated.unchanged?.nodeCount, 1);
|
||||
assert.equal(stored?.snapshot?.nodes, repeated.nodes);
|
||||
|
||||
const forced = await device.capture.snapshot({ session: 'default', forceFull: true });
|
||||
assert.equal(forced.unchanged, undefined);
|
||||
|
||||
rect = { ...rect, x: 11 };
|
||||
const moved = await device.capture.snapshot({ session: 'default' });
|
||||
assert.equal(moved.unchanged, undefined);
|
||||
assert.equal(stored?.snapshot?.nodes[0]?.rect?.x, 11);
|
||||
});
|
||||
|
||||
test('runtime snapshot uses the Appium sparse-tree disclosure for Appium acquisition', async () => {
|
||||
const device = createSnapshotOnlyDevice({
|
||||
snapshot: {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const snapshotCommandFacet = defineCommandFacet({
|
||||
name: SNAPSHOT_COMMAND_NAME,
|
||||
text: {
|
||||
summary: 'Capture or diff the accessibility tree',
|
||||
cliDetail: `For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff. iOS backend capability contract: ${snapshotBackendCapabilityHelp}.`,
|
||||
cliDetail: `Repeated equivalent unfiltered Android snapshots return a compact unchanged acknowledgement. Use --force-full to re-emit the tree; --json and --raw retain full output. For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff. iOS backend capability contract: ${snapshotBackendCapabilityHelp}.`,
|
||||
},
|
||||
metadata: snapshotCommandMetadata,
|
||||
run: (client, input) => client.capture.snapshot(input),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
scrollToVisibleSelector,
|
||||
} from './live-assertions.ts';
|
||||
import { type LiveContext, runStep, verifyBehavior, verifyCommand } from './live-harness.ts';
|
||||
import { assertHumanSnapshotCompaction } from './live-snapshot-compaction.ts';
|
||||
|
||||
const C = PUBLIC_COMMANDS;
|
||||
|
||||
@@ -52,6 +53,13 @@ export async function assertAutomationSystem(context: LiveContext): Promise<void
|
||||
await runStep(context, 'open automation lab', ['click', 'id="open-automation-lab"']);
|
||||
await assertWaitText(context, 'Automation lab');
|
||||
|
||||
await assertHumanSnapshotCompaction(context);
|
||||
await runStep(context, 'return to automation lab after snapshot compaction check', [
|
||||
'click',
|
||||
'id="open-automation-lab"',
|
||||
]);
|
||||
await assertWaitText(context, 'Automation lab');
|
||||
|
||||
const snapshot = await runStep(context, 'capture Android automation tree', ['snapshot', '-i']);
|
||||
const eventName = requireAndroidResourceId(snapshot, 'automation-event-name');
|
||||
assert.match(eventName.identifier, /(^|:id\/)automation-event-name$/, eventName.identifier);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runCmd, type ExecResult } from '@agent-device/host-kit/command';
|
||||
import type { LiveContext } from './live-harness.ts';
|
||||
|
||||
const CLI_TIMEOUT_MS = 120_000;
|
||||
const UI_TRANSITION_SETTLE_MS = 500;
|
||||
const MAX_SNAPSHOT_ATTEMPTS = 4;
|
||||
|
||||
/**
|
||||
* Exercises compact human output on the one built-in comparison-safe capture: an unfiltered
|
||||
* Android snapshot. The route change is sent through adb so no Agent Device observation can
|
||||
* replace the baseline before the following snapshot proves that changed content reprints.
|
||||
*/
|
||||
export async function assertHumanSnapshotCompaction(context: LiveContext): Promise<void> {
|
||||
const evidence: { command: string; result: ExecResult }[] = [];
|
||||
try {
|
||||
const baseline = await runSnapshot(context, ['--force-full'], evidence);
|
||||
assertFullSnapshot(baseline.stdout ?? '', 'forced baseline');
|
||||
|
||||
const { baseline: stableBaseline, unchanged } = await readUntilCompact(
|
||||
context,
|
||||
baseline,
|
||||
evidence,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
unchanged.stdout ?? '',
|
||||
/@e\d+/,
|
||||
'compact output must not re-emit element refs',
|
||||
);
|
||||
assert.ok(
|
||||
Buffer.byteLength(unchanged.stdout ?? '') < Buffer.byteLength(stableBaseline.stdout ?? ''),
|
||||
'compact output should be smaller than the full tree',
|
||||
);
|
||||
|
||||
const forced = await runSnapshot(context, ['--force-full'], evidence);
|
||||
assertFullSnapshot(forced.stdout ?? '', 'forced repeat');
|
||||
|
||||
const back = await runCmd(
|
||||
'adb',
|
||||
['-s', context.serial, 'shell', 'input', 'keyevent', 'KEYCODE_BACK'],
|
||||
{
|
||||
allowFailure: true,
|
||||
env: context.env,
|
||||
timeoutMs: CLI_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
evidence.push({ command: 'adb shell input keyevent KEYCODE_BACK', result: back });
|
||||
assert.equal(back.exitCode, 0, `external Android Back failed: ${back.stderr ?? ''}`);
|
||||
|
||||
const changed = await readUntilChangedRoute(context, evidence);
|
||||
|
||||
console.log(
|
||||
`Android human snapshot compaction passed: full=${Buffer.byteLength(stableBaseline.stdout ?? '')}B, compact=${Buffer.byteLength(unchanged.stdout ?? '')}B, forced=${Buffer.byteLength(forced.stdout ?? '')}B, changed=${Buffer.byteLength(changed.stdout ?? '')}B`,
|
||||
);
|
||||
} finally {
|
||||
fs.writeFileSync(
|
||||
path.join(context.artifactDir, 'snapshot-compaction.txt'),
|
||||
evidence
|
||||
.map(({ command, result }) =>
|
||||
[`$ ${command}`, result.stdout ?? '', result.stderr ?? ''].filter(Boolean).join('\n'),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readUntilCompact(
|
||||
context: LiveContext,
|
||||
initialBaseline: ExecResult,
|
||||
evidence: { command: string; result: ExecResult }[],
|
||||
): Promise<{ baseline: ExecResult; unchanged: ExecResult }> {
|
||||
let baseline = initialBaseline;
|
||||
for (let attempt = 1; attempt <= MAX_SNAPSHOT_ATTEMPTS; attempt += 1) {
|
||||
const current = await runSnapshot(context, [], evidence);
|
||||
if (/^Snapshot unchanged since previous read /m.test(current.stdout ?? '')) {
|
||||
return { baseline, unchanged: current };
|
||||
}
|
||||
assertFullSnapshot(current.stdout ?? '', `comparison attempt ${attempt.toString()}`);
|
||||
baseline = current;
|
||||
}
|
||||
assert.fail(`no identical Android snapshot compacted after ${MAX_SNAPSHOT_ATTEMPTS} attempts`);
|
||||
}
|
||||
|
||||
async function readUntilChangedRoute(
|
||||
context: LiveContext,
|
||||
evidence: { command: string; result: ExecResult }[],
|
||||
): Promise<ExecResult> {
|
||||
for (let attempt = 1; attempt <= MAX_SNAPSHOT_ATTEMPTS; attempt += 1) {
|
||||
const current = await runSnapshot(context, [], evidence);
|
||||
if (/Open automation lab/.test(current.stdout ?? '')) {
|
||||
assertFullSnapshot(current.stdout ?? '', 'changed route');
|
||||
return current;
|
||||
}
|
||||
if (attempt < MAX_SNAPSHOT_ATTEMPTS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, UI_TRANSITION_SETTLE_MS));
|
||||
}
|
||||
}
|
||||
assert.fail(
|
||||
`the externally changed Android route was not visible after ${MAX_SNAPSHOT_ATTEMPTS} attempts`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runSnapshot(
|
||||
context: LiveContext,
|
||||
extraArgs: string[],
|
||||
evidence: { command: string; result: ExecResult }[],
|
||||
): Promise<ExecResult> {
|
||||
const args = [
|
||||
'bin/agent-device.mjs',
|
||||
'snapshot',
|
||||
...extraArgs,
|
||||
'--platform',
|
||||
'android',
|
||||
'--serial',
|
||||
context.serial,
|
||||
'--session',
|
||||
context.session,
|
||||
'--daemon-server-mode',
|
||||
'dual',
|
||||
];
|
||||
const result = await runCmd(process.execPath, args, {
|
||||
allowFailure: true,
|
||||
env: context.env,
|
||||
timeoutMs: CLI_TIMEOUT_MS,
|
||||
});
|
||||
evidence.push({ command: `agent-device ${args.slice(1).join(' ')}`, result });
|
||||
assert.equal(result.exitCode, 0, `human snapshot failed: ${result.stderr ?? ''}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertFullSnapshot(output: string, description: string): void {
|
||||
assert.match(
|
||||
output,
|
||||
/^Snapshot: \d+(?: visible)? nodes?(?: \(\d+ total\))?(?: \(truncated\))?$/m,
|
||||
`${description} should print a tree header`,
|
||||
);
|
||||
assert.match(output, /@e\d+/, `${description} should print element refs`);
|
||||
assert.doesNotMatch(output, /snapshot unchanged/i, `${description} must not compact`);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ agent-device snapshot --diff # Alias for the same diff operation
|
||||
|
||||
- iOS and Android share the same mobile snapshot contract: visible-first output, actionable-now refs, and hidden list content communicated via discovery hints.
|
||||
- Default to `snapshot -i` for agent loops.
|
||||
- Repeated unfiltered Android snapshots with unchanged presented content and bounds return a compact acknowledgement. `-i`, `-d`, `-s`, `--json`, and `--raw` retain full output. Use `--force-full` to re-emit the tree explicitly.
|
||||
- Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions. It is visible-first and may collapse helper/accessibility noise; use `--raw` or `--json` when you need the full provider tree.
|
||||
- Off-screen interactive content is collapsed into discovery summaries such as `[off-screen below] 3 interactive items: "Privacy", "Battery", "About"`.
|
||||
- If a target only appears in an off-screen summary, use `scroll <direction>` and re-snapshot until the target becomes visible.
|
||||
|
||||
Reference in New Issue
Block a user