Skip to content

Commit 0a269fd

Browse files
authored
Merge pull request #51 from happyprime/image-cache
Add an opt-in image cache to capture
2 parents f06c845 + c510d10 commit 0a269fd

9 files changed

Lines changed: 503 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ The package is a thin CLI over a set of focused modules:
2828
- `src/capture.mjs` — Playwright capture with parallel contexts, retries,
2929
auto-scroll, and network-idle waiting. Exports `capture(config, options)`.
3030
- `src/control.mjs` — Moves the latest captures into `controls/`.
31+
- `src/image-cache.mjs` — Opt-in disk cache for image responses during capture
32+
(`imageCache` in `reglance.json`). Keyed by full URL including query string,
33+
with in-flight coalescing; capture intercepts image requests via
34+
`context.route()` and fulfills repeats locally instead of hitting the origin.
3135
- `src/compare.mjs` — pixelmatch comparison + HTML diffing. Exports
3236
`compare(config, options)`; opens the generated report via `file://`.
3337
- `src/report.mjs` — All HTML/report generation and asset copying, driven by the
@@ -47,7 +51,7 @@ config works across developers who each run against their own local domain.
4751
All artifacts are written under the configured output directory (`.reglance` by
4852
default), which gets a generated `.gitignore` so nothing is committed to the host
4953
project. Subdirectories: `captures/`, `controls/`, `compares/`, `reports/`,
50-
`assets/`.
54+
`assets/`, and `image-cache/` when the image cache is enabled.
5155

5256
## Templates
5357

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ self-ignored `.reglance/` directory — nothing to add to `.gitignore`.
5454
| `pixelmatchOptions` | no | [pixelmatch](https://github.com/mapbox/pixelmatch) options, e.g. `{ "threshold": 0.1 }`. |
5555
| `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. |
5656
| `blockHosts` | no | Hostnames to block requests to during capture, e.g. `["captcha.example.com"]`. Each entry also blocks its subdomains. |
57+
| `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. |
5758

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

71+
`imageCache` keeps a capture run from swarming the origin with the same image
72+
requests once per viewport per parallel context. Image requests are intercepted
73+
in the browser: the first request for a URL is fetched from the origin and
74+
stored under `.reglance/image-cache/`, and every repeat is answered locally —
75+
simultaneous requests for the same URL share a single origin fetch. The full
76+
URL, query string included, is the cache key, so CDN resize variants
77+
(`photo.jpg?w=400` vs `photo.jpg?w=800`) stay distinct. Nothing in the page is
78+
rewritten and only images are cached — the HTML, CSS, and JS under test always
79+
load from the origin. With `true` the cache is cleared at the start of every
80+
run, so within-run traffic drops with zero risk of a stale image masking a real
81+
change. `{ "persist": true }` keeps the cache across runs — useful when
82+
re-capturing repeatedly while iterating on CSS — but a changed origin image
83+
will then go unnoticed until you clear it with `--fresh-images`.
84+
7085
A viewport's optional `deviceScaleFactor` (device pixel ratio) renders the page
7186
as it would appear on a higher-density display — use `2` for a retina capture,
7287
`3` for some phones. It defaults to `1`. Captures sharing a DPR run in one
@@ -101,6 +116,7 @@ Append path keys to limit a command to specific pages:
101116
| `--stagger=<ms>` | capture | Delay between starting contexts (default: 500). `0` disables staggering. |
102117
| `--skip-reload` | capture | Reuse the page between viewports instead of reloading. |
103118
| `--fail-on-degraded` | capture | Exit non-zero if any page failed to load cleanly (for CI). Default: warn, exit 0. |
119+
| `--fresh-images` | capture | Clear a persistent image cache before capturing (see `imageCache`). |
104120
| `--insecure` | capture | Ignore TLS certificate errors for non-local hosts (already ignored for `.test`/localhost). |
105121
| `--compare-concurrency=<n>`| compare | Parallel diff workers (default: CPU count − 1). Lower it for very tall pages. |
106122
| `--no-open` | compare | Don't open the report when finished. |
@@ -141,6 +157,7 @@ guards against silently baselining bad data:
141157
controls/ Baseline screenshots + HTML (+ manifest.json)
142158
compares/ Diff images and HTML diffs
143159
reports/ The report — open reports/index.html
160+
image-cache/ Cached image responses (only with imageCache enabled)
144161
```
145162

146163
## Development

bin/reglance.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Options:
3030
--insecure Ignore TLS certificate errors for non-local hosts
3131
(already ignored for .test/localhost).
3232
--fail-on-degraded Exit non-zero if any capture failed to load cleanly.
33+
--fresh-images Clear a persistent image cache before capturing
34+
(requires "imageCache" in reglance.json).
3335
--compare-concurrency=<n> Parallel diff workers for compare
3436
(default: CPU count - 1; lower it for very tall pages).
3537
--no-open Don't open the report automatically after compare.
@@ -51,6 +53,7 @@ const { values, positionals } = parseArgs({
5153
'skip-reload': { type: 'boolean' },
5254
insecure: { type: 'boolean' },
5355
'fail-on-degraded': { type: 'boolean' },
56+
'fresh-images': { type: 'boolean' },
5457
'compare-concurrency': { type: 'string' },
5558
'no-open': { type: 'boolean' },
5659
help: { type: 'boolean', short: 'h' },
@@ -93,6 +96,7 @@ async function main() {
9396
: undefined,
9497
skipReload: values['skip-reload'],
9598
failOnDegraded: values['fail-on-degraded'],
99+
freshImages: values['fresh-images'],
96100
insecure: values.insecure,
97101
});
98102
break;

reglance.example.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@
2121
"goto": 15000,
2222
"settle": 8000
2323
},
24-
"blockHosts": ["captcha.example.com"]
24+
"blockHosts": ["captcha.example.com"],
25+
"imageCache": true
2526
}

src/capture.mjs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { chromium } from 'playwright';
44
import { filterTargets, DEFAULT_TIMEOUTS } from './config.mjs';
5+
import { openImageCache } from './image-cache.mjs';
56

67
/**
78
* Whether a host is a local development host, for which self-signed/invalid
@@ -217,6 +218,7 @@ async function captureTarget(browser, target, viewports, dirs, options) {
217218
timeouts = DEFAULT_TIMEOUTS,
218219
ignoreHTTPSErrors = false,
219220
blockHosts = [],
221+
imageCache = null,
220222
} = options;
221223
const failures = [];
222224
let currentSlug = target.key;
@@ -227,12 +229,43 @@ async function captureTarget(browser, target, viewports, dirs, options) {
227229
deviceScaleFactor: group.deviceScaleFactor,
228230
});
229231

230-
if (blockHosts.length) {
231-
await context.route('**/*', (route) => {
232-
if (isBlockedHost(route.request().url(), blockHosts)) {
232+
if (blockHosts.length || imageCache) {
233+
await context.route('**/*', async (route) => {
234+
const request = route.request();
235+
236+
if (isBlockedHost(request.url(), blockHosts)) {
233237
return route.abort('blockedbyclient');
234238
}
235-
return route.continue();
239+
240+
// Only GET image requests go through the cache; everything
241+
// else — documents, CSS, JS — is the content under test and
242+
// always loads from the origin.
243+
if (
244+
!imageCache ||
245+
request.resourceType() !== 'image' ||
246+
request.method() !== 'GET'
247+
) {
248+
return route.continue();
249+
}
250+
251+
try {
252+
const entry =
253+
(await imageCache.get(request.url())) ??
254+
(await imageCache.fetchOnce(request.url(), () =>
255+
route.fetch()
256+
));
257+
258+
return route.fulfill({
259+
status: entry.status,
260+
contentType: entry.contentType ?? undefined,
261+
body: entry.body,
262+
});
263+
} catch {
264+
// The cache must never break a capture: hand the request
265+
// back to the browser to fetch directly. (continue() can
266+
// itself fail if the page navigated away mid-flight.)
267+
return route.continue().catch(() => {});
268+
}
236269
});
237270
}
238271

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

457+
// The image cache answers repeat image requests locally so a run doesn't
458+
// swarm the origin once per viewport per context. A per-run cache starts
459+
// empty every time; a persistent one carries over unless --fresh-images.
460+
let imageCache = null;
461+
if (config.imageCache?.enabled) {
462+
const persist = config.imageCache.persist;
463+
imageCache = openImageCache(config.dirs.imageCache, {
464+
clear: !persist || Boolean(options.freshImages),
465+
});
466+
console.log(
467+
`Image cache: ${persist ? 'persistent' : 'per-run'}${
468+
persist && options.freshImages ? ' (cleared)' : ''
469+
}`
470+
);
471+
} else if (options.freshImages) {
472+
console.warn(
473+
'⚠️ --fresh-images has no effect: the image cache is not enabled. ' +
474+
'Set "imageCache" in reglance.json.'
475+
);
476+
}
477+
423478
// Ignore TLS certificate errors only for local development hosts (where
424479
// self-signed certs are normal). For a non-local host, validation stays on
425480
// unless explicitly opted out with --insecure, and we warn when it does.
@@ -477,6 +532,7 @@ export async function capture(config, options = {}) {
477532
timeouts: config.timeouts,
478533
ignoreHTTPSErrors,
479534
blockHosts: config.blockHosts ?? [],
535+
imageCache,
480536
}
481537
);
482538
failures.push(...targetFailures);
@@ -490,6 +546,13 @@ export async function capture(config, options = {}) {
490546
await browser.close();
491547
}
492548

549+
if (imageCache) {
550+
const { hits, fetches } = imageCache.stats();
551+
console.log(
552+
`Image cache: ${hits} request(s) served locally, ${fetches} fetched from the origin.`
553+
);
554+
}
555+
493556
if (failures.length) {
494557
console.error(
495558
`\n⚠️ ${failures.length} capture(s) did not load cleanly:`

src/config.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,44 @@ export function normalizeBlockHosts(blockHosts) {
228228
});
229229
}
230230

231+
/**
232+
* Normalize and validate the `imageCache` config value.
233+
*
234+
* The cache is opt-in: absent (or `false`) leaves capture fetching every
235+
* image from the origin as before. `true` enables a per-run cache that is
236+
* cleared at the start of each capture; `{ "persist": true }` keeps entries
237+
* across runs (cleared on demand with `--fresh-images`).
238+
*
239+
* @param {*} imageCache The raw config value.
240+
* @returns {{ enabled: boolean, persist: boolean }} The normalized options.
241+
*/
242+
export function normalizeImageCache(imageCache) {
243+
if (imageCache === undefined || imageCache === false) {
244+
return { enabled: false, persist: false };
245+
}
246+
247+
if (imageCache === true) {
248+
return { enabled: true, persist: false };
249+
}
250+
251+
if (typeof imageCache !== 'object' || Array.isArray(imageCache)) {
252+
throw new Error(
253+
'❌ Invalid "imageCache": expected true or an options object.\n' +
254+
'💡 Use true for a per-run cache, or { "persist": true } to keep it across runs.'
255+
);
256+
}
257+
258+
const { persist = false } = imageCache;
259+
260+
if (typeof persist !== 'boolean') {
261+
throw new Error(
262+
`❌ Invalid "imageCache.persist": expected true or false, got ${JSON.stringify(persist)}.`
263+
);
264+
}
265+
266+
return { enabled: true, persist };
267+
}
268+
231269
/**
232270
* Join a domain origin and a path into a full URL.
233271
*
@@ -340,6 +378,7 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
340378
targets,
341379
pixelmatchOptions,
342380
blockHosts: normalizeBlockHosts(raw.blockHosts),
381+
imageCache: normalizeImageCache(raw.imageCache),
343382
timeouts: {
344383
...DEFAULT_TIMEOUTS,
345384
...raw.timeouts,
@@ -353,6 +392,7 @@ export function loadConfig({ configPath = 'reglance.json', domain } = {}) {
353392
compares: path.join(outputDir, 'compares'),
354393
reports: path.join(outputDir, 'reports'),
355394
assets: path.join(outputDir, 'assets'),
395+
imageCache: path.join(outputDir, 'image-cache'),
356396
},
357397
};
358398
}

0 commit comments

Comments
 (0)