Skip to content

Commit a4bc7d1

Browse files
committed
feat: add snapshot diff command and snapshot efficiency guidance
1 parent 089cf96 commit a4bc7d1

20 files changed

Lines changed: 929 additions & 215 deletions

File tree

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The project is in early development and considered experimental. Pull requests a
1515
## Features
1616
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
1717
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
18-
- Inspection commands: `snapshot` (accessibility tree), `appstate`, `apps`, `devices`.
18+
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
1919
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2020
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
2121

@@ -39,9 +39,11 @@ Use `press` as the canonical tap command; `click` is an equivalent alias.
3939
```bash
4040
agent-device open Contacts --platform ios # creates session on iOS Simulator
4141
agent-device snapshot
42+
agent-device diff snapshot # first run initializes baseline
4243
agent-device press @e5
4344
agent-device fill @e6 "John"
4445
agent-device fill @e7 "Doe"
46+
agent-device diff snapshot # subsequent runs compare against previous baseline
4547
agent-device press @e3
4648
agent-device close
4749
```
@@ -135,7 +137,7 @@ agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-p
135137
## Command Index
136138
- `boot`, `open`, `close`, `reinstall`, `home`, `back`, `app-switcher`
137139
- `batch`
138-
- `snapshot`, `find`, `get`
140+
- `snapshot`, `diff snapshot`, `find`, `get`
139141
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
140142
- `alert`, `wait`, `screenshot`
141143
- `trace start`, `trace stop`
@@ -149,6 +151,20 @@ Notes:
149151
- iOS snapshots use XCTest on simulators and physical devices.
150152
- Scope snapshots with `-s "<label>"` or `-s @ref`.
151153
- If XCTest returns 0 nodes (e.g., foreground app changed), agent-device fails explicitly.
154+
- `diff snapshot` uses the same snapshot flags and compares the current capture with the previous session baseline, then updates baseline.
155+
156+
Diff snapshots:
157+
- Run `diff snapshot` once to initialize baseline for the current session.
158+
- Run `diff snapshot` again after UI changes to get unified-style output (`-` removed, `+` added, unchanged context).
159+
- Use `--json` to get `{ mode, baselineInitialized, summary, lines }`.
160+
161+
Efficient snapshot usage:
162+
- Default to `snapshot -i` for iterative agent loops.
163+
- Add `-s "<label>"` (or `-s @ref`) for screen-local work to reduce payload size.
164+
- Add `-d <depth>` when lower tree levels are not needed.
165+
- Re-snapshot after UI mutations before reusing refs.
166+
- Use `diff snapshot` for low-noise structural change verification between adjacent states.
167+
- Reserve `--raw` for troubleshooting and parser/debug investigations.
152168

153169
Flags:
154170
- `--version, -V` print version and exit

skills/agent-device/SKILL.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,16 @@ agent-device snapshot -c # Compact output
6464
agent-device snapshot -d 3 # Limit depth
6565
agent-device snapshot -s "Camera" # Scope to label/identifier
6666
agent-device snapshot --raw # Raw node output
67+
agent-device diff snapshot # Structural diff against previous session baseline
6768
```
6869

6970
XCTest is the iOS snapshot engine: fast, complete, and no Accessibility permission required.
7071

72+
Snapshot diff notes:
73+
- First `diff snapshot` call initializes baseline for the current session.
74+
- Subsequent `diff snapshot` calls compare current UI to prior baseline and then update baseline.
75+
- Use this for compact change tracking between adjacent UI states.
76+
7177
### Find (semantic)
7278

7379
```bash
@@ -240,6 +246,11 @@ agent-device apps --platform android --user-installed
240246
- Snapshot refs are the core mechanism for interactive agent flows.
241247
- Use selectors for deterministic replay artifacts and assertions (e.g. in e2e test workflows).
242248
- Prefer `snapshot -i` to reduce output size.
249+
- Prefer scoped snapshots (`-s "<label>"` or `-s @ref`) for screen-local tasks.
250+
- Add `-d <depth>` when only upper tree levels matter; avoid full-tree snapshots by default.
251+
- Use `diff snapshot` after mutations to detect structural changes with less output than full re-read.
252+
- Refresh refs immediately after navigation/modal/list mutations before issuing next ref-targeted action.
253+
- Use `--raw` only for debugging parser/tree edge-cases; avoid it for normal agent loops due to size.
243254
- On iOS, snapshots use XCTest and do not require Accessibility permission.
244255
- If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
245256
- `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.

skills/agent-device/references/snapshot-refs.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,22 @@ agent-device snapshot -i -s "Camera"
5151
agent-device snapshot -i -s @e3
5252
```
5353

54+
## Diff snapshots (structural)
55+
56+
Use `diff snapshot` when you need compact state-change visibility between nearby UI states:
57+
58+
```bash
59+
agent-device diff snapshot # First run initializes baseline
60+
agent-device press @e5
61+
agent-device diff snapshot # Shows +/− structural lines vs prior baseline
62+
```
63+
64+
Efficient pattern:
65+
- Initialize once at a stable point.
66+
- Mutate UI (`press`, `fill`, `swipe`).
67+
- Run `diff snapshot` to confirm expected change shape.
68+
- Re-run full/scoped `snapshot` only when you need fresh refs for next step selection.
69+
5470
## Troubleshooting
5571

5672
- Ref not found: re-snapshot.

src/__tests__/cli-diff.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { runCli } from '../cli.ts';
4+
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
5+
6+
class ExitSignal extends Error {
7+
public readonly code: number;
8+
9+
constructor(code: number) {
10+
super(`EXIT_${code}`);
11+
this.code = code;
12+
}
13+
}
14+
15+
type RunResult = {
16+
code: number | null;
17+
stdout: string;
18+
stderr: string;
19+
calls: Omit<DaemonRequest, 'token'>[];
20+
};
21+
22+
async function runCliCapture(argv: string[]): Promise<RunResult> {
23+
let stdout = '';
24+
let stderr = '';
25+
let code: number | null = null;
26+
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
27+
28+
const originalExit = process.exit;
29+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
30+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
31+
32+
(process as any).exit = ((nextCode?: number) => {
33+
throw new ExitSignal(nextCode ?? 0);
34+
}) as typeof process.exit;
35+
(process.stdout as any).write = ((chunk: unknown) => {
36+
stdout += String(chunk);
37+
return true;
38+
}) as typeof process.stdout.write;
39+
(process.stderr as any).write = ((chunk: unknown) => {
40+
stderr += String(chunk);
41+
return true;
42+
}) as typeof process.stderr.write;
43+
44+
const sendToDaemon = async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
45+
calls.push(req);
46+
return {
47+
ok: true,
48+
data: {
49+
mode: 'snapshot',
50+
baselineInitialized: false,
51+
summary: { additions: 1, removals: 1, unchanged: 1 },
52+
lines: [
53+
{ kind: 'unchanged', text: '@e2 [window]' },
54+
{ kind: 'removed', text: ' @e3 [text] "67"' },
55+
{ kind: 'added', text: ' @e3 [text] "134"' },
56+
],
57+
},
58+
};
59+
};
60+
61+
try {
62+
await runCli(argv, { sendToDaemon });
63+
} catch (error) {
64+
if (error instanceof ExitSignal) code = error.code;
65+
else throw error;
66+
} finally {
67+
process.exit = originalExit;
68+
process.stdout.write = originalStdoutWrite;
69+
process.stderr.write = originalStderrWrite;
70+
}
71+
72+
return { code, stdout, stderr, calls };
73+
}
74+
75+
test('diff snapshot renders human-readable unified diff text', async () => {
76+
const result = await runCliCapture(['diff', 'snapshot']);
77+
assert.equal(result.code, null);
78+
assert.equal(result.calls.length, 1);
79+
assert.match(result.stdout, /^@e2 \[window\]/m);
80+
assert.match(result.stdout, /^- @e3 \[text\] "67"$/m);
81+
assert.match(result.stdout, /^\+ @e3 \[text\] "134"$/m);
82+
assert.match(result.stdout, /1 additions, 1 removals, 1 unchanged/);
83+
assert.equal(result.stderr, '');
84+
});
85+
86+
test('diff snapshot --json passes daemon payload through unchanged', async () => {
87+
const result = await runCliCapture(['diff', 'snapshot', '--json']);
88+
assert.equal(result.code, null);
89+
assert.equal(result.calls.length, 1);
90+
const payload = JSON.parse(result.stdout);
91+
assert.equal(payload.success, true);
92+
assert.equal(payload.data.mode, 'snapshot');
93+
assert.equal(payload.data.baselineInitialized, false);
94+
assert.equal(Array.isArray(payload.data.lines), true);
95+
assert.equal(result.stderr, '');
96+
});

src/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { parseArgs, toDaemonFlags, usage, usageForCommand } from './utils/args.ts';
22
import { asAppError, AppError, normalizeError } from './utils/errors.ts';
3-
import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
3+
import { formatSnapshotDiffText, formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
44
import { readVersion } from './utils/version.ts';
55
import { pathToFileURL } from 'node:url';
66
import { sendToDaemon } from './daemon-client.ts';
@@ -189,6 +189,11 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
189189
if (logTailStopper) logTailStopper();
190190
return;
191191
}
192+
if (command === 'diff' && positionals[0] === 'snapshot') {
193+
process.stdout.write(formatSnapshotDiffText((response.data ?? {}) as Record<string, unknown>));
194+
if (logTailStopper) logTailStopper();
195+
return;
196+
}
192197
if (command === 'get') {
193198
const sub = positionals[0];
194199
if (sub === 'text') {

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
5454
'boot',
5555
'click',
5656
'close',
57+
'diff',
5758
'fill',
5859
'find',
5960
'focus',

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
2323
click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2424
close: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2525
fill: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
26+
diff: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2627
find: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2728
focus: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
2829
get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { attachRefs, type RawSnapshotNode } from '../../utils/snapshot.ts';
4+
import { buildSnapshotDiff } from '../snapshot-diff.ts';
5+
6+
function nodes(raw: RawSnapshotNode[]) {
7+
return attachRefs(raw);
8+
}
9+
10+
test('buildSnapshotDiff reports unchanged lines when snapshots are equal', () => {
11+
const previous = nodes([
12+
{ index: 0, depth: 0, type: 'XCUIElementTypeWindow' },
13+
{ index: 1, depth: 1, type: 'XCUIElementTypeButton', label: 'Increment' },
14+
]);
15+
const current = nodes([
16+
{ index: 0, depth: 0, type: 'XCUIElementTypeWindow' },
17+
{ index: 1, depth: 1, type: 'XCUIElementTypeButton', label: 'Increment' },
18+
]);
19+
20+
const diff = buildSnapshotDiff(previous, current);
21+
assert.equal(diff.summary.additions, 0);
22+
assert.equal(diff.summary.removals, 0);
23+
assert.equal(diff.summary.unchanged, 2);
24+
assert.deepEqual(diff.lines.map((line) => line.kind), ['unchanged', 'unchanged']);
25+
});
26+
27+
test('buildSnapshotDiff reports added and removed lines', () => {
28+
const previous = nodes([
29+
{ index: 0, depth: 0, type: 'XCUIElementTypeWindow' },
30+
{ index: 1, depth: 1, type: 'XCUIElementTypeStaticText', label: '67' },
31+
{ index: 2, depth: 1, type: 'XCUIElementTypeButton', label: 'Increment' },
32+
]);
33+
const current = nodes([
34+
{ index: 0, depth: 0, type: 'XCUIElementTypeWindow' },
35+
{ index: 1, depth: 1, type: 'XCUIElementTypeStaticText', label: '134' },
36+
{ index: 2, depth: 1, type: 'XCUIElementTypeButton', label: 'Increment' },
37+
]);
38+
39+
const diff = buildSnapshotDiff(previous, current);
40+
assert.equal(diff.summary.additions, 1);
41+
assert.equal(diff.summary.removals, 1);
42+
assert.equal(diff.summary.unchanged, 2);
43+
assert.deepEqual(diff.lines.map((line) => line.kind), ['unchanged', 'removed', 'added', 'unchanged']);
44+
});
45+
46+
test('buildSnapshotDiff treats value changes as remove plus add', () => {
47+
const previous = nodes([{ index: 0, depth: 0, type: 'XCUIElementTypeTextField', label: 'Amount', value: '67' }]);
48+
const current = nodes([{ index: 0, depth: 0, type: 'XCUIElementTypeTextField', label: 'Amount', value: '134' }]);
49+
50+
const diff = buildSnapshotDiff(previous, current);
51+
assert.equal(diff.summary.additions, 1);
52+
assert.equal(diff.summary.removals, 1);
53+
assert.equal(diff.summary.unchanged, 0);
54+
assert.deepEqual(diff.lines.map((line) => line.kind), ['removed', 'added']);
55+
});
56+
57+
test('buildSnapshotDiff preserves surrounding context ordering', () => {
58+
const previous = nodes([
59+
{ index: 0, depth: 0, type: 'XCUIElementTypeWindow' },
60+
{ index: 1, depth: 1, type: 'XCUIElementTypeStaticText', label: 'Count' },
61+
{ index: 2, depth: 1, type: 'XCUIElementTypeStaticText', label: '67' },
62+
{ index: 3, depth: 1, type: 'XCUIElementTypeButton', label: 'Increment' },
63+
]);
64+
const current = nodes([
65+
{ index: 0, depth: 0, type: 'XCUIElementTypeWindow' },
66+
{ index: 1, depth: 1, type: 'XCUIElementTypeStaticText', label: 'Count' },
67+
{ index: 2, depth: 1, type: 'XCUIElementTypeStaticText', label: '134' },
68+
{ index: 3, depth: 1, type: 'XCUIElementTypeButton', label: 'Increment' },
69+
]);
70+
71+
const diff = buildSnapshotDiff(previous, current);
72+
assert.equal(diff.lines[0]?.kind, 'unchanged');
73+
assert.equal(diff.lines[1]?.kind, 'unchanged');
74+
assert.equal(diff.lines[2]?.kind, 'removed');
75+
assert.equal(diff.lines[3]?.kind, 'added');
76+
assert.equal(diff.lines[4]?.kind, 'unchanged');
77+
});

0 commit comments

Comments
 (0)