@jsenv/snapshot 1.3.9 → 1.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/snapshot",
3
- "version": "1.3.9",
3
+ "version": "1.4.1",
4
4
  "description": "Snapshot testing",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -35,10 +35,11 @@
35
35
  "test": "node --conditions=development ./scripts/test.mjs"
36
36
  },
37
37
  "dependencies": {
38
- "@jsenv/filesystem": "4.7.0",
38
+ "@jsenv/assert": "4.0.10",
39
+ "@jsenv/filesystem": "4.7.2",
39
40
  "@jsenv/urls": "2.2.7",
40
41
  "@jsenv/utils": "2.1.1",
41
- "@jsenv/assert": "4.0.8",
42
+ "pixelmatch": "6.0.0",
42
43
  "prettier": "3.3.2"
43
44
  },
44
45
  "devDependencies": {
@@ -0,0 +1,49 @@
1
+ // https://github.com/image-size/image-size/blob/main/lib/types/png.ts
2
+
3
+ import pixelmatch from "pixelmatch";
4
+ import { PNG } from "pngjs";
5
+
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(
12
+ actualPng.data,
13
+ expectPng.data,
14
+ null,
15
+ width,
16
+ height,
17
+ {
18
+ threshold: 0.1,
19
+ },
20
+ );
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
+ };
37
+ };
38
+
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));
44
+
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];
package/src/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ import { assert } from "@jsenv/assert";
2
+
3
+ export class FileContentNotFoundAssertionError extends assert.AssertionError {}
4
+ export class FileMissingAssertionError extends assert.AssertionError {}
5
+ export class ExtraFileAssertionError extends assert.AssertionError {}
6
+ export class FileContentAssertionError extends assert.AssertionError {}
@@ -1,4 +1,5 @@
1
1
  import { readdirSync, statSync, readFileSync } from "node:fs";
2
+ import { URL_META } from "@jsenv/url-meta";
2
3
  import {
3
4
  assertAndNormalizeDirectoryUrl,
4
5
  assertAndNormalizeFileUrl,
@@ -7,31 +8,39 @@ import {
7
8
  removeFileSync,
8
9
  writeFileSync,
9
10
  removeDirectorySync,
10
- writeFileStructureSync,
11
11
  } from "@jsenv/filesystem";
12
- import { urlToFilename, urlToRelativeUrl } from "@jsenv/urls";
13
12
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
13
+ import { urlToRelativeUrl, urlToFilename } from "@jsenv/urls";
14
14
 
15
15
  import { assert } from "@jsenv/assert";
16
+ import {
17
+ FileContentNotFoundAssertionError,
18
+ FileMissingAssertionError,
19
+ ExtraFileAssertionError,
20
+ FileContentAssertionError,
21
+ } from "./errors.js";
22
+ import { comparePngFiles } from "./compare_png_files.js";
16
23
 
17
24
  export const takeFileSnapshot = (fileUrl) => {
18
25
  fileUrl = assertAndNormalizeFileUrl(fileUrl);
19
- const expectedFileSnapshot = createFileSnapshot(fileUrl);
26
+ const fileSnapshot = createFileSnapshot(fileUrl);
20
27
  removeFileSync(fileUrl, { allowUseless: true });
21
-
22
28
  return {
23
- compare: () => {
24
- compareFileSnapshots(createFileSnapshot(fileUrl), expectedFileSnapshot);
29
+ compare: (doIt = process.env.CI) => {
30
+ if (!doIt) {
31
+ return;
32
+ }
33
+ fileSnapshot.compare(createFileSnapshot(fileUrl));
25
34
  },
26
35
  writeContent: (content) => {
27
36
  writeFileSync(fileUrl, content);
28
37
  },
29
38
  restore: () => {
30
- if (expectedFileSnapshot.empty) {
39
+ if (fileSnapshot.empty) {
31
40
  removeFileSync(fileUrl, { allowUseless: true });
32
41
  return;
33
42
  }
34
- writeFileSync(fileUrl, expectedFileSnapshot.content);
43
+ writeFileSync(fileUrl, fileSnapshot.content);
35
44
  },
36
45
  };
37
46
  };
@@ -40,7 +49,54 @@ const createFileSnapshot = (fileUrl) => {
40
49
  type: "file",
41
50
  url: fileUrl,
42
51
  stat: null,
52
+ contentType: CONTENT_TYPE.fromUrlExtension(fileUrl),
43
53
  content: "",
54
+ compare: (nextFileSnapshot) => {
55
+ const filename = urlToFilename(fileUrl);
56
+ const failureMessage = `snapshot comparison failed for "${filename}"`;
57
+
58
+ if (!nextFileSnapshot.stat) {
59
+ const fileNotFoundAssertionError =
60
+ new FileContentNotFoundAssertionError(`${failureMessage}
61
+ --- reason ---
62
+ file not found
63
+ --- file ---
64
+ ${fileUrl}`);
65
+ throw fileNotFoundAssertionError;
66
+ }
67
+ if (!fileSnapshot.stat) {
68
+ return;
69
+ }
70
+ const fileContent = fileSnapshot.content;
71
+ const nextFileContent = nextFileSnapshot.content;
72
+ if (Buffer.isBuffer(nextFileContent)) {
73
+ if (nextFileContent.equals(fileContent)) {
74
+ return;
75
+ }
76
+ if (fileSnapshot.contentType === "image/png") {
77
+ if (comparePngFiles(fileContent, nextFileContent)) {
78
+ return;
79
+ }
80
+ }
81
+ const fileContentAssertionError =
82
+ new FileContentAssertionError(`${failureMessage}
83
+ --- reason ---
84
+ content has changed
85
+ --- file ---
86
+ ${fileUrl}`);
87
+ throw fileContentAssertionError;
88
+ }
89
+ if (nextFileContent === fileContent) {
90
+ return;
91
+ }
92
+ assert({
93
+ message: failureMessage,
94
+ actual: nextFileContent,
95
+ expect: fileContent,
96
+ details: fileUrl,
97
+ forceMultilineDiff: true,
98
+ });
99
+ },
44
100
  };
45
101
 
46
102
  try {
@@ -55,9 +111,7 @@ const createFileSnapshot = (fileUrl) => {
55
111
  throw new Error(`file expect at ${fileUrl}`);
56
112
  }
57
113
 
58
- const isTextual = CONTENT_TYPE.isTextual(
59
- CONTENT_TYPE.fromUrlExtension(fileUrl),
60
- );
114
+ const isTextual = CONTENT_TYPE.isTextual(fileSnapshot.contentType);
61
115
  if (isTextual) {
62
116
  const contentAsString = readFileSync(new URL(fileUrl), "utf8");
63
117
  if (process.platform === "win32") {
@@ -76,63 +130,86 @@ const createFileSnapshot = (fileUrl) => {
76
130
  }
77
131
  return fileSnapshot;
78
132
  };
79
- const compareFileSnapshots = (actualFileSnapshot, expectedFileSnapshot) => {
80
- const fileUrl = actualFileSnapshot.url;
81
- const filename = urlToFilename(fileUrl);
82
- const failureMessage = `snapshot comparison failed for "${filename}"`;
83
133
 
84
- if (!actualFileSnapshot.stat) {
85
- const fileNotFoundAssertionError =
86
- assert.createAssertionError(`${failureMessage}
87
- --- reason ---
88
- file not found
89
- --- file ---
90
- ${fileUrl}`);
91
- fileNotFoundAssertionError.name = "FileNotFoundAssertionError";
92
- throw fileNotFoundAssertionError;
93
- }
94
- if (!expectedFileSnapshot.stat) {
95
- return;
96
- }
97
- const actualFileContent = actualFileSnapshot.content;
98
- const expectedFileContent = expectedFileSnapshot.content;
99
- if (Buffer.isBuffer(actualFileContent)) {
100
- if (actualFileContent.equals(expectedFileContent)) {
101
- return;
102
- }
103
- const fileContentAssertionError =
104
- assert.createAssertionError(`${failureMessage}
105
- --- reason ---
106
- content has changed
107
- --- file ---
108
- ${fileUrl}`);
109
- fileContentAssertionError.name = "FileContentAssertionError";
110
- throw fileContentAssertionError;
111
- }
112
- if (actualFileContent === expectedFileContent) {
113
- return;
114
- }
115
- assert({
116
- message: failureMessage,
117
- details: fileUrl,
118
- actual: actualFileContent,
119
- expect: expectedFileContent,
120
- forceMultilineDiff: true,
121
- });
122
- };
123
-
124
- export const takeDirectorySnapshot = (directoryUrl) => {
134
+ export const takeDirectorySnapshot = (
135
+ directoryUrl,
136
+ pattern = { "**/*": true },
137
+ ) => {
125
138
  directoryUrl = assertAndNormalizeDirectoryUrl(directoryUrl);
126
139
  directoryUrl = new URL(directoryUrl);
127
-
128
- const expectedDirectorySnapshot = createDirectorySnapshot(directoryUrl);
129
- ensureEmptyDirectorySync(directoryUrl);
140
+ const associations = URL_META.resolveAssociations(
141
+ {
142
+ included: pattern,
143
+ },
144
+ directoryUrl,
145
+ );
146
+ const predicate = ({ included }) => included;
147
+ const shouldVisitDirectory = (url) =>
148
+ URL_META.urlChildMayMatch({
149
+ url,
150
+ associations,
151
+ predicate,
152
+ });
153
+ const shouldIncludeFile = (url) =>
154
+ predicate(
155
+ URL_META.applyAssociations({
156
+ url,
157
+ associations,
158
+ }),
159
+ );
160
+ const directorySnapshot = createDirectorySnapshot(directoryUrl, {
161
+ shouldVisitDirectory,
162
+ shouldIncludeFile,
163
+ clean: true,
164
+ });
130
165
  return {
131
- compare: () => {
166
+ __snapshot: directorySnapshot,
167
+ compare: (doIt = process.env.CI) => {
168
+ if (!doIt) {
169
+ return;
170
+ }
171
+ const nextDirectorySnapshot = createDirectorySnapshot(directoryUrl, {
172
+ shouldVisitDirectory,
173
+ shouldIncludeFile,
174
+ });
175
+ directorySnapshot.compare(nextDirectorySnapshot);
176
+ },
177
+ addFile: (relativeUrl, content) => {
178
+ writeFileSync(new URL(relativeUrl, directoryUrl), content);
179
+ },
180
+ restore: () => {
181
+ if (directorySnapshot.notFound) {
182
+ removeDirectorySync(directoryUrl, {
183
+ recursive: true,
184
+ allowUseless: true,
185
+ });
186
+ return;
187
+ }
188
+ if (directorySnapshot.empty) {
189
+ ensureEmptyDirectorySync(directoryUrl);
190
+ return;
191
+ }
192
+ for (const relativeUrl of directorySnapshot.contentSnapshot) {
193
+ const snapshot = directorySnapshot.contentSnapshot[relativeUrl];
194
+ snapshot.restore();
195
+ }
196
+ },
197
+ };
198
+ };
199
+ const createDirectorySnapshot = (
200
+ directoryUrl,
201
+ { shouldVisitDirectory, shouldIncludeFile, clean },
202
+ ) => {
203
+ const directorySnapshot = {
204
+ type: "directory",
205
+ url: directoryUrl.href,
206
+ stat: null,
207
+ empty: false,
208
+ contentSnapshot: {},
209
+ compare: (nextDirectorySnapshot) => {
132
210
  const dirname = `${urlToFilename(directoryUrl)}/`;
133
211
  const failureMessage = `snapshot comparison failed for "${dirname}"`;
134
- const actualDirectorySnapshot = createDirectorySnapshot(directoryUrl);
135
- if (!expectedDirectorySnapshot.stat || expectedDirectorySnapshot.empty) {
212
+ if (!directorySnapshot.stat || directorySnapshot.empty) {
136
213
  // the snapshot taken for directory/file/whatever is empty:
137
214
  // - first time code executes:
138
215
  // it defines snapshot that will be used for comparison by future runs
@@ -142,17 +219,15 @@ export const takeDirectorySnapshot = (directoryUrl) => {
142
219
  // to review them using git diff)
143
220
  return;
144
221
  }
145
-
146
- const actualFileSnapshots = actualDirectorySnapshot.fileSnapshots;
147
- const expectedFileSnapshots = expectedDirectorySnapshot.fileSnapshots;
148
- const actualRelativeUrls = Object.keys(actualFileSnapshots);
149
- const expectedRelativeUrls = Object.keys(expectedFileSnapshots);
150
-
151
- // missing_files
222
+ const directoryContentSnapshot = directorySnapshot.contentSnapshot;
223
+ const relativeUrls = Object.keys(directoryContentSnapshot);
224
+ const nextDirectoryContentSnapshot =
225
+ nextDirectorySnapshot.contentSnapshot;
226
+ const nextRelativeUrls = Object.keys(nextDirectoryContentSnapshot);
227
+ // missing content
152
228
  {
153
- const missingRelativeUrls = expectedRelativeUrls.filter(
154
- (expectedRelativeUrl) =>
155
- !actualRelativeUrls.includes(expectedRelativeUrl),
229
+ const missingRelativeUrls = relativeUrls.filter(
230
+ (relativeUrl) => !nextRelativeUrls.includes(relativeUrl),
156
231
  );
157
232
  const missingFileCount = missingRelativeUrls.length;
158
233
  if (missingFileCount > 0) {
@@ -161,30 +236,26 @@ export const takeDirectorySnapshot = (directoryUrl) => {
161
236
  );
162
237
  if (missingFileCount === 1) {
163
238
  const fileMissingAssertionError =
164
- assert.createAssertionError(`${failureMessage}
239
+ new FileMissingAssertionError(`${failureMessage}
165
240
  --- reason ---
166
- "${missingRelativeUrls[0]}" is missing
167
- --- file missing ---
241
+ "${missingRelativeUrls[0]}" directory entry is missing
242
+ --- missing entry ---
168
243
  ${missingUrls[0]}`);
169
- fileMissingAssertionError.name = "FileMissingAssertionError";
170
244
  throw fileMissingAssertionError;
171
245
  }
172
246
  const fileMissingAssertionError =
173
- assert.createAssertionError(`${failureMessage}
247
+ new FileMissingAssertionError(`${failureMessage}
174
248
  --- reason ---
175
- ${missingFileCount} files are missing
176
- --- files missing ---
249
+ ${missingFileCount} directory entries are missing
250
+ --- missing entries ---
177
251
  ${missingUrls.join("\n")}`);
178
- fileMissingAssertionError.name = "FileMissingAssertionError";
179
252
  throw fileMissingAssertionError;
180
253
  }
181
254
  }
182
-
183
- // unexpected files
255
+ // unexpected content
184
256
  {
185
- const extraRelativeUrls = actualRelativeUrls.filter(
186
- (actualRelativeUrl) =>
187
- !expectedRelativeUrls.includes(actualRelativeUrl),
257
+ const extraRelativeUrls = nextRelativeUrls.filter(
258
+ (nextRelativeUrl) => !relativeUrls.includes(nextRelativeUrl),
188
259
  );
189
260
  const extraFileCount = extraRelativeUrls.length;
190
261
  if (extraFileCount > 0) {
@@ -193,78 +264,34 @@ ${missingUrls.join("\n")}`);
193
264
  );
194
265
  if (extraFileCount === 1) {
195
266
  const extraFileAssertionError =
196
- assert.createAssertionError(`${failureMessage}
267
+ new ExtraFileAssertionError(`${failureMessage}
197
268
  --- reason ---
198
- "${extraRelativeUrls[0]}" is unexpected
199
- --- file unexpected ---
269
+ "${extraRelativeUrls[0]}" directory entry is unexpected
270
+ --- unexpected entry ---
200
271
  ${extraUrls[0]}`);
201
- extraFileAssertionError.name = "ExtraFileAssertionError";
202
272
  throw extraFileAssertionError;
203
273
  }
204
274
  const extraFileAssertionError =
205
- assert.createAssertionError(`${failureMessage}
275
+ new ExtraFileAssertionError(`${failureMessage}
206
276
  --- reason ---
207
- ${extraFileCount} files are unexpected
208
- --- files unexpected ---
277
+ ${extraFileCount} directory entries are unexpected
278
+ --- unexpected entries ---
209
279
  ${extraUrls.join("\n")}`);
210
- extraFileAssertionError.name = "ExtraFileAssertionError";
211
280
  throw extraFileAssertionError;
212
281
  }
213
282
  }
214
-
215
- // file contents
283
+ // content
216
284
  {
217
- for (const relativeUrl of actualRelativeUrls) {
218
- const actualFileSnapshot = actualFileSnapshots[relativeUrl];
219
- const expectedFileSnapshot = expectedFileSnapshots[relativeUrl];
220
- compareFileSnapshots(actualFileSnapshot, expectedFileSnapshot);
285
+ for (const relativeUrl of nextRelativeUrls) {
286
+ const snapshot = directoryContentSnapshot[relativeUrl];
287
+ const nextSnapshot = nextDirectoryContentSnapshot[relativeUrl];
288
+ snapshot.compare(nextSnapshot);
221
289
  }
222
290
  }
223
291
  },
224
- addFile: (relativeUrl, content) => {
225
- writeFileSync(new URL(relativeUrl, directoryUrl), content);
226
- },
227
- restore: () => {
228
- if (expectedDirectorySnapshot.notFound) {
229
- removeDirectorySync(directoryUrl, {
230
- recursive: true,
231
- allowUseless: true,
232
- });
233
- return;
234
- }
235
- if (expectedDirectorySnapshot.empty) {
236
- ensureEmptyDirectorySync(directoryUrl);
237
- return;
238
- }
239
-
240
- const fileStructure = {};
241
- Object.keys(expectedDirectorySnapshot.fileSnapshots).forEach(
242
- (relativeUrl) => {
243
- const fileSnapshot =
244
- expectedDirectorySnapshot.fileSnapshots[relativeUrl];
245
- if (!fileSnapshot.empty) {
246
- fileStructure[relativeUrl] = fileSnapshot.content;
247
- }
248
- },
249
- );
250
- writeFileStructureSync(
251
- directoryUrl,
252
- expectedDirectorySnapshot.fileStructure,
253
- );
254
- },
255
- };
256
- };
257
- const createDirectorySnapshot = (directoryUrl) => {
258
- const directorySnapshot = {
259
- type: "directory",
260
- url: directoryUrl.href,
261
- stat: null,
262
- empty: false,
263
- fileSnapshots: {},
264
292
  };
265
-
266
293
  try {
267
- directorySnapshot.stat = statSync(directoryUrl);
294
+ directorySnapshot.stat = statSync(new URL(directoryUrl));
268
295
  } catch (e) {
269
296
  if (e.code === "ENOENT") {
270
297
  return directorySnapshot;
@@ -272,48 +299,67 @@ const createDirectorySnapshot = (directoryUrl) => {
272
299
  if (e.code === "ENOTDIR") {
273
300
  // trailing slash is forced on directoryUrl
274
301
  // as a result Node.js throw ENOTDIR when doing "stat" operation
275
- throw new Error(`directory expect at ${directoryUrl}`);
302
+ throw new Error(`directory expected at ${directoryUrl}`);
276
303
  }
277
304
  throw e;
278
305
  }
279
306
  if (!directorySnapshot.stat.isDirectory()) {
280
- throw new Error(`directory expect at ${directoryUrl}`);
307
+ throw new Error(`directory expected at ${directoryUrl}`);
281
308
  }
282
-
283
309
  const entryNames = readdirSync(directoryUrl);
284
310
  if (entryNames.length === 0) {
285
311
  directorySnapshot.empty = true;
286
312
  return directorySnapshot;
287
313
  }
288
-
289
- const fileSnapshotsNaturalOrder = {};
290
- const visitDirectory = (url) => {
291
- try {
292
- const directoryContent = readdirSync(url);
293
- for (const filename of directoryContent) {
294
- const contentUrl = new URL(filename, url);
295
- const stat = statSync(contentUrl);
296
- if (stat.isDirectory()) {
297
- visitDirectory(new URL(`${contentUrl}/`));
298
- return;
314
+ const contentSnapshotNaturalOrder = {};
315
+ try {
316
+ const directoryItemArray = readdirSync(directoryUrl);
317
+ for (const directoryItem of directoryItemArray) {
318
+ const directoryItemUrl = new URL(directoryItem, directoryUrl);
319
+ let directoryItemStat;
320
+ try {
321
+ directoryItemStat = statSync(directoryItemUrl);
322
+ } catch (e) {
323
+ if (e.code === "ENOENT") {
324
+ continue;
299
325
  }
300
- const relativeUrl = urlToRelativeUrl(contentUrl, directoryUrl);
301
- fileSnapshotsNaturalOrder[relativeUrl] = createFileSnapshot(contentUrl);
326
+ throw e;
302
327
  }
303
- } catch (e) {
304
- if (e && e.code === "ENOENT") {
305
- return;
328
+ const relativeUrl = urlToRelativeUrl(directoryItemUrl, directoryUrl);
329
+ if (directoryItemStat.isDirectory()) {
330
+ if (!shouldVisitDirectory(directoryUrl)) {
331
+ continue;
332
+ }
333
+ contentSnapshotNaturalOrder[relativeUrl] = createDirectorySnapshot(
334
+ new URL(`${directoryItemUrl}/`),
335
+ {
336
+ shouldVisitDirectory,
337
+ shouldIncludeFile,
338
+ clean,
339
+ },
340
+ );
341
+ continue;
342
+ }
343
+ if (!shouldIncludeFile(directoryItemUrl)) {
344
+ continue;
306
345
  }
346
+ contentSnapshotNaturalOrder[relativeUrl] =
347
+ createFileSnapshot(directoryItemUrl);
348
+ if (clean) {
349
+ removeFileSync(directoryItemUrl, { allowUseless: true });
350
+ }
351
+ }
352
+ } catch (e) {
353
+ if (e && e.code === "ENOENT") {
354
+ } else {
307
355
  throw e;
308
356
  }
309
- };
310
- visitDirectory(directoryUrl);
311
-
312
- const relativeUrls = Object.keys(fileSnapshotsNaturalOrder);
357
+ }
358
+ const relativeUrls = Object.keys(contentSnapshotNaturalOrder);
313
359
  relativeUrls.sort(comparePathnames);
314
360
  relativeUrls.forEach((relativeUrl) => {
315
- directorySnapshot.fileSnapshots[relativeUrl] =
316
- fileSnapshotsNaturalOrder[relativeUrl];
361
+ directorySnapshot.contentSnapshot[relativeUrl] =
362
+ contentSnapshotNaturalOrder[relativeUrl];
317
363
  });
318
364
  return directorySnapshot;
319
365
  };