Skip to content

Commit df0892b

Browse files
authored
feat: colorize snapshot diff output (#90)
* feat: colorize snapshot diff output with node util * docs: update snapshot diff color guidance
1 parent 6153312 commit df0892b

5 files changed

Lines changed: 85 additions & 40 deletions

File tree

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,10 @@ 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
4342
agent-device press @e5
43+
agent-device diff snapshot # subsequent runs compare against previous baseline
4444
agent-device fill @e6 "John"
4545
agent-device fill @e7 "Doe"
46-
agent-device diff snapshot # subsequent runs compare against previous baseline
4746
agent-device press @e3
4847
agent-device close
4948
```

skills/agent-device/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ agent-device snapshot -i
1515
agent-device press @e3
1616
agent-device wait text "Camera"
1717
agent-device alert wait 10000
18+
agent-device diff snapshot -i
1819
agent-device fill @e5 "test"
1920
agent-device close
2021
```

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,15 @@ agent-device snapshot -i -s @e3
5656
Use `diff snapshot` when you need compact state-change visibility between nearby UI states:
5757

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

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

7070
## Troubleshooting

src/utils/__tests__/output.test.ts

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,52 @@ import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import { formatSnapshotDiffText } from '../output.ts';
44

5-
test('formatSnapshotDiffText renders unified diff lines with summary', () => {
6-
const text = formatSnapshotDiffText({
7-
baselineInitialized: false,
8-
summary: { additions: 2, removals: 2, unchanged: 4 },
9-
lines: [
10-
{ kind: 'unchanged', text: '@e0 [application]' },
11-
{ kind: 'unchanged', text: '@e2 [window]' },
12-
{ kind: 'removed', text: ' @e3 [other] "67"' },
13-
{ kind: 'removed', text: ' @e4 [text] "67"' },
14-
{ kind: 'added', text: ' @e3 [other] "134"' },
15-
{ kind: 'added', text: ' @e4 [text] "134"' },
16-
{ kind: 'unchanged', text: ' @e5 [button] "Increment"' },
17-
{ kind: 'unchanged', text: ' @e6 [text] "Footer"' },
18-
],
19-
});
5+
const DIFF_DATA = {
6+
mode: 'snapshot',
7+
baselineInitialized: false,
8+
summary: { additions: 1, removals: 1, unchanged: 1 },
9+
lines: [
10+
{ kind: 'unchanged', text: '@e2 [window]' },
11+
{ kind: 'removed', text: ' @e3 [text] "67"' },
12+
{ kind: 'added', text: ' @e3 [text] "134"' },
13+
],
14+
} as const;
2015

21-
assert.doesNotMatch(text, /^@e0 \[application\]$/m);
22-
assert.match(text, /^@e2 \[window\]/m);
23-
assert.match(text, /^- @e3 \[other\] "67"$/m);
24-
assert.match(text, /^\+ @e3 \[other\] "134"$/m);
25-
assert.match(text, /^ @e5 \[button\] "Increment"$/m);
26-
assert.doesNotMatch(text, /^ @e6 \[text\] "Footer"$/m);
27-
assert.match(text, /2 additions, 2 removals, 4 unchanged/);
16+
test('formatSnapshotDiffText renders plain text when color is disabled', () => {
17+
const originalForceColor = process.env.FORCE_COLOR;
18+
const originalNoColor = process.env.NO_COLOR;
19+
process.env.FORCE_COLOR = '0';
20+
delete process.env.NO_COLOR;
21+
try {
22+
const text = formatSnapshotDiffText({ ...DIFF_DATA });
23+
assert.match(text, /^@e2 \[window\]/m);
24+
assert.match(text, /^- @e3 \[text\] "67"$/m);
25+
assert.match(text, /^\+ @e3 \[text\] "134"$/m);
26+
assert.match(text, /1 additions, 1 removals, 1 unchanged/);
27+
assert.equal(text.includes('\x1b['), false);
28+
} finally {
29+
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
30+
else delete process.env.FORCE_COLOR;
31+
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
32+
else delete process.env.NO_COLOR;
33+
}
2834
});
2935

30-
test('formatSnapshotDiffText renders baseline initialization text', () => {
31-
const text = formatSnapshotDiffText({
32-
baselineInitialized: true,
33-
summary: { additions: 0, removals: 0, unchanged: 5 },
34-
lines: [],
35-
});
36-
37-
assert.match(text, /Baseline initialized \(5 lines\)\./);
38-
assert.doesNotMatch(text, /additions|removals|unchanged/);
36+
test('formatSnapshotDiffText renders ANSI colors when forced', () => {
37+
const originalForceColor = process.env.FORCE_COLOR;
38+
const originalNoColor = process.env.NO_COLOR;
39+
process.env.FORCE_COLOR = '1';
40+
delete process.env.NO_COLOR;
41+
try {
42+
const text = formatSnapshotDiffText({ ...DIFF_DATA });
43+
assert.equal(text.includes('\x1b[31m'), true);
44+
assert.equal(text.includes('\x1b[32m'), true);
45+
assert.equal(text.includes('\x1b[2m'), true);
46+
assert.match(text, /\x1b\[[0-9;]+m/);
47+
} finally {
48+
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
49+
else delete process.env.FORCE_COLOR;
50+
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
51+
else delete process.env.NO_COLOR;
52+
}
3953
});

src/utils/output.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AppError, normalizeError, type NormalizedError } from './errors.ts';
22
import { buildSnapshotDisplayLines, formatSnapshotLine } from './snapshot-lines.ts';
33
import type { SnapshotNode } from './snapshot.ts';
4+
import { styleText } from 'node:util';
45

56
type JsonResult =
67
| { success: true; data?: Record<string, unknown> }
@@ -80,19 +81,34 @@ export function formatSnapshotDiffText(data: Record<string, unknown>): string {
8081
const additions = toNumber(summaryRaw.additions);
8182
const removals = toNumber(summaryRaw.removals);
8283
const unchanged = toNumber(summaryRaw.unchanged);
84+
const useColor = supportsColor();
8385
if (baselineInitialized) {
8486
return `Baseline initialized (${unchanged} lines).\n`;
8587
}
8688
const rawLines = Array.isArray(data.lines) ? (data.lines as SnapshotDiffLine[]) : [];
8789
const contextLines = applyContextWindow(rawLines, 1);
8890
const lines = contextLines.map((line) => {
8991
const text = typeof line.text === 'string' ? line.text : '';
90-
if (line.kind === 'added') return text.startsWith(' ') ? `+${text}` : `+ ${text}`;
91-
if (line.kind === 'removed') return text.startsWith(' ') ? `-${text}` : `- ${text}`;
92-
return text;
92+
if (line.kind === 'added') {
93+
const prefix = text.startsWith(' ') ? `+${text}` : `+ ${text}`;
94+
return useColor ? colorize(prefix, 'green') : prefix;
95+
}
96+
if (line.kind === 'removed') {
97+
const prefix = text.startsWith(' ') ? `-${text}` : `- ${text}`;
98+
return useColor ? colorize(prefix, 'red') : prefix;
99+
}
100+
return useColor ? colorize(text, 'dim') : text;
93101
});
94102
const body = lines.length > 0 ? `${lines.join('\n')}\n` : '';
95-
return `${body}${additions} additions, ${removals} removals, ${unchanged} unchanged\n`;
103+
if (!useColor) {
104+
return `${body}${additions} additions, ${removals} removals, ${unchanged} unchanged\n`;
105+
}
106+
const summary = [
107+
`${colorize(String(additions), 'green')} additions`,
108+
`${colorize(String(removals), 'red')} removals`,
109+
`${colorize(String(unchanged), 'dim')} unchanged`,
110+
].join(', ');
111+
return `${body}${summary}\n`;
96112
}
97113

98114
function toNumber(value: unknown): number {
@@ -117,3 +133,18 @@ function applyContextWindow(lines: SnapshotDiffLine[], contextWindow: number): S
117133
}
118134
return lines.filter((_, index) => keep[index]);
119135
}
136+
137+
function supportsColor(): boolean {
138+
const forceColor = process.env.FORCE_COLOR;
139+
if (typeof forceColor === 'string') {
140+
return forceColor !== '0';
141+
}
142+
if (typeof process.env.NO_COLOR === 'string') {
143+
return false;
144+
}
145+
return Boolean(process.stdout.isTTY);
146+
}
147+
148+
function colorize(text: string, format: Parameters<typeof styleText>[0]): string {
149+
return styleText(format, text);
150+
}

0 commit comments

Comments
 (0)