1- import test from 'node:test' ;
1+ import { describe , test } from 'node:test' ;
22import 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' ;
37import { runCli } from '../cli.ts' ;
48import 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 , / ^ @ e 2 \[ w i n d o w \] / m) ;
80- assert . match ( result . stdout , / ^ - @ e 3 \[ t e x t \] " 6 7 " $ / m) ;
81- assert . match ( result . stdout , / ^ \+ @ e 3 \[ t e x t \] " 1 3 4 " $ / m) ;
82- assert . match ( result . stdout , / 1 a d d i t i o n s , 1 r e m o v a l s , 1 u n c h a n g e d / ) ;
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 , / ^ @ e 2 \[ w i n d o w \] / m) ;
140+ assert . match ( result . stdout , / ^ - @ e 3 \[ t e x t \] " 6 7 " $ / m) ;
141+ assert . match ( result . stdout , / ^ \+ @ e 3 \[ t e x t \] " 1 3 4 " $ / m) ;
142+ assert . match ( result . stdout , / 1 a d d i t i o n s , 1 r e m o v a l s , 1 u n c h a n g e d / ) ;
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 , / 1 0 0 % p i x e l s d i f f e r / ) ;
179+ assert . match ( result . stdout , / 1 0 0 d i f f e r e n t \/ 1 0 0 t o t a l p i x e l s / ) ;
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