@@ -24,6 +24,8 @@ interface Options {
2424 diffAlpha : number ;
2525 summaryFile : string ;
2626 paths : string ;
27+ maxDiffRatio : number ;
28+ navRetries : number ;
2729}
2830
2931function parseArgs ( ) : Options {
@@ -38,6 +40,8 @@ function parseArgs(): Options {
3840 diffAlpha : 1 ,
3941 summaryFile : "visual_diffs/results.json" ,
4042 paths : "" ,
43+ maxDiffRatio : 0.005 ,
44+ navRetries : 3 ,
4145 } ;
4246 for ( let i = 0 ; i < args . length ; i ++ ) {
4347 const arg = args [ i ] ;
@@ -78,11 +82,64 @@ function parseArgs(): Options {
7882 case "--paths" :
7983 opts . paths = args [ ++ i ] ;
8084 break ;
85+ case "-r" :
86+ case "--max-diff-ratio" :
87+ opts . maxDiffRatio = Number ( args [ ++ i ] ) ;
88+ break ;
89+ case "--nav-retries" :
90+ opts . navRetries = Number ( args [ ++ i ] ) ;
91+ break ;
8192 }
8293 }
8394 return opts ;
8495}
8596
97+ // Navigate with retry-on-5xx, then settle the page so screenshots aren't
98+ // taken mid-render. Throws on persistent failure so the caller can mark the
99+ // page as `skip` rather than logging a phantom `diff`.
100+ async function gotoSettled ( page : any , url : string , maxAttempts : number ) {
101+ let lastErr : unknown ;
102+ for ( let attempt = 1 ; attempt <= maxAttempts ; attempt ++ ) {
103+ try {
104+ const resp = await page . goto ( url , { waitUntil : "load" , timeout : 60_000 } ) ;
105+ const status = resp ?. status ( ) ?? 0 ;
106+ if ( status >= 500 ) {
107+ throw new Error ( `HTTP ${ status } for ${ url } ` ) ;
108+ }
109+ // Web fonts loading late are a major source of vertical layout shift.
110+ await page . evaluate ( ( ) => ( document as any ) . fonts ?. ready ) ;
111+ // Scroll the full page to trigger lazy-loaded images/iframes, then
112+ // return to the top so the screenshot origin is deterministic.
113+ await page . evaluate ( async ( ) => {
114+ const step = 600 ;
115+ const delay = 80 ;
116+ while (
117+ window . scrollY + window . innerHeight <
118+ document . documentElement . scrollHeight
119+ ) {
120+ window . scrollBy ( 0 , step ) ;
121+ await new Promise ( ( r ) => setTimeout ( r , delay ) ) ;
122+ }
123+ window . scrollTo ( 0 , 0 ) ;
124+ } ) ;
125+ await page
126+ . waitForLoadState ( "networkidle" , { timeout : 15_000 } )
127+ . catch ( ( ) => undefined ) ;
128+ return ;
129+ } catch ( e ) {
130+ lastErr = e ;
131+ if ( attempt < maxAttempts ) {
132+ const backoff = 5_000 * attempt ;
133+ console . warn (
134+ `Retry ${ attempt } /${ maxAttempts } for ${ url } after ${ backoff } ms: ${ e } `
135+ ) ;
136+ await new Promise ( ( r ) => setTimeout ( r , backoff ) ) ;
137+ }
138+ }
139+ }
140+ throw lastErr ;
141+ }
142+
86143async function fetchSitemap ( url : string ) : Promise < string > {
87144 const resp = await fetch ( url ) ;
88145 if ( ! resp . ok ) throw new Error ( `Failed to fetch ${ url } : ${ resp . status } ` ) ;
@@ -97,8 +154,13 @@ function parseUrlsFromSitemap(xml: string): string[] {
97154 return arr . map ( ( u : any ) => String ( u . loc ) . trim ( ) ) . filter ( Boolean ) ;
98155}
99156
100- async function screenshotFullPage ( page : any , url : string , outputPath : string ) {
101- await page . goto ( url , { waitUntil : "load" } ) ;
157+ async function screenshotFullPage (
158+ page : any ,
159+ url : string ,
160+ outputPath : string ,
161+ navRetries : number
162+ ) {
163+ await gotoSettled ( page , url , navRetries ) ;
102164 // Freeze all CSS animations and transitions before screenshotting so that
103165 // mid-animation frames don't produce spurious pixel differences.
104166 await page . addStyleTag ( {
@@ -146,7 +208,8 @@ function compareImages(
146208 prevPath : string ,
147209 diffPath : string ,
148210 tolerance : number ,
149- diffAlpha : number
211+ diffAlpha : number ,
212+ maxDiffRatio : number
150213) : boolean {
151214 let prod = PNG . sync . read ( fs . readFileSync ( prodPath ) ) ;
152215 let prev = PNG . sync . read ( fs . readFileSync ( prevPath ) ) ;
@@ -171,7 +234,9 @@ function compareImages(
171234 alpha : diffAlpha ,
172235 }
173236 ) ;
174- if ( numDiff > 0 ) {
237+ const totalPixels = prod . width * prod . height ;
238+ const diffRatio = totalPixels > 0 ? numDiff / totalPixels : 0 ;
239+ if ( diffRatio > maxDiffRatio ) {
175240 fs . mkdirSync ( path . dirname ( diffPath ) , { recursive : true } ) ;
176241 fs . writeFileSync ( diffPath , PNG . sync . write ( diff ) ) ;
177242 return false ;
@@ -239,20 +304,22 @@ async function run() {
239304 if ( fs . existsSync ( prodSnap ) ) {
240305 console . log ( `CACHED prod: /${ cleanPath } ` ) ;
241306 } else {
242- await screenshotFullPage ( page , url , prodSnap ) ;
307+ await screenshotFullPage ( page , url , prodSnap , opts . navRetries ) ;
243308 }
244309 await screenshotFullPage (
245310 page ,
246311 new URL ( cleanPath , opts . previewUrl ) . toString ( ) ,
247- prevSnap
312+ prevSnap ,
313+ opts . navRetries
248314 ) ;
249315 if (
250316 compareImages (
251317 prodSnap ,
252318 prevSnap ,
253319 diffImg ,
254320 opts . tolerance ,
255- opts . diffAlpha
321+ opts . diffAlpha ,
322+ opts . maxDiffRatio
256323 )
257324 ) {
258325 console . log ( `MATCH: /${ cleanPath } ` ) ;
0 commit comments