Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,10 @@ Use `press` as the canonical tap command; `click` is an equivalent alias.
```bash
agent-device open Contacts --platform ios # creates session on iOS Simulator
agent-device snapshot
agent-device diff snapshot # first run initializes baseline
agent-device press @e5
agent-device diff snapshot # subsequent runs compare against previous baseline
agent-device fill @e6 "John"
agent-device fill @e7 "Doe"
agent-device diff snapshot # subsequent runs compare against previous baseline
agent-device press @e3
agent-device close
```
Expand Down
1 change: 1 addition & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ agent-device snapshot -i
agent-device press @e3
agent-device wait text "Camera"
agent-device alert wait 10000
agent-device diff snapshot -i
agent-device fill @e5 "test"
agent-device close
```
Expand Down
6 changes: 3 additions & 3 deletions skills/agent-device/references/snapshot-refs.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ agent-device snapshot -i -s @e3
Use `diff snapshot` when you need compact state-change visibility between nearby UI states:

```bash
agent-device diff snapshot # First run initializes baseline
agent-device snapshot -i # First snapshot initializes baseline
agent-device press @e5
agent-device diff snapshot # Shows +/− structural lines vs prior baseline
agent-device diff snapshot -i # Shows +/− structural lines vs prior snapshot
```

Efficient pattern:
- Initialize once at a stable point.
- Mutate UI (`press`, `fill`, `swipe`).
- Run `diff snapshot` to confirm expected change shape.
- Run `diff snapshot` to confirm expected change shape. Prefer `diff snapshot` before interactions for smaller token usage.
- Re-run full/scoped `snapshot` only when you need fresh refs for next step selection.

## Troubleshooting
Expand Down
76 changes: 45 additions & 31 deletions src/utils/__tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,52 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { formatSnapshotDiffText } from '../output.ts';

test('formatSnapshotDiffText renders unified diff lines with summary', () => {
const text = formatSnapshotDiffText({
baselineInitialized: false,
summary: { additions: 2, removals: 2, unchanged: 4 },
lines: [
{ kind: 'unchanged', text: '@e0 [application]' },
{ kind: 'unchanged', text: '@e2 [window]' },
{ kind: 'removed', text: ' @e3 [other] "67"' },
{ kind: 'removed', text: ' @e4 [text] "67"' },
{ kind: 'added', text: ' @e3 [other] "134"' },
{ kind: 'added', text: ' @e4 [text] "134"' },
{ kind: 'unchanged', text: ' @e5 [button] "Increment"' },
{ kind: 'unchanged', text: ' @e6 [text] "Footer"' },
],
});
const DIFF_DATA = {
mode: 'snapshot',
baselineInitialized: false,
summary: { additions: 1, removals: 1, unchanged: 1 },
lines: [
{ kind: 'unchanged', text: '@e2 [window]' },
{ kind: 'removed', text: ' @e3 [text] "67"' },
{ kind: 'added', text: ' @e3 [text] "134"' },
],
} as const;

assert.doesNotMatch(text, /^@e0 \[application\]$/m);
assert.match(text, /^@e2 \[window\]/m);
assert.match(text, /^- @e3 \[other\] "67"$/m);
assert.match(text, /^\+ @e3 \[other\] "134"$/m);
assert.match(text, /^ @e5 \[button\] "Increment"$/m);
assert.doesNotMatch(text, /^ @e6 \[text\] "Footer"$/m);
assert.match(text, /2 additions, 2 removals, 4 unchanged/);
test('formatSnapshotDiffText renders plain text when color is disabled', () => {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '0';
delete process.env.NO_COLOR;
try {
const text = formatSnapshotDiffText({ ...DIFF_DATA });
assert.match(text, /^@e2 \[window\]/m);
assert.match(text, /^- @e3 \[text\] "67"$/m);
assert.match(text, /^\+ @e3 \[text\] "134"$/m);
assert.match(text, /1 additions, 1 removals, 1 unchanged/);
assert.equal(text.includes('\x1b['), false);
} finally {
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
}
});

test('formatSnapshotDiffText renders baseline initialization text', () => {
const text = formatSnapshotDiffText({
baselineInitialized: true,
summary: { additions: 0, removals: 0, unchanged: 5 },
lines: [],
});

assert.match(text, /Baseline initialized \(5 lines\)\./);
assert.doesNotMatch(text, /additions|removals|unchanged/);
test('formatSnapshotDiffText renders ANSI colors when forced', () => {
const originalForceColor = process.env.FORCE_COLOR;
const originalNoColor = process.env.NO_COLOR;
process.env.FORCE_COLOR = '1';
delete process.env.NO_COLOR;
try {
const text = formatSnapshotDiffText({ ...DIFF_DATA });
assert.equal(text.includes('\x1b[31m'), true);
assert.equal(text.includes('\x1b[32m'), true);
assert.equal(text.includes('\x1b[2m'), true);
assert.match(text, /\x1b\[[0-9;]+m/);
} finally {
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
else delete process.env.FORCE_COLOR;
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
else delete process.env.NO_COLOR;
}
});
39 changes: 35 additions & 4 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppError, normalizeError, type NormalizedError } from './errors.ts';
import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts';
import type { SnapshotNode } from './snapshot.ts';
import { styleText } from 'node:util';

type JsonResult =
| { success: true; data?: Record<string, unknown> }
Expand Down Expand Up @@ -80,19 +81,34 @@ export function formatSnapshotDiffText(data: Record<string, unknown>): string {
const additions = toNumber(summaryRaw.additions);
const removals = toNumber(summaryRaw.removals);
const unchanged = toNumber(summaryRaw.unchanged);
const useColor = supportsColor();
if (baselineInitialized) {
return `Baseline initialized (${unchanged} lines).\n`;
}
const rawLines = Array.isArray(data.lines) ? (data.lines as SnapshotDiffLine[]) : [];
const contextLines = applyContextWindow(rawLines, 1);
const lines = contextLines.map((line) => {
const text = typeof line.text === 'string' ? line.text : '';
if (line.kind === 'added') return text.startsWith(' ') ? `+${text}` : `+ ${text}`;
if (line.kind === 'removed') return text.startsWith(' ') ? `-${text}` : `- ${text}`;
return text;
if (line.kind === 'added') {
const prefix = text.startsWith(' ') ? `+${text}` : `+ ${text}`;
return useColor ? colorize(prefix, 'green') : prefix;
}
if (line.kind === 'removed') {
const prefix = text.startsWith(' ') ? `-${text}` : `- ${text}`;
return useColor ? colorize(prefix, 'red') : prefix;
}
return useColor ? colorize(text, 'dim') : text;
});
const body = lines.length > 0 ? `${lines.join('\n')}\n` : '';
return `${body}${additions} additions, ${removals} removals, ${unchanged} unchanged\n`;
if (!useColor) {
return `${body}${additions} additions, ${removals} removals, ${unchanged} unchanged\n`;
}
const summary = [
`${colorize(String(additions), 'green')} additions`,
`${colorize(String(removals), 'red')} removals`,
`${colorize(String(unchanged), 'dim')} unchanged`,
].join(', ');
return `${body}${summary}\n`;
}

function toNumber(value: unknown): number {
Expand All @@ -117,3 +133,18 @@ function applyContextWindow(lines: SnapshotDiffLine[], contextWindow: number): S
}
return lines.filter((_, index) => keep[index]);
}

function supportsColor(): boolean {
const forceColor = process.env.FORCE_COLOR;
if (typeof forceColor === 'string') {
return forceColor !== '0';
}
if (typeof process.env.NO_COLOR === 'string') {
return false;
}
return Boolean(process.stdout.isTTY);
}

function colorize(text: string, format: Parameters<typeof styleText>[0]): string {
return styleText(format, text);
}
Loading