@jsenv/snapshot 2.18.11 → 2.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/snapshot",
3
- "version": "2.18.11",
3
+ "version": "2.19.0",
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.8.4",
31
+ "@jsenv/ast": "6.8.5",
32
32
  "@jsenv/exception": "1.2.2",
33
33
  "@jsenv/filesystem": "4.15.17",
34
34
  "@jsenv/humanize": "1.7.8",
@@ -1,49 +1,102 @@
1
- // https://github.com/image-size/image-size/blob/main/lib/types/png.ts
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
- export const comparePngFiles = (actualData, expectData) => {
7
- const { width, height } = getPngDimensions(actualData);
8
- const actualPng = PNG.sync.read(actualData);
9
- const expectPng = PNG.sync.read(expectData);
10
- const numberOfPixels = width * height;
11
- const numberOfPixelsConsideredAsDiff = pixelmatch(
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 = numberOfPixelsConsideredAsDiff / numberOfPixels;
22
- const diffPercentage = diffRatio * 100;
23
- return diffPercentage <= 1;
24
- };
25
-
26
- const getPngDimensions = (buffer) => {
27
- if (toUTF8String(buffer, 12, 16) === pngFriedChunkName) {
28
- return {
29
- height: readUInt32BE(buffer, 36),
30
- width: readUInt32BE(buffer, 32),
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 pngFriedChunkName = "CgBI";
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 readUInt32BE = (input, offset = 0) =>
46
- input[offset] * 2 ** 24 +
47
- input[offset + 1] * 2 ** 16 +
48
- input[offset + 2] * 2 ** 8 +
49
- input[offset + 3];
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 { comparePngFiles } from "./compare_png_files.js";
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), { throwWhenDiff });
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
- if (comparePngFiles(fileContent, nextFileContent)) {
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
- content has changed
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
- { shouldVisitDirectory, shouldIncludeFile, shouldCompareFileContent, clean },
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
- createFileSnapshot(directoryItemUrl);
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
+ };
package/src/main.js CHANGED
@@ -1,3 +1,7 @@
1
+ export {
2
+ comparePngFiles,
3
+ comparePngFilesDetailed,
4
+ } from "./compare_png_files.js";
1
5
  export {
2
6
  takeDirectorySnapshot,
3
7
  takeFileSnapshot,