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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ The package is a thin CLI over a set of focused modules:
- `src/capture.mjs` — Playwright capture with parallel contexts, retries,
auto-scroll, and network-idle waiting. Exports `capture(config, options)`.
- `src/control.mjs` — Moves the latest captures into `controls/`.
- `src/image-cache.mjs` — Opt-in disk cache for image responses during capture
(`imageCache` in `reglance.json`). Keyed by full URL including query string,
with in-flight coalescing; capture intercepts image requests via
`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/report.mjs` — All HTML/report generation and asset copying, driven by the
Expand All @@ -47,7 +51,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/`, `reports/`,
`assets/`.
`assets/`, and `image-cache/` when the image cache is enabled.

## Templates

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ self-ignored `.reglance/` directory — nothing to add to `.gitignore`.
| `pixelmatchOptions` | no | [pixelmatch](https://github.com/mapbox/pixelmatch) options, e.g. `{ "threshold": 0.1 }`. |
| `timeouts` | no | `{ goto, settle }` in ms. `goto` bounds navigation; `settle` bounds each post-scroll wait (network idle, then image load/decode). Defaults `{ goto: 15000, settle: 8000 }`. Raise `settle` for slow, lazy-loading pages. |
| `blockHosts` | no | Hostnames to block requests to during capture, e.g. `["captcha.example.com"]`. Each entry also blocks its subdomains. |
| `imageCache` | no | Serve repeat image requests from a local cache during capture. `true` for a per-run cache, `{ "persist": true }` to keep it across runs. Off by default. |

`domain` is only needed by `capture`; `control` and `compare` work on the files
already captured. See [`reglance.example.json`](reglance.example.json) for a full
Expand All @@ -67,6 +68,20 @@ retries indefinitely will otherwise time out every viewport on pages that
embed it. Entries are bare hostnames; `"example.org"` blocks `example.org` and
`sub.example.org` alike.

`imageCache` keeps a capture run from swarming the origin with the same image
requests once per viewport per parallel context. Image requests are intercepted
in the browser: the first request for a URL is fetched from the origin and
stored under `.reglance/image-cache/`, and every repeat is answered locally —
simultaneous requests for the same URL share a single origin fetch. The full
URL, query string included, is the cache key, so CDN resize variants
(`photo.jpg?w=400` vs `photo.jpg?w=800`) stay distinct. Nothing in the page is
rewritten and only images are cached — the HTML, CSS, and JS under test always
load from the origin. With `true` the cache is cleared at the start of every
run, so within-run traffic drops with zero risk of a stale image masking a real
change. `{ "persist": true }` keeps the cache across runs — useful when
re-capturing repeatedly while iterating on CSS — but a changed origin image
will then go unnoticed until you clear it with `--fresh-images`.

A viewport's optional `deviceScaleFactor` (device pixel ratio) renders the page
as it would appear on a higher-density display — use `2` for a retina capture,
`3` for some phones. It defaults to `1`. Captures sharing a DPR run in one
Expand Down Expand Up @@ -101,6 +116,7 @@ Append path keys to limit a command to specific pages:
| `--stagger=<ms>` | capture | Delay between starting contexts (default: 500). `0` disables staggering. |
| `--skip-reload` | capture | Reuse the page between viewports instead of reloading. |
| `--fail-on-degraded` | capture | Exit non-zero if any page failed to load cleanly (for CI). Default: warn, exit 0. |
| `--fresh-images` | capture | Clear a persistent image cache before capturing (see `imageCache`). |
| `--insecure` | capture | Ignore TLS certificate errors for non-local hosts (already ignored for `.test`/localhost). |
| `--compare-concurrency=<n>`| compare | Parallel diff workers (default: CPU count − 1). Lower it for very tall pages. |
| `--no-open` | compare | Don't open the report when finished. |
Expand Down Expand Up @@ -141,6 +157,7 @@ guards against silently baselining bad data:
controls/ Baseline screenshots + HTML (+ manifest.json)
compares/ Diff images and HTML diffs
reports/ The report — open reports/index.html
image-cache/ Cached image responses (only with imageCache enabled)
```

## Development
Expand Down
4 changes: 4 additions & 0 deletions bin/reglance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Options:
--insecure Ignore TLS certificate errors for non-local hosts
(already ignored for .test/localhost).
--fail-on-degraded Exit non-zero if any capture failed to load cleanly.
--fresh-images Clear a persistent image cache before capturing
(requires "imageCache" in reglance.json).
--compare-concurrency=<n> Parallel diff workers for compare
(default: CPU count - 1; lower it for very tall pages).
--no-open Don't open the report automatically after compare.
Expand All @@ -51,6 +53,7 @@ const { values, positionals } = parseArgs({
'skip-reload': { type: 'boolean' },
insecure: { type: 'boolean' },
'fail-on-degraded': { type: 'boolean' },
'fresh-images': { type: 'boolean' },
'compare-concurrency': { type: 'string' },
'no-open': { type: 'boolean' },
help: { type: 'boolean', short: 'h' },
Expand Down Expand Up @@ -93,6 +96,7 @@ async function main() {
: undefined,
skipReload: values['skip-reload'],
failOnDegraded: values['fail-on-degraded'],
freshImages: values['fresh-images'],
insecure: values.insecure,
});
break;
Expand Down
3 changes: 2 additions & 1 deletion reglance.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
"goto": 15000,
"settle": 8000
},
"blockHosts": ["captcha.example.com"]
"blockHosts": ["captcha.example.com"],
"imageCache": true
}
71 changes: 67 additions & 4 deletions src/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright';
import { filterTargets, DEFAULT_TIMEOUTS } from './config.mjs';
import { openImageCache } from './image-cache.mjs';

/**
* Whether a host is a local development host, for which self-signed/invalid
Expand Down Expand Up @@ -217,6 +218,7 @@ async function captureTarget(browser, target, viewports, dirs, options) {
timeouts = DEFAULT_TIMEOUTS,
ignoreHTTPSErrors = false,
blockHosts = [],
imageCache = null,
} = options;
const failures = [];
let currentSlug = target.key;
Expand All @@ -227,12 +229,43 @@ async function captureTarget(browser, target, viewports, dirs, options) {
deviceScaleFactor: group.deviceScaleFactor,
});

if (blockHosts.length) {
await context.route('**/*', (route) => {
if (isBlockedHost(route.request().url(), blockHosts)) {
if (blockHosts.length || imageCache) {
await context.route('**/*', async (route) => {
const request = route.request();

if (isBlockedHost(request.url(), blockHosts)) {
return route.abort('blockedbyclient');
}
return route.continue();

// Only GET image requests go through the cache; everything
// else — documents, CSS, JS — is the content under test and
// always loads from the origin.
if (
!imageCache ||
request.resourceType() !== 'image' ||
request.method() !== 'GET'
) {
return route.continue();
}

try {
const entry =
(await imageCache.get(request.url())) ??
(await imageCache.fetchOnce(request.url(), () =>
route.fetch()
));

return route.fulfill({
status: entry.status,
contentType: entry.contentType ?? undefined,
body: entry.body,
});
} catch {
// The cache must never break a capture: hand the request
// back to the browser to fetch directly. (continue() can
// itself fail if the page navigated away mid-flight.)
return route.continue().catch(() => {});
}
});
}

Expand Down Expand Up @@ -384,6 +417,7 @@ export function shouldFailRun(failures, failOnDegraded = false) {
* @param {number} [options.staggerDelay] Delay (ms) between context starts.
* @param {boolean} [options.skipReload] Reuse the page between viewports.
* @param {boolean} [options.failOnDegraded] Exit non-zero if any capture is degraded.
* @param {boolean} [options.freshImages] Clear a persistent image cache first.
* @param {Array} [options.only] Limit to these target keys.
* @returns {Promise<{ failures: Array }>} The degraded-slug records.
*/
Expand Down Expand Up @@ -420,6 +454,27 @@ export async function capture(config, options = {}) {
);
}

// The image cache answers repeat image requests locally so a run doesn't
// swarm the origin once per viewport per context. A per-run cache starts
// empty every time; a persistent one carries over unless --fresh-images.
let imageCache = null;
if (config.imageCache?.enabled) {
const persist = config.imageCache.persist;
imageCache = openImageCache(config.dirs.imageCache, {
clear: !persist || Boolean(options.freshImages),
});
console.log(
`Image cache: ${persist ? 'persistent' : 'per-run'}${
persist && options.freshImages ? ' (cleared)' : ''
}`
);
} else if (options.freshImages) {
console.warn(
'⚠️ --fresh-images has no effect: the image cache is not enabled. ' +
'Set "imageCache" in reglance.json.'
);
}

// Ignore TLS certificate errors only for local development hosts (where
// self-signed certs are normal). For a non-local host, validation stays on
// unless explicitly opted out with --insecure, and we warn when it does.
Expand Down Expand Up @@ -477,6 +532,7 @@ export async function capture(config, options = {}) {
timeouts: config.timeouts,
ignoreHTTPSErrors,
blockHosts: config.blockHosts ?? [],
imageCache,
}
);
failures.push(...targetFailures);
Expand All @@ -490,6 +546,13 @@ export async function capture(config, options = {}) {
await browser.close();
}

if (imageCache) {
const { hits, fetches } = imageCache.stats();
console.log(
`Image cache: ${hits} request(s) served locally, ${fetches} fetched from the origin.`
);
}

if (failures.length) {
console.error(
`\n⚠️ ${failures.length} capture(s) did not load cleanly:`
Expand Down
40 changes: 40 additions & 0 deletions src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,44 @@ export function normalizeBlockHosts(blockHosts) {
});
}

/**
* Normalize and validate the `imageCache` config value.
*
* The cache is opt-in: absent (or `false`) leaves capture fetching every
* image from the origin as before. `true` enables a per-run cache that is
* cleared at the start of each capture; `{ "persist": true }` keeps entries
* across runs (cleared on demand with `--fresh-images`).
*
* @param {*} imageCache The raw config value.
* @returns {{ enabled: boolean, persist: boolean }} The normalized options.
*/
export function normalizeImageCache(imageCache) {
if (imageCache === undefined || imageCache === false) {
return { enabled: false, persist: false };
}

if (imageCache === true) {
return { enabled: true, persist: false };
}

if (typeof imageCache !== 'object' || Array.isArray(imageCache)) {
throw new Error(
'❌ Invalid "imageCache": expected true or an options object.\n' +
'💡 Use true for a per-run cache, or { "persist": true } to keep it across runs.'
);
}

const { persist = false } = imageCache;

if (typeof persist !== 'boolean') {
throw new Error(
`❌ Invalid "imageCache.persist": expected true or false, got ${JSON.stringify(persist)}.`
);
}

return { enabled: true, persist };
}

/**
* Join a domain origin and a path into a full URL.
*
Expand Down Expand Up @@ -340,6 +378,7 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
targets,
pixelmatchOptions,
blockHosts: normalizeBlockHosts(raw.blockHosts),
imageCache: normalizeImageCache(raw.imageCache),
timeouts: {
...DEFAULT_TIMEOUTS,
...raw.timeouts,
Expand All @@ -353,6 +392,7 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
compares: path.join(outputDir, 'compares'),
reports: path.join(outputDir, 'reports'),
assets: path.join(outputDir, 'assets'),
imageCache: path.join(outputDir, 'image-cache'),
},
};
}
Expand Down
Loading
Loading