Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ The package is a thin CLI over a set of focused modules:
`context.route()` and fulfills repeats locally instead of hitting the origin.
- `src/compare.mjs` — pixelmatch comparison + HTML diffing. Exports
`compare(config, options)`; opens the generated report via `file://`.
- `src/downscale.mjs` — area-filter image downscaling. `compare` uses it to
produce browser-safe display copies of captures that exceed the ~32,767px
decode limit, while pixelmatch still runs against the full-res originals.
- `src/report.mjs` — Assembles the report data and writes the single-page
report. `buildHtmlDiff` turns two HTML snapshots into changed-line counts and
unified-diff hunk JSON; `generateReport` groups the per-slug results into one
Expand All @@ -53,6 +56,7 @@ config works across developers who each run against their own local domain.
All artifacts are written under the configured output directory (`.reglance` by
default), which gets a generated `.gitignore` so nothing is committed to the host
project. Subdirectories: `captures/`, `controls/`, `compares/` (diff PNGs only),
`display/` (downscaled copies of any capture too large for a browser to render),
`reports/`, `assets/`, and `image-cache/` when the image cache is enabled.

## Templates
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ guards against silently baselining bad data:
captures/ Latest screenshots + HTML snapshots
controls/ Baseline screenshots + HTML (+ manifest.json)
compares/ Diff images
display/ Downscaled copies of any capture too large for the browser to render
reports/ The report — open reports/index.html
assets/ Report stylesheet + script
image-cache/ Cached image responses (only with imageCache enabled)
Expand All @@ -167,6 +168,18 @@ and renders three views client-side from the URL hash — a triage overview
view (swipe, side-by-side, onion skin, diff overlay, blink), and a unified HTML
diff. It opens straight from disk with no network access.

### Oversized captures

Browsers refuse to decode an image taller or wider than 32,767px and show it as
a broken/corrupt image. Full-page mobile captures cross this easily — a narrow
viewport stacks content into a very tall page, and a `deviceScaleFactor` of 2
doubles the pixel height again. `capture` warns when it writes a screenshot past
the limit, and `compare` writes a downscaled copy to `display/`, points the
report at it, and flags the view with a "Downscaled" notice. Pixel diffs are
still computed against the full-resolution originals; only what the browser
paints is shrunk. To capture such a page at full resolution instead, lower that
viewport's `deviceScaleFactor`.

## Development

```bash
Expand Down
51 changes: 51 additions & 0 deletions src/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@ import path from 'node:path';
import { chromium } from 'playwright';
import { filterTargets, DEFAULT_TIMEOUTS } from './config.mjs';
import { openImageCache } from './image-cache.mjs';
import { MAX_DISPLAY_DIMENSION } from './downscale.mjs';

/**
* Read a PNG's pixel dimensions from its IHDR header without decoding it.
*
* A full-page mobile screenshot can be tens of millions of pixels; the width
* and height live at fixed offsets in the first chunk, so a 24-byte read is
* enough and avoids inflating the whole image just to measure it.
*
* @param {string} file The PNG path.
* @returns {{ width: number, height: number }|null} The dimensions, or null if
* the header can't be read.
*/
export function pngDimensions(file) {
let fd;
try {
fd = fs.openSync(file, 'r');
const header = Buffer.alloc(24);
fs.readSync(fd, header, 0, 24, 0);
// Bytes 0-7 are the PNG signature; the IHDR width/height follow at 16/20.
return {
width: header.readUInt32BE(16),
height: header.readUInt32BE(20),
};
} catch {
return null;
} finally {
if (fd !== undefined) {
fs.closeSync(fd);
}
}
}

/**
* Whether a host is a local development host, for which self-signed/invalid
Expand Down Expand Up @@ -373,6 +405,25 @@ async function captureTarget(browser, target, viewports, dirs, options) {
const imagePath = path.join(dirs.captures, `${slug}.png`);
await page.screenshot({ path: imagePath, fullPage: true });

// Browsers refuse to decode an image past ~32,767px on a side and
// show it as broken; tall mobile captures (narrow page, doubled by
// a 2x deviceScaleFactor) cross this. The file is still valid and
// compare downscales a display copy, but flag it here so the cause
// is visible at capture time, not just in the report.
const shot = pngDimensions(imagePath);
if (
shot &&
(shot.width > MAX_DISPLAY_DIMENSION ||
shot.height > MAX_DISPLAY_DIMENSION)
) {
console.warn(
` ⚠️ ${slug}: ${shot.width}×${shot.height}px exceeds the browser ` +
`image limit (${MAX_DISPLAY_DIMENSION}px) — the report will downscale ` +
"it for display. Lower this viewport's deviceScaleFactor to capture " +
'it at full size.'
);
}

const htmlPath = path.join(dirs.capturesHtml, `${slug}.html`);
fs.writeFileSync(htmlPath, await page.content());

Expand Down
62 changes: 54 additions & 8 deletions src/compare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import open from 'open';
import { filterTargets } from './config.mjs';
import { readManifest, detectStaleControls } from './manifest.mjs';
import { copyAssets, buildHtmlDiff, generateReport } from './report.mjs';
import { downscaleToDisplay } from './downscale.mjs';

// Skip the inline HTML line-diff above this snapshot size; line-diffing a
// multi-MB (often minified) document is slow and produces no useful result.
Expand Down Expand Up @@ -77,21 +78,21 @@ export function compareSlug(config, target, viewport) {
return null;
}

let img1 = PNG.sync.read(fs.readFileSync(controlImage));
let img2 = PNG.sync.read(fs.readFileSync(captureImage));
const control = PNG.sync.read(fs.readFileSync(controlImage));
const capture = PNG.sync.read(fs.readFileSync(captureImage));

// Pad both onto a common canvas so width changes (a real layout
// regression) surface as a large diff instead of dropping the slug from
// the report. Height was already handled this way; width is too now.
const maxWidth = Math.max(img1.width, img2.width);
const maxHeight = Math.max(img1.height, img2.height);
img1 = padImage(img1, maxWidth, maxHeight);
img2 = padImage(img2, maxWidth, maxHeight);
const maxWidth = Math.max(control.width, capture.width);
const maxHeight = Math.max(control.height, capture.height);
const padded1 = padImage(control, maxWidth, maxHeight);
const padded2 = padImage(capture, maxWidth, maxHeight);

const diff = new PNG({ width: maxWidth, height: maxHeight });
const numDiffPixels = pixelmatch(
img1.data,
img2.data,
padded1.data,
padded2.data,
diff.data,
maxWidth,
maxHeight,
Expand All @@ -101,6 +102,44 @@ export function compareSlug(config, target, viewport) {
const diffImage = path.join(dirs.compares, `${slug}-diff.png`);
writeArtifact(diffImage, PNG.sync.write(diff));

// pixelmatch runs at full resolution above; the report, however, is viewed
// in a browser that can't decode an image past ~32,767px on a side. Hand
// the report a downscaled copy of any oversized image (and remember the
// original size so it can say so), leaving the full-res artifact in place
// as the source of truth and the baseline pixelmatch compares against.
const displayOf = (source, original, name) => {
const result = downscaleToDisplay(source);
if (!result.downscaled) {
return { image: original, scaled: null };
}
const displayImage = path.join(dirs.display, `${slug}-${name}.png`);
writeArtifact(displayImage, PNG.sync.write(result.png));
return {
image: displayImage,
scaled: {
from: [result.originalWidth, result.originalHeight],
to: [result.width, result.height],
},
};
};

// control/capture display at their own native size in the report; the diff
// is shown at the padded common size.
const controlDisp = displayOf(control, controlImage, 'control');
const captureDisp = displayOf(capture, captureImage, 'capture');
const diffDisp = displayOf(diff, diffImage, 'diff');

const scaled = {};
if (controlDisp.scaled) {
scaled.control = controlDisp.scaled;
}
if (captureDisp.scaled) {
scaled.capture = captureDisp.scaled;
}
if (diffDisp.scaled) {
scaled.diff = diffDisp.scaled;
}

const totalPixels = maxWidth * maxHeight;
const diffPercentage = (numDiffPixels / totalPixels) * 100;

Expand Down Expand Up @@ -143,6 +182,12 @@ export function compareSlug(config, target, viewport) {
controlImage,
captureImage,
diffImage,
// Browser-safe paths the report links to (the original when it already
// fits, a downscaled copy when it didn't).
controlDisplay: controlDisp.image,
captureDisplay: captureDisp.image,
diffDisplay: diffDisp.image,
scaled: Object.keys(scaled).length ? scaled : null,
diffPercentage,
htmlAdd: htmlResult.add,
htmlDel: htmlResult.del,
Expand Down Expand Up @@ -337,6 +382,7 @@ export async function compare(config, options = {}) {
const startedAt = Date.now();

fs.mkdirSync(dirs.compares, { recursive: true });
fs.mkdirSync(dirs.display, { recursive: true });
fs.mkdirSync(dirs.reports, { recursive: true });
copyAssets(config);

Expand Down
1 change: 1 addition & 0 deletions src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
controls: path.join(outputDir, 'controls'),
controlsHtml: path.join(outputDir, 'controls', 'html'),
compares: path.join(outputDir, 'compares'),
display: path.join(outputDir, 'display'),
reports: path.join(outputDir, 'reports'),
assets: path.join(outputDir, 'assets'),
imageCache: path.join(outputDir, 'image-cache'),
Expand Down
Loading
Loading