@jsenv/snapshot 1.0.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/README.md +5 -0
- package/package.json +43 -0
- package/src/cli.mjs +44 -0
- package/src/main.js +262 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jsenv/snapshot",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Snapshot testing",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "dmail",
|
|
8
|
+
"email": "dmaillard06@gmail.com",
|
|
9
|
+
"url": "https://twitter.com/damienmaillard"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/jsenv/core",
|
|
14
|
+
"directory": "packages/independent/snapshot"
|
|
15
|
+
},
|
|
16
|
+
"bin": "./src/cli.mjs",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.0.0"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"import": "./src/main.js"
|
|
27
|
+
},
|
|
28
|
+
"./*": "./*"
|
|
29
|
+
},
|
|
30
|
+
"main": "./src/main.js",
|
|
31
|
+
"files": [
|
|
32
|
+
"/src/"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node --conditions=development ./scripts/test.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@jsenv/filesystem": "4.4.0",
|
|
39
|
+
"@jsenv/urls": "2.2.1",
|
|
40
|
+
"@jsenv/utils": "2.0.1",
|
|
41
|
+
"@jsenv/assert": "2.13.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { clearDirectorySync } from "@jsenv/filesystem";
|
|
6
|
+
|
|
7
|
+
const options = {
|
|
8
|
+
"help": {
|
|
9
|
+
type: "boolean",
|
|
10
|
+
},
|
|
11
|
+
"include-dev": {
|
|
12
|
+
type: "boolean",
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
const { values, positionals } = parseArgs({ options, allowPositionals: true });
|
|
16
|
+
|
|
17
|
+
if (values.help || positionals.length === 0) {
|
|
18
|
+
console.log(`snapshot: Manage snapshot files generated during tests.
|
|
19
|
+
|
|
20
|
+
Usage: npx @jsenv/snapshot clear [pattern]
|
|
21
|
+
|
|
22
|
+
https://github.com/jsenv/core/tree/main/packages/independent/snapshot
|
|
23
|
+
|
|
24
|
+
pattern: files matching this pattern will be removed; can use "*" and "**"
|
|
25
|
+
`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const commandHandlers = {
|
|
30
|
+
clear: async (pattern) => {
|
|
31
|
+
const currentDirectoryUrl = pathToFileURL(`${process.cwd()}/`);
|
|
32
|
+
clearDirectorySync(currentDirectoryUrl, pattern);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const [command] = positionals;
|
|
37
|
+
const commandHandler = commandHandlers[command];
|
|
38
|
+
|
|
39
|
+
if (!commandHandler) {
|
|
40
|
+
console.error(`Error: unknown command ${command}.`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await commandHandler(positionals.slice(1));
|
package/src/main.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
assertAndNormalizeDirectoryUrl,
|
|
4
|
+
assertAndNormalizeFileUrl,
|
|
5
|
+
comparePathnames,
|
|
6
|
+
} from "@jsenv/filesystem";
|
|
7
|
+
import { urlToFilename, urlToRelativeUrl } from "@jsenv/urls";
|
|
8
|
+
import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createAssertionError,
|
|
12
|
+
formatStringAssertionErrorMessage,
|
|
13
|
+
} from "@jsenv/assert";
|
|
14
|
+
|
|
15
|
+
const snapshotSymbol = Symbol.for("snapshot");
|
|
16
|
+
|
|
17
|
+
export const takeDirectorySnapshot = (directoryUrl) => {
|
|
18
|
+
directoryUrl = assertAndNormalizeDirectoryUrl(directoryUrl);
|
|
19
|
+
directoryUrl = new URL(directoryUrl);
|
|
20
|
+
|
|
21
|
+
const directorySnapshot = {
|
|
22
|
+
[snapshotSymbol]: true,
|
|
23
|
+
empty: false,
|
|
24
|
+
type: "directory",
|
|
25
|
+
url: directoryUrl.href,
|
|
26
|
+
notFound: false,
|
|
27
|
+
fileStructure: {},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = statSync(directoryUrl);
|
|
33
|
+
if (!stat.isDirectory()) {
|
|
34
|
+
throw new Error(`directory expected at ${directoryUrl}`);
|
|
35
|
+
}
|
|
36
|
+
const entryNames = readdirSync(directoryUrl);
|
|
37
|
+
if (entryNames.length === 0) {
|
|
38
|
+
directorySnapshot.empty = true;
|
|
39
|
+
return directorySnapshot;
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e.code === "ENOENT") {
|
|
43
|
+
directorySnapshot.empty = true;
|
|
44
|
+
directorySnapshot.notFound = true;
|
|
45
|
+
return directorySnapshot;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const snapshotNaturalOrder = {};
|
|
50
|
+
const visitDirectory = (url) => {
|
|
51
|
+
try {
|
|
52
|
+
const directoryContent = readdirSync(url);
|
|
53
|
+
directoryContent.forEach((filename) => {
|
|
54
|
+
const contentUrl = new URL(filename, url);
|
|
55
|
+
const stat = statSync(contentUrl);
|
|
56
|
+
if (stat.isDirectory()) {
|
|
57
|
+
visitDirectory(new URL(`${contentUrl}/`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const relativeUrl = urlToRelativeUrl(contentUrl, directoryUrl);
|
|
61
|
+
snapshotNaturalOrder[relativeUrl] = takeFileSnapshot(contentUrl);
|
|
62
|
+
});
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e && e.code === "ENOENT") {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visitDirectory(directoryUrl);
|
|
71
|
+
|
|
72
|
+
const relativeUrls = Object.keys(snapshotNaturalOrder);
|
|
73
|
+
relativeUrls.sort(comparePathnames);
|
|
74
|
+
relativeUrls.forEach((relativeUrl) => {
|
|
75
|
+
directorySnapshot.fileStructure[relativeUrl] =
|
|
76
|
+
snapshotNaturalOrder[relativeUrl];
|
|
77
|
+
});
|
|
78
|
+
return directorySnapshot;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const takeFileSnapshot = (fileUrl) => {
|
|
82
|
+
fileUrl = assertAndNormalizeFileUrl(fileUrl);
|
|
83
|
+
|
|
84
|
+
const fileSnapshot = {
|
|
85
|
+
[snapshotSymbol]: true,
|
|
86
|
+
empty: false,
|
|
87
|
+
type: "file",
|
|
88
|
+
url: fileUrl,
|
|
89
|
+
content: "",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let stat;
|
|
93
|
+
try {
|
|
94
|
+
stat = statSync(new URL(fileUrl));
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code === "ENOENT") {
|
|
97
|
+
fileSnapshot.empty = true;
|
|
98
|
+
return fileSnapshot;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!stat.isFile()) {
|
|
102
|
+
throw new Error(`file expected at ${fileUrl}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isTextual = CONTENT_TYPE.isTextual(
|
|
106
|
+
CONTENT_TYPE.fromUrlExtension(fileUrl),
|
|
107
|
+
);
|
|
108
|
+
if (isTextual) {
|
|
109
|
+
const contentAsString = readFileSync(new URL(fileUrl), "utf8");
|
|
110
|
+
if (process.platform === "win32") {
|
|
111
|
+
// ensure unix line breaks
|
|
112
|
+
fileSnapshot.content = contentAsString.replace(/\r\n/g, "\n");
|
|
113
|
+
} else {
|
|
114
|
+
fileSnapshot.content = contentAsString;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
const contentAsBuffer = readFileSync(new URL(fileUrl));
|
|
118
|
+
if (contentAsBuffer.length === 0) {
|
|
119
|
+
fileSnapshot.content = "";
|
|
120
|
+
} else {
|
|
121
|
+
fileSnapshot.content = contentAsBuffer;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return fileSnapshot;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const compareSnapshots = (currentSnapshot, previousSnapshot) => {
|
|
128
|
+
if (!currentSnapshot || !currentSnapshot[snapshotSymbol]) {
|
|
129
|
+
throw new TypeError(
|
|
130
|
+
`1st argument must be a snapshot, received ${currentSnapshot}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!previousSnapshot || !previousSnapshot[snapshotSymbol]) {
|
|
134
|
+
throw new TypeError(
|
|
135
|
+
`2nd argument must be a snapshot, received ${previousSnapshot}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const currentShapsnotType = currentSnapshot.type;
|
|
140
|
+
const previousSnapshotType = previousSnapshot.type;
|
|
141
|
+
if (currentShapsnotType !== previousSnapshotType) {
|
|
142
|
+
throw new TypeError(
|
|
143
|
+
`cannot compare snapshots of different types "${currentShapsnotType}" vs "${previousSnapshotType}"`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const comparer = snapshotComparers[currentShapsnotType];
|
|
147
|
+
if (!comparer) {
|
|
148
|
+
throw new TypeError(`Unknow snapshot type "${currentShapsnotType}"`);
|
|
149
|
+
}
|
|
150
|
+
if (previousSnapshot.empty) {
|
|
151
|
+
// the snapshot taken for directory/file/whatever is empty:
|
|
152
|
+
// - first time code executes:
|
|
153
|
+
// it defines snapshot that will be used for comparison by future runs
|
|
154
|
+
// - snapshot have been cleaned:
|
|
155
|
+
// we want to re-generated all snapshots without failing tests
|
|
156
|
+
// (happens when we know beforehand snapshot will change and we just want
|
|
157
|
+
// to review them using git diff)
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
comparer(currentSnapshot, previousSnapshot);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const snapshotComparers = {
|
|
164
|
+
directory: (currentDirectorySnapshot, previousDirectorySnapshot) => {
|
|
165
|
+
const failureMessage = `comparison with previous directory snapshot failed`;
|
|
166
|
+
const currentFileStructure = currentDirectorySnapshot.fileStructure;
|
|
167
|
+
const previousFileStructure = previousDirectorySnapshot.fileStructure;
|
|
168
|
+
const currentRelativeUrls = Object.keys(currentFileStructure);
|
|
169
|
+
const previousRelativeUrls = Object.keys(previousFileStructure);
|
|
170
|
+
|
|
171
|
+
// missing_files
|
|
172
|
+
{
|
|
173
|
+
const missingRelativeUrls = previousRelativeUrls.filter(
|
|
174
|
+
(previousRelativeUrl) =>
|
|
175
|
+
!currentRelativeUrls.includes(previousRelativeUrl),
|
|
176
|
+
);
|
|
177
|
+
const missingFileCount = missingRelativeUrls.length;
|
|
178
|
+
if (missingFileCount > 0) {
|
|
179
|
+
const missingUrls = missingRelativeUrls.map(
|
|
180
|
+
(relativeUrl) =>
|
|
181
|
+
new URL(relativeUrl, currentDirectorySnapshot.url).href,
|
|
182
|
+
);
|
|
183
|
+
if (missingFileCount === 1) {
|
|
184
|
+
throw createAssertionError(`${failureMessage}
|
|
185
|
+
--- reason ---
|
|
186
|
+
"${missingRelativeUrls[0]}" is missing
|
|
187
|
+
--- file missing ---
|
|
188
|
+
${missingUrls[0]}`);
|
|
189
|
+
}
|
|
190
|
+
throw createAssertionError(`${failureMessage}
|
|
191
|
+
--- reason ---
|
|
192
|
+
${missingFileCount} files are missing
|
|
193
|
+
--- files missing ---
|
|
194
|
+
${missingUrls.join("\n")}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// unexpected files
|
|
199
|
+
{
|
|
200
|
+
const extraRelativeUrls = currentRelativeUrls.filter(
|
|
201
|
+
(currentRelativeUrl) =>
|
|
202
|
+
!previousRelativeUrls.includes(currentRelativeUrl),
|
|
203
|
+
);
|
|
204
|
+
const extraFileCount = extraRelativeUrls.length;
|
|
205
|
+
if (extraFileCount > 0) {
|
|
206
|
+
const extraUrls = extraRelativeUrls.map(
|
|
207
|
+
(relativeUrl) =>
|
|
208
|
+
new URL(relativeUrl, currentDirectorySnapshot.url).href,
|
|
209
|
+
);
|
|
210
|
+
if (extraFileCount === 1) {
|
|
211
|
+
throw createAssertionError(`${failureMessage}
|
|
212
|
+
--- reason ---
|
|
213
|
+
"${extraRelativeUrls[0]}" is unexpected
|
|
214
|
+
--- file unexpected ---
|
|
215
|
+
${extraUrls[0]}`);
|
|
216
|
+
}
|
|
217
|
+
throw createAssertionError(`${failureMessage}
|
|
218
|
+
--- reason ---
|
|
219
|
+
${extraFileCount} files are unexpected
|
|
220
|
+
--- files unexpected ---
|
|
221
|
+
${extraUrls.join("\n")}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// file contents
|
|
226
|
+
{
|
|
227
|
+
for (const relativeUrl of currentRelativeUrls) {
|
|
228
|
+
const currentFileSnapshot = currentFileStructure[relativeUrl];
|
|
229
|
+
const previousFileSnapshot = previousFileStructure[relativeUrl];
|
|
230
|
+
compareSnapshots(currentFileSnapshot, previousFileSnapshot);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
file: (currentFileSnapshot, previousFileSnapshot) => {
|
|
235
|
+
const failureMessage = `comparison with previous file snapshot failed`;
|
|
236
|
+
const currentFileContent = currentFileSnapshot.content;
|
|
237
|
+
const previousFileContent = previousFileSnapshot.content;
|
|
238
|
+
if (Buffer.isBuffer(currentFileContent)) {
|
|
239
|
+
if (currentFileContent.equals(previousFileContent)) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
throw createAssertionError(`${failureMessage}
|
|
243
|
+
--- reason ---
|
|
244
|
+
"${urlToFilename(currentFileSnapshot.url)}" content has changed
|
|
245
|
+
--- file ---
|
|
246
|
+
${currentFileSnapshot.url}`);
|
|
247
|
+
}
|
|
248
|
+
if (currentFileContent === previousFileContent) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const message = formatStringAssertionErrorMessage({
|
|
252
|
+
actual: currentFileContent,
|
|
253
|
+
expected: previousFileContent,
|
|
254
|
+
name: `"${urlToFilename(currentFileSnapshot.url)}" content`,
|
|
255
|
+
});
|
|
256
|
+
throw createAssertionError(`${failureMessage}
|
|
257
|
+
--- reason ---
|
|
258
|
+
${message}
|
|
259
|
+
--- file ---
|
|
260
|
+
${currentFileSnapshot.url}`);
|
|
261
|
+
},
|
|
262
|
+
};
|