Skip to content

Commit b27517c

Browse files
tinchox5claude
andcommitted
Initial commit: snapDiff v0.1.0
Client-side visual regression testing powered by snapDOM. Pixel-diff engine, IndexedDB store, in-page reporter, vitest integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0 parents  commit b27517c

25 files changed

Lines changed: 6775 additions & 0 deletions

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
node_modules/
2+
dist/
3+
coverage/
4+
*.tgz
5+
.DS_Store
6+
__snapshots__/visual/_artifacts/
7+
__snapshots__/visual/report.html
8+
.vitest-cache/
9+
.claude

README.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

__tests__/diff.test.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { diffPixels, diffCanvas } from '../src/diff.js'
3+
4+
function makePixels(w, h, fn) {
5+
const buf = new Uint8ClampedArray(w * h * 4)
6+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
7+
const i = (y * w + x) * 4
8+
const [r, g, b, a] = fn(x, y)
9+
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a
10+
}
11+
return buf
12+
}
13+
14+
function fill(w, h, color) { return makePixels(w, h, () => color) }
15+
16+
describe('diffPixels', () => {
17+
it('returns 0 mismatches when buffers are identical', () => {
18+
const a = fill(20, 20, [255, 0, 0, 255])
19+
const b = fill(20, 20, [255, 0, 0, 255])
20+
const result = diffPixels(a, b, null, 20, 20)
21+
expect(result).toEqual({ diff: 0, total: 400, ratio: 0 })
22+
})
23+
24+
it('flags every pixel when buffers are inverted', () => {
25+
const a = fill(10, 10, [255, 255, 255, 255])
26+
const b = fill(10, 10, [0, 0, 0, 255])
27+
const result = diffPixels(a, b, null, 10, 10)
28+
expect(result.diff).toBe(100)
29+
expect(result.ratio).toBe(1)
30+
})
31+
32+
it('suppresses sub-threshold differences', () => {
33+
const a = fill(10, 10, [128, 128, 128, 255])
34+
const b = fill(10, 10, [129, 128, 128, 255])
35+
const result = diffPixels(a, b, null, 10, 10, { threshold: 0.1 })
36+
expect(result.diff).toBe(0)
37+
})
38+
39+
it('counts only the changed region', () => {
40+
const a = fill(40, 40, [255, 255, 255, 255])
41+
const b = makePixels(40, 40, (x, y) => (
42+
x >= 10 && x < 30 && y >= 10 && y < 30
43+
? [255, 0, 0, 255]
44+
: [255, 255, 255, 255]
45+
))
46+
const result = diffPixels(a, b, null, 40, 40)
47+
// 20x20 = 400 changed pixels.
48+
expect(result.diff).toBe(400)
49+
expect(result.ratio).toBeCloseTo(0.25, 5)
50+
})
51+
52+
it('writes a diff buffer when out is provided', () => {
53+
const a = fill(4, 4, [0, 0, 0, 255])
54+
const b = fill(4, 4, [255, 255, 255, 255])
55+
const out = new Uint8ClampedArray(4 * 4 * 4)
56+
diffPixels(a, b, out, 4, 4)
57+
// Every pixel becomes red (diffColor default).
58+
for (let i = 0; i < out.length; i += 4) {
59+
expect(out[i]).toBe(255)
60+
expect(out[i + 1]).toBe(0)
61+
expect(out[i + 2]).toBe(0)
62+
expect(out[i + 3]).toBe(255)
63+
}
64+
})
65+
66+
it('treats lowered threshold as stricter', () => {
67+
const a = fill(10, 10, [100, 100, 100, 255])
68+
const b = fill(10, 10, [110, 110, 110, 255])
69+
const lenient = diffPixels(a, b, null, 10, 10, { threshold: 0.5 })
70+
const strict = diffPixels(a, b, null, 10, 10, { threshold: 0.01 })
71+
expect(strict.diff).toBeGreaterThan(lenient.diff)
72+
})
73+
74+
it('throws when buffers have different lengths', () => {
75+
const a = fill(10, 10, [0, 0, 0, 255])
76+
const b = fill(8, 8, [0, 0, 0, 255])
77+
expect(() => diffPixels(a, b, null, 10, 10)).toThrow(/same dimensions/i)
78+
})
79+
})
80+
81+
describe('diffCanvas', () => {
82+
function canvasOf(w, h, color) {
83+
const c = document.createElement('canvas')
84+
c.width = w; c.height = h
85+
const ctx = c.getContext('2d')
86+
ctx.fillStyle = color
87+
ctx.fillRect(0, 0, w, h)
88+
return c
89+
}
90+
91+
it('returns identical-shaped result canvas with stats', () => {
92+
const a = canvasOf(40, 30, '#ffffff')
93+
const b = canvasOf(40, 30, '#ffffff')
94+
const result = diffCanvas(a, b)
95+
expect(result.width).toBe(40)
96+
expect(result.height).toBe(30)
97+
expect(result.dimsMatch).toBe(true)
98+
expect(result.diff).toBe(0)
99+
expect(result.canvas).toBeInstanceOf(HTMLCanvasElement)
100+
})
101+
102+
it('flags dimension mismatch and letterboxes to the larger size', () => {
103+
const a = canvasOf(40, 30, '#ffffff')
104+
const b = canvasOf(50, 40, '#ffffff')
105+
const result = diffCanvas(a, b)
106+
expect(result.dimsMatch).toBe(false)
107+
expect(result.width).toBe(50)
108+
expect(result.height).toBe(40)
109+
})
110+
111+
it('detects a real visual difference', () => {
112+
const a = canvasOf(30, 30, '#ffffff')
113+
const b = canvasOf(30, 30, '#000000')
114+
const result = diffCanvas(a, b)
115+
expect(result.ratio).toBe(1)
116+
})
117+
})

0 commit comments

Comments
 (0)