vitest-screenshot 1.0.0 → 1.1.0
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 +2 -9
- package/README.md +41 -5
- package/dist/index.d.ts +19 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/update-policy.d.ts +3 -0
- package/dist/update-policy.d.ts.map +1 -0
- package/dist/update-policy.js +11 -0
- package/dist/update-policy.js.map +1 -0
- package/package.json +4 -2
- package/src/index.ts +186 -0
- package/src/metrics.ts +187 -0
- package/src/update-policy.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
## [vitest-screenshot-v1.
|
|
2
|
-
|
|
3
|
-
### ⚠ BREAKING CHANGES
|
|
4
|
-
|
|
5
|
-
* **screenshot:** `threshold` and `maxDiffRatio` are no longer top-level options. Use
|
|
6
|
-
`comparatorOptions: { threshold, allowedMismatchedPixelRatio }` (Vitest's names) instead.
|
|
7
|
-
* **screenshot:** the matcher is async and must be awaited; reference paths need an image extension.
|
|
1
|
+
## [vitest-screenshot-v1.1.0](https://github.com/bhouston/vitest-gpu/compare/vitest-screenshot-v1.0.1...vitest-screenshot-v1.1.0) (2026-09-21)
|
|
8
2
|
|
|
9
3
|
### Features
|
|
10
4
|
|
|
11
|
-
* **screenshot:**
|
|
12
|
-
* **screenshot:** vitest-style comparators with an imagemagick metrics comparator ([fd37a14](https://github.com/bhouston/vitest-gpu/commit/fd37a14651b3281ec3cdeec9cfd8f434a4233570)), closes [#3](https://github.com/bhouston/vitest-gpu/issues/3)
|
|
5
|
+
* **screenshot:** follow snapshot update mode ([1af74a4](https://github.com/bhouston/vitest-gpu/commit/1af74a4a94dec9912d5c5d37169adf3ff1259f73)), closes [#27](https://github.com/bhouston/vitest-gpu/issues/27)
|
package/README.md
CHANGED
|
@@ -45,8 +45,12 @@ relative to `baselineDir` unless absolute.
|
|
|
45
45
|
- Any format sharp reads and writes works: png, jpg, gif, webp, avif, tiff. **We recommend png**: it is lossless,
|
|
46
46
|
so a baseline written on the first run matches exactly. jpg and webp are re-encoded lossily on write and gif is
|
|
47
47
|
limited to 256 colours, so loosen the comparator if you use them.
|
|
48
|
-
-
|
|
49
|
-
|
|
48
|
+
- Screenshot baselines follow Vitest's snapshot update mode: missing baselines are created locally, fail in CI,
|
|
49
|
+
and `vitest -u` creates or overwrites them.
|
|
50
|
+
- `UPDATE_SCREENSHOTS=1 vitest` remains an alias for updating all screenshot baselines.
|
|
51
|
+
- Per assertion, `update: true` creates or overwrites a baseline and `update: false` prevents both overwriting and
|
|
52
|
+
creating one. This explicit option takes precedence over Vitest's mode and `UPDATE_SCREENSHOTS`.
|
|
53
|
+
- `.not.toMatchScreenshot()` is unsupported and fails without reading pixels or writing baseline files.
|
|
50
54
|
- On failure `<name>.actual.png` and `<name>.diff.png` are written to `baselineDir` (named after the test when the
|
|
51
55
|
reference is in memory). Gitignore them.
|
|
52
56
|
|
|
@@ -60,11 +64,12 @@ comparators, go to `extendMatchers()` (the equivalent of `test.browser.expect.to
|
|
|
60
64
|
extendMatchers({
|
|
61
65
|
comparatorName: 'pixelmatch',
|
|
62
66
|
comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
|
|
63
|
-
comparators: { 'my-comparator': (reference, actual, { createDiff, ...options }) => ({ pass, diff, message }) },
|
|
64
67
|
});
|
|
65
68
|
```
|
|
66
69
|
|
|
67
|
-
Per-assertion `comparatorOptions` are merged over the global ones when they name the same comparator.
|
|
70
|
+
Per-assertion `comparatorOptions` are merged over the global ones when they name the same comparator. When an
|
|
71
|
+
assertion omits `comparatorName`, its options may match any registered comparator because the global default selects
|
|
72
|
+
their meaning at runtime. Supplying `comparatorName` checks the options strictly against that comparator.
|
|
68
73
|
|
|
69
74
|
### `pixelmatch` (default)
|
|
70
75
|
|
|
@@ -119,6 +124,37 @@ knot.png: AE 760 (fuzz 0), PAE 0.1569, MAE 0.01042, MSE 0.0007709, RMSE 0.02777,
|
|
|
119
124
|
A comparator has Vitest's signature: `(reference, actual, options) => { pass, diff, message }`, where `reference`
|
|
120
125
|
and `actual` are `{ width, height, data }`, `options` carries `comparatorOptions` plus `createDiff`, and `diff`
|
|
121
126
|
(an RGBA image or `null`) is written as `<name>.diff.png` on failure. Register it under `comparators` and select
|
|
122
|
-
it with `comparatorName`.
|
|
127
|
+
it with `comparatorName`. Add its option type to `ComparatorRegistry` so registration and assertions check the
|
|
128
|
+
custom name and options:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { type Comparator, extendMatchers } from 'vitest-screenshot';
|
|
132
|
+
|
|
133
|
+
declare module 'vitest-screenshot' {
|
|
134
|
+
interface ComparatorRegistry {
|
|
135
|
+
'red-channel': { tolerance?: number };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const redChannel: Comparator<{ tolerance?: number }> = (reference, actual, { tolerance = 0 }) => {
|
|
140
|
+
const difference = Math.abs(reference.data[0]! - actual.data[0]!);
|
|
141
|
+
return { pass: difference <= tolerance, diff: null, message: `red channel differs by ${difference}` };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
extendMatchers({ comparators: { 'red-channel': redChannel } });
|
|
145
|
+
|
|
146
|
+
await expect(image).toMatchScreenshot('baseline.png', {
|
|
147
|
+
comparatorName: 'red-channel',
|
|
148
|
+
comparatorOptions: { tolerance: 2 },
|
|
149
|
+
});
|
|
150
|
+
```
|
|
123
151
|
|
|
124
152
|
See [`demo/test/screenshots`](../../demo/test/screenshots) for every source, format and comparator combination.
|
|
153
|
+
|
|
154
|
+
## Author
|
|
155
|
+
|
|
156
|
+
Created by [Ben Houston](https://ben3d.ca) and sponsored by [Land of Assets](https://landofassets.com).
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -37,26 +37,34 @@ export type PixelmatchOptions = NonNullable<Parameters<typeof pixelmatch>[5]> &
|
|
|
37
37
|
/** Number of pixels allowed to differ. With both limits given the stricter wins; with neither, none may differ. */
|
|
38
38
|
allowedMismatchedPixels?: number;
|
|
39
39
|
};
|
|
40
|
-
/**
|
|
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. */
|
|
41
48
|
export type ComparatorSelection = {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
comparatorOptions?: Record<string, unknown>;
|
|
49
|
+
[Name in ComparatorName]: {
|
|
50
|
+
comparatorName: Name;
|
|
51
|
+
comparatorOptions?: ComparatorOptions<Name>;
|
|
52
|
+
};
|
|
53
|
+
}[ComparatorName] | {
|
|
54
|
+
comparatorName?: undefined;
|
|
55
|
+
comparatorOptions?: ComparatorOptions<ComparatorName>;
|
|
50
56
|
};
|
|
51
57
|
export type ScreenshotOptions = ComparatorSelection & {
|
|
52
58
|
/** Directory relative reference paths and diff images live in. Default: `__screenshots__` beside the test file. */
|
|
53
59
|
baselineDir?: string;
|
|
54
|
-
/**
|
|
60
|
+
/** Override Vitest's snapshot update mode. `true` creates/overwrites; `false` prevents both. */
|
|
55
61
|
update?: boolean;
|
|
56
62
|
};
|
|
57
63
|
/** Defaults for every assertion plus custom comparators, like `test.browser.expect.toMatchScreenshot` in Vitest config. */
|
|
58
64
|
export type ScreenshotConfig = ComparatorSelection & {
|
|
59
|
-
comparators?:
|
|
65
|
+
comparators?: {
|
|
66
|
+
[Name in ComparatorName]?: Comparator<ComparatorOptions<Name>>;
|
|
67
|
+
};
|
|
60
68
|
};
|
|
61
69
|
declare module 'vitest' {
|
|
62
70
|
interface Assertion {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,UAAU,MAAM,YAAY,CAAC;AAGpC,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import pixelmatch from 'pixelmatch';
|
|
|
4
4
|
import sharp from 'sharp';
|
|
5
5
|
import { expect } from 'vitest';
|
|
6
6
|
import { metricsComparator } from './metrics.js';
|
|
7
|
+
import { resolveScreenshotUpdateState } from './update-policy.js';
|
|
7
8
|
const result = (pass, message) => ({ pass, message: () => message });
|
|
8
9
|
const raw = ({ width, height, data }) => sharp(data, { raw: { width, height, channels: 4 } });
|
|
9
10
|
/** Decodes any format sharp reads (png, jpg, gif, webp, ...) into RGBA8. */
|
|
@@ -54,6 +55,8 @@ export function extendMatchers(config = {}) {
|
|
|
54
55
|
};
|
|
55
56
|
expect.extend({
|
|
56
57
|
async toMatchScreenshot(received, reference, options = {}) {
|
|
58
|
+
if (this.isNot)
|
|
59
|
+
throw new Error('`.not.toMatchScreenshot()` is not supported');
|
|
57
60
|
const dir = options.baselineDir ?? join(dirname(this.testPath), '__screenshots__');
|
|
58
61
|
const actual = await toRgba(received);
|
|
59
62
|
let file;
|
|
@@ -62,13 +65,14 @@ export function extendMatchers(config = {}) {
|
|
|
62
65
|
if (!extname(reference))
|
|
63
66
|
throw new Error(`reference "${reference}" needs an image extension, e.g. "${reference}.png"`);
|
|
64
67
|
file = isAbsolute(reference) ? reference : join(dir, reference);
|
|
65
|
-
const
|
|
66
|
-
|
|
68
|
+
const exists = existsSync(file);
|
|
69
|
+
const update = resolveScreenshotUpdateState(this.snapshotState, options.update, Boolean(process.env.UPDATE_SCREENSHOTS), Boolean(process.env.CI));
|
|
70
|
+
if (update === 'all' || (update === 'new' && !exists)) {
|
|
67
71
|
await writeImage(file, actual);
|
|
68
72
|
return result(true, `wrote baseline ${file}`);
|
|
69
73
|
}
|
|
70
|
-
if (!
|
|
71
|
-
return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1`);
|
|
74
|
+
if (!exists)
|
|
75
|
+
return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1 or vitest -u`);
|
|
72
76
|
baseline = await readImage(file);
|
|
73
77
|
}
|
|
74
78
|
else {
|
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,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACzE,OAAO,UAAU,MAAM,YAAY,CAAC;AACpC,OAAO,KAA0B,MAAM,OAAO,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAuB,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACzE,OAAO,UAAU,MAAM,YAAY,CAAC;AACpC,OAAO,KAA0B,MAAM,OAAO,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,iBAAiB,EAAuB,MAAM,cAAc,CAAC;AACtE,OAAO,EAAE,4BAA4B,EAAE,MAAM,oBAAoB,CAAC;AA2ElE,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,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAa,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;AAEzG,4EAA4E;AAC5E,MAAM,SAAS,GAAG,KAAK,EAAE,IAAY,EAAsB,EAAE;IAC3D,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,iBAAiB,EAAE,IAAI,EAAE,CAAC,CAAC;IACnG,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC;AAC1D,CAAC,CAAC;AAEF,kFAAkF;AAClF,MAAM,UAAU,GAAG,CAAC,IAAY,EAAE,KAAgB,EAAE,EAAE;IACpD,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9C,OAAO,GAAG,CAAC,KAAK,CAAC;SACd,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAqB,CAAC;SACpD,MAAM,CAAC,IAAI,CAAC,CAAC;AAClB,CAAC,CAAC;AAEF,MAAM,MAAM,GAAG,KAAK,EAAE,MAAmB,EAAsB,EAAE;IAC/D,IAAI,MAAM,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC;IACpC,IAAI,YAAY,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC,UAAU,EAAE,CAAC;IACvD,IAAI,cAAc,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC,YAAY,EAAE,CAAC;IAC3D,MAAM,MAAM,CAAC,MAAM,EAAE,CAAC;IACtB,4GAA4G;IAC5G,MAAM,IAAI,GAAI,MAAyC,CAAC,QAAQ,EAAE,EAAE,CAAC;IACrE,IAAI,CAAC,IAAI;QAAE,MAAM,IAAI,KAAK,CAAC,wFAAwF,CAAC,CAAC;IACrH,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAEF,2FAA2F;AAC3F,MAAM,CAAC,MAAM,oBAAoB,GAAkC,CACjE,SAAS,EACT,MAAM,EACN,EAAE,UAAU,EAAE,2BAA2B,EAAE,uBAAuB,EAAE,GAAG,iBAAiB,EAAE,EAC1F,EAAE;IACF,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IACjC,MAAM,KAAK,GAAG,KAAK,GAAG,MAAM,CAAC;IAC7B,MAAM,IAAI,GAAG,UAAU,CAAC,CAAC,CAAC,IAAI,UAAU,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAChE,MAAM,KAAK,GAAG,UAAU,CAAC,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC9F,MAAM,MAAM,GAAG,CAAC,uBAAuB,EAAE,2BAA2B,IAAI,2BAA2B,GAAG,KAAK,CAAC,CAAC;IAC7G,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7G,OAAO;QACL,IAAI,EAAE,KAAK,IAAI,OAAO;QACtB,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI;QACjD,OAAO,EAAE,GAAG,KAAK,YAAY,CAAC,CAAC,KAAK,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,cAAc,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU;KAC3G,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,UAAU,cAAc,CAAC,MAAM,GAAqB,EAAE;IAC1D,MAAM,WAAW,GAAG;QAClB,UAAU,EAAE,oBAAoB;QAChC,OAAO,EAAE,iBAAiB;QAC1B,GAAG,MAAM,CAAC,WAAW;KACmB,CAAC;IAC3C,MAAM,CAAC,MAAM,CAAC;QACZ,KAAK,CAAC,iBAAiB,CAAC,QAAqB,EAAE,SAAoB,EAAE,OAAO,GAAsB,EAAE;YAClG,IAAI,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;YAC/E,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,QAAS,CAAC,EAAE,iBAAiB,CAAC,CAAC;YACpF,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;YACtC,IAAI,IAAwB,CAAC;YAC7B,IAAI,QAAmB,CAAC;YACxB,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;gBAClC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;oBACrB,MAAM,IAAI,KAAK,CAAC,cAAc,SAAS,qCAAqC,SAAS,OAAO,CAAC,CAAC;gBAChG,IAAI,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;gBAChE,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;gBAChC,MAAM,MAAM,GAAG,4BAA4B,CACzC,IAAI,CAAC,aAAa,EAClB,OAAO,CAAC,MAAM,EACd,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,EACvC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CACxB,CAAC;gBACF,IAAI,MAAM,KAAK,KAAK,IAAI,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;oBACtD,MAAM,UAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;oBAC/B,OAAO,MAAM,CAAC,IAAI,EAAE,kBAAkB,IAAI,EAAE,CAAC,CAAC;gBAChD,CAAC;gBACD,IAAI,CAAC,MAAM;oBAAE,OAAO,MAAM,CAAC,KAAK,EAAE,oBAAoB,IAAI,8CAA8C,CAAC,CAAC;gBAC1G,QAAQ,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;YACnC,CAAC;iBAAM,CAAC;gBACN,QAAQ,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,CAAC;YACrC,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,eAAgB,CAAC;YAC5D,IAAI,QAAQ,CAAC,KAAK,KAAK,MAAM,CAAC,KAAK,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC;gBACzE,OAAO,MAAM,CACX,KAAK,EACL,GAAG,KAAK,6BAA6B,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,MAAM,YAAY,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,CAClH,CAAC;YACJ,CAAC;YACD,MAAM,IAAI,GAAG,OAAO,CAAC,cAAc,IAAI,MAAM,CAAC,cAAc,IAAI,YAAY,CAAC;YAC7E,MAAM,UAAU,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC;YACrC,IAAI,CAAC,UAAU;gBAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,IAAI,aAAa,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAChH,MAAM,SAAS,GAAG,IAAI,KAAK,CAAC,MAAM,CAAC,cAAc,IAAI,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,CAAC,SAAS,CAAC;YAC1G,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE;gBAC3D,GAAG,SAAS;gBACZ,GAAG,OAAO,CAAC,iBAAiB;gBAC5B,UAAU,EAAE,IAAI;aACjB,CAAC,CAAC;YACH,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC;YAC9F,MAAM,OAAO,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,IAAI,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9D,IAAI,CAAC,IAAI;gBACP,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,GAAG,IAAI,aAAa,EAAE,MAAM,CAAC,EAAE,IAAI,IAAI,UAAU,CAAC,GAAG,IAAI,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;YAC9G,OAAO,MAAM,CAAC,IAAI,EAAE,GAAG,KAAK,KAAK,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,OAAO,EAAE,CAAC,CAAC;QAC1F,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type ScreenshotUpdateState = 'all' | 'new' | 'none';
|
|
2
|
+
export declare const resolveScreenshotUpdateState: (snapshotState: unknown, explicitUpdate: boolean | undefined, environmentUpdate: boolean, isCi: boolean) => ScreenshotUpdateState;
|
|
3
|
+
//# sourceMappingURL=update-policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-policy.d.ts","sourceRoot":"","sources":["../src/update-policy.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,qBAAqB,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,CAAC;AAW3D,eAAO,MAAM,4BAA4B,kBACxB,OAAO,kBACN,OAAO,GAAG,SAAS,qBAChB,OAAO,QACpB,OAAO,KACZ,qBAMF,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const isUpdateState = (value) => value === 'all' || value === 'new' || value === 'none';
|
|
2
|
+
export const resolveScreenshotUpdateState = (snapshotState, explicitUpdate, environmentUpdate, isCi) => {
|
|
3
|
+
if (explicitUpdate !== undefined)
|
|
4
|
+
return explicitUpdate ? 'all' : 'none';
|
|
5
|
+
if (environmentUpdate)
|
|
6
|
+
return 'all';
|
|
7
|
+
const compatible = snapshotState;
|
|
8
|
+
const state = compatible?.snapshotUpdateState ?? compatible?._updateSnapshot;
|
|
9
|
+
return isUpdateState(state) ? state : isCi ? 'none' : 'new';
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=update-policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"update-policy.js","sourceRoot":"","sources":["../src/update-policy.ts"],"names":[],"mappings":"AAQA,MAAM,aAAa,GAAG,CAAC,KAAc,EAAkC,EAAE,CACvE,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,MAAM,CAAC;AAEzD,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAC1C,aAAsB,EACtB,cAAmC,EACnC,iBAA0B,EAC1B,IAAa,EACU,EAAE;IACzB,IAAI,cAAc,KAAK,SAAS;QAAE,OAAO,cAAc,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC;IACzE,IAAI,iBAAiB;QAAE,OAAO,KAAK,CAAC;IACpC,MAAM,UAAU,GAAG,aAAoD,CAAC;IACxE,MAAM,KAAK,GAAG,UAAU,EAAE,mBAAmB,IAAI,UAAU,EAAE,eAAe,CAAC;IAC7E,OAAO,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC;AAC9D,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vitest-screenshot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
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",
|
|
@@ -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",
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
},
|
|
44
46
|
"scripts": {
|
|
45
47
|
"build": "tsc",
|
|
46
|
-
"tsc": "tsc"
|
|
48
|
+
"tsc": "tsc && tsc -p tsconfig.type-tests.json"
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
49
51
|
"pixelmatch": "^7.1.0",
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
import { resolveScreenshotUpdateState } from './update-policy.js';
|
|
8
|
+
|
|
9
|
+
export type { Metric, MetricsOptions } from './metrics.js';
|
|
10
|
+
|
|
11
|
+
/** Anything with RGBA8 pixels, top row first: `ImageData`, node-webgl's `canvas.getImageData()`, or a WebGPU readback. */
|
|
12
|
+
export type RgbaImage = { width: number; height: number; data: Uint8Array | Uint8ClampedArray };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A source of pixels: an `RgbaImage` / `ImageData`, a WebGPU `HeadlessCanvas` (`readPixels()`), a node-webgl
|
|
16
|
+
* `Canvas` (`getImageData()`), or a node-webgl `Image` (decoded with `decode()`).
|
|
17
|
+
*/
|
|
18
|
+
export type ImageSource =
|
|
19
|
+
| RgbaImage
|
|
20
|
+
| { readPixels(): Promise<RgbaImage> }
|
|
21
|
+
| { getImageData(): RgbaImage }
|
|
22
|
+
| { decode(): Promise<void> };
|
|
23
|
+
|
|
24
|
+
/** A baseline: a file path with extension (relative to `baselineDir` or absolute), or an in-memory `ImageSource`. */
|
|
25
|
+
export type Reference = string | ImageSource;
|
|
26
|
+
|
|
27
|
+
/** What a comparator returns, as in Vitest browser mode: `diff` is written next to the baseline on failure. */
|
|
28
|
+
export type ComparatorResult = { pass: boolean; diff: RgbaImage | null; message: string | null };
|
|
29
|
+
|
|
30
|
+
/** A comparator, with the signature Vitest browser mode uses for `comparators`. */
|
|
31
|
+
export type Comparator<Options extends object = Record<string, unknown>> = (
|
|
32
|
+
reference: RgbaImage,
|
|
33
|
+
actual: RgbaImage,
|
|
34
|
+
options: Options & { createDiff: boolean },
|
|
35
|
+
) => ComparatorResult;
|
|
36
|
+
|
|
37
|
+
/** Options of the default `pixelmatch` comparator, named as in Vitest browser mode. */
|
|
38
|
+
export type PixelmatchOptions = NonNullable<Parameters<typeof pixelmatch>[5]> & {
|
|
39
|
+
/** Fraction (0..1) of pixels allowed to differ. */
|
|
40
|
+
allowedMismatchedPixelRatio?: number;
|
|
41
|
+
/** Number of pixels allowed to differ. With both limits given the stricter wins; with neither, none may differ. */
|
|
42
|
+
allowedMismatchedPixels?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Comparator names and their options. Augment this interface when registering a custom comparator. */
|
|
46
|
+
export interface ComparatorRegistry {
|
|
47
|
+
pixelmatch: PixelmatchOptions;
|
|
48
|
+
metrics: MetricsOptions;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ComparatorName = Extract<keyof ComparatorRegistry, string>;
|
|
52
|
+
type ComparatorOptions<Name extends ComparatorName> = Extract<ComparatorRegistry[Name], object>;
|
|
53
|
+
|
|
54
|
+
/** Which registered comparator judges the images. `pixelmatch` is the default. */
|
|
55
|
+
export type ComparatorSelection =
|
|
56
|
+
| {
|
|
57
|
+
[Name in ComparatorName]: {
|
|
58
|
+
comparatorName: Name;
|
|
59
|
+
comparatorOptions?: ComparatorOptions<Name>;
|
|
60
|
+
};
|
|
61
|
+
}[ComparatorName]
|
|
62
|
+
| { comparatorName?: undefined; comparatorOptions?: ComparatorOptions<ComparatorName> };
|
|
63
|
+
|
|
64
|
+
export type ScreenshotOptions = ComparatorSelection & {
|
|
65
|
+
/** Directory relative reference paths and diff images live in. Default: `__screenshots__` beside the test file. */
|
|
66
|
+
baselineDir?: string;
|
|
67
|
+
/** Override Vitest's snapshot update mode. `true` creates/overwrites; `false` prevents both. */
|
|
68
|
+
update?: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Defaults for every assertion plus custom comparators, like `test.browser.expect.toMatchScreenshot` in Vitest config. */
|
|
72
|
+
export type ScreenshotConfig = ComparatorSelection & {
|
|
73
|
+
comparators?: { [Name in ComparatorName]?: Comparator<ComparatorOptions<Name>> };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
declare module 'vitest' {
|
|
77
|
+
interface Assertion {
|
|
78
|
+
toMatchScreenshot(reference: Reference, options?: ScreenshotOptions): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = (pass: boolean, message: string) => ({ pass, message: () => message });
|
|
83
|
+
|
|
84
|
+
const raw = ({ width, height, data }: RgbaImage) => sharp(data, { raw: { width, height, channels: 4 } });
|
|
85
|
+
|
|
86
|
+
/** Decodes any format sharp reads (png, jpg, gif, webp, ...) into RGBA8. */
|
|
87
|
+
const readImage = async (file: string): Promise<RgbaImage> => {
|
|
88
|
+
const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
89
|
+
return { width: info.width, height: info.height, data };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Encodes by file extension; lossy formats (jpg, webp) alter pixels on write. */
|
|
93
|
+
const writeImage = (file: string, image: RgbaImage) => {
|
|
94
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
95
|
+
return raw(image)
|
|
96
|
+
.toFormat(extname(file).slice(1) as keyof FormatEnum)
|
|
97
|
+
.toFile(file);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const toRgba = async (source: ImageSource): Promise<RgbaImage> => {
|
|
101
|
+
if ('data' in source) return source;
|
|
102
|
+
if ('readPixels' in source) return source.readPixels();
|
|
103
|
+
if ('getImageData' in source) return source.getImageData();
|
|
104
|
+
await source.decode();
|
|
105
|
+
// node-webgl's Image and ImageBitmap expose decoded pixels this way (it is what their own texImage2D uses).
|
|
106
|
+
const rgba = (source as { _toRGBA8?: () => RgbaImage })._toRGBA8?.();
|
|
107
|
+
if (!rgba) throw new Error('unsupported image source: expected RGBA data, readPixels(), getImageData() or an Image');
|
|
108
|
+
return rgba;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** `comparatorName: 'pixelmatch'` (default): Vitest browser mode's built-in comparison. */
|
|
112
|
+
export const pixelmatchComparator: Comparator<PixelmatchOptions> = (
|
|
113
|
+
reference,
|
|
114
|
+
actual,
|
|
115
|
+
{ createDiff, allowedMismatchedPixelRatio, allowedMismatchedPixels, ...pixelmatchOptions },
|
|
116
|
+
) => {
|
|
117
|
+
const { width, height } = actual;
|
|
118
|
+
const total = width * height;
|
|
119
|
+
const diff = createDiff ? new Uint8Array(total * 4) : undefined;
|
|
120
|
+
const count = pixelmatch(reference.data, actual.data, diff, width, height, pixelmatchOptions);
|
|
121
|
+
const limits = [allowedMismatchedPixels, allowedMismatchedPixelRatio && allowedMismatchedPixelRatio * total];
|
|
122
|
+
const allowed = limits.some((l) => l !== undefined) ? Math.min(...limits.filter((l) => l !== undefined)) : 0;
|
|
123
|
+
return {
|
|
124
|
+
pass: count <= allowed,
|
|
125
|
+
diff: diff ? { width, height, data: diff } : null,
|
|
126
|
+
message: `${count} pixels (${((count / total) * 100).toFixed(3)}%) differ, ${Math.floor(allowed)} allowed`,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export function extendMatchers(config: ScreenshotConfig = {}): void {
|
|
131
|
+
const comparators = {
|
|
132
|
+
pixelmatch: pixelmatchComparator,
|
|
133
|
+
metrics: metricsComparator,
|
|
134
|
+
...config.comparators,
|
|
135
|
+
} as unknown as Record<string, Comparator>;
|
|
136
|
+
expect.extend({
|
|
137
|
+
async toMatchScreenshot(received: ImageSource, reference: Reference, options: ScreenshotOptions = {}) {
|
|
138
|
+
if (this.isNot) throw new Error('`.not.toMatchScreenshot()` is not supported');
|
|
139
|
+
const dir = options.baselineDir ?? join(dirname(this.testPath!), '__screenshots__');
|
|
140
|
+
const actual = await toRgba(received);
|
|
141
|
+
let file: string | undefined;
|
|
142
|
+
let baseline: RgbaImage;
|
|
143
|
+
if (typeof reference === 'string') {
|
|
144
|
+
if (!extname(reference))
|
|
145
|
+
throw new Error(`reference "${reference}" needs an image extension, e.g. "${reference}.png"`);
|
|
146
|
+
file = isAbsolute(reference) ? reference : join(dir, reference);
|
|
147
|
+
const exists = existsSync(file);
|
|
148
|
+
const update = resolveScreenshotUpdateState(
|
|
149
|
+
this.snapshotState,
|
|
150
|
+
options.update,
|
|
151
|
+
Boolean(process.env.UPDATE_SCREENSHOTS),
|
|
152
|
+
Boolean(process.env.CI),
|
|
153
|
+
);
|
|
154
|
+
if (update === 'all' || (update === 'new' && !exists)) {
|
|
155
|
+
await writeImage(file, actual);
|
|
156
|
+
return result(true, `wrote baseline ${file}`);
|
|
157
|
+
}
|
|
158
|
+
if (!exists) return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1 or vitest -u`);
|
|
159
|
+
baseline = await readImage(file);
|
|
160
|
+
} else {
|
|
161
|
+
baseline = await toRgba(reference);
|
|
162
|
+
}
|
|
163
|
+
const label = file ? basename(file) : this.currentTestName!;
|
|
164
|
+
if (baseline.width !== actual.width || baseline.height !== actual.height) {
|
|
165
|
+
return result(
|
|
166
|
+
false,
|
|
167
|
+
`${label}: size mismatch: baseline ${baseline.width}x${baseline.height}, actual ${actual.width}x${actual.height}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const name = options.comparatorName ?? config.comparatorName ?? 'pixelmatch';
|
|
171
|
+
const comparator = comparators[name];
|
|
172
|
+
if (!comparator) throw new Error(`unknown comparator "${name}"; known: ${Object.keys(comparators).join(', ')}`);
|
|
173
|
+
const inherited = name === (config.comparatorName ?? 'pixelmatch') ? config.comparatorOptions : undefined;
|
|
174
|
+
const { pass, diff, message } = comparator(baseline, actual, {
|
|
175
|
+
...inherited,
|
|
176
|
+
...options.comparatorOptions,
|
|
177
|
+
createDiff: true,
|
|
178
|
+
});
|
|
179
|
+
const stem = join(dir, file ? basename(file, extname(file)) : label.replace(/[^\w-]+/g, '_'));
|
|
180
|
+
const seeDiff = !pass && diff ? `; see ${stem}.diff.png` : '';
|
|
181
|
+
if (!pass)
|
|
182
|
+
await Promise.all([writeImage(`${stem}.actual.png`, actual), diff && writeImage(`${stem}.diff.png`, diff)]);
|
|
183
|
+
return result(pass, `${label}: ${message ?? (pass ? 'matches' : 'differs')}${seeDiff}`);
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
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
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ScreenshotUpdateState = 'all' | 'new' | 'none';
|
|
2
|
+
|
|
3
|
+
type CompatibleSnapshotState = {
|
|
4
|
+
readonly snapshotUpdateState?: unknown;
|
|
5
|
+
/** Vitest 3 compatibility; remove when the peer range no longer includes Vitest 3. */
|
|
6
|
+
readonly _updateSnapshot?: unknown;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const isUpdateState = (value: unknown): value is ScreenshotUpdateState =>
|
|
10
|
+
value === 'all' || value === 'new' || value === 'none';
|
|
11
|
+
|
|
12
|
+
export const resolveScreenshotUpdateState = (
|
|
13
|
+
snapshotState: unknown,
|
|
14
|
+
explicitUpdate: boolean | undefined,
|
|
15
|
+
environmentUpdate: boolean,
|
|
16
|
+
isCi: boolean,
|
|
17
|
+
): ScreenshotUpdateState => {
|
|
18
|
+
if (explicitUpdate !== undefined) return explicitUpdate ? 'all' : 'none';
|
|
19
|
+
if (environmentUpdate) return 'all';
|
|
20
|
+
const compatible = snapshotState as CompatibleSnapshotState | undefined;
|
|
21
|
+
const state = compatible?.snapshotUpdateState ?? compatible?._updateSnapshot;
|
|
22
|
+
return isUpdateState(state) ? state : isCi ? 'none' : 'new';
|
|
23
|
+
};
|