CLI ยท commit f1bb448

snapdiff

Visual regression testing for web pages with perceptual diffing

Download snapdiff.zip
Quickstart
unzip snapdiff.zip && cd snapdiff
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

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.

report

Features

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):

pricing diff

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.

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