Skip to content

Commit 82b553a

Browse files
sserrataclaude
andcommitted
chore(scripts): further reduce visual-diff false positives
Follow-up to the retry/font/scroll hardening. After the first round, three distinct false-positive shapes remained; each gets a targeted fix: - `compareImages` now crops to the smaller of the two captures instead of padding the smaller one with transparent black. Padding compared blank pixels against real content and produced a giant phantom diff at the bottom whenever the two captures rendered at slightly different heights. - Inject `html { scrollbar-gutter: stable; }` via context init script so the browser always reserves scrollbar space. Without this, captures where content barely fits toggle the scrollbar between runs, changing effective page width by ~15px and reflowing card titles / ellipsis truncation. - After `networkidle`, wait for `requestIdleCallback` plus two animation frames before capturing. Catches async client-side work that finishes after the network is quiet (Prism syntax highlighting, late hydration). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8c42688 commit 82b553a

1 file changed

Lines changed: 38 additions & 7 deletions

File tree

scripts/sitemap-visual-diff.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ async function gotoSettled(page: any, url: string, maxAttempts: number) {
125125
await page
126126
.waitForLoadState("networkidle", { timeout: 15_000 })
127127
.catch(() => undefined);
128+
// Async client-side work (e.g., Prism syntax highlighting, late hydration)
129+
// can still be pending after networkidle. Wait until the browser is idle
130+
// and the next two animation frames have painted before capturing.
131+
await page.evaluate(
132+
() =>
133+
new Promise<void>((resolve) => {
134+
const done = () =>
135+
requestAnimationFrame(() =>
136+
requestAnimationFrame(() => resolve())
137+
);
138+
if (typeof (window as any).requestIdleCallback === "function") {
139+
(window as any).requestIdleCallback(done, { timeout: 2000 });
140+
} else {
141+
setTimeout(done, 500);
142+
}
143+
})
144+
);
128145
return;
129146
} catch (e) {
130147
lastErr = e;
@@ -194,12 +211,17 @@ async function screenshotFullPage(
194211
}
195212
}
196213

197-
function padImage(img: PNG, width: number, height: number): PNG {
214+
// Crop an image to the given dimensions (which must be <= the image's own).
215+
// Used to bring two captures to a common size by trimming overflow rather
216+
// than padding with transparent black — padding compares blank pixels against
217+
// real content and produces a massive phantom diff at the bottom/right when
218+
// the two captures rendered at different heights.
219+
function cropImage(img: PNG, width: number, height: number): PNG {
198220
if (img.width === width && img.height === height) {
199221
return img;
200222
}
201223
const out = new PNG({ width, height });
202-
PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0);
224+
PNG.bitblt(img, out, 0, 0, width, height, 0, 0);
203225
return out;
204226
}
205227

@@ -214,13 +236,13 @@ function compareImages(
214236
let prod = PNG.sync.read(fs.readFileSync(prodPath));
215237
let prev = PNG.sync.read(fs.readFileSync(prevPath));
216238
if (prod.width !== prev.width || prod.height !== prev.height) {
217-
const width = Math.max(prod.width, prev.width);
218-
const height = Math.max(prod.height, prev.height);
239+
const width = Math.min(prod.width, prev.width);
240+
const height = Math.min(prod.height, prev.height);
219241
console.warn(
220-
`Size mismatch for ${prevPath}, padding images to ${width}x${height}`
242+
`Size mismatch for ${prevPath}, cropping images to ${width}x${height}`
221243
);
222-
prod = padImage(prod, width, height);
223-
prev = padImage(prev, width, height);
244+
prod = cropImage(prod, width, height);
245+
prev = cropImage(prev, width, height);
224246
}
225247
const diff = new PNG({ width: prod.width, height: prod.height });
226248
const numDiff = pixelmatch(
@@ -286,6 +308,15 @@ async function run() {
286308
const context = await browser.newContext({
287309
viewport: { width: opts.width, height: opts.viewHeight },
288310
});
311+
// Reserve scrollbar space whether or not a scrollbar is actually shown.
312+
// Without this, captures where content barely fits the viewport toggle the
313+
// scrollbar between runs, changing effective page width by ~15px and
314+
// reflowing flex/grid layouts (e.g., card title ellipsis truncation).
315+
await context.addInitScript(() => {
316+
const style = document.createElement("style");
317+
style.textContent = "html { scrollbar-gutter: stable; }";
318+
document.documentElement.appendChild(style);
319+
});
289320
let total = 0;
290321
let matches = 0;
291322
let mismatches = 0;

0 commit comments

Comments
 (0)