Skip to content

Commit 8c42688

Browse files
sserrataclaude
andcommitted
chore(scripts): harden visual-diff against transient noise
Reduces false-positive `diff` results from the sitemap visual-diff job: - Retry navigation on HTTP 5xx (default 3 attempts, 5s/10s backoff). Pages whose prod or preview capture cannot be retrieved cleanly are now marked `skip` instead of producing a phantom `diff`. - Wait for `document.fonts.ready` before capture to eliminate vertical layout shift from late-loading web fonts. - Scroll top-to-bottom to trigger lazy-loaded images/iframes, then return to the origin so the screenshot is deterministic. - Wait for `networkidle` (15s cap) as a final settle step. - Add `--max-diff-ratio` option (default 0.005) so single-pixel and sub-0.5% pixel jitter is no longer flagged as `diff`. No workflow change required; defaults take over on the next run. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 49fd36f commit 8c42688

1 file changed

Lines changed: 74 additions & 7 deletions

File tree

scripts/sitemap-visual-diff.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface Options {
2424
diffAlpha: number;
2525
summaryFile: string;
2626
paths: string;
27+
maxDiffRatio: number;
28+
navRetries: number;
2729
}
2830

2931
function 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+
86143
async 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

Comments
 (0)