snapdiff
Visual regression testing for web pages, with no SaaS. snapdiff screenshots your pages at several viewports and colour schemes using headless Chrome (driven over a hand-written DevTools Protocol client), compares them to approved baselines with an anti-aliasing-aware perceptual diff (a numpy reimplementation of the pixelmatch algorithm), and writes a self-contained HTML report you can review and attach to CI.

Features
- YAML config: base URL or a static directory (served automatically), pages, viewports with
device_scale_factor/mobile emulation, light/dark scheme, full-page / viewport / element screenshots, per-page thresholds and retries. - Wait strategies (selector, network-idle-ish, fixed delay) and actions (
click,hover,type,scroll,wait) before capture. - Determinism helpers: animations, transitions and the caret are disabled;
hideselectors are made invisible without moving layout;maskselectors are painted over with solid boxes (dates, ads, random content). - Own CDP client over WebSockets; parallel capture in N tabs;
google-chrome --screenshotfallback for simple pages. - Diff engine: YIQ delta with threshold, anti-aliasing detection, red/yellow diff image, mismatch percentage, bounding boxes of changed regions, padding for size changes.
- Workflow:
capture,compare(exit 1 on regressions),approve,report,run. - CI: JUnit XML, Markdown summary, stable
page@viewport@scheme.pngfile names. - HTML report: side by side, overlay slider, onion skin, diff-only view with region boxes, filters for changed / new / removed / passed. Images are embedded, so it is one file.
Install
Requires Python 3.12+ and Google Chrome (/usr/bin/google-chrome, or set chrome_path / SNAPDIFF_CHROME).
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
Quickstart
Run the bundled example: approve examples/site (v1) as the baseline, then check examples/site-v2, which contains deliberate visual changes.
cd examples
../.venv/bin/snapdiff --port 8172 capture # screenshots of site v1
../.venv/bin/snapdiff approve --all # v1 becomes the baseline
../.venv/bin/snapdiff --static-dir site-v2 --port 8172 run \
--junit .snapdiff/junit.xml --markdown .snapdiff/summary.md
open .snapdiff/report.html # or xdg-open
Real output of the last command (exit code 1):
Capturing 7 screenshot(s) from http://127.0.0.1:8172
Captured 7/7 into examples/.snapdiff/current
passed about@desktop@light 0.000%
changed home@desktop@dark 0.915%
changed home@desktop@light 0.915%
changed home@mobile@dark 2.348%
changed home@mobile@light 2.348%
changed pricing-plan@desktop@light 9.393%
changed pricing@desktop@light 5.296%
6 changed, 0 new, 0 removed, 1 passed
Report written to examples/.snapdiff/report.html
What v2 changed, and what snapdiff made of it:
| Change in v2 | Result |
|---|---|
| Button colour blue to green, headline text change | home flagged (2 regions) |
| Promo banner pushes pricing content down (layout shift) | pricing flagged, 21 regions |
| Avatar circle edge shifted by 0.4 px (anti-aliasing only) | about passes: 196 anti-aliased pixels ignored |
#today timestamp and #ad box (dynamic content) |
masked / hidden, never flagged |
The pricing diff image (red = changed pixels, yellow = anti-aliasing, faded = unchanged):

The Markdown summary written by --markdown:
**6 changed**, 0 new, 0 removed, 1 passed
| Status | Screenshot | Mismatch | Regions |
|---|---|---|---|
| changed | `home@desktop@dark` | 0.915% | 2 |
| changed | `pricing@desktop@light` | 5.296% | 21 |
| passed | `about@desktop@light` | 0.000% | - |
Usage
snapdiff [-c snapdiff.yaml] [--static-dir DIR] [--base-url URL] [--port N] COMMAND
| Command | What it does |
|---|---|
capture [-o PAGE]... [-j N] |
Screenshots into .snapdiff/current (clears it first unless --only is used). |
compare [-o PAGE]... [--strict] [--junit F] [--markdown F] |
Diffs current vs baseline, writes .snapdiff/diff/ and results.json. Exit 1 if any screenshot is changed; --strict also fails on new/removed. |
approve [NAME... \| --all] |
Copies current to baseline. NAME is a page name or full id (home@mobile@dark). Removed screenshots delete their baseline. |
report [-o FILE] |
Builds .snapdiff/report.html. |
run [...] |
capture + compare + report. Exit 1 on a regression or a failed capture. |
Statuses: passed, changed (mismatch above the page's max_diff_percent, or the image size changed), new (no baseline), removed (no current). Exit code 2 means a config error.
--static-dir and --base-url override the config's site source, which is how the same config checks a different build. All paths in the config are relative to the config file.
Configuration
examples/snapdiff.yaml:
static_dir: site # or base_url: https://staging.example.com
output_dir: .snapdiff
concurrency: 3 # parallel tabs
# serve_port: 0 # port of the built-in server (0 = free port)
# chrome_path: /usr/bin/google-chrome
# fallback: true # use `chrome --screenshot` if CDP fails (simple pages only)
viewports: # default: desktop 1280x800, tablet, mobile
desktop: {width: 1280, height: 800}
mobile: {width: 390, height: 844, device_scale_factor: 2, mobile: true}
defaults: # any page key except name/path/selector
threshold: 0.1
wait: {delay_ms: 100}
pages:
- name: home
path: /index.html
schemes: [light, dark]
viewports: [desktop, mobile]
wait: {selector: "#cta", network_idle_ms: 100}
- name: pricing-plan
path: /pricing.html
mode: element # full_page (default) | viewport | element
selector: ".plans"
actions:
- hover: ".plan .btn"
- name: about
path: /about.html
mask: ["#today"] # painted over with a solid box
hide: ["#ad"] # visibility:hidden, layout preserved
Page keys: name, path, mode, selector, schemes, viewports, wait, actions, hide, mask, threshold, max_diff_percent, retries.
threshold(0 to 1, default 0.1): per-pixel perceptual tolerance, as in pixelmatch.max_diff_percent(default 0): share of changed pixels a page may have and still pass.wait:selector(+timeout_ms),network_idle_ms(no new resource requests for that long),delay_ms. A bare number or string is shorthand for a delay or a selector.actions:click: sel,hover: sel,type: {selector, text},scroll: sel | y | {selector|y},wait: ms. Click and hover use real mouse events.- Names may not contain
@or/; viewport names may not contain@.
Invalid configs fail fast with a message naming the offending key.
Architecture
| Module | Role |
|---|---|
snapdiff/config.py |
YAML parsing and validation into dataclasses. |
snapdiff/cdp.py |
Chrome launch, minimal CDP client (Connection, Tab, Browser). |
snapdiff/capture.py |
Per-page capture pipeline, parallel jobs, retries, CLI fallback. |
snapdiff/server.py |
Static file server for static_dir. |
snapdiff/diff.py |
Pixelmatch reimplementation, connected components, size handling. |
snapdiff/workflow.py |
compare / approve over the .snapdiff/ directories. |
snapdiff/ci.py |
JUnit XML and Markdown summary. |
snapdiff/report.py |
Self-contained HTML report. |
snapdiff/cli.py |
Click commands. |
Layout of the output directory: baseline/, current/, diff/ (PNG named page@viewport@scheme.png), results.json, report.html. Commit baseline/ to git, ignore the rest.
Deeper reading: docs/diff-algorithm.md, docs/cdp-notes.md, docs/stable-visual-tests.md.
Tests
.venv/bin/python -m pytest -q
53 tests: diff engine, config validation, workflow and reports, plus integration tests that drive real headless Chrome (skipped when Chrome is missing). The Chrome tests use ports 8173 to 8175 and take about two minutes in total on a machine where Chrome's first navigation is slow.
Limitations
- Baselines are only valid for the machine/Chrome/font setup that produced them; generate them in the same environment (ideally CI) that compares them. See the stable-tests doc.
- The
google-chrome --screenshotfallback captures the viewport only and cannot do actions, masks, element mode or dark mode. - Full-page captures are capped at 16000 CSS px tall.
network_idle_msapproximates network idle by watching the Performance resource timeline; it does not see WebSockets or fetches that never complete.- No retry-until-stable heuristics: flaky pages should be fixed with masks and waits rather than averaged away.