@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 ADDED
@@ -0,0 +1,5 @@
1
+ # snapshots [![npm package](https://img.shields.io/npm/v/@jsenv/snapshot.svg?logo=npm&label=package)](https://www.npmjs.com/package/@jsenv/snapshot)
2
+
3
+ ## takeDirectorySnapshot
4
+
5
+ TODO
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
+ };