A visual test that cannot tell “the page changed” from “the capture is not reproducible” gets muted within a week. Keeping those apart is the whole job — and it is deterministic by construction: same inputs, same verdict.
A gate that cannot separate them becomes noise and someone turns it off. No AI in the loop: same input, same verdict.
diff.png and the region crops first.git HEAD, mask
config or the PNG itself changed). Re-baseline deliberately or unstage the change — never
“just re-run”.STALE exists because the classic failure of visual testing is not flakiness — it is
a baseline quietly re-generated until the test went green. Tying the baseline to git
HEAD plus a hash of the mask config makes re-baselining a deliberate, reviewable act.
0 PASS 1 FAIL 2 STALE 3 ERROR
python scripts/visual_gate.py fingerprint baseline/home.png \
--url https://app.example/home --viewport 1280x720 \
--config baseline/home.masks.json
python scripts/visual_gate.py compare baseline/home.png actual/home.png \
--meta baseline/home.png.meta.json --masks baseline/home.masks.json \
--max-diff 0.001 --max-regions 1 --bundle artifacts/home
With --json you get the verdict, the diff percentage, the region count and every
region's bounding box — enough for CI to publish what moved instead of “the test
failed”.
--max-diff is a safety net, not the primary signalAntialiasing and
font hinting shift a few thousand pixels with zero real change; a real component move shifts tens
of thousands. Start at 0.001 (0.1%) and tighten once masks are in place.--max-regions is the signal that catches small, meaningful changesA
wrong colour on a 60×40 button is ~0.26% of a 1280×720 page, but exactly one region. Budget
1.--tolerancePer-channel delta (default 0) for capture noise on
gradients. If you need more than ~8, the capture is the problem.masked_pixels) — nobody hides a regression inside one without it showing in the
report.prefers-reduced-motion: reduce and
*{animation:none!important;transition:none!important}.document.fonts.ready and network idle — a
fallback font diffs the whole page.Math.random, freeze it before capturing.15 tests passing — pytest -q (28.9s)
identical screenshots -> PASS, diff 0.00000%, 0 regions
one 60x40 block changed -> FAIL, 1 region at the exact painted coordinates
same change inside a mask -> PASS, masked_pixels >= the masked area
change outside the mask -> FAIL (a mask must not hide the rest of the page)
two changes with --max-regions 1 -> FAIL; with --max-regions 2 -> PASS
stale baseline (git HEAD differs)-> STALE, exit 2
mask config changed -> STALE
640x480 vs 1280x720 -> ERROR, "size mismatch" (never a silent PASS)
decoder vs PIL + ImageMagick -> identical pixels across 6 encodings
16-bit capture -> high bytes equal the 8-bit rendering
failure bundle -> diff.png re-decodes, red area >= the changed region
The two-way test is the point: a gate tested only against “no change” is decoration. Both
directions are asserted — including the case that bites in production: an optimised baseline
(optipng, pngquant, ImageMagick) that a naive decoder refuses.
And the PNG decoder is stdlib (zlib + struct) — no PIL, OpenCV or ImageMagick on the runner: a CI gate that needs a C library on every machine breaks on the machine that matters.
.meta.jsonWithout it the gate cannot
detect staleness. Commit both.STALE verdict exists to
prevent: the diff is in the bundle, review it before writing a new baseline.ERROR by design: it is a
capture bug, not a regression.sweep reports
MISSING and fails the run — an empty actual directory is not “all good”.The Visual Regression Gates skill ships the stdlib gate, 15 tests, the Playwright capture template, the mask format and both guides (capture determinism, mask/fingerprint strategy).
See the Visual Regression Gates skill See all skills
Want it wired into your pipeline? Guided setup from R$ 1.500, with the first cycle run together.
Sent when I publish, together with the free skill used here. No spam, one-click unsubscribe.