|
| 1 | +# snapDiff |
| 2 | + |
| 3 | +**Visual regression testing that runs entirely in the browser.** Powered by [snapDOM](https://github.com/zumerlab/snapdom). |
| 4 | + |
| 5 | +```js |
| 6 | +import { snapdom } from '@zumer/snapdom' |
| 7 | +import { createRunner, Reporter } from '@zumer/snapdiff' |
| 8 | + |
| 9 | +const runner = createRunner({ snapdom }) |
| 10 | +runner.test('hero', () => document.querySelector('.hero')) |
| 11 | + |
| 12 | +new Reporter(runner).mount() |
| 13 | +await runner.run() |
| 14 | +``` |
| 15 | + |
| 16 | +That's it. No headless browser, no Puppeteer, no Playwright (\*), no Jest, no `pixelmatch`. Snap, diff, review — all in the page. |
| 17 | + |
| 18 | +> (\*) Optional vitest + Playwright integration is available for CI. See [Run on CI](#run-on-ci). |
| 19 | +
|
| 20 | +--- |
| 21 | + |
| 22 | +## Why does this exist? |
| 23 | + |
| 24 | +Visual regression testing — the practice of catching unintended UI changes by comparing screenshots over time — has historically been **expensive to set up**: |
| 25 | + |
| 26 | +| Traditional stack | What it does | |
| 27 | +| ------------------------- | --------------------------------------------- | |
| 28 | +| Puppeteer / Playwright | Spins up a headless Chromium binary | |
| 29 | +| `page.screenshot()` | Renders the page to PNG | |
| 30 | +| `pixelmatch` | Pixel-diffs PNGs and outputs a diff image | |
| 31 | +| Jest / Mocha | Test orchestration, snapshot management | |
| 32 | +| Storybook test runner | (Optional) ties it to a component catalog | |
| 33 | +| A separate review tool | Lets humans approve diffs | |
| 34 | + |
| 35 | +Six moving parts. Two binaries. CI image bloat. Slow startup. Snapshots that drift between machines because of font rendering, DPI, or scrollbar widths. |
| 36 | + |
| 37 | +**snapDOM solved one of those problems already**: it captures any DOM element to an image, in the browser, with near-native fidelity. It does what `page.screenshot()` does, but client-side and in milliseconds. |
| 38 | + |
| 39 | +That removes the *need* for the headless browser. And once capture is in the page, **so is the diff**, **so are the baselines**, **so is the review UI**. |
| 40 | + |
| 41 | +snapDiff is the rest of the toolkit on top of snapDOM. It's small (under 30 KB minified), framework-free, and turns visual regression from "schedule a sprint" into "drop a `<script>`". |
| 42 | + |
| 43 | +## What's in the box |
| 44 | + |
| 45 | +- **Pixel-diff engine** with the YIQ perceptual delta and anti-aliasing detection. Same algorithm as `pixelmatch`, reimplemented from scratch with no runtime dependencies. |
| 46 | +- **Storage**: IndexedDB by default (zero setup), or filesystem (via vitest commands) when you want baselines on disk and CI integration. |
| 47 | +- **Test runner** with a familiar `test(name, fn)` API. Returns either an `Element` for snapDOM to capture, or a `Canvas` you built yourself. |
| 48 | +- **In-page reporter** with three view modes (split / slider / diff overlay), one-click *Approve as new baseline*, baseline export/import to JSON. |
| 49 | +- **Static report generator** for offline / CI review (a self-contained HTML you can open with `file://`). |
| 50 | +- **Vitest integration** for projects that already test in the browser — adds a `test()` per demo HTML, baselines on disk, full report on every run. |
| 51 | + |
| 52 | +## 30-second tour |
| 53 | + |
| 54 | +```sh |
| 55 | +git clone https://github.com/zumerlab/snapdiff |
| 56 | +cd snapdiff |
| 57 | +npm install |
| 58 | +npm run demo |
| 59 | +``` |
| 60 | + |
| 61 | +Open `http://localhost:3000/demo/`. Click *Run tests* (records baselines), then *Toggle mutation* (introduces visual drift), then *Run tests* again. The overlay shows you the diffs in three modes. |
| 62 | + |
| 63 | +## Install |
| 64 | + |
| 65 | +```sh |
| 66 | +npm install --save-dev @zumer/snapdiff @zumer/snapdom |
| 67 | +``` |
| 68 | + |
| 69 | +`@zumer/snapdom` is a peer dependency — snapDiff is the runner around snapDOM, not a fork of it. |
| 70 | + |
| 71 | +## API at a glance |
| 72 | + |
| 73 | +### In-page (zero setup) |
| 74 | + |
| 75 | +```js |
| 76 | +import { snapdom } from '@zumer/snapdom' |
| 77 | +import { createRunner, Reporter } from '@zumer/snapdiff' |
| 78 | + |
| 79 | +const runner = createRunner({ |
| 80 | + snapdom, |
| 81 | + namespace: 'my-app', // scopes baselines per project |
| 82 | + threshold: 0.1, // YIQ perceptual threshold (0..1) |
| 83 | + failureRatio: 0, // mismatch ratio that fails a test |
| 84 | + snapdomOptions: { // passed to snapdom for every capture |
| 85 | + dpr: 1, // pin DPR so baselines are portable |
| 86 | + scale: 1, |
| 87 | + embedFonts: true, |
| 88 | + }, |
| 89 | +}) |
| 90 | + |
| 91 | +runner.test('homepage hero', () => document.querySelector('.hero')) |
| 92 | +runner.test('pricing table', () => document.querySelector('.pricing')) |
| 93 | + |
| 94 | +const reporter = new Reporter(runner) |
| 95 | +reporter.mount() |
| 96 | +await reporter.runAndShow() |
| 97 | +``` |
| 98 | + |
| 99 | +The first run records each fixture as a *new* baseline (test passes). Every run after that diffs against the baseline. Failures are highlighted; click *Approve as new baseline* to update. |
| 100 | + |
| 101 | +### Vitest integration (recommended for CI) |
| 102 | + |
| 103 | +For projects that already test in the browser via `vitest --browser`, snapDiff plugs in as a single test file that loops over a folder of demo HTML pages. |
| 104 | + |
| 105 | +```js |
| 106 | +// vitest.config.js |
| 107 | +import { defineConfig } from 'vitest/config' |
| 108 | +import { snapDiffCommands } from '@zumer/snapdiff/vitest' |
| 109 | + |
| 110 | +export default defineConfig({ |
| 111 | + test: { |
| 112 | + browser: { |
| 113 | + enabled: true, |
| 114 | + provider: 'playwright', |
| 115 | + instances: [{ browser: 'chromium' }], |
| 116 | + screenshotFailures: false, |
| 117 | + commands: snapDiffCommands({ baseDir: '__snapshots__/visual' }), |
| 118 | + }, |
| 119 | + }, |
| 120 | +}) |
| 121 | +``` |
| 122 | + |
| 123 | +```js |
| 124 | +// __tests__/visual.demos.test.js |
| 125 | +import { defineDemoSuite } from '@zumer/snapdiff/vitest/suite' |
| 126 | + |
| 127 | +defineDemoSuite({ |
| 128 | + demos: import.meta.glob('/demos/*.html'), |
| 129 | + defaultTarget: ['#target', 'body'], |
| 130 | + snapdomOptions: { dpr: 1, scale: 1, embedFonts: true }, |
| 131 | + demoOptions: { |
| 132 | + 'login': { target: '#login-form' }, |
| 133 | + 'modal': { wait: 500 }, |
| 134 | + }, |
| 135 | +}) |
| 136 | +``` |
| 137 | + |
| 138 | +Each demo becomes a vitest test. Baselines land at `__snapshots__/visual/<name>.png` (commit them). On every run, a self-contained `report.html` is regenerated with side-by-side / slider / diff views. |
| 139 | + |
| 140 | +Update baselines: `UPDATE_VISUAL=1 npx vitest run`. |
| 141 | + |
| 142 | +## Determinism (this is the part everyone gets wrong) |
| 143 | + |
| 144 | +Visual baselines must be reproducible across machines, browsers, headed/headless, retina/non-retina, and CI. snapDiff ships safe defaults, but you should know why they matter: |
| 145 | + |
| 146 | +| option | snapDiff default | why | |
| 147 | +| --------------- | ------------------ | ------------------------------------------------------------------ | |
| 148 | +| `dpr` | `1` | otherwise capture is `devicePixelRatio`-scaled — 2x on retina, 1x in headless | |
| 149 | +| `scale` | `1` | same as DPR — affects output canvas dimensions | |
| 150 | +| `embedFonts` | `true` | otherwise font availability across machines changes layout | |
| 151 | +| viewport | `1280x1024` | element bounds depend on it | |
| 152 | + |
| 153 | +If you change any of these between recording and verifying, every test fails with `dims differ`. snapDiff catches this case and tells you exactly what to do. |
| 154 | + |
| 155 | +## Threshold cheat sheet |
| 156 | + |
| 157 | +The `threshold` is the per-pixel YIQ perceptual delta. Below it, the pixel is considered visually unchanged. Sensible defaults: |
| 158 | + |
| 159 | +- `0.05` — strict. Catches subtle gradient and shadow shifts. |
| 160 | +- `0.1` — default. Tolerates antialiasing flicker, catches real changes. |
| 161 | +- `0.2` — lenient. Useful when text rendering varies across machines. |
| 162 | + |
| 163 | +The `failureRatio` is how much overall mismatch is allowed before a test fails. Default `0` (any mismatch fails). Increase to `0.001` (0.1%) if you have noisy fixtures. |
| 164 | + |
| 165 | +## Run on CI |
| 166 | + |
| 167 | +Two options: |
| 168 | + |
| 169 | +**1. Vitest + Playwright (full integration)** |
| 170 | +The vitest suite runs your demos in real Chromium and writes baselines / artifacts to disk. CI just runs `npm test`. This is the path the snapDOM project itself uses. |
| 171 | + |
| 172 | +**2. Pure browser, then ship the report** |
| 173 | +Run snapDiff in any browser (headed CI agent, Saucelabs, BrowserStack), call `runner.run()`, send the result + a generated `report.html` as the build artifact. Reviewers open it directly — no infrastructure needed. |
| 174 | + |
| 175 | +A standalone CLI is on the roadmap but optional; it would only be a thin wrapper around (1). |
| 176 | + |
| 177 | +## Architecture |
| 178 | + |
| 179 | +``` |
| 180 | + ┌───────────────────┐ |
| 181 | + │ snapdom │ captures DOM → SVG → Canvas |
| 182 | + └─────────┬─────────┘ |
| 183 | + │ |
| 184 | + ▼ |
| 185 | + ┌───────────────────┐ |
| 186 | + │ snapDiff.runner │ orchestrates: capture → diff → record |
| 187 | + └─────────┬─────────┘ |
| 188 | + ┌───────┴────────┐ |
| 189 | + ▼ ▼ |
| 190 | + ┌─────────────────┐ ┌─────────────────┐ |
| 191 | + │ snapDiff.diff │ │ BaselineStore │ IndexedDB or filesystem |
| 192 | + └─────────────────┘ └─────────────────┘ |
| 193 | + │ |
| 194 | + ▼ |
| 195 | + ┌─────────────────┐ |
| 196 | + │ Reporter │ in-page UI: split / slider / diff |
| 197 | + └─────────────────┘ |
| 198 | +``` |
| 199 | + |
| 200 | +The diff engine, the store, and the reporter are independent — you can use any of them on their own. For example, `import { diffPixels } from '@zumer/snapdiff/diff'` works in Node + `node-canvas` if you just need pixel-diff without snapDOM. |
| 201 | + |
| 202 | +## API reference |
| 203 | + |
| 204 | +### `createRunner(options)` → runner |
| 205 | + |
| 206 | +| option | default | meaning | |
| 207 | +| ----------------- | ----------- | ------------------------------------------------------------- | |
| 208 | +| `snapdom` | required | the snapdom function | |
| 209 | +| `store` | IndexedDB | custom store (e.g. `FileBaselineStore`) | |
| 210 | +| `namespace` | `'default'` | scopes baselines per project in IndexedDB | |
| 211 | +| `threshold` | `0.1` | YIQ delta threshold per pixel | |
| 212 | +| `failureRatio` | `0` | mismatch ratio above which a test fails | |
| 213 | +| `includeAA` | `false` | if true, anti-aliased pixels count as mismatches | |
| 214 | +| `snapdomOptions` | `{}` | options passed to `snapdom(el, options)` for every test | |
| 215 | + |
| 216 | +Returned methods: `test(name, fn, opts?)`, `run({ filter?, onProgress? })`, `approve(name, canvas?)`, `approveAll(results)`, `summary(results)`, `store`. |
| 217 | + |
| 218 | +### `Reporter(runner, opts?)` → ui |
| 219 | + |
| 220 | +`reporter.mount(target?)`, `reporter.unmount()`, `reporter.runAndShow(filter?)`, `reporter.setResults(results)`. |
| 221 | + |
| 222 | +### `diffPixels(a, b, out|null, w, h, opts?)` → `{ diff, total, ratio }` |
| 223 | + |
| 224 | +Pure function over RGBA `Uint8ClampedArray` buffers. `diffCanvas(baseline, actual, opts?)` is the canvas-aware wrapper. |
| 225 | + |
| 226 | +### `BaselineStore(namespace?)` |
| 227 | + |
| 228 | +`put(name, blob, meta)`, `get(name)`, `delete(name)`, `list()`, `clear()`, `export()`, `import(bundle, { overwrite })`. |
| 229 | + |
| 230 | +### `defineDemoSuite(options)` (vitest browser) |
| 231 | + |
| 232 | +| option | default | meaning | |
| 233 | +| ----------------- | ------------------------- | ------------------------------------------------------ | |
| 234 | +| `demos` | required | `import.meta.glob('/demos/*.html')` or array of URLs | |
| 235 | +| `baseDir` | `'__snapshots__/visual'` | where baselines + report go (must match commands) | |
| 236 | +| `defaultTarget` | `['#target', 'body']` | selectors tried in order; `body` always appended | |
| 237 | +| `defaultWait` | `0` | ms to wait after iframe load before capture | |
| 238 | +| `snapdomUrl` | `'/dist/snapdom.mjs'` | URL to snapdom inside each iframe | |
| 239 | +| `snapdomOptions` | `{ dpr: 1, scale: 1, embedFonts: true }` | passed to snapdom for every demo | |
| 240 | +| `demoOptions` | `{}` | per-demo overrides keyed by file basename | |
| 241 | +| `viewport` | `{ width: 1280, height: 1024 }` | iframe dimensions | |
| 242 | + |
| 243 | +Per-demo override fields: `target`, `wait`, `snapdomOptions`, `setup(win, doc)`, `threshold`, `failureRatio`, `skip`, `strictTarget`. |
| 244 | + |
| 245 | +## About |
| 246 | + |
| 247 | +snapDiff is a project of [Zumerlab](https://github.com/zumerlab) — same authors as snapDOM. It exists to **showcase what snapDOM enables**: in-the-page DOM capture is fast and faithful enough that the entire visual regression toolchain can collapse into a single browser session. |
| 248 | + |
| 249 | +snapDOM itself uses snapDiff to test its 50+ demo pages on every commit. If it can verify snapDOM's renderings, it can verify yours. |
| 250 | + |
| 251 | +## License |
| 252 | + |
| 253 | +MIT — Juan Martin Muda |
0 commit comments