Skip to content

Commit 3f9eee0

Browse files
dannyhwthymikee
andauthored
feat: add diff screenshot command for pixel-level image comparison (#214)
* fix: rebase PR 214 onto main * fix: clean up screenshot diff output handling * refactor: split android screenshot module * chore: remove clack prompts dependency * chore: format command schema * refactor: unify user path resolution * fix: let android screenshot ui settle --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent 2be4de0 commit 3f9eee0

24 files changed

Lines changed: 1617 additions & 107 deletions

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,13 @@
6565
"trailingComma": "all",
6666
"printWidth": 100
6767
},
68+
"dependencies": {
69+
"pngjs": "^7.0.0"
70+
},
6871
"devDependencies": {
69-
"@types/node": "^22.0.0",
7072
"@rslib/core": "0.19.4",
73+
"@types/node": "^22.0.0",
74+
"@types/pngjs": "^6.0.5",
7175
"prettier": "^3.3.3",
7276
"typescript": "^5.9.3"
7377
}

pnpm-lock.yaml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/cli-diff.test.ts

Lines changed: 219 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import test from 'node:test';
1+
import { describe, test } from 'node:test';
22
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { PNG } from 'pngjs';
37
import { runCli } from '../cli.ts';
48
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
59

@@ -19,7 +23,30 @@ type RunResult = {
1923
calls: Omit<DaemonRequest, 'token'>[];
2024
};
2125

22-
async function runCliCapture(argv: string[]): Promise<RunResult> {
26+
type RunCliCaptureOptions = {
27+
preserveHome?: boolean;
28+
};
29+
30+
/** Create a solid-color PNG buffer. */
31+
function solidPngBuffer(
32+
width: number,
33+
height: number,
34+
color: { r: number; g: number; b: number },
35+
): Buffer {
36+
const png = new PNG({ width, height });
37+
for (let i = 0; i < png.data.length; i += 4) {
38+
png.data[i] = color.r;
39+
png.data[i + 1] = color.g;
40+
png.data[i + 2] = color.b;
41+
png.data[i + 3] = 255;
42+
}
43+
return PNG.sync.write(png);
44+
}
45+
46+
async function runCliCapture(
47+
argv: string[],
48+
options: RunCliCaptureOptions = {},
49+
): Promise<RunResult> {
2350
let stdout = '';
2451
let stderr = '';
2552
let code: number | null = null;
@@ -28,21 +55,45 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
2855
const originalExit = process.exit;
2956
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3057
const originalStderrWrite = process.stderr.write.bind(process.stderr);
58+
const originalForceColor = process.env.FORCE_COLOR;
59+
const originalNoColor = process.env.NO_COLOR;
60+
const originalHome = process.env.HOME;
61+
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-home-'));
62+
63+
// Disable ANSI colors so assertions can match plain text
64+
process.env.FORCE_COLOR = '0';
65+
delete process.env.NO_COLOR;
66+
if (!options.preserveHome) {
67+
process.env.HOME = tempHome;
68+
}
3169

3270
(process as any).exit = ((nextCode?: number) => {
3371
throw new ExitSignal(nextCode ?? 0);
3472
}) as typeof process.exit;
35-
(process.stdout as any).write = ((chunk: unknown) => {
73+
(process.stdout as any).write = ((chunk: unknown, ...args: unknown[]) => {
74+
// Pass through the test runner's binary protocol messages (raw Buffers)
75+
if (Buffer.isBuffer(chunk)) return originalStdoutWrite(chunk, ...(args as [any]));
3676
stdout += String(chunk);
3777
return true;
3878
}) as typeof process.stdout.write;
39-
(process.stderr as any).write = ((chunk: unknown) => {
79+
(process.stderr as any).write = ((chunk: unknown, ...args: unknown[]) => {
80+
if (Buffer.isBuffer(chunk)) return originalStderrWrite(chunk, ...(args as [any]));
4081
stderr += String(chunk);
4182
return true;
4283
}) as typeof process.stderr.write;
4384

4485
const sendToDaemon = async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
4586
calls.push(req);
87+
if (req.command === 'screenshot') {
88+
// The client-backed diff handler captures a screenshot via the client.
89+
// Write a real PNG to the requested path so compareScreenshots can read it.
90+
const outPath = req.positionals?.[0] ?? req.flags?.out;
91+
if (typeof outPath === 'string') {
92+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
93+
fs.writeFileSync(outPath, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
94+
}
95+
return { ok: true, data: { path: outPath } };
96+
}
4697
return {
4798
ok: true,
4899
data: {
@@ -67,30 +118,174 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
67118
process.exit = originalExit;
68119
process.stdout.write = originalStdoutWrite;
69120
process.stderr.write = originalStderrWrite;
121+
if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor;
122+
else delete process.env.FORCE_COLOR;
123+
if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor;
124+
else delete process.env.NO_COLOR;
125+
if (typeof originalHome === 'string') process.env.HOME = originalHome;
126+
else delete process.env.HOME;
127+
fs.rmSync(tempHome, { recursive: true, force: true });
70128
}
71129

72130
return { code, stdout, stderr, calls };
73131
}
74132

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-
});
133+
// Tests must run serially because they monkey-patch process.exit and process.stdout.write.
134+
describe('cli diff commands', { concurrency: false }, () => {
135+
test('diff snapshot renders human-readable unified diff text', async () => {
136+
const result = await runCliCapture(['diff', 'snapshot']);
137+
assert.equal(result.code, null);
138+
assert.equal(result.calls.length, 1);
139+
assert.match(result.stdout, /^@e2 \[window\]/m);
140+
assert.match(result.stdout, /^- @e3 \[text\] "67"$/m);
141+
assert.match(result.stdout, /^\+ @e3 \[text\] "134"$/m);
142+
assert.match(result.stdout, /1 additions, 1 removals, 1 unchanged/);
143+
assert.equal(result.stderr, '');
144+
});
145+
146+
test('diff snapshot --json passes daemon payload through unchanged', async () => {
147+
const result = await runCliCapture(['diff', 'snapshot', '--json']);
148+
assert.equal(result.code, null);
149+
assert.equal(result.calls.length, 1);
150+
const payload = JSON.parse(result.stdout);
151+
assert.equal(payload.success, true);
152+
assert.equal(payload.data.mode, 'snapshot');
153+
assert.equal(payload.data.baselineInitialized, false);
154+
assert.equal(Array.isArray(payload.data.lines), true);
155+
assert.equal(result.stderr, '');
156+
});
157+
158+
test('diff screenshot renders human-readable mismatch output', async () => {
159+
// Create a real baseline PNG (black) so compareScreenshots can run against it.
160+
// The mock sendToDaemon writes a white PNG as the "current" screenshot.
161+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
162+
const baseline = path.join(dir, 'baseline.png');
163+
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 0, g: 0, b: 0 }));
164+
165+
try {
166+
const result = await runCliCapture([
167+
'diff',
168+
'screenshot',
169+
'--baseline',
170+
baseline,
171+
'--threshold',
172+
'0',
173+
]);
174+
assert.equal(result.code, null);
175+
// Client-backed command sends a screenshot request to daemon
176+
assert.equal(result.calls.length, 1);
177+
assert.equal(result.calls[0]!.command, 'screenshot');
178+
assert.match(result.stdout, /100% pixels differ/);
179+
assert.match(result.stdout, /100 different \/ 100 total pixels/);
180+
assert.equal(result.stdout.includes('Diff image:'), false);
181+
assert.equal(result.stderr, '');
182+
} finally {
183+
fs.rmSync(dir, { recursive: true, force: true });
184+
}
185+
});
186+
187+
test('diff screenshot --json outputs structured result', async () => {
188+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
189+
const baseline = path.join(dir, 'baseline.png');
190+
// Same color as mock current screenshot → should match
191+
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
192+
193+
try {
194+
const result = await runCliCapture(['diff', 'screenshot', '--baseline', baseline, '--json']);
195+
assert.equal(result.code, null);
196+
const payload = JSON.parse(result.stdout);
197+
assert.equal(payload.success, true);
198+
assert.equal(payload.data.match, true);
199+
assert.equal(payload.data.differentPixels, 0);
200+
assert.equal(payload.data.totalPixels, 100);
201+
assert.equal(payload.data.mismatchPercentage, 0);
202+
assert.equal(result.stderr, '');
203+
} finally {
204+
fs.rmSync(dir, { recursive: true, force: true });
205+
}
206+
});
207+
208+
test('diff screenshot sends screenshot capture request to daemon', async () => {
209+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
210+
const baseline = path.join(dir, 'baseline.png');
211+
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
212+
213+
try {
214+
const result = await runCliCapture([
215+
'diff',
216+
'screenshot',
217+
'--baseline',
218+
baseline,
219+
'--threshold',
220+
'0.2',
221+
]);
222+
assert.equal(result.code, null);
223+
// The client-backed command captures a screenshot via the daemon client
224+
assert.equal(result.calls.length, 1);
225+
const call = result.calls[0]!;
226+
assert.equal(call.command, 'screenshot');
227+
assert.equal(result.stderr, '');
228+
} finally {
229+
fs.rmSync(dir, { recursive: true, force: true });
230+
}
231+
});
232+
233+
test('diff screenshot uses os.tmpdir for temporary current capture', async () => {
234+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-test-'));
235+
const baseline = path.join(dir, 'baseline.png');
236+
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
237+
238+
try {
239+
const result = await runCliCapture(['diff', 'screenshot', '--baseline', baseline]);
240+
assert.equal(result.code, null);
241+
assert.equal(result.calls.length, 1);
242+
const call = result.calls[0]!;
243+
assert.equal(call.command, 'screenshot');
244+
const capturePath = call.positionals?.[0];
245+
assert.equal(typeof capturePath, 'string');
246+
assert.equal(capturePath!.startsWith(os.tmpdir()), true);
247+
} finally {
248+
fs.rmSync(dir, { recursive: true, force: true });
249+
}
250+
});
251+
252+
test('diff screenshot expands ~/ for baseline and out paths', async () => {
253+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-diff-home-'));
254+
const originalHome = process.env.HOME;
255+
const baselineRelative = path.join('fixtures', 'baseline.png');
256+
const diffRelative = path.join('fixtures', 'diff.png');
257+
const baseline = path.join(fakeHome, baselineRelative);
258+
const diffOut = path.join(fakeHome, diffRelative);
259+
260+
fs.mkdirSync(path.dirname(baseline), { recursive: true });
261+
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
262+
fs.writeFileSync(diffOut, 'stale diff');
263+
process.env.HOME = fakeHome;
264+
265+
try {
266+
const result = await runCliCapture(
267+
[
268+
'diff',
269+
'screenshot',
270+
'--baseline',
271+
`~/${baselineRelative}`,
272+
'--out',
273+
`~/${diffRelative}`,
274+
'--json',
275+
],
276+
{ preserveHome: true },
277+
);
85278

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, '');
279+
assert.equal(result.code, null);
280+
assert.equal(result.calls.length, 1);
281+
const payload = JSON.parse(result.stdout);
282+
assert.equal(payload.success, true);
283+
assert.equal(payload.data.match, true);
284+
assert.equal(fs.existsSync(diffOut), false);
285+
} finally {
286+
if (typeof originalHome === 'string') process.env.HOME = originalHome;
287+
else delete process.env.HOME;
288+
fs.rmSync(fakeHome, { recursive: true, force: true });
289+
}
290+
});
96291
});

0 commit comments

Comments
 (0)