@jsenv/snapshot 2.18.12 → 2.19.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/package.json +2 -2
- package/src/compare_png_files.js +90 -37
- package/src/filesystem_snapshot.js +42 -10
- package/src/main.js +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/snapshot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Snapshot testing",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@jsenv/assert": "4.5.7",
|
|
31
|
-
"@jsenv/ast": "6.
|
|
31
|
+
"@jsenv/ast": "6.9.0",
|
|
32
32
|
"@jsenv/exception": "1.2.2",
|
|
33
33
|
"@jsenv/filesystem": "4.15.17",
|
|
34
34
|
"@jsenv/humanize": "1.7.8",
|
package/src/compare_png_files.js
CHANGED
|
@@ -1,49 +1,102 @@
|
|
|
1
|
-
|
|
1
|
+
/*
|
|
2
|
+
* Compare two PNG buffers visually: a screenshot re-generated on a different
|
|
3
|
+
* machine (or just a different run) is never byte-identical because of
|
|
4
|
+
* antialiasing, subpixel rounding and compression, so a byte comparison is
|
|
5
|
+
* useless as a regression signal.
|
|
6
|
+
*
|
|
7
|
+
* Two images that cannot be compared (different dimensions, unreadable data)
|
|
8
|
+
* are reported as different, never as an error: "the image changed" is a
|
|
9
|
+
* normal outcome, not a failure of the comparison itself.
|
|
10
|
+
*/
|
|
2
11
|
|
|
3
12
|
import pixelmatch from "pixelmatch";
|
|
4
13
|
import { PNG } from "pngjs";
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
/**
|
|
16
|
+
* @param {Buffer} actualData
|
|
17
|
+
* @param {Buffer} expectData
|
|
18
|
+
* @param {Object} [options]
|
|
19
|
+
* @param {number} [options.threshold] pixelmatch per pixel color sensitivity, from 0 (strict) to 1 (permissive)
|
|
20
|
+
* @param {number} [options.maxDiffRatio] ratio of differing pixels still considered as "same", from 0 to 1
|
|
21
|
+
* @param {boolean} [options.includeAA] count antialiased pixels as differences
|
|
22
|
+
* @param {boolean} [options.createDiff] generate a PNG highlighting the differing pixels
|
|
23
|
+
* @returns {{ same: boolean, reason: "size"|"pixels"|"unreadable"|undefined, width: number, height: number, diffPixelCount: number, diffRatio: number, diffFileContent: Buffer|undefined }}
|
|
24
|
+
*/
|
|
25
|
+
export const comparePngFilesDetailed = (
|
|
26
|
+
actualData,
|
|
27
|
+
expectData,
|
|
28
|
+
{ threshold = 0.1, maxDiffRatio = 0.01, includeAA = false, createDiff } = {},
|
|
29
|
+
) => {
|
|
30
|
+
let actualPng;
|
|
31
|
+
let expectPng;
|
|
32
|
+
try {
|
|
33
|
+
actualPng = PNG.sync.read(actualData);
|
|
34
|
+
expectPng = PNG.sync.read(expectData);
|
|
35
|
+
} catch {
|
|
36
|
+
return createResult({ same: false, reason: "unreadable" });
|
|
37
|
+
}
|
|
38
|
+
const { width, height } = actualPng;
|
|
39
|
+
if (width !== expectPng.width || height !== expectPng.height) {
|
|
40
|
+
return createResult({
|
|
41
|
+
same: false,
|
|
42
|
+
reason: "size",
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const pixelCount = width * height;
|
|
48
|
+
// pngjs decodes some PNG (16 bits, exotic palettes) into something else than
|
|
49
|
+
// the RGBA buffer pixelmatch expects; there is nothing to compare then
|
|
50
|
+
if (
|
|
51
|
+
actualPng.data.length !== pixelCount * 4 ||
|
|
52
|
+
expectPng.data.length !== pixelCount * 4
|
|
53
|
+
) {
|
|
54
|
+
return createResult({
|
|
55
|
+
same: false,
|
|
56
|
+
reason: "unreadable",
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const diffPng = createDiff ? new PNG({ width, height }) : null;
|
|
62
|
+
const diffPixelCount = pixelmatch(
|
|
12
63
|
actualPng.data,
|
|
13
64
|
expectPng.data,
|
|
14
|
-
null,
|
|
65
|
+
diffPng ? diffPng.data : null,
|
|
15
66
|
width,
|
|
16
67
|
height,
|
|
17
|
-
{
|
|
18
|
-
threshold: 0.1,
|
|
19
|
-
},
|
|
68
|
+
{ threshold, includeAA },
|
|
20
69
|
);
|
|
21
|
-
const diffRatio =
|
|
22
|
-
const
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return {
|
|
34
|
-
height: readUInt32BE(buffer, 20),
|
|
35
|
-
width: readUInt32BE(buffer, 16),
|
|
36
|
-
};
|
|
70
|
+
const diffRatio = diffPixelCount / pixelCount;
|
|
71
|
+
const same = diffRatio <= maxDiffRatio;
|
|
72
|
+
return createResult({
|
|
73
|
+
same,
|
|
74
|
+
reason: same ? undefined : "pixels",
|
|
75
|
+
width,
|
|
76
|
+
height,
|
|
77
|
+
diffPixelCount,
|
|
78
|
+
diffRatio,
|
|
79
|
+
diffFileContent: diffPng ? PNG.sync.write(diffPng) : undefined,
|
|
80
|
+
});
|
|
37
81
|
};
|
|
38
82
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const decoder = new TextDecoder();
|
|
42
|
-
const toUTF8String = (input, start = 0, end = input.length) =>
|
|
43
|
-
decoder.decode(input.slice(start, end));
|
|
83
|
+
export const comparePngFiles = (actualData, expectData, options) =>
|
|
84
|
+
comparePngFilesDetailed(actualData, expectData, options).same;
|
|
44
85
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
86
|
+
const createResult = ({
|
|
87
|
+
same,
|
|
88
|
+
reason,
|
|
89
|
+
width = 0,
|
|
90
|
+
height = 0,
|
|
91
|
+
diffPixelCount = 0,
|
|
92
|
+
diffRatio = same ? 0 : 1,
|
|
93
|
+
diffFileContent,
|
|
94
|
+
}) => ({
|
|
95
|
+
same,
|
|
96
|
+
reason,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
diffPixelCount,
|
|
100
|
+
diffRatio,
|
|
101
|
+
diffFileContent,
|
|
102
|
+
});
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
|
|
19
19
|
import { readdirSync, readFileSync } from "node:fs";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import {
|
|
21
|
+
import { comparePngFilesDetailed } from "./compare_png_files.js";
|
|
22
22
|
import {
|
|
23
23
|
ExtraFileAssertionError,
|
|
24
24
|
FileContentAssertionError,
|
|
@@ -27,12 +27,14 @@ import {
|
|
|
27
27
|
} from "./errors.js";
|
|
28
28
|
import { replaceFluctuatingValues } from "./replace_fluctuating_values.js";
|
|
29
29
|
|
|
30
|
-
export const takeFileSnapshot = (fileUrl) => {
|
|
30
|
+
export const takeFileSnapshot = (fileUrl, { png } = {}) => {
|
|
31
31
|
fileUrl = assertAndNormalizeFileUrl(fileUrl);
|
|
32
|
-
const fileSnapshot = createFileSnapshot(fileUrl);
|
|
32
|
+
const fileSnapshot = createFileSnapshot(fileUrl, { png });
|
|
33
33
|
removeFileSync(fileUrl, { allowUseless: true });
|
|
34
34
|
const compare = (throwWhenDiff = process.env.CI) => {
|
|
35
|
-
fileSnapshot.compare(createFileSnapshot(fileUrl
|
|
35
|
+
fileSnapshot.compare(createFileSnapshot(fileUrl, { png }), {
|
|
36
|
+
throwWhenDiff,
|
|
37
|
+
});
|
|
36
38
|
};
|
|
37
39
|
return {
|
|
38
40
|
compare,
|
|
@@ -61,7 +63,7 @@ export const takeFileSnapshot = (fileUrl) => {
|
|
|
61
63
|
},
|
|
62
64
|
};
|
|
63
65
|
};
|
|
64
|
-
const createFileSnapshot = (fileUrl) => {
|
|
66
|
+
const createFileSnapshot = (fileUrl, { png } = {}) => {
|
|
65
67
|
const fileSnapshot = {
|
|
66
68
|
type: "file",
|
|
67
69
|
url: fileUrl,
|
|
@@ -93,12 +95,19 @@ ${fileUrl}`);
|
|
|
93
95
|
if (nextFileContent.equals(fileContent)) {
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
98
|
+
let reason = "content has changed";
|
|
96
99
|
if (fileSnapshot.contentType === "image/png") {
|
|
97
|
-
|
|
100
|
+
const pngComparison = comparePngFilesDetailed(
|
|
101
|
+
fileContent,
|
|
102
|
+
nextFileContent,
|
|
103
|
+
png,
|
|
104
|
+
);
|
|
105
|
+
if (pngComparison.same) {
|
|
98
106
|
// restore old version to prevent git diff
|
|
99
107
|
writeFileSync(fileUrl, fileContent);
|
|
100
108
|
return;
|
|
101
109
|
}
|
|
110
|
+
reason = describePngDiff(pngComparison);
|
|
102
111
|
}
|
|
103
112
|
if (!throwWhenDiff) {
|
|
104
113
|
return;
|
|
@@ -106,7 +115,7 @@ ${fileUrl}`);
|
|
|
106
115
|
const fileContentAssertionError =
|
|
107
116
|
new FileContentAssertionError(`${failureMessage}
|
|
108
117
|
--- reason ---
|
|
109
|
-
|
|
118
|
+
${reason}
|
|
110
119
|
--- file ---
|
|
111
120
|
${fileUrl}`);
|
|
112
121
|
throw fileContentAssertionError;
|
|
@@ -167,6 +176,7 @@ export const takeDirectorySnapshot = (
|
|
|
167
176
|
"**/*": true,
|
|
168
177
|
"**/.*/": false,
|
|
169
178
|
},
|
|
179
|
+
{ png } = {},
|
|
170
180
|
) => {
|
|
171
181
|
directoryUrl = assertAndNormalizeDirectoryUrl(directoryUrl);
|
|
172
182
|
directoryUrl = new URL(directoryUrl);
|
|
@@ -205,6 +215,7 @@ export const takeDirectorySnapshot = (
|
|
|
205
215
|
shouldVisitDirectory,
|
|
206
216
|
shouldIncludeFile,
|
|
207
217
|
shouldCompareFileContent,
|
|
218
|
+
png,
|
|
208
219
|
clean: true,
|
|
209
220
|
});
|
|
210
221
|
return {
|
|
@@ -214,6 +225,7 @@ export const takeDirectorySnapshot = (
|
|
|
214
225
|
shouldVisitDirectory,
|
|
215
226
|
shouldIncludeFile,
|
|
216
227
|
shouldCompareFileContent,
|
|
228
|
+
png,
|
|
217
229
|
});
|
|
218
230
|
directorySnapshot.compare(nextDirectorySnapshot, { throwWhenDiff });
|
|
219
231
|
},
|
|
@@ -241,7 +253,13 @@ export const takeDirectorySnapshot = (
|
|
|
241
253
|
};
|
|
242
254
|
const createDirectorySnapshot = (
|
|
243
255
|
directoryUrl,
|
|
244
|
-
{
|
|
256
|
+
{
|
|
257
|
+
shouldVisitDirectory,
|
|
258
|
+
shouldIncludeFile,
|
|
259
|
+
shouldCompareFileContent,
|
|
260
|
+
png,
|
|
261
|
+
clean,
|
|
262
|
+
},
|
|
245
263
|
) => {
|
|
246
264
|
const directorySnapshot = {
|
|
247
265
|
type: "directory",
|
|
@@ -393,6 +411,7 @@ ${extraUrls.join("\n")}`);
|
|
|
393
411
|
shouldVisitDirectory,
|
|
394
412
|
shouldIncludeFile,
|
|
395
413
|
shouldCompareFileContent,
|
|
414
|
+
png,
|
|
396
415
|
clean,
|
|
397
416
|
});
|
|
398
417
|
contentSnapshotNaturalOrder[relativeUrl] = subdirSnapshot;
|
|
@@ -404,8 +423,10 @@ ${extraUrls.join("\n")}`);
|
|
|
404
423
|
if (!shouldIncludeFile(directoryItemUrl.href)) {
|
|
405
424
|
continue;
|
|
406
425
|
}
|
|
407
|
-
contentSnapshotNaturalOrder[relativeUrl] =
|
|
408
|
-
|
|
426
|
+
contentSnapshotNaturalOrder[relativeUrl] = createFileSnapshot(
|
|
427
|
+
directoryItemUrl,
|
|
428
|
+
{ png },
|
|
429
|
+
);
|
|
409
430
|
if (clean) {
|
|
410
431
|
removeFileSync(directoryItemUrl, { allowUseless: true });
|
|
411
432
|
}
|
|
@@ -424,3 +445,14 @@ ${extraUrls.join("\n")}`);
|
|
|
424
445
|
});
|
|
425
446
|
return directorySnapshot;
|
|
426
447
|
};
|
|
448
|
+
|
|
449
|
+
const describePngDiff = ({ reason, diffPixelCount, diffRatio }) => {
|
|
450
|
+
if (reason === "size") {
|
|
451
|
+
return "image dimensions have changed";
|
|
452
|
+
}
|
|
453
|
+
if (reason === "unreadable") {
|
|
454
|
+
return "image cannot be compared";
|
|
455
|
+
}
|
|
456
|
+
const diffPercentage = (diffRatio * 100).toFixed(3);
|
|
457
|
+
return `${diffPixelCount} pixels have changed (${diffPercentage}%)`;
|
|
458
|
+
};
|