vitest-screenshot 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ ## [vitest-screenshot-v1.0.1](https://github.com/bhouston/vitest-gpu/compare/vitest-screenshot-v1.0.0...vitest-screenshot-v1.0.1) (2026-09-18)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **screenshot:** enforce comparator option types ([1af7d9c](https://github.com/bhouston/vitest-gpu/commit/1af7d9cefe5531e0d5ed160455aa02418de39491)), closes [#28](https://github.com/bhouston/vitest-gpu/issues/28)
6
+ * **screenshot:** reject negated assertions ([0753f31](https://github.com/bhouston/vitest-gpu/commit/0753f317972e73d9e50d7e594d1ed22db6359c4a)), closes [#22](https://github.com/bhouston/vitest-gpu/issues/22)
7
+ * ship sources referenced by package maps ([8db482e](https://github.com/bhouston/vitest-gpu/commit/8db482ed56714f375b53d9a30df3f080a86fb81d)), closes [#23](https://github.com/bhouston/vitest-gpu/issues/23)
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # vitest-screenshot
2
2
 
3
- `expect(image).toMatchScreenshot(name)`: pixel-diff an RGBA image against a PNG baseline committed
4
- next to your tests. Backend-agnostic: feed it `canvas.getImageData()` from
5
- [`vitest-environment-webgl-node`](../vitest-environment-webgl-node), a WebGPU texture readback, or
6
- pixels from a real browser.
3
+ `await expect(image).toMatchScreenshot(reference)`: compare an image against a baseline committed next to your
4
+ tests, with the same options as Vitest browser mode's `toMatchScreenshot`. Backend-agnostic: feed it a canvas from
5
+ [`vitest-environment-webgl-node`](../vitest-environment-webgl-node) or
6
+ [`vitest-environment-webgpu-node`](../vitest-environment-webgpu-node), an `ImageData`, a WebGPU texture
7
+ readback, or pixels from a real browser.
7
8
 
8
9
  ## Install
9
10
 
@@ -17,16 +18,132 @@ pnpm add -D vitest vitest-screenshot
17
18
  import { extendMatchers } from 'vitest-screenshot';
18
19
  extendMatchers();
19
20
 
20
- it('renders the knot', () => {
21
+ it('renders the knot', async () => {
21
22
  renderer.render(scene, camera);
22
- expect(canvas.getImageData()).toMatchScreenshot('knot', { maxDiffRatio: 0.01 });
23
+ await expect(canvas).toMatchScreenshot('knot.png', {
24
+ comparatorOptions: { allowedMismatchedPixelRatio: 0.01 },
25
+ });
23
26
  });
24
27
  ```
25
28
 
26
- - Baselines live in `__screenshots__/<name>.png` beside the test file (override with `baselineDir`).
29
+ The matcher is async: always `await` it.
30
+
31
+ ### What you can compare
32
+
33
+ The received value and the reference can each be any of:
34
+
35
+ - `{ width, height, data }` with RGBA8 pixels, top row first (an `ImageData`, `canvas.getImageData()`, a WebGPU readback).
36
+ - A canvas: node-webgl's `Canvas` (read with `getImageData()`) or the WebGPU environment's `HeadlessCanvas` (read with `readPixels()`).
37
+ - An `Image` from `vitest-environment-webgl-node` (decoded with `decode()` first).
38
+
39
+ The reference may also be a file path. It must include the extension (`'knot.png'`, not `'knot'`), and resolves
40
+ relative to `baselineDir` unless absolute.
41
+
42
+ ### Baseline files
43
+
44
+ - Baselines live in `__screenshots__/` beside the test file (override with `baselineDir`).
45
+ - Any format sharp reads and writes works: png, jpg, gif, webp, avif, tiff. **We recommend png**: it is lossless,
46
+ so a baseline written on the first run matches exactly. jpg and webp are re-encoded lossily on write and gif is
47
+ limited to 256 colours, so loosen the comparator if you use them.
27
48
  - A missing baseline is written on first run, except when `CI` is set, where it fails.
28
49
  - `UPDATE_SCREENSHOTS=1 vitest` (or `update: true`) rewrites baselines.
29
- - On failure `<name>.actual.png` and `<name>.diff.png` are written beside the baseline. Gitignore them.
30
- - `threshold` is pixelmatch's per-pixel colour tolerance (default 0.1); `maxDiffRatio` is the fraction
31
- of pixels allowed to differ (default 0.001). GPUs and drivers rasterize slightly differently, so loosen
32
- `maxDiffRatio` for baselines shared across platforms.
50
+ - `.not.toMatchScreenshot()` is unsupported and fails without reading pixels or writing baseline files.
51
+ - On failure `<name>.actual.png` and `<name>.diff.png` are written to `baselineDir` (named after the test when the
52
+ reference is in memory). Gitignore them.
53
+
54
+ ## Comparators
55
+
56
+ As in [Vitest browser mode](https://vitest.dev/guide/browser/visual-regression-testing), `comparatorName` picks
57
+ how the two images are judged and `comparatorOptions` configures it. Defaults for every assertion, and custom
58
+ comparators, go to `extendMatchers()` (the equivalent of `test.browser.expect.toMatchScreenshot` in Vitest config):
59
+
60
+ ```ts
61
+ extendMatchers({
62
+ comparatorName: 'pixelmatch',
63
+ comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
64
+ });
65
+ ```
66
+
67
+ Per-assertion `comparatorOptions` are merged over the global ones when they name the same comparator. When an
68
+ assertion omits `comparatorName`, its options may match any registered comparator because the global default selects
69
+ their meaning at runtime. Supplying `comparatorName` checks the options strictly against that comparator.
70
+
71
+ ### `pixelmatch` (default)
72
+
73
+ Vitest's built-in comparison: counts pixels whose perceptual colour distance exceeds `threshold`.
74
+
75
+ | Option | Default | Meaning |
76
+ | ------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------- |
77
+ | `threshold` | `0.1` | Per-pixel colour distance (0..1) tolerated; smaller is stricter. |
78
+ | `allowedMismatchedPixelRatio` | | Fraction (0..1) of pixels allowed to differ. |
79
+ | `allowedMismatchedPixels` | | Number of pixels allowed to differ. |
80
+ | `includeAA`, `alpha`, `aaColor`, `diffColor`, `diffColorAlt`, `diffMask` | pixelmatch defaults | Forwarded to [pixelmatch](https://github.com/mapbox/pixelmatch). |
81
+
82
+ With both limits given the stricter wins; with neither, no pixel may differ. GPUs and drivers rasterize edges
83
+ slightly differently, so set a ratio for baselines shared across machines.
84
+
85
+ ### `metrics`
86
+
87
+ Whole-image error bounds with the names and semantics of ImageMagick's `compare -metric`. Give any subset; the
88
+ assertion passes only if every bound holds. With none given it requires an exact match (`AE: 0`), like
89
+ `compare -metric AE`.
90
+
91
+ | Option | Bound | Unit | Meaning |
92
+ | ------- | ----- | ------ | ------------------------------------------------------------------------------------------------------------------ |
93
+ | `fuzz` | | 0..1 | `-fuzz`: colour distance within which two pixels count as equal (default 0). Only affects `AE` and the diff image. |
94
+ | `AE` | max | pixels | Absolute error: pixels differing by more than `fuzz`. |
95
+ | `PAE` | max | 0..1 | Peak absolute error: the largest channel difference anywhere. |
96
+ | `MAE` | max | 0..1 | Mean absolute error. |
97
+ | `MSE` | max | 0..1 | Mean squared error. |
98
+ | `RMSE` | max | 0..1 | Root mean squared error. |
99
+ | `PSNR` | min | dB | Peak signal-to-noise ratio; `Infinity` for identical images. 30–50 dB is typical for "same picture". |
100
+ | `SSIM` | min | 0..1 | Structural similarity (Gaussian 11×11 window, σ 1.5, over RGB); 1 is identical. |
101
+ | `DSSIM` | max | 0..1 | Structural dissimilarity, `(1 − SSIM) / 2`. |
102
+
103
+ ```ts
104
+ await expect(canvas).toMatchScreenshot('knot.png', {
105
+ comparatorName: 'metrics',
106
+ comparatorOptions: { fuzz: 0.05, AE: 50, PSNR: 40 }, // few pixels off by >5%, and low overall error
107
+ });
108
+ ```
109
+
110
+ `AE`, `PAE`, `MAE`, `MSE`, `RMSE` and `PSNR` are computed as ImageMagick does for images with an alpha channel
111
+ (colours alpha-premultiplied, all four channels averaged) and agree with `magick compare` to the printed digits.
112
+ `SSIM` is the textbook definition over RGB, the value `magick compare -metric SSIM` on an opaque image reports
113
+ as `(1 − SSIM) / 2`. The failure message lists every computed metric so you can pick a bound from a real run:
114
+
115
+ ```
116
+ knot.png: AE 760 (fuzz 0), PAE 0.1569, MAE 0.01042, MSE 0.0007709, RMSE 0.02777, PSNR 31.13; failed: AE 760 > 0; see __screenshots__/knot.diff.png
117
+ ```
118
+
119
+ ### Custom comparators
120
+
121
+ A comparator has Vitest's signature: `(reference, actual, options) => { pass, diff, message }`, where `reference`
122
+ and `actual` are `{ width, height, data }`, `options` carries `comparatorOptions` plus `createDiff`, and `diff`
123
+ (an RGBA image or `null`) is written as `<name>.diff.png` on failure. Register it under `comparators` and select
124
+ it with `comparatorName`. Add its option type to `ComparatorRegistry` so registration and assertions check the
125
+ custom name and options:
126
+
127
+ ```ts
128
+ import { type Comparator, extendMatchers } from 'vitest-screenshot';
129
+
130
+ declare module 'vitest-screenshot' {
131
+ interface ComparatorRegistry {
132
+ 'red-channel': { tolerance?: number };
133
+ }
134
+ }
135
+
136
+ const redChannel: Comparator<{ tolerance?: number }> = (reference, actual, { tolerance = 0 }) => {
137
+ const difference = Math.abs(reference.data[0]! - actual.data[0]!);
138
+ return { pass: difference <= tolerance, diff: null, message: `red channel differs by ${difference}` };
139
+ };
140
+
141
+ extendMatchers({ comparators: { 'red-channel': redChannel } });
142
+
143
+ await expect(image).toMatchScreenshot('baseline.png', {
144
+ comparatorName: 'red-channel',
145
+ comparatorOptions: { tolerance: 2 },
146
+ });
147
+ ```
148
+
149
+ See [`demo/test/screenshots`](../../demo/test/screenshots) for every source, format and comparator combination.
package/dist/index.d.ts CHANGED
@@ -1,23 +1,77 @@
1
+ import pixelmatch from 'pixelmatch';
2
+ import { type MetricsOptions } from './metrics.js';
3
+ export type { Metric, MetricsOptions } from './metrics.js';
1
4
  /** Anything with RGBA8 pixels, top row first: `ImageData`, node-webgl's `canvas.getImageData()`, or a WebGPU readback. */
2
5
  export type RgbaImage = {
3
6
  width: number;
4
7
  height: number;
5
8
  data: Uint8Array | Uint8ClampedArray;
6
9
  };
7
- export type ScreenshotOptions = {
8
- /** Directory holding baseline PNGs. Default: `__screenshots__` beside the test file. */
10
+ /**
11
+ * A source of pixels: an `RgbaImage` / `ImageData`, a WebGPU `HeadlessCanvas` (`readPixels()`), a node-webgl
12
+ * `Canvas` (`getImageData()`), or a node-webgl `Image` (decoded with `decode()`).
13
+ */
14
+ export type ImageSource = RgbaImage | {
15
+ readPixels(): Promise<RgbaImage>;
16
+ } | {
17
+ getImageData(): RgbaImage;
18
+ } | {
19
+ decode(): Promise<void>;
20
+ };
21
+ /** A baseline: a file path with extension (relative to `baselineDir` or absolute), or an in-memory `ImageSource`. */
22
+ export type Reference = string | ImageSource;
23
+ /** What a comparator returns, as in Vitest browser mode: `diff` is written next to the baseline on failure. */
24
+ export type ComparatorResult = {
25
+ pass: boolean;
26
+ diff: RgbaImage | null;
27
+ message: string | null;
28
+ };
29
+ /** A comparator, with the signature Vitest browser mode uses for `comparators`. */
30
+ export type Comparator<Options extends object = Record<string, unknown>> = (reference: RgbaImage, actual: RgbaImage, options: Options & {
31
+ createDiff: boolean;
32
+ }) => ComparatorResult;
33
+ /** Options of the default `pixelmatch` comparator, named as in Vitest browser mode. */
34
+ export type PixelmatchOptions = NonNullable<Parameters<typeof pixelmatch>[5]> & {
35
+ /** Fraction (0..1) of pixels allowed to differ. */
36
+ allowedMismatchedPixelRatio?: number;
37
+ /** Number of pixels allowed to differ. With both limits given the stricter wins; with neither, none may differ. */
38
+ allowedMismatchedPixels?: number;
39
+ };
40
+ /** Comparator names and their options. Augment this interface when registering a custom comparator. */
41
+ export interface ComparatorRegistry {
42
+ pixelmatch: PixelmatchOptions;
43
+ metrics: MetricsOptions;
44
+ }
45
+ type ComparatorName = Extract<keyof ComparatorRegistry, string>;
46
+ type ComparatorOptions<Name extends ComparatorName> = Extract<ComparatorRegistry[Name], object>;
47
+ /** Which registered comparator judges the images. `pixelmatch` is the default. */
48
+ export type ComparatorSelection = {
49
+ [Name in ComparatorName]: {
50
+ comparatorName: Name;
51
+ comparatorOptions?: ComparatorOptions<Name>;
52
+ };
53
+ }[ComparatorName] | {
54
+ comparatorName?: undefined;
55
+ comparatorOptions?: ComparatorOptions<ComparatorName>;
56
+ };
57
+ export type ScreenshotOptions = ComparatorSelection & {
58
+ /** Directory relative reference paths and diff images live in. Default: `__screenshots__` beside the test file. */
9
59
  baselineDir?: string;
10
- /** Per-pixel colour distance (0..1) tolerated by pixelmatch. Default 0.1. */
11
- threshold?: number;
12
- /** Fraction (0..1) of pixels allowed to differ. Default 0.001. */
13
- maxDiffRatio?: number;
14
- /** Overwrite the baseline. Default: `UPDATE_SCREENSHOTS` env var is set. */
60
+ /** Overwrite the baseline file. Default: `UPDATE_SCREENSHOTS` env var is set. */
15
61
  update?: boolean;
16
62
  };
63
+ /** Defaults for every assertion plus custom comparators, like `test.browser.expect.toMatchScreenshot` in Vitest config. */
64
+ export type ScreenshotConfig = ComparatorSelection & {
65
+ comparators?: {
66
+ [Name in ComparatorName]?: Comparator<ComparatorOptions<Name>>;
67
+ };
68
+ };
17
69
  declare module 'vitest' {
18
70
  interface Assertion {
19
- toMatchScreenshot(name: string, options?: ScreenshotOptions): void;
71
+ toMatchScreenshot(reference: Reference, options?: ScreenshotOptions): Promise<void>;
20
72
  }
21
73
  }
22
- export declare function extendMatchers(): void;
74
+ /** `comparatorName: 'pixelmatch'` (default): Vitest browser mode's built-in comparison. */
75
+ export declare const pixelmatchComparator: Comparator<PixelmatchOptions>;
76
+ export declare function extendMatchers(config?: ScreenshotConfig): void;
23
77
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,0HAA0H;AAC1H,MAAM,MAAM,SAAS,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,UAAU,GAAG,iBAAiB,CAAA;CAAE,CAAC;AAEhG,MAAM,MAAM,iBAAiB,GAAG;IAC9B,wFAAwF;IACxF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,OAAO,QAAQ,QAAQ,CAAC;IACtB,UAAU,SAAS;QACjB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAC;KACpE;CACF;AAUD,wBAAgB,cAAc,IAAI,IAAI,CAoCrC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,43 +1,100 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join } from 'node:path';
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { basename, dirname, extname, isAbsolute, join } from 'node:path';
3
3
  import pixelmatch from 'pixelmatch';
4
- import { PNG } from 'pngjs';
4
+ import sharp from 'sharp';
5
5
  import { expect } from 'vitest';
6
+ import { metricsComparator } from './metrics.js';
6
7
  const result = (pass, message) => ({ pass, message: () => message });
7
- const toPng = ({ width, height, data }) => {
8
- const png = new PNG({ width, height });
9
- png.data = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
10
- return png;
8
+ const raw = ({ width, height, data }) => sharp(data, { raw: { width, height, channels: 4 } });
9
+ /** Decodes any format sharp reads (png, jpg, gif, webp, ...) into RGBA8. */
10
+ const readImage = async (file) => {
11
+ const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
12
+ return { width: info.width, height: info.height, data };
11
13
  };
12
- export function extendMatchers() {
14
+ /** Encodes by file extension; lossy formats (jpg, webp) alter pixels on write. */
15
+ const writeImage = (file, image) => {
16
+ mkdirSync(dirname(file), { recursive: true });
17
+ return raw(image)
18
+ .toFormat(extname(file).slice(1))
19
+ .toFile(file);
20
+ };
21
+ const toRgba = async (source) => {
22
+ if ('data' in source)
23
+ return source;
24
+ if ('readPixels' in source)
25
+ return source.readPixels();
26
+ if ('getImageData' in source)
27
+ return source.getImageData();
28
+ await source.decode();
29
+ // node-webgl's Image and ImageBitmap expose decoded pixels this way (it is what their own texImage2D uses).
30
+ const rgba = source._toRGBA8?.();
31
+ if (!rgba)
32
+ throw new Error('unsupported image source: expected RGBA data, readPixels(), getImageData() or an Image');
33
+ return rgba;
34
+ };
35
+ /** `comparatorName: 'pixelmatch'` (default): Vitest browser mode's built-in comparison. */
36
+ export const pixelmatchComparator = (reference, actual, { createDiff, allowedMismatchedPixelRatio, allowedMismatchedPixels, ...pixelmatchOptions }) => {
37
+ const { width, height } = actual;
38
+ const total = width * height;
39
+ const diff = createDiff ? new Uint8Array(total * 4) : undefined;
40
+ const count = pixelmatch(reference.data, actual.data, diff, width, height, pixelmatchOptions);
41
+ const limits = [allowedMismatchedPixels, allowedMismatchedPixelRatio && allowedMismatchedPixelRatio * total];
42
+ const allowed = limits.some((l) => l !== undefined) ? Math.min(...limits.filter((l) => l !== undefined)) : 0;
43
+ return {
44
+ pass: count <= allowed,
45
+ diff: diff ? { width, height, data: diff } : null,
46
+ message: `${count} pixels (${((count / total) * 100).toFixed(3)}%) differ, ${Math.floor(allowed)} allowed`,
47
+ };
48
+ };
49
+ export function extendMatchers(config = {}) {
50
+ const comparators = {
51
+ pixelmatch: pixelmatchComparator,
52
+ metrics: metricsComparator,
53
+ ...config.comparators,
54
+ };
13
55
  expect.extend({
14
- toMatchScreenshot(received, name, options = {}) {
15
- const dir = options.baselineDir ?? join(dirname(this.testPath ?? ''), '__screenshots__');
16
- const file = join(dir, `${name}.png`);
17
- const actual = toPng(received);
18
- const update = options.update ?? Boolean(process.env.UPDATE_SCREENSHOTS);
19
- if (update || (!existsSync(file) && !process.env.CI)) {
20
- mkdirSync(dir, { recursive: true });
21
- writeFileSync(file, PNG.sync.write(actual));
22
- return result(true, `wrote baseline ${file}`);
56
+ async toMatchScreenshot(received, reference, options = {}) {
57
+ if (this.isNot)
58
+ throw new Error('`.not.toMatchScreenshot()` is not supported');
59
+ const dir = options.baselineDir ?? join(dirname(this.testPath), '__screenshots__');
60
+ const actual = await toRgba(received);
61
+ let file;
62
+ let baseline;
63
+ if (typeof reference === 'string') {
64
+ if (!extname(reference))
65
+ throw new Error(`reference "${reference}" needs an image extension, e.g. "${reference}.png"`);
66
+ file = isAbsolute(reference) ? reference : join(dir, reference);
67
+ const update = options.update ?? Boolean(process.env.UPDATE_SCREENSHOTS);
68
+ if (update || (!existsSync(file) && !process.env.CI)) {
69
+ await writeImage(file, actual);
70
+ return result(true, `wrote baseline ${file}`);
71
+ }
72
+ if (!existsSync(file))
73
+ return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1`);
74
+ baseline = await readImage(file);
23
75
  }
24
- if (!existsSync(file))
25
- return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1`);
26
- const baseline = PNG.sync.read(readFileSync(file));
76
+ else {
77
+ baseline = await toRgba(reference);
78
+ }
79
+ const label = file ? basename(file) : this.currentTestName;
27
80
  if (baseline.width !== actual.width || baseline.height !== actual.height) {
28
- return result(false, `size mismatch: baseline ${baseline.width}x${baseline.height}, actual ${actual.width}x${actual.height}`);
81
+ return result(false, `${label}: size mismatch: baseline ${baseline.width}x${baseline.height}, actual ${actual.width}x${actual.height}`);
29
82
  }
30
- const diff = new PNG({ width: actual.width, height: actual.height });
31
- const count = pixelmatch(baseline.data, actual.data, diff.data, actual.width, actual.height, {
32
- threshold: options.threshold ?? 0.1,
83
+ const name = options.comparatorName ?? config.comparatorName ?? 'pixelmatch';
84
+ const comparator = comparators[name];
85
+ if (!comparator)
86
+ throw new Error(`unknown comparator "${name}"; known: ${Object.keys(comparators).join(', ')}`);
87
+ const inherited = name === (config.comparatorName ?? 'pixelmatch') ? config.comparatorOptions : undefined;
88
+ const { pass, diff, message } = comparator(baseline, actual, {
89
+ ...inherited,
90
+ ...options.comparatorOptions,
91
+ createDiff: true,
33
92
  });
34
- const ratio = count / (actual.width * actual.height);
35
- const pass = ratio <= (options.maxDiffRatio ?? 0.001);
36
- if (!pass) {
37
- writeFileSync(join(dir, `${name}.actual.png`), PNG.sync.write(actual));
38
- writeFileSync(join(dir, `${name}.diff.png`), PNG.sync.write(diff));
39
- }
40
- return result(pass, `${basename(file)}: ${count} pixels (${(ratio * 100).toFixed(3)}%) differ; see ${name}.diff.png`);
93
+ const stem = join(dir, file ? basename(file, extname(file)) : label.replace(/[^\w-]+/g, '_'));
94
+ const seeDiff = !pass && diff ? `; see ${stem}.diff.png` : '';
95
+ if (!pass)
96
+ await Promise.all([writeImage(`${stem}.actual.png`, actual), diff && writeImage(`${stem}.diff.png`, diff)]);
97
+ return result(pass, `${label}: ${message ?? (pass ? 'matches' : 'differs')}${seeDiff}`);
41
98
  },
42
99
  });
43
100
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,UAAU,MAAM,YAAY,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAC5B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAsBhC,MAAM,MAAM,GAAG,CAAC,IAAa,EAAE,OAAe,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC;AAEtF,MAAM,KAAK,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAa,EAAO,EAAE;IACxD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC,GAAG,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;IACtE,OAAO,GAAG,CAAC;AACb,CAAC,CAAC;AAEF,MAAM,UAAU,cAAc;IAC5B,MAAM,CAAC,MAAM,CAAC;QACZ,iBAAiB,CAAC,QAAmB,EAAE,IAAY,EAAE,OAAO,GAAsB,EAAE;YAClF,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,EAAE,iBAAiB,CAAC,CAAC;YACzF,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,MAAM,CAAC,CAAC;YACtC,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;YACzE,IAAI,MAAM,IAAI,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;gBACrD,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;gBACpC,aAAa,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;gBAC5C,OAAO,MAAM,CAAC,IAAI,EAAE,kBAAkB,IAAI,EAAE,CAAC,CAAC;YAChD,CAAC;YACD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;gBAAE,OAAO,MAAM,CAAC,KAAK,EAAE,oBAAoB,IAAI,iCAAiC,CAAC,CAAC;YACvG,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC;YACnD,IAAI,QAAQ,CAAC,KAAK,KAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC;gBACzE,OAAO,MAAM,CACX,KAAK,EACL,2BAA2B,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,MAAM,YAAY,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CACxG,CAAC;YACJ,CAAC;YACD,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;YACrE,MAAM,KAAK,GAAG,UAAU,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE;gBAC3F,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,GAAG;aACpC,CAAC,CAAC;YACH,MAAM,KAAK,GAAG,KAAK,GAAG,CAAC,MAAM,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;YACrD,MAAM,IAAI,GAAG,KAAK,IAAI,CAAC,OAAO,CAAC,YAAY,IAAI,KAAK,CAAC,CAAC;YACtD,IAAI,CAAC,IAAI,EAAE,CAAC;gBACV,aAAa,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,aAAa,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;gBACvE,aAAa,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,WAAW,CAAC,EAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;YACrE,CAAC;YACD,OAAO,MAAM,CACX,IAAI,EACJ,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,KAAK,YAAY,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,kBAAkB,IAAI,WAAW,CACjG,CAAC;QACJ,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ import type { Comparator, RgbaImage } from './index.js';
2
+ /** ImageMagick `compare -metric` names. Upper bounds: AE PAE MAE MSE RMSE DSSIM. Lower bounds: PSNR SSIM. */
3
+ export type Metric = 'AE' | 'PAE' | 'MAE' | 'MSE' | 'RMSE' | 'PSNR' | 'SSIM' | 'DSSIM';
4
+ export type MetricsOptions = {
5
+ /** ImageMagick `-fuzz`: per-channel RMS colour distance (0..1) within which two pixels count as equal. Default 0. */
6
+ fuzz?: number;
7
+ /** Absolute error: pixels differing by more than `fuzz`. Default bound when no metric is given: 0. */
8
+ AE?: number;
9
+ /** Peak absolute error, 0..1. */
10
+ PAE?: number;
11
+ /** Mean absolute error, 0..1. */
12
+ MAE?: number;
13
+ /** Mean squared error, 0..1. */
14
+ MSE?: number;
15
+ /** Root mean squared error, 0..1. */
16
+ RMSE?: number;
17
+ /** Peak signal-to-noise ratio in dB (lower bound). `Infinity` for identical images. */
18
+ PSNR?: number;
19
+ /** Structural similarity, 1 = identical (lower bound). Gaussian 11x11 window, sigma 1.5, over RGB. */
20
+ SSIM?: number;
21
+ /** Structural dissimilarity, `(1 - SSIM) / 2`. */
22
+ DSSIM?: number;
23
+ };
24
+ /**
25
+ * AE, PAE, MAE, MSE, RMSE and PSNR in one pass, matching `magick compare` for RGBA images: colour channels are
26
+ * alpha-premultiplied and all four channels are averaged. Also returns which pixels differ by more than `fuzz`.
27
+ */
28
+ export declare function distortion(a: RgbaImage, b: RgbaImage, fuzz: number): {
29
+ AE: number;
30
+ PAE: number;
31
+ MAE: number;
32
+ MSE: number;
33
+ RMSE: number;
34
+ PSNR: number;
35
+ mask: Uint8Array<ArrayBuffer>;
36
+ };
37
+ /** Mean SSIM over RGB (K1 0.01, K2 0.03), the textbook definition also used by ssim.js and Playwright. */
38
+ export declare function ssim(a: RgbaImage, b: RgbaImage): number;
39
+ /**
40
+ * `comparatorName: 'metrics'`: ImageMagick `compare -metric` bounds. Give any subset; all must pass. With no
41
+ * bound given, requires `AE: 0` (an exact match, like `compare -metric AE`).
42
+ */
43
+ export declare const metricsComparator: Comparator<MetricsOptions>;
44
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAExD,6GAA6G;AAC7G,MAAM,MAAM,MAAM,GAAG,IAAI,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEvF,MAAM,MAAM,cAAc,GAAG;IAC3B,qHAAqH;IACrH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sGAAsG;IACtG,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uFAAuF;IACvF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sGAAsG;IACtG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAqBF;;;GAGG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM;;;;;;;;EAgClE;AA+BD,0GAA0G;AAC1G,wBAAgB,IAAI,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,GAAG,MAAM,CAmBvD;AAkBD;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,UAAU,CAAC,cAAc,CAgCxD,CAAC"}
@@ -0,0 +1,150 @@
1
+ const LOWER_IS_WORSE = new Set(['PSNR', 'SSIM']);
2
+ const METRICS = new Set(['AE', 'PAE', 'MAE', 'MSE', 'RMSE', 'PSNR', 'SSIM', 'DSSIM']);
3
+ /**
4
+ * ImageMagick's IsFuzzyEquivalencePixel: alpha must be within `fuzz`, then the colour distance, scaled by both
5
+ * alphas (a 4D cone: transparent pixels always match), must be within `fuzz` per channel RMS.
6
+ */
7
+ const fuzzyEqual = (a, b, i, fuzz2) => {
8
+ const sa = a[i + 3] / 255;
9
+ const sb = b[i + 3] / 255;
10
+ const dAlpha = (sa - sb) ** 2;
11
+ if (dAlpha > fuzz2)
12
+ return false;
13
+ const scale = sa * sb;
14
+ if (scale <= Number.EPSILON)
15
+ return true;
16
+ let d = 3 * dAlpha;
17
+ for (let c = 0; c < 3; c++)
18
+ d += scale * ((a[i + c] - b[i + c]) / 255) ** 2;
19
+ return d <= 3 * fuzz2;
20
+ };
21
+ /**
22
+ * AE, PAE, MAE, MSE, RMSE and PSNR in one pass, matching `magick compare` for RGBA images: colour channels are
23
+ * alpha-premultiplied and all four channels are averaged. Also returns which pixels differ by more than `fuzz`.
24
+ */
25
+ export function distortion(a, b, fuzz) {
26
+ const n = a.width * a.height;
27
+ const mask = new Uint8Array(n);
28
+ const fuzz2 = fuzz * fuzz;
29
+ let AE = 0;
30
+ let PAE = 0;
31
+ let sumAbs = 0;
32
+ let sumSq = 0;
33
+ for (let p = 0, i = 0; p < n; p++, i += 4) {
34
+ if (!fuzzyEqual(a.data, b.data, i, fuzz2)) {
35
+ mask[p] = 1;
36
+ AE++;
37
+ }
38
+ const sa = a.data[i + 3] / 255;
39
+ const sb = b.data[i + 3] / 255;
40
+ for (let c = 0; c < 4; c++) {
41
+ const d = c === 3 ? Math.abs(sa - sb) : Math.abs((a.data[i + c] / 255) * sa - (b.data[i + c] / 255) * sb);
42
+ if (d > PAE)
43
+ PAE = d;
44
+ sumAbs += d;
45
+ sumSq += d * d;
46
+ }
47
+ }
48
+ const MSE = sumSq / (n * 4);
49
+ return {
50
+ AE,
51
+ PAE,
52
+ MAE: sumAbs / (n * 4),
53
+ MSE,
54
+ RMSE: Math.sqrt(MSE),
55
+ PSNR: MSE > 0 ? 10 * Math.log10(1 / MSE) : Infinity,
56
+ mask,
57
+ };
58
+ }
59
+ /** Gaussian taps for an 11x11 window with sigma 1.5, normalised to sum 1 (Wang et al. 2004). */
60
+ const KERNEL = (() => {
61
+ const k = Float64Array.from({ length: 11 }, (_, i) => Math.exp(-((i - 5) ** 2) / (2 * 1.5 * 1.5)));
62
+ const sum = k.reduce((s, v) => s + v, 0);
63
+ return k.map((v) => v / sum);
64
+ })();
65
+ /** Separable Gaussian blur of a 3-channel float image, clamping to edge pixels. */
66
+ const blur = (src, width, height) => {
67
+ const pass = (input, dx, dy) => {
68
+ const out = new Float32Array(input.length);
69
+ for (let y = 0; y < height; y++)
70
+ for (let x = 0; x < width; x++)
71
+ for (let c = 0; c < 3; c++) {
72
+ let s = 0;
73
+ for (let k = -5; k <= 5; k++) {
74
+ const sx = Math.min(width - 1, Math.max(0, x + k * dx));
75
+ const sy = Math.min(height - 1, Math.max(0, y + k * dy));
76
+ s += KERNEL[k + 5] * input[(sy * width + sx) * 3 + c];
77
+ }
78
+ out[(y * width + x) * 3 + c] = s;
79
+ }
80
+ return out;
81
+ };
82
+ return pass(pass(src, 1, 0), 0, 1);
83
+ };
84
+ const mul = (x, y) => x.map((v, i) => v * y[i]);
85
+ /** Mean SSIM over RGB (K1 0.01, K2 0.03), the textbook definition also used by ssim.js and Playwright. */
86
+ export function ssim(a, b) {
87
+ const { width, height } = a;
88
+ const rgb = (img) => Float32Array.from({ length: width * height * 3 }, (_, i) => img.data[i + Math.floor(i / 3)] / 255);
89
+ const fa = rgb(a);
90
+ const fb = rgb(b);
91
+ const [ma, mb, maa, mbb, mab] = [fa, fb, mul(fa, fa), mul(fb, fb), mul(fa, fb)].map((img) => blur(img, width, height));
92
+ const c1 = 0.01 ** 2;
93
+ const c2 = 0.03 ** 2;
94
+ let sum = 0;
95
+ for (let i = 0; i < fa.length; i++) {
96
+ const va = maa[i] - ma[i] ** 2;
97
+ const vb = mbb[i] - mb[i] ** 2;
98
+ const cov = mab[i] - ma[i] * mb[i];
99
+ sum += ((2 * ma[i] * mb[i] + c1) * (2 * cov + c2)) / ((ma[i] ** 2 + mb[i] ** 2 + c1) * (va + vb + c2));
100
+ }
101
+ return sum / fa.length;
102
+ }
103
+ /** Diff image in pixelmatch's style: the actual image faded towards white, differing pixels in red. */
104
+ const diffImage = (actual, mask) => {
105
+ const data = new Uint8Array(actual.data.length);
106
+ for (let p = 0, i = 0; p < mask.length; p++, i += 4) {
107
+ if (mask[p])
108
+ data.set([255, 0, 0, 255], i);
109
+ else {
110
+ const grey = 255 - 0.1 * (255 - (0.299 * actual.data[i] + 0.587 * actual.data[i + 1] + 0.114 * actual.data[i + 2]));
111
+ data.set([grey, grey, grey, 255], i);
112
+ }
113
+ }
114
+ return { width: actual.width, height: actual.height, data };
115
+ };
116
+ const fmt = (n) => (Number.isFinite(n) ? String(Number(n.toPrecision(4))) : String(n));
117
+ /**
118
+ * `comparatorName: 'metrics'`: ImageMagick `compare -metric` bounds. Give any subset; all must pass. With no
119
+ * bound given, requires `AE: 0` (an exact match, like `compare -metric AE`).
120
+ */
121
+ export const metricsComparator = (reference, actual, { createDiff, fuzz = 0, ...bounds }) => {
122
+ for (const key of Object.keys(bounds)) {
123
+ if (!METRICS.has(key))
124
+ throw new Error(`unknown metric "${key}"; expected one of ${[...METRICS].join(', ')}`);
125
+ }
126
+ const wanted = Object.keys(bounds).filter((k) => bounds[k] !== undefined);
127
+ if (wanted.length === 0) {
128
+ bounds.AE = 0;
129
+ wanted.push('AE');
130
+ }
131
+ const values = distortion(reference, actual, fuzz);
132
+ if (wanted.includes('SSIM') || wanted.includes('DSSIM')) {
133
+ values.SSIM = ssim(reference, actual);
134
+ values.DSSIM = (1 - values.SSIM) / 2;
135
+ }
136
+ const failed = wanted.filter((k) => (LOWER_IS_WORSE.has(k) ? values[k] < bounds[k] : values[k] > bounds[k]));
137
+ const report = [...METRICS]
138
+ .filter((k) => values[k] !== undefined)
139
+ .map((k) => `${k} ${fmt(values[k])}${k === 'AE' ? ` (fuzz ${fmt(fuzz)})` : ''}`)
140
+ .join(', ');
141
+ const why = failed
142
+ .map((k) => `${k} ${fmt(values[k])} ${LOWER_IS_WORSE.has(k) ? '<' : '>'} ${fmt(bounds[k])}`)
143
+ .join(', ');
144
+ return {
145
+ pass: failed.length === 0,
146
+ diff: createDiff ? diffImage(actual, values.mask) : null,
147
+ message: failed.length ? `${report}; failed: ${why}` : report,
148
+ };
149
+ };
150
+ //# sourceMappingURL=metrics.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../src/metrics.ts"],"names":[],"mappings":"AA0BA,MAAM,cAAc,GAAG,IAAI,GAAG,CAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AACzD,MAAM,OAAO,GAAG,IAAI,GAAG,CAAS,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAE9F;;;GAGG;AACH,MAAM,UAAU,GAAG,CAAC,CAAoB,EAAE,CAAoB,EAAE,CAAS,EAAE,KAAa,EAAE,EAAE;IAC1F,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,GAAG,CAAC;IAC3B,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,GAAG,CAAC;IAC3B,MAAM,MAAM,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,MAAM,GAAG,KAAK;QAAE,OAAO,KAAK,CAAC;IACjC,MAAM,KAAK,GAAG,EAAE,GAAG,EAAE,CAAC;IACtB,IAAI,KAAK,IAAI,MAAM,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IACzC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;IACnB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE;QAAE,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC;IAC9E,OAAO,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;AACxB,CAAC,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,CAAY,EAAE,CAAY,EAAE,IAAY;IACjE,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7B,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC;IAC/B,MAAM,KAAK,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,GAAG,CAAC,CAAC;IACX,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1C,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;YAC1C,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YACZ,EAAE,EAAE,CAAC;QACP,CAAC;QACD,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,GAAG,CAAC;QAChC,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,GAAG,CAAC;QAChC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YAC3B,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;YAC5G,IAAI,CAAC,GAAG,GAAG;gBAAE,GAAG,GAAG,CAAC,CAAC;YACrB,MAAM,IAAI,CAAC,CAAC;YACZ,KAAK,IAAI,CAAC,GAAG,CAAC,CAAC;QACjB,CAAC;IACH,CAAC;IACD,MAAM,GAAG,GAAG,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5B,OAAO;QACL,EAAE;QACF,GAAG;QACH,GAAG,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;QACrB,GAAG;QACH,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;QACpB,IAAI,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ;QACnD,IAAI;KACL,CAAC;AACJ,CAAC;AAED,gGAAgG;AAChG,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE;IACnB,MAAM,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;IACnG,MAAM,GAAG,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACzC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC;AAC/B,CAAC,CAAC,EAAE,CAAC;AAEL,mFAAmF;AACnF,MAAM,IAAI,GAAG,CAAC,GAAiB,EAAE,KAAa,EAAE,MAAc,EAAE,EAAE;IAChE,MAAM,IAAI,GAAG,CAAC,KAAmB,EAAE,EAAU,EAAE,EAAU,EAAE,EAAE;QAC3D,MAAM,GAAG,GAAG,IAAI,YAAY,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE;YAC7B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE;gBAC5B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;oBAC3B,IAAI,CAAC,GAAG,CAAC,CAAC;oBACV,KAAK,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;wBAC7B,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;wBACxD,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;wBACzD,CAAC,IAAI,MAAM,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,KAAK,CAAC,CAAC,EAAE,GAAG,KAAK,GAAG,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAE,CAAC;oBAC1D,CAAC;oBACD,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;gBACnC,CAAC;QACL,OAAO,GAAG,CAAC;IACb,CAAC,CAAC;IACF,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AACrC,CAAC,CAAC;AAEF,MAAM,GAAG,GAAG,CAAC,CAAe,EAAE,CAAe,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC;AAE7E,0GAA0G;AAC1G,MAAM,UAAU,IAAI,CAAC,CAAY,EAAE,CAAY;IAC7C,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;IAC5B,MAAM,GAAG,GAAG,CAAC,GAAc,EAAE,EAAE,CAC7B,YAAY,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAE,GAAG,GAAG,CAAC,CAAC;IACtG,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;IAClB,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;IAClB,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAC1F,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,CACP,CAAC;IACpB,MAAM,EAAE,GAAG,IAAI,IAAI,CAAC,CAAC;IACrB,MAAM,EAAE,GAAG,IAAI,IAAI,CAAC,CAAC;IACrB,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,MAAM,EAAE,GAAG,GAAI,CAAC,CAAC,CAAE,GAAG,EAAG,CAAC,CAAC,CAAE,IAAI,CAAC,CAAC;QACnC,MAAM,EAAE,GAAG,GAAI,CAAC,CAAC,CAAE,GAAG,EAAG,CAAC,CAAC,CAAE,IAAI,CAAC,CAAC;QACnC,MAAM,GAAG,GAAG,GAAI,CAAC,CAAC,CAAE,GAAG,EAAG,CAAC,CAAC,CAAE,GAAG,EAAG,CAAC,CAAC,CAAE,CAAC;QACzC,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,EAAG,CAAC,CAAC,CAAE,GAAG,EAAG,CAAC,CAAC,CAAE,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,EAAG,CAAC,CAAC,CAAE,IAAI,CAAC,GAAG,EAAG,CAAC,CAAC,CAAE,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;IACjH,CAAC;IACD,OAAO,GAAG,GAAG,EAAE,CAAC,MAAM,CAAC;AACzB,CAAC;AAED,uGAAuG;AACvG,MAAM,SAAS,GAAG,CAAC,MAAiB,EAAE,IAAgB,EAAa,EAAE;IACnE,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAChD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACpD,IAAI,IAAI,CAAC,CAAC,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;aACtC,CAAC;YACJ,MAAM,IAAI,GACR,GAAG,GAAG,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAE,GAAG,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAE,GAAG,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC,CAAC,CAAC;YAC5G,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QACvC,CAAC;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC;AAC9D,CAAC,CAAC;AAEF,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;AAE/F;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAA+B,CAC3D,SAAS,EACT,MAAM,EACN,EAAE,UAAU,EAAE,IAAI,GAAG,CAAC,EAAE,GAAG,MAAM,EAAE,EACnC,EAAE;IACF,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QACtC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAa,CAAC;YAC7B,MAAM,IAAI,KAAK,CAAC,mBAAmB,GAAG,sBAAsB,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC3F,CAAC;IACD,MAAM,MAAM,GAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAc,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;IACxF,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxB,MAAM,CAAC,EAAE,GAAG,CAAC,CAAC;QACd,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpB,CAAC;IACD,MAAM,MAAM,GAAoE,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;IACpH,IAAI,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;QACxD,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;QACtC,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACvC,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAE,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAE,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC;IACjH,MAAM,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC;SACxB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC;SACtC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAE,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,UAAU,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;SAChF,IAAI,CAAC,IAAI,CAAC,CAAC;IACd,MAAM,GAAG,GAAG,MAAM;SACf,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAE,CAAC,IAAI,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,CAAE,CAAC,EAAE,CAAC;SAC7F,IAAI,CAAC,IAAI,CAAC,CAAC;IACd,OAAO;QACL,IAAI,EAAE,MAAM,CAAC,MAAM,KAAK,CAAC;QACzB,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;QACxD,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,aAAa,GAAG,EAAE,CAAC,CAAC,CAAC,MAAM;KAC9D,CAAC;AACJ,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vitest-screenshot",
3
- "version": "0.1.0",
4
- "description": "toMatchScreenshot(): pixel-diff an RGBA image against a committed PNG baseline in Vitest",
3
+ "version": "1.0.1",
4
+ "description": "toMatchScreenshot(): pixel-diff a canvas, Image or ImageData against a committed png/jpg/gif/webp baseline in Vitest",
5
5
  "keywords": [
6
6
  "matcher",
7
7
  "pixelmatch",
@@ -27,6 +27,8 @@
27
27
  "dist/**/*.js.map",
28
28
  "dist/**/*.d.ts",
29
29
  "dist/**/*.d.ts.map",
30
+ "src/**/*.ts",
31
+ "!src/**/*.test.ts",
30
32
  "CHANGELOG.md"
31
33
  ],
32
34
  "type": "module",
@@ -42,15 +44,12 @@
42
44
  "access": "public"
43
45
  },
44
46
  "scripts": {
45
- "build": "tsc -b",
46
- "tsc": "tsc -b"
47
+ "build": "tsc",
48
+ "tsc": "tsc && tsc -p tsconfig.type-tests.json"
47
49
  },
48
50
  "dependencies": {
49
51
  "pixelmatch": "^7.1.0",
50
- "pngjs": "^7.0.0"
51
- },
52
- "devDependencies": {
53
- "@types/pngjs": "^6.0.5"
52
+ "sharp": "^0.35.4"
54
53
  },
55
54
  "peerDependencies": {
56
55
  "vitest": ">=3 <5"
package/src/index.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { basename, dirname, extname, isAbsolute, join } from 'node:path';
3
+ import pixelmatch from 'pixelmatch';
4
+ import sharp, { type FormatEnum } from 'sharp';
5
+ import { expect } from 'vitest';
6
+ import { metricsComparator, type MetricsOptions } from './metrics.js';
7
+
8
+ export type { Metric, MetricsOptions } from './metrics.js';
9
+
10
+ /** Anything with RGBA8 pixels, top row first: `ImageData`, node-webgl's `canvas.getImageData()`, or a WebGPU readback. */
11
+ export type RgbaImage = { width: number; height: number; data: Uint8Array | Uint8ClampedArray };
12
+
13
+ /**
14
+ * A source of pixels: an `RgbaImage` / `ImageData`, a WebGPU `HeadlessCanvas` (`readPixels()`), a node-webgl
15
+ * `Canvas` (`getImageData()`), or a node-webgl `Image` (decoded with `decode()`).
16
+ */
17
+ export type ImageSource =
18
+ | RgbaImage
19
+ | { readPixels(): Promise<RgbaImage> }
20
+ | { getImageData(): RgbaImage }
21
+ | { decode(): Promise<void> };
22
+
23
+ /** A baseline: a file path with extension (relative to `baselineDir` or absolute), or an in-memory `ImageSource`. */
24
+ export type Reference = string | ImageSource;
25
+
26
+ /** What a comparator returns, as in Vitest browser mode: `diff` is written next to the baseline on failure. */
27
+ export type ComparatorResult = { pass: boolean; diff: RgbaImage | null; message: string | null };
28
+
29
+ /** A comparator, with the signature Vitest browser mode uses for `comparators`. */
30
+ export type Comparator<Options extends object = Record<string, unknown>> = (
31
+ reference: RgbaImage,
32
+ actual: RgbaImage,
33
+ options: Options & { createDiff: boolean },
34
+ ) => ComparatorResult;
35
+
36
+ /** Options of the default `pixelmatch` comparator, named as in Vitest browser mode. */
37
+ export type PixelmatchOptions = NonNullable<Parameters<typeof pixelmatch>[5]> & {
38
+ /** Fraction (0..1) of pixels allowed to differ. */
39
+ allowedMismatchedPixelRatio?: number;
40
+ /** Number of pixels allowed to differ. With both limits given the stricter wins; with neither, none may differ. */
41
+ allowedMismatchedPixels?: number;
42
+ };
43
+
44
+ /** Comparator names and their options. Augment this interface when registering a custom comparator. */
45
+ export interface ComparatorRegistry {
46
+ pixelmatch: PixelmatchOptions;
47
+ metrics: MetricsOptions;
48
+ }
49
+
50
+ type ComparatorName = Extract<keyof ComparatorRegistry, string>;
51
+ type ComparatorOptions<Name extends ComparatorName> = Extract<ComparatorRegistry[Name], object>;
52
+
53
+ /** Which registered comparator judges the images. `pixelmatch` is the default. */
54
+ export type ComparatorSelection =
55
+ | {
56
+ [Name in ComparatorName]: {
57
+ comparatorName: Name;
58
+ comparatorOptions?: ComparatorOptions<Name>;
59
+ };
60
+ }[ComparatorName]
61
+ | { comparatorName?: undefined; comparatorOptions?: ComparatorOptions<ComparatorName> };
62
+
63
+ export type ScreenshotOptions = ComparatorSelection & {
64
+ /** Directory relative reference paths and diff images live in. Default: `__screenshots__` beside the test file. */
65
+ baselineDir?: string;
66
+ /** Overwrite the baseline file. Default: `UPDATE_SCREENSHOTS` env var is set. */
67
+ update?: boolean;
68
+ };
69
+
70
+ /** Defaults for every assertion plus custom comparators, like `test.browser.expect.toMatchScreenshot` in Vitest config. */
71
+ export type ScreenshotConfig = ComparatorSelection & {
72
+ comparators?: { [Name in ComparatorName]?: Comparator<ComparatorOptions<Name>> };
73
+ };
74
+
75
+ declare module 'vitest' {
76
+ interface Assertion {
77
+ toMatchScreenshot(reference: Reference, options?: ScreenshotOptions): Promise<void>;
78
+ }
79
+ }
80
+
81
+ const result = (pass: boolean, message: string) => ({ pass, message: () => message });
82
+
83
+ const raw = ({ width, height, data }: RgbaImage) => sharp(data, { raw: { width, height, channels: 4 } });
84
+
85
+ /** Decodes any format sharp reads (png, jpg, gif, webp, ...) into RGBA8. */
86
+ const readImage = async (file: string): Promise<RgbaImage> => {
87
+ const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
88
+ return { width: info.width, height: info.height, data };
89
+ };
90
+
91
+ /** Encodes by file extension; lossy formats (jpg, webp) alter pixels on write. */
92
+ const writeImage = (file: string, image: RgbaImage) => {
93
+ mkdirSync(dirname(file), { recursive: true });
94
+ return raw(image)
95
+ .toFormat(extname(file).slice(1) as keyof FormatEnum)
96
+ .toFile(file);
97
+ };
98
+
99
+ const toRgba = async (source: ImageSource): Promise<RgbaImage> => {
100
+ if ('data' in source) return source;
101
+ if ('readPixels' in source) return source.readPixels();
102
+ if ('getImageData' in source) return source.getImageData();
103
+ await source.decode();
104
+ // node-webgl's Image and ImageBitmap expose decoded pixels this way (it is what their own texImage2D uses).
105
+ const rgba = (source as { _toRGBA8?: () => RgbaImage })._toRGBA8?.();
106
+ if (!rgba) throw new Error('unsupported image source: expected RGBA data, readPixels(), getImageData() or an Image');
107
+ return rgba;
108
+ };
109
+
110
+ /** `comparatorName: 'pixelmatch'` (default): Vitest browser mode's built-in comparison. */
111
+ export const pixelmatchComparator: Comparator<PixelmatchOptions> = (
112
+ reference,
113
+ actual,
114
+ { createDiff, allowedMismatchedPixelRatio, allowedMismatchedPixels, ...pixelmatchOptions },
115
+ ) => {
116
+ const { width, height } = actual;
117
+ const total = width * height;
118
+ const diff = createDiff ? new Uint8Array(total * 4) : undefined;
119
+ const count = pixelmatch(reference.data, actual.data, diff, width, height, pixelmatchOptions);
120
+ const limits = [allowedMismatchedPixels, allowedMismatchedPixelRatio && allowedMismatchedPixelRatio * total];
121
+ const allowed = limits.some((l) => l !== undefined) ? Math.min(...limits.filter((l) => l !== undefined)) : 0;
122
+ return {
123
+ pass: count <= allowed,
124
+ diff: diff ? { width, height, data: diff } : null,
125
+ message: `${count} pixels (${((count / total) * 100).toFixed(3)}%) differ, ${Math.floor(allowed)} allowed`,
126
+ };
127
+ };
128
+
129
+ export function extendMatchers(config: ScreenshotConfig = {}): void {
130
+ const comparators = {
131
+ pixelmatch: pixelmatchComparator,
132
+ metrics: metricsComparator,
133
+ ...config.comparators,
134
+ } as unknown as Record<string, Comparator>;
135
+ expect.extend({
136
+ async toMatchScreenshot(received: ImageSource, reference: Reference, options: ScreenshotOptions = {}) {
137
+ if (this.isNot) throw new Error('`.not.toMatchScreenshot()` is not supported');
138
+ const dir = options.baselineDir ?? join(dirname(this.testPath!), '__screenshots__');
139
+ const actual = await toRgba(received);
140
+ let file: string | undefined;
141
+ let baseline: RgbaImage;
142
+ if (typeof reference === 'string') {
143
+ if (!extname(reference))
144
+ throw new Error(`reference "${reference}" needs an image extension, e.g. "${reference}.png"`);
145
+ file = isAbsolute(reference) ? reference : join(dir, reference);
146
+ const update = options.update ?? Boolean(process.env.UPDATE_SCREENSHOTS);
147
+ if (update || (!existsSync(file) && !process.env.CI)) {
148
+ await writeImage(file, actual);
149
+ return result(true, `wrote baseline ${file}`);
150
+ }
151
+ if (!existsSync(file)) return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1`);
152
+ baseline = await readImage(file);
153
+ } else {
154
+ baseline = await toRgba(reference);
155
+ }
156
+ const label = file ? basename(file) : this.currentTestName!;
157
+ if (baseline.width !== actual.width || baseline.height !== actual.height) {
158
+ return result(
159
+ false,
160
+ `${label}: size mismatch: baseline ${baseline.width}x${baseline.height}, actual ${actual.width}x${actual.height}`,
161
+ );
162
+ }
163
+ const name = options.comparatorName ?? config.comparatorName ?? 'pixelmatch';
164
+ const comparator = comparators[name];
165
+ if (!comparator) throw new Error(`unknown comparator "${name}"; known: ${Object.keys(comparators).join(', ')}`);
166
+ const inherited = name === (config.comparatorName ?? 'pixelmatch') ? config.comparatorOptions : undefined;
167
+ const { pass, diff, message } = comparator(baseline, actual, {
168
+ ...inherited,
169
+ ...options.comparatorOptions,
170
+ createDiff: true,
171
+ });
172
+ const stem = join(dir, file ? basename(file, extname(file)) : label.replace(/[^\w-]+/g, '_'));
173
+ const seeDiff = !pass && diff ? `; see ${stem}.diff.png` : '';
174
+ if (!pass)
175
+ await Promise.all([writeImage(`${stem}.actual.png`, actual), diff && writeImage(`${stem}.diff.png`, diff)]);
176
+ return result(pass, `${label}: ${message ?? (pass ? 'matches' : 'differs')}${seeDiff}`);
177
+ },
178
+ });
179
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,187 @@
1
+ import type { Comparator, RgbaImage } from './index.js';
2
+
3
+ /** ImageMagick `compare -metric` names. Upper bounds: AE PAE MAE MSE RMSE DSSIM. Lower bounds: PSNR SSIM. */
4
+ export type Metric = 'AE' | 'PAE' | 'MAE' | 'MSE' | 'RMSE' | 'PSNR' | 'SSIM' | 'DSSIM';
5
+
6
+ export type MetricsOptions = {
7
+ /** ImageMagick `-fuzz`: per-channel RMS colour distance (0..1) within which two pixels count as equal. Default 0. */
8
+ fuzz?: number;
9
+ /** Absolute error: pixels differing by more than `fuzz`. Default bound when no metric is given: 0. */
10
+ AE?: number;
11
+ /** Peak absolute error, 0..1. */
12
+ PAE?: number;
13
+ /** Mean absolute error, 0..1. */
14
+ MAE?: number;
15
+ /** Mean squared error, 0..1. */
16
+ MSE?: number;
17
+ /** Root mean squared error, 0..1. */
18
+ RMSE?: number;
19
+ /** Peak signal-to-noise ratio in dB (lower bound). `Infinity` for identical images. */
20
+ PSNR?: number;
21
+ /** Structural similarity, 1 = identical (lower bound). Gaussian 11x11 window, sigma 1.5, over RGB. */
22
+ SSIM?: number;
23
+ /** Structural dissimilarity, `(1 - SSIM) / 2`. */
24
+ DSSIM?: number;
25
+ };
26
+
27
+ const LOWER_IS_WORSE = new Set<Metric>(['PSNR', 'SSIM']);
28
+ const METRICS = new Set<Metric>(['AE', 'PAE', 'MAE', 'MSE', 'RMSE', 'PSNR', 'SSIM', 'DSSIM']);
29
+
30
+ /**
31
+ * ImageMagick's IsFuzzyEquivalencePixel: alpha must be within `fuzz`, then the colour distance, scaled by both
32
+ * alphas (a 4D cone: transparent pixels always match), must be within `fuzz` per channel RMS.
33
+ */
34
+ const fuzzyEqual = (a: RgbaImage['data'], b: RgbaImage['data'], i: number, fuzz2: number) => {
35
+ const sa = a[i + 3]! / 255;
36
+ const sb = b[i + 3]! / 255;
37
+ const dAlpha = (sa - sb) ** 2;
38
+ if (dAlpha > fuzz2) return false;
39
+ const scale = sa * sb;
40
+ if (scale <= Number.EPSILON) return true;
41
+ let d = 3 * dAlpha;
42
+ for (let c = 0; c < 3; c++) d += scale * ((a[i + c]! - b[i + c]!) / 255) ** 2;
43
+ return d <= 3 * fuzz2;
44
+ };
45
+
46
+ /**
47
+ * AE, PAE, MAE, MSE, RMSE and PSNR in one pass, matching `magick compare` for RGBA images: colour channels are
48
+ * alpha-premultiplied and all four channels are averaged. Also returns which pixels differ by more than `fuzz`.
49
+ */
50
+ export function distortion(a: RgbaImage, b: RgbaImage, fuzz: number) {
51
+ const n = a.width * a.height;
52
+ const mask = new Uint8Array(n);
53
+ const fuzz2 = fuzz * fuzz;
54
+ let AE = 0;
55
+ let PAE = 0;
56
+ let sumAbs = 0;
57
+ let sumSq = 0;
58
+ for (let p = 0, i = 0; p < n; p++, i += 4) {
59
+ if (!fuzzyEqual(a.data, b.data, i, fuzz2)) {
60
+ mask[p] = 1;
61
+ AE++;
62
+ }
63
+ const sa = a.data[i + 3]! / 255;
64
+ const sb = b.data[i + 3]! / 255;
65
+ for (let c = 0; c < 4; c++) {
66
+ const d = c === 3 ? Math.abs(sa - sb) : Math.abs((a.data[i + c]! / 255) * sa - (b.data[i + c]! / 255) * sb);
67
+ if (d > PAE) PAE = d;
68
+ sumAbs += d;
69
+ sumSq += d * d;
70
+ }
71
+ }
72
+ const MSE = sumSq / (n * 4);
73
+ return {
74
+ AE,
75
+ PAE,
76
+ MAE: sumAbs / (n * 4),
77
+ MSE,
78
+ RMSE: Math.sqrt(MSE),
79
+ PSNR: MSE > 0 ? 10 * Math.log10(1 / MSE) : Infinity,
80
+ mask,
81
+ };
82
+ }
83
+
84
+ /** Gaussian taps for an 11x11 window with sigma 1.5, normalised to sum 1 (Wang et al. 2004). */
85
+ const KERNEL = (() => {
86
+ const k = Float64Array.from({ length: 11 }, (_, i) => Math.exp(-((i - 5) ** 2) / (2 * 1.5 * 1.5)));
87
+ const sum = k.reduce((s, v) => s + v, 0);
88
+ return k.map((v) => v / sum);
89
+ })();
90
+
91
+ /** Separable Gaussian blur of a 3-channel float image, clamping to edge pixels. */
92
+ const blur = (src: Float32Array, width: number, height: number) => {
93
+ const pass = (input: Float32Array, dx: number, dy: number) => {
94
+ const out = new Float32Array(input.length);
95
+ for (let y = 0; y < height; y++)
96
+ for (let x = 0; x < width; x++)
97
+ for (let c = 0; c < 3; c++) {
98
+ let s = 0;
99
+ for (let k = -5; k <= 5; k++) {
100
+ const sx = Math.min(width - 1, Math.max(0, x + k * dx));
101
+ const sy = Math.min(height - 1, Math.max(0, y + k * dy));
102
+ s += KERNEL[k + 5]! * input[(sy * width + sx) * 3 + c]!;
103
+ }
104
+ out[(y * width + x) * 3 + c] = s;
105
+ }
106
+ return out;
107
+ };
108
+ return pass(pass(src, 1, 0), 0, 1);
109
+ };
110
+
111
+ const mul = (x: Float32Array, y: Float32Array) => x.map((v, i) => v * y[i]!);
112
+
113
+ /** Mean SSIM over RGB (K1 0.01, K2 0.03), the textbook definition also used by ssim.js and Playwright. */
114
+ export function ssim(a: RgbaImage, b: RgbaImage): number {
115
+ const { width, height } = a;
116
+ const rgb = (img: RgbaImage) =>
117
+ Float32Array.from({ length: width * height * 3 }, (_, i) => img.data[i + Math.floor(i / 3)]! / 255);
118
+ const fa = rgb(a);
119
+ const fb = rgb(b);
120
+ const [ma, mb, maa, mbb, mab] = [fa, fb, mul(fa, fa), mul(fb, fb), mul(fa, fb)].map((img) =>
121
+ blur(img, width, height),
122
+ ) as Float32Array[];
123
+ const c1 = 0.01 ** 2;
124
+ const c2 = 0.03 ** 2;
125
+ let sum = 0;
126
+ for (let i = 0; i < fa.length; i++) {
127
+ const va = maa![i]! - ma![i]! ** 2;
128
+ const vb = mbb![i]! - mb![i]! ** 2;
129
+ const cov = mab![i]! - ma![i]! * mb![i]!;
130
+ sum += ((2 * ma![i]! * mb![i]! + c1) * (2 * cov + c2)) / ((ma![i]! ** 2 + mb![i]! ** 2 + c1) * (va + vb + c2));
131
+ }
132
+ return sum / fa.length;
133
+ }
134
+
135
+ /** Diff image in pixelmatch's style: the actual image faded towards white, differing pixels in red. */
136
+ const diffImage = (actual: RgbaImage, mask: Uint8Array): RgbaImage => {
137
+ const data = new Uint8Array(actual.data.length);
138
+ for (let p = 0, i = 0; p < mask.length; p++, i += 4) {
139
+ if (mask[p]) data.set([255, 0, 0, 255], i);
140
+ else {
141
+ const grey =
142
+ 255 - 0.1 * (255 - (0.299 * actual.data[i]! + 0.587 * actual.data[i + 1]! + 0.114 * actual.data[i + 2]!));
143
+ data.set([grey, grey, grey, 255], i);
144
+ }
145
+ }
146
+ return { width: actual.width, height: actual.height, data };
147
+ };
148
+
149
+ const fmt = (n: number) => (Number.isFinite(n) ? String(Number(n.toPrecision(4))) : String(n));
150
+
151
+ /**
152
+ * `comparatorName: 'metrics'`: ImageMagick `compare -metric` bounds. Give any subset; all must pass. With no
153
+ * bound given, requires `AE: 0` (an exact match, like `compare -metric AE`).
154
+ */
155
+ export const metricsComparator: Comparator<MetricsOptions> = (
156
+ reference,
157
+ actual,
158
+ { createDiff, fuzz = 0, ...bounds },
159
+ ) => {
160
+ for (const key of Object.keys(bounds)) {
161
+ if (!METRICS.has(key as Metric))
162
+ throw new Error(`unknown metric "${key}"; expected one of ${[...METRICS].join(', ')}`);
163
+ }
164
+ const wanted = (Object.keys(bounds) as Metric[]).filter((k) => bounds[k] !== undefined);
165
+ if (wanted.length === 0) {
166
+ bounds.AE = 0;
167
+ wanted.push('AE');
168
+ }
169
+ const values: Partial<Record<Metric, number>> & ReturnType<typeof distortion> = distortion(reference, actual, fuzz);
170
+ if (wanted.includes('SSIM') || wanted.includes('DSSIM')) {
171
+ values.SSIM = ssim(reference, actual);
172
+ values.DSSIM = (1 - values.SSIM) / 2;
173
+ }
174
+ const failed = wanted.filter((k) => (LOWER_IS_WORSE.has(k) ? values[k]! < bounds[k]! : values[k]! > bounds[k]!));
175
+ const report = [...METRICS]
176
+ .filter((k) => values[k] !== undefined)
177
+ .map((k) => `${k} ${fmt(values[k]!)}${k === 'AE' ? ` (fuzz ${fmt(fuzz)})` : ''}`)
178
+ .join(', ');
179
+ const why = failed
180
+ .map((k) => `${k} ${fmt(values[k]!)} ${LOWER_IS_WORSE.has(k) ? '<' : '>'} ${fmt(bounds[k]!)}`)
181
+ .join(', ');
182
+ return {
183
+ pass: failed.length === 0,
184
+ diff: createDiff ? diffImage(actual, values.mask) : null,
185
+ message: failed.length ? `${report}; failed: ${why}` : report,
186
+ };
187
+ };