diff --git a/README.md b/README.md index 6ed8b9ee..ca73cdb9 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index 5fe0f1d6..085951be 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -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 ``` diff --git a/skills/agent-device/references/snapshot-refs.md b/skills/agent-device/references/snapshot-refs.md index 1bbac129..ab754c97 100644 --- a/skills/agent-device/references/snapshot-refs.md +++ b/skills/agent-device/references/snapshot-refs.md @@ -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 diff --git a/src/utils/__tests__/output.test.ts b/src/utils/__tests__/output.test.ts index 1380c4c7..3acceddd 100644 --- a/src/utils/__tests__/output.test.ts +++ b/src/utils/__tests__/output.test.ts @@ -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; + } }); diff --git a/src/utils/output.ts b/src/utils/output.ts index ddf3d155..23460b6b 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -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 } @@ -80,6 +81,7 @@ export function formatSnapshotDiffText(data: Record): 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`; } @@ -87,12 +89,26 @@ export function formatSnapshotDiffText(data: Record): string { 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 { @@ -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[0]): string { + return styleText(format, text); +}