npm install
npx playwright-core install chromium
npm testNode 18.18 or newer. No build step, no transpiler, no linter config to fight — the code is plain ESM that runs as written.
npm test # everything
node --test test/config.test.js # one file
node --test --test-concurrency=1 # serial, easier to read when debuggingThe suite never touches the network. Fixture pages are served by a node:http server on an
ephemeral port, and the fixture server asserts that shotdiff only ever issues GET requests.
Tests that need Chromium skip with an explanatory message when none is available. Do not rely on that. If you touch capture, stabilisation, or image encoding, make sure a browser is installed and the tests actually run — a skipped test proves nothing.
A few invariants are load-bearing. Changing them is not a refactor.
Read the header comment in src/imagecodec.js before touching anything to do with image formats.
- Baselines are lossless PNG. Always.
--baselineignoresstorage.format, and combining--baselinewith--formatis a usage error rather than a silent override. - The comparison runs on the in-memory PNG buffer, before any storage encoding.
comparerefuses a lossy operand up front, incollectPairs, before decoding anything.
WebP at q80 produces content-dependent artifacts. Storing a lossy baseline would put a few units of noise on every pixel of every future diff — which looks exactly like a real change and would undo the entire point of the stabilisation layer.
src/prune.js contains no reference to the baselines/ directory and must not gain one. The
safety property is that there is no code path to reach it, not that a filter excludes it. If you
find yourself adding if (kind !== 'baseline') somewhere, stop and reconsider the layout.
prunableRoots() is exported specifically so a test can assert this.
capture writes the marker as its final act; prune refuses to delete a run without one. This is
what makes a prune safe to run alongside a capture. Do not move the write earlier "for tidiness".
All output goes through Logger, which scrubs registered secrets. If you add an output path,
route it through the logger rather than calling process.stdout.write directly.
GETs only. No logins, no POSTs, no form submissions, no mutations. The single exception is
purge.command, which is a command the user wrote. A test asserts the fixture server never sees a
non-GET request; keep it that way.
If you add one, add a measurement with it. The README states what each technique is worth in measured percentages, not adjectives, and a new one should arrive with:
- A fixture that exhibits the nondeterminism, ideally in
test/helpers/server.jsunder/isolate?feature=...so it can be measured in isolation. - A test proving the noise goes away with it on.
- A control test proving the noise is really there with it off. Without the control, a fixture that quietly stops being chaotic turns the first test into a tautology.
- A row in the README's stabilisation section with the measured numbers.
- ESM,
node:prefixes for builtins. - Comments explain why, especially where the obvious-looking simplification is a bug. There are several of those in this codebase and they are commented at the point of temptation.
- Errors name the thing that is wrong: the site, the page, the file, and what to do next. A message that only says "invalid config" is a bug report waiting to happen.
- Keep the dependency list at three. Every addition needs a paragraph in the README explaining why it earns its place, and native dependencies are refused outright — that constraint is why WebP encoding goes through Chromium.
- One logical change per commit, present tense, explaining why rather than what.
npm testgreen before you open the PR, with a browser installed.- Update
CHANGELOG.mdunder an## [Unreleased]heading for anything user-visible.