@jsenv/snapshot 2.6.0 → 2.6.2

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.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "Snapshot testing",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -34,11 +34,11 @@
34
34
  "test": "node --conditions=development ./scripts/test.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@jsenv/assert": "4.1.6",
38
- "@jsenv/ast": "6.2.7",
39
- "@jsenv/exception": "1.0.0",
40
- "@jsenv/filesystem": "4.9.4",
41
- "@jsenv/urls": "2.4.0",
37
+ "@jsenv/assert": "4.1.7",
38
+ "@jsenv/ast": "6.2.9",
39
+ "@jsenv/exception": "1.0.1",
40
+ "@jsenv/filesystem": "4.9.5",
41
+ "@jsenv/urls": "2.4.1",
42
42
  "@jsenv/utils": "2.1.2",
43
43
  "pixelmatch": "6.0.0",
44
44
  "prettier": "3.3.3",
@@ -0,0 +1,249 @@
1
+ import {
2
+ ensurePathnameTrailingSlash,
3
+ removePathnameTrailingSlash,
4
+ } from "@jsenv/urls";
5
+ import { escapeRegexpSpecialChars } from "@jsenv/utils/src/string/escape_regexp_special_chars.js";
6
+ import { readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+
10
+ // remember this: https://stackoverflow.com/a/31976060/24573072
11
+ // when deciding which replacement to use and willBeWrittenOnFilesystem is true
12
+ const WELL_KNOWN_ROOT = {
13
+ name: "root",
14
+ getReplacement: ({ preferFileUrl }) => {
15
+ if (preferFileUrl) {
16
+ return "file:///[root]";
17
+ }
18
+ return "[root]";
19
+ },
20
+ };
21
+ const WELL_KNOWN_HOMEDIR = {
22
+ name: "homedir",
23
+ getReplacement: ({ willBeWrittenOnFilesystem, preferFileUrl }) => {
24
+ if (willBeWrittenOnFilesystem) {
25
+ if (preferFileUrl) {
26
+ return "file:///[homedir]";
27
+ }
28
+ return "[homedir]";
29
+ }
30
+ if (preferFileUrl) {
31
+ return "file:///~";
32
+ }
33
+ return "~";
34
+ },
35
+ };
36
+ const WELL_KNOWN_CWD = {
37
+ name: "cwd",
38
+ getReplacement: ({ preferFileUrl }) => {
39
+ if (preferFileUrl) {
40
+ return "file:///cwd()";
41
+ }
42
+ return "cwd()";
43
+ },
44
+ };
45
+ const createWellKnownPackage = (name) => {
46
+ return {
47
+ name,
48
+ getReplacement: () => name,
49
+ };
50
+ };
51
+ export const createWellKnown = (name, replacement = name) => {
52
+ return {
53
+ name,
54
+ getReplacement: () => replacement,
55
+ };
56
+ };
57
+
58
+ export const createReplaceFilesystemWellKnownValues = ({
59
+ rootDirectoryUrl,
60
+ // for unit tests
61
+ isWindows = process.platform === "win32",
62
+ ancestorPackagesDisabled,
63
+ ancestorPackagesRootDirectoryUrl = "file:///",
64
+ homedirDisabled,
65
+ cwdDisabled,
66
+ cwdUrl,
67
+ cwdPath = process.cwd(),
68
+ } = {}) => {
69
+ const wellKownUrlArray = [];
70
+ const wellKnownPathArray = [];
71
+ const addWellKnownFileUrl = (url, wellKnown, { position = "end" } = {}) => {
72
+ url = new URL(url);
73
+ const urlWithoutTrailingSlash = String(removePathnameTrailingSlash(url));
74
+ const wellKnownUrl = {
75
+ url: urlWithoutTrailingSlash,
76
+ replace: (string, { willBeWrittenOnFilesystem }) => {
77
+ const replacement = wellKnown.getReplacement({
78
+ preferFileUrl: true,
79
+ willBeWrittenOnFilesystem,
80
+ });
81
+ return string.replaceAll(urlWithoutTrailingSlash, replacement);
82
+ },
83
+ };
84
+ const path =
85
+ String(url) === String(cwdUrl)
86
+ ? cwdPath
87
+ : fileURLToPath(urlWithoutTrailingSlash);
88
+ const windowPathRegex = new RegExp(
89
+ `${escapeRegexpSpecialChars(path)}(((?:\\\\(?:[\\w !#()-]+|[.]{1,2})+)*)(?:\\\\)?)`,
90
+ "gm",
91
+ );
92
+ const wellKnownPath = {
93
+ path,
94
+ replace: isWindows
95
+ ? (string, { willBeWrittenOnFilesystem }) => {
96
+ const replacement = wellKnown.getReplacement({
97
+ willBeWrittenOnFilesystem,
98
+ });
99
+ return string.replaceAll(windowPathRegex, (match, after) => {
100
+ return `${replacement}${after.replaceAll("\\", "/")}`;
101
+ });
102
+ }
103
+ : (string, { willBeWrittenOnFilesystem }) => {
104
+ const replacement = wellKnown.getReplacement({
105
+ willBeWrittenOnFilesystem,
106
+ });
107
+ return string.replaceAll(path, replacement);
108
+ },
109
+ };
110
+ if (position === "start") {
111
+ wellKownUrlArray.unshift(wellKnownUrl);
112
+ wellKnownPathArray.unshift(wellKnownPath);
113
+ } else {
114
+ wellKownUrlArray.push(wellKnownUrl);
115
+ wellKnownPathArray.push(wellKnownPath);
116
+ }
117
+ };
118
+ if (rootDirectoryUrl) {
119
+ addWellKnownFileUrl(rootDirectoryUrl, WELL_KNOWN_ROOT);
120
+ }
121
+ /*
122
+ * When running code inside a node project ancestor packages
123
+ * should make things super predictible because
124
+ * it will use a package.json name field
125
+ * to replace files urls
126
+ * And uses the highest ancestor package so that even if the file
127
+ * is executed once within a package then outside that package
128
+ * the replace value remains predictible as the highest package is used
129
+ * The highest package is used because it's pushed first by
130
+ * addWellKnownFileUrl
131
+ */
132
+ ancestor_packages: {
133
+ if (ancestorPackagesDisabled) {
134
+ break ancestor_packages;
135
+ }
136
+ const ancestorPackages = [];
137
+ const cwd = cwdPath || process.cwd();
138
+ const cwdUrl = ensurePathnameTrailingSlash(pathToFileURL(cwd));
139
+ let currentUrl = cwdUrl;
140
+ while (currentUrl.href !== ancestorPackagesRootDirectoryUrl) {
141
+ const packageFileUrl = new URL("package.json", currentUrl);
142
+ const packageDirectoryUrl = currentUrl;
143
+ currentUrl = new URL(getParentUrl(currentUrl));
144
+ let packageFileContent;
145
+ try {
146
+ packageFileContent = readFileSync(packageFileUrl);
147
+ } catch (e) {
148
+ if (e.code === "ENOENT") {
149
+ continue;
150
+ }
151
+ throw e;
152
+ }
153
+ let packageObject;
154
+ try {
155
+ packageObject = JSON.parse(packageFileContent);
156
+ } catch (e) {
157
+ continue;
158
+ }
159
+ const packageName = packageObject.name;
160
+ ancestorPackages.unshift({
161
+ packageDirectoryUrl,
162
+ packageName,
163
+ });
164
+ }
165
+ for (const ancestorPackage of ancestorPackages) {
166
+ addWellKnownFileUrl(
167
+ ancestorPackage.packageDirectoryUrl,
168
+ createWellKnownPackage(ancestorPackage.packageName),
169
+ );
170
+ }
171
+ }
172
+ home_dir: {
173
+ if (homedirDisabled) {
174
+ break home_dir;
175
+ }
176
+ const homedirPath = homedir();
177
+ const homedirUrl = pathToFileURL(homedirPath);
178
+ addWellKnownFileUrl(homedirUrl, WELL_KNOWN_HOMEDIR);
179
+ }
180
+ process_cwd: {
181
+ if (cwdDisabled) {
182
+ break process_cwd;
183
+ }
184
+ // we fallback on process.cwd()
185
+ // but it's brittle because a file might be execute from anywhere
186
+ // so it should be the last resort
187
+ cwdUrl = cwdUrl || pathToFileURL(cwdPath);
188
+ addWellKnownFileUrl(cwdUrl, WELL_KNOWN_CWD);
189
+ }
190
+
191
+ const replaceFileUrls = (string, { willBeWrittenOnFilesystem }) => {
192
+ for (const wellKownUrl of wellKownUrlArray) {
193
+ const replaceResult = wellKownUrl.replace(string, {
194
+ willBeWrittenOnFilesystem,
195
+ });
196
+ if (replaceResult !== string) {
197
+ return replaceResult;
198
+ }
199
+ }
200
+ return string;
201
+ };
202
+ const replaceFilePaths = (string, { willBeWrittenOnFilesystem }) => {
203
+ for (const wellKownPath of wellKnownPathArray) {
204
+ const replaceResult = wellKownPath.replace(string, {
205
+ willBeWrittenOnFilesystem,
206
+ });
207
+ if (replaceResult !== string) {
208
+ return replaceResult;
209
+ }
210
+ }
211
+ return string;
212
+ };
213
+
214
+ const replaceFilesystemWellKnownValues = (
215
+ string,
216
+ { willBeWrittenOnFilesystem = true } = {},
217
+ ) => {
218
+ const isUrl = typeof string === "object" && typeof string.href === "string";
219
+ if (isUrl) {
220
+ string = string.href;
221
+ }
222
+ string = replaceFileUrls(string, { willBeWrittenOnFilesystem });
223
+ string = replaceFilePaths(string, { willBeWrittenOnFilesystem });
224
+ return string;
225
+ };
226
+ replaceFilesystemWellKnownValues.addWellKnownFileUrl = addWellKnownFileUrl;
227
+ return replaceFilesystemWellKnownValues;
228
+ };
229
+
230
+ const getParentUrl = (url) => {
231
+ url = String(url);
232
+ // With node.js new URL('../', 'file:///C:/').href
233
+ // returns "file:///C:/" instead of "file:///"
234
+ const resource = url.slice("file://".length);
235
+ const slashLastIndex = resource.lastIndexOf("/");
236
+ if (slashLastIndex === -1) {
237
+ return url;
238
+ }
239
+ const lastCharIndex = resource.length - 1;
240
+ if (slashLastIndex === lastCharIndex) {
241
+ const slashBeforeLastIndex = resource.lastIndexOf("/", slashLastIndex - 1);
242
+ if (slashBeforeLastIndex === -1) {
243
+ return url;
244
+ }
245
+ return `file://${resource.slice(0, slashBeforeLastIndex + 1)}`;
246
+ }
247
+
248
+ return `file://${resource.slice(0, slashLastIndex + 1)}`;
249
+ };
@@ -0,0 +1,13 @@
1
+ import { getCommonPathname } from "@jsenv/urls";
2
+
3
+ export const findCommonAncestorPath = (paths, castAsPath) => {
4
+ return paths.reduce((a, b) => {
5
+ if (typeof a !== "string" && castAsPath) a = castAsPath(a);
6
+ if (typeof b !== "string" && castAsPath) b = castAsPath(b);
7
+ if (a === b) {
8
+ return a;
9
+ }
10
+ const common = getCommonPathname(a, b);
11
+ return common;
12
+ });
13
+ };
@@ -0,0 +1,14 @@
1
+ import { assert } from "@jsenv/assert";
2
+ import { findCommonAncestorPath } from "./common_ancestor_path.js";
3
+
4
+ const test = (paths, expect) => {
5
+ const actual = findCommonAncestorPath(paths);
6
+ assert({ actual, expect });
7
+ };
8
+
9
+ test(["/a/b/c/d", "/a/b/c/d/"], "/a/b/c/d");
10
+ test(["/a/b/c", "/a/b/d", "/a/x/y"], "/a/");
11
+ test(["/a/b/", "/a/b/"], "/a/b/");
12
+ test(["/a", "/a/b/c"], "/a");
13
+ test(["/a/b/c", "/a"], "/a/");
14
+ test(["/a/b/c"], "/a/b/c");
@@ -14,6 +14,7 @@ export const collectFunctionSideEffects = (
14
14
  const sideEffects = [];
15
15
  const addSideEffect = (sideEffect) => {
16
16
  sideEffects.push(sideEffect);
17
+ return sideEffect;
17
18
  };
18
19
  const finallyCallbackSet = new Set();
19
20
  for (const sideEffectDetector of sideEffectDetectors) {
@@ -36,7 +37,6 @@ export const collectFunctionSideEffects = (
36
37
  `collectFunctionSideEffects called while other function(s) side effects are collected`,
37
38
  );
38
39
  }
39
- functionExecutingCount++;
40
40
 
41
41
  const onCatch = (valueThrow) => {
42
42
  sideEffects.push({
@@ -98,6 +98,7 @@ export const collectFunctionSideEffects = (
98
98
  });
99
99
  };
100
100
  const onFinally = () => {
101
+ delete process.env.SNAPSHOTING_FUNCTION_SIDE_EFFECTS;
101
102
  functionExecutingCount--;
102
103
  for (const finallyCallback of finallyCallbackSet) {
103
104
  finallyCallback();
@@ -105,6 +106,8 @@ export const collectFunctionSideEffects = (
105
106
  finallyCallbackSet.clear();
106
107
  };
107
108
 
109
+ process.env.SNAPSHOTING_FUNCTION_SIDE_EFFECTS = "1";
110
+ functionExecutingCount++;
108
111
  let returnedPromise = false;
109
112
  try {
110
113
  const valueReturned = fn();
@@ -2,6 +2,9 @@ export const renderSideEffects = (sideEffects) => {
2
2
  let string = "";
3
3
  let index = 0;
4
4
  for (const sideEffect of sideEffects) {
5
+ if (sideEffect.skippable) {
6
+ continue;
7
+ }
5
8
  if (string) {
6
9
  string += "\n\n";
7
10
  }
@@ -17,7 +20,7 @@ export const renderSideEffects = (sideEffects) => {
17
20
  return string;
18
21
  };
19
22
 
20
- export const wrapIntoMarkdownBlock = (value, blockName) => {
23
+ export const wrapIntoMarkdownBlock = (value, blockName = "") => {
21
24
  const start = "```";
22
25
  const end = "```";
23
26
  return `${start}${blockName}
@@ -1,6 +1,7 @@
1
- import { writeFileSync } from "@jsenv/filesystem";
1
+ import { readDirectorySync, writeFileSync } from "@jsenv/filesystem";
2
2
  import {
3
3
  ensurePathnameTrailingSlash,
4
+ urlIsInsideOf,
4
5
  urlToExtension,
5
6
  urlToRelativeUrl,
6
7
  } from "@jsenv/urls";
@@ -8,12 +9,17 @@ import {
8
9
  takeDirectorySnapshot,
9
10
  takeFileSnapshot,
10
11
  } from "../filesystem_snapshot.js";
12
+ import {
13
+ createReplaceFilesystemWellKnownValues,
14
+ createWellKnown,
15
+ } from "../filesystem_well_known_values.js";
11
16
  import { replaceFluctuatingValues } from "../replace_fluctuating_values.js";
12
17
  import { collectFunctionSideEffects } from "./function_side_effects_collector.js";
13
18
  import {
14
19
  renderSideEffects,
15
20
  wrapIntoMarkdownBlock,
16
21
  } from "./function_side_effects_renderer.js";
22
+ import { groupFileSideEffectsPerDirectory } from "./group_file_side_effects_per_directory.js";
17
23
  import { spyConsoleCalls } from "./spy_console_calls.js";
18
24
  import { spyFilesystemCalls } from "./spy_filesystem_calls.js";
19
25
 
@@ -27,13 +33,8 @@ const consoleEffectsDefault = {
27
33
 
28
34
  export const snapshotFunctionSideEffects = (
29
35
  fn,
30
- fnFileUrl,
31
- sideEffectFileRelativeUrl,
32
- {
33
- rootDirectoryUrl = new URL("./", fnFileUrl),
34
- consoleEffects = true,
35
- filesystemEffects = true,
36
- } = {},
36
+ sideEffectFileUrl,
37
+ { consoleEffects = true, filesystemEffects = true, rootDirectoryUrl } = {},
37
38
  ) => {
38
39
  if (consoleEffects === true) {
39
40
  consoleEffects = {};
@@ -41,7 +42,10 @@ export const snapshotFunctionSideEffects = (
41
42
  if (filesystemEffects === true) {
42
43
  filesystemEffects = {};
43
44
  }
44
- const sideEffectFileUrl = new URL(sideEffectFileRelativeUrl, fnFileUrl);
45
+ const replaceFilesystemWellKnownValues =
46
+ createReplaceFilesystemWellKnownValues({
47
+ rootDirectoryUrl,
48
+ });
45
49
  const sideEffectFileSnapshot = takeFileSnapshot(sideEffectFileUrl);
46
50
  const callbackSet = new Set();
47
51
  const sideEffectDetectors = [
@@ -60,7 +64,7 @@ export const snapshotFunctionSideEffects = (
60
64
  text: wrapIntoMarkdownBlock(
61
65
  replaceFluctuatingValues(message, {
62
66
  stringType: "console",
63
- rootDirectoryUrl,
67
+ replaceFilesystemWellKnownValues,
64
68
  }),
65
69
  "console",
66
70
  ),
@@ -80,6 +84,34 @@ export const snapshotFunctionSideEffects = (
80
84
  log: (message) => {
81
85
  onConsole("log", message);
82
86
  },
87
+ stdout: (message) => {
88
+ addSideEffect({
89
+ type: `process:stdout`,
90
+ value: message,
91
+ label: `process.stdout`,
92
+ text: wrapIntoMarkdownBlock(
93
+ replaceFluctuatingValues(message, {
94
+ stringType: "console",
95
+ replaceFilesystemWellKnownValues,
96
+ }),
97
+ "console",
98
+ ),
99
+ });
100
+ },
101
+ stderr: (message) => {
102
+ addSideEffect({
103
+ type: `process:stderr`,
104
+ value: message,
105
+ label: `process.stderr`,
106
+ text: wrapIntoMarkdownBlock(
107
+ replaceFluctuatingValues(message, {
108
+ stringType: "console",
109
+ replaceFilesystemWellKnownValues,
110
+ }),
111
+ "console",
112
+ ),
113
+ });
114
+ },
83
115
  },
84
116
  {
85
117
  preventConsoleSideEffects: prevent,
@@ -102,7 +134,21 @@ export const snapshotFunctionSideEffects = (
102
134
  ...filesystemEffects,
103
135
  };
104
136
  let writeFile;
105
- const { preserve, outDirectory } = filesystemEffects;
137
+ const { include, preserve, baseDirectory, outDirectory } =
138
+ filesystemEffects;
139
+ if (baseDirectory) {
140
+ replaceFilesystemWellKnownValues.addWellKnownFileUrl(
141
+ baseDirectory,
142
+ createWellKnown("base"),
143
+ { position: "start" },
144
+ );
145
+ }
146
+ const renderLabel = (label) => {
147
+ return replaceFluctuatingValues(label, {
148
+ replaceFilesystemWellKnownValues,
149
+ });
150
+ };
151
+
106
152
  if (outDirectory) {
107
153
  const fsEffectsOutDirectoryUrl = ensurePathnameTrailingSlash(
108
154
  new URL(outDirectory, sideEffectFileUrl),
@@ -110,12 +156,53 @@ export const snapshotFunctionSideEffects = (
110
156
  const fsEffectsOutDirectorySnapshot = takeDirectorySnapshot(
111
157
  fsEffectsOutDirectoryUrl,
112
158
  );
113
- const fsEffectsOutDirectoryRelativeUrl = urlToRelativeUrl(
114
- fsEffectsOutDirectoryUrl,
115
- sideEffectFileUrl,
116
- );
117
159
  const writeFileCallbackSet = new Set();
118
- callbackSet.add(() => {
160
+ const getFilesystemActionInfo = (action, url) => {
161
+ let toUrl;
162
+ let urlDisplayed = url;
163
+ if (baseDirectory) {
164
+ urlDisplayed = urlToRelativeUrl(url, baseDirectory, {
165
+ preferRelativeNotation: true,
166
+ });
167
+ if (
168
+ url.href === baseDirectory.href ||
169
+ urlIsInsideOf(url, baseDirectory)
170
+ ) {
171
+ const toRelativeUrl = urlToRelativeUrl(
172
+ url,
173
+ baseDirectory,
174
+ );
175
+ toUrl = new URL(toRelativeUrl, fsEffectsOutDirectoryUrl);
176
+ } else {
177
+ const toRelativeUrl =
178
+ replaceFilesystemWellKnownValues(url);
179
+ toUrl = new URL(toRelativeUrl, fsEffectsOutDirectoryUrl);
180
+ }
181
+ // otherwise we need to replace the url with well known
182
+ } else {
183
+ const toRelativeUrl = replaceFilesystemWellKnownValues(url);
184
+ toUrl = new URL(toRelativeUrl, fsEffectsOutDirectoryUrl);
185
+ }
186
+ const toUrlDisplayed = urlToRelativeUrl(
187
+ toUrl,
188
+ sideEffectFileUrl,
189
+ { preferRelativeNotation: true },
190
+ );
191
+ return {
192
+ toUrl,
193
+ label: renderLabel(
194
+ `${action} "${urlDisplayed}" (see ${toUrlDisplayed})`,
195
+ ),
196
+ };
197
+ };
198
+
199
+ callbackSet.add((sideEffects) => {
200
+ // gather all file side effect next to each other
201
+ // collapse them if they have a shared ancestor
202
+ groupFileSideEffectsPerDirectory(sideEffects, {
203
+ baseDirectory,
204
+ getFilesystemActionInfo,
205
+ });
119
206
  for (const writeFileCallback of writeFileCallbackSet) {
120
207
  writeFileCallback();
121
208
  }
@@ -123,25 +210,32 @@ export const snapshotFunctionSideEffects = (
123
210
  fsEffectsOutDirectorySnapshot.compare();
124
211
  });
125
212
  writeFile = (url, content) => {
126
- const relativeUrl = urlToRelativeUrl(url, fnFileUrl);
127
- const toUrl = new URL(relativeUrl, fsEffectsOutDirectoryUrl);
213
+ const { toUrl, label } = getFilesystemActionInfo(
214
+ "write file",
215
+ url,
216
+ );
128
217
  writeFileCallbackSet.add(() => {
129
218
  writeFileSync(toUrl, content);
130
219
  });
131
220
  addSideEffect({
132
221
  type: "fs:write_file",
133
- value: { relativeUrl, content },
134
- label: `write file "${relativeUrl}" (see ./${fsEffectsOutDirectoryRelativeUrl}${relativeUrl})`,
222
+ value: { url: String(url), content },
223
+ label,
135
224
  text: null,
136
225
  });
137
226
  };
138
227
  } else {
139
228
  writeFile = (url, content) => {
140
- const relativeUrl = urlToRelativeUrl(url, fnFileUrl);
229
+ let urlDisplayed = url;
230
+ if (baseDirectory) {
231
+ urlDisplayed = urlToRelativeUrl(url, baseDirectory, {
232
+ preferRelativeNotation: true,
233
+ });
234
+ }
141
235
  addSideEffect({
142
236
  type: "fs:write_file",
143
- value: { relativeUrl, content },
144
- label: `write file "${relativeUrl}"`,
237
+ value: { url: String(url), content },
238
+ label: renderLabel(`write file "${urlDisplayed}"`),
145
239
  text: wrapIntoMarkdownBlock(
146
240
  content,
147
241
  urlToExtension(url).slice(1),
@@ -153,16 +247,28 @@ export const snapshotFunctionSideEffects = (
153
247
  {
154
248
  writeFile,
155
249
  writeDirectory: (url) => {
156
- const relativeUrl = urlToRelativeUrl(url, fnFileUrl);
157
- addSideEffect({
250
+ const writeDirectorySideEffect = addSideEffect({
158
251
  type: "fs:write_directory",
159
- value: { relativeUrl },
160
- label: `write directory "${relativeUrl}"`,
252
+ value: { url: String(url) },
253
+ label: renderLabel(`write directory "${url}"`),
161
254
  text: null,
162
255
  });
256
+ // if directory ends up with something inside we'll not report
257
+ // this side effect because:
258
+ // - it was likely created to write the file
259
+ // - the file creation will be reported and implies directory creation
260
+ filesystemSpy.addBeforeUndoCallback(() => {
261
+ try {
262
+ const dirContent = readDirectorySync(url);
263
+ if (dirContent.length) {
264
+ writeDirectorySideEffect.skippable = true;
265
+ }
266
+ } catch (e) {}
267
+ });
163
268
  },
164
269
  },
165
270
  {
271
+ include,
166
272
  undoFilesystemSideEffects: !preserve,
167
273
  },
168
274
  );
@@ -176,7 +282,7 @@ export const snapshotFunctionSideEffects = (
176
282
  ];
177
283
  const onSideEffectsCollected = (sideEffects) => {
178
284
  for (const callback of callbackSet) {
179
- callback();
285
+ callback(sideEffects);
180
286
  }
181
287
  callbackSet.clear();
182
288
  sideEffectFileSnapshot.update(renderSideEffects(sideEffects), {
@@ -0,0 +1,114 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { findCommonAncestorPath } from "./common_ancestor_path.js";
3
+
4
+ export const groupFileSideEffectsPerDirectory = (
5
+ sideEffects,
6
+ { getFilesystemActionInfo },
7
+ ) => {
8
+ const groupArray = groupFileTogether(sideEffects);
9
+
10
+ const convertToPathname = (writeFileSideEffect) => {
11
+ return new URL(writeFileSideEffect.value.url).pathname;
12
+ };
13
+
14
+ for (const group of groupArray) {
15
+ if (group.id !== "file") {
16
+ continue;
17
+ }
18
+ const fileEffectArray = group.values;
19
+ if (fileEffectArray.length < 2) {
20
+ continue;
21
+ }
22
+ const commonAncestorPath = findCommonAncestorPath(
23
+ fileEffectArray,
24
+ convertToPathname,
25
+ );
26
+ const firstEffect = fileEffectArray[0];
27
+ const firstEffectIndex = sideEffects.indexOf(firstEffect);
28
+ const commonAncestorUrl = pathToFileURL(commonAncestorPath);
29
+ const numberOfFiles = fileEffectArray.length;
30
+ const { label } = getFilesystemActionInfo(
31
+ `write ${numberOfFiles} files into`,
32
+ commonAncestorUrl,
33
+ );
34
+ for (const fileEffect of fileEffectArray) {
35
+ sideEffects.splice(sideEffects.indexOf(fileEffect), 1);
36
+ }
37
+ sideEffects.splice(firstEffectIndex, 0, {
38
+ type: "fs:write_file",
39
+ label,
40
+ });
41
+ }
42
+ };
43
+
44
+ const groupBy = (array, groupCallback) => {
45
+ let i = 0;
46
+ const groupArray = [];
47
+ let currentGroup = null;
48
+ while (i < array.length) {
49
+ const value = array[i];
50
+ i++;
51
+ let ignoreCalled = false;
52
+ let ignore = () => {
53
+ ignoreCalled = true;
54
+ };
55
+ const groupId = groupCallback(value, { ignore });
56
+ if (ignoreCalled) {
57
+ continue;
58
+ }
59
+ if (currentGroup === null) {
60
+ currentGroup = {
61
+ id: groupId,
62
+ values: [value],
63
+ };
64
+ groupArray.push(currentGroup);
65
+ continue;
66
+ }
67
+ if (groupId === currentGroup.id) {
68
+ currentGroup.values.push(value);
69
+ continue;
70
+ }
71
+ currentGroup = {
72
+ id: groupId,
73
+ values: [value],
74
+ };
75
+ groupArray.push(currentGroup);
76
+ }
77
+ return groupArray;
78
+ };
79
+
80
+ const groupFileTogether = (sideEffects) =>
81
+ groupBy(sideEffects, (sideEffect, { ignore }) => {
82
+ if (sideEffect.type === "fs:write_directory") {
83
+ ignore();
84
+ return null;
85
+ }
86
+ if (sideEffect.type === "fs:write_file") {
87
+ return "file";
88
+ }
89
+ return "other";
90
+ });
91
+
92
+ // const groups = groupFileTogether([
93
+ // {
94
+ // name: "a",
95
+ // type: "fs:write_file",
96
+ // },
97
+ // {
98
+ // name: "b",
99
+ // type: "fs:write_directory",
100
+ // },
101
+ // {
102
+ // name: "c",
103
+ // type: "fs:write_file",
104
+ // },
105
+ // {
106
+ // name: "d",
107
+ // type: "other",
108
+ // },
109
+ // {
110
+ // name: "e",
111
+ // type: "fs:write_file",
112
+ // },
113
+ // ]);
114
+ // debugger;
@@ -1,7 +1,7 @@
1
1
  import { hookIntoMethod } from "./hook_into_method.js";
2
2
 
3
3
  export const spyConsoleCalls = (
4
- { error, warn, info, log, trace },
4
+ { error, warn, info, log, trace, stdout, stderr },
5
5
  { preventConsoleSideEffects },
6
6
  ) => {
7
7
  const restoreCallbackSet = new Set();
@@ -45,12 +45,38 @@ export const spyConsoleCalls = (
45
45
  },
46
46
  };
47
47
  });
48
+ const processStdouthook = hookIntoMethod(
49
+ process.stdout,
50
+ "write",
51
+ (message) => {
52
+ return {
53
+ preventOriginalCall: preventConsoleSideEffects,
54
+ return: () => {
55
+ stdout(message);
56
+ },
57
+ };
58
+ },
59
+ );
60
+ const processStderrHhook = hookIntoMethod(
61
+ process.stderr,
62
+ "write",
63
+ (message) => {
64
+ return {
65
+ preventOriginalCall: preventConsoleSideEffects,
66
+ return: () => {
67
+ stderr(message);
68
+ },
69
+ };
70
+ },
71
+ );
48
72
  restoreCallbackSet.add(() => {
49
73
  errorHook.remove();
50
74
  warnHook.remove();
51
75
  infoHook.remove();
52
76
  logHook.remove();
53
77
  traceHook.remove();
78
+ processStdouthook.remove();
79
+ processStderrHhook.remove();
54
80
  });
55
81
  return {
56
82
  restore: () => {
@@ -3,8 +3,13 @@
3
3
  // https://github.com/tschaub/mock-fs/blob/6e84d5bb320022624c7d770432e3322323ce043e/lib/binding.js#L353
4
4
  // https://github.com/tschaub/mock-fs/issues/348
5
5
 
6
- import { removeDirectorySync, removeFileSync } from "@jsenv/filesystem";
7
- import { readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import {
7
+ removeDirectorySync,
8
+ removeFileSync,
9
+ writeFileSync,
10
+ } from "@jsenv/filesystem";
11
+ import { URL_META } from "@jsenv/url-meta";
12
+ import { readFileSync, statSync } from "node:fs";
8
13
  import { pathToFileURL } from "node:url";
9
14
  import {
10
15
  disableHooksWhileCalling,
@@ -20,8 +25,12 @@ export const spyFilesystemCalls = (
20
25
  removeFile = () => {}, // TODO
21
26
  // removeDirectory = () => {},
22
27
  },
23
- { undoFilesystemSideEffects } = {},
28
+ { include, undoFilesystemSideEffects } = {},
24
29
  ) => {
30
+ const shouldReport = include
31
+ ? URL_META.createFilter(include, "file:///")
32
+ : () => true;
33
+
25
34
  const _internalFs = process.binding("fs");
26
35
  const filesystemStateInfoMap = new Map();
27
36
  const fileDescriptorPathMap = new Map();
@@ -47,8 +56,10 @@ export const spyFilesystemCalls = (
47
56
  });
48
57
  }
49
58
  }
50
- writeFile(fileUrl, stateAfter.content);
51
- return;
59
+ if (shouldReport(fileUrl)) {
60
+ writeFile(fileUrl, stateAfter.content);
61
+ return;
62
+ }
52
63
  }
53
64
  // file is exactly the same
54
65
  // function did not have any effect on the file
@@ -65,8 +76,11 @@ export const spyFilesystemCalls = (
65
76
  });
66
77
  });
67
78
  }
68
- writeDirectory(directoryUrl);
79
+ if (shouldReport(directoryUrl)) {
80
+ writeDirectory(directoryUrl);
81
+ }
69
82
  };
83
+ const beforeUndoCallbackSet = new Set();
70
84
  const restoreCallbackSet = new Set();
71
85
 
72
86
  const getFileStateWithinHook = (fileUrl) => {
@@ -166,11 +180,18 @@ export const spyFilesystemCalls = (
166
180
  unlinkHook.remove();
167
181
  });
168
182
  return {
183
+ addBeforeUndoCallback: (callback) => {
184
+ beforeUndoCallbackSet.add(callback);
185
+ },
169
186
  restore: () => {
170
187
  for (const restoreCallback of restoreCallbackSet) {
171
188
  restoreCallback();
172
189
  }
173
190
  restoreCallbackSet.clear();
191
+ for (const beforeUndoCallback of beforeUndoCallbackSet) {
192
+ beforeUndoCallback();
193
+ }
194
+ beforeUndoCallbackSet.clear();
174
195
  for (const [, restore] of fileRestoreMap) {
175
196
  restore();
176
197
  }
package/src/main.js CHANGED
@@ -2,5 +2,6 @@ export {
2
2
  takeDirectorySnapshot,
3
3
  takeFileSnapshot,
4
4
  } from "./filesystem_snapshot.js";
5
+ export { createReplaceFilesystemWellKnownValues } from "./filesystem_well_known_values.js";
5
6
  export { snapshotFunctionSideEffects } from "./function_side_effects/function_side_effects_snapshot.js";
6
7
  export { replaceFluctuatingValues } from "./replace_fluctuating_values.js";
@@ -11,168 +11,56 @@ import {
11
11
  stringifyHtmlAst,
12
12
  visitHtmlNodes,
13
13
  } from "@jsenv/ast";
14
- import {
15
- ensurePathnameTrailingSlash,
16
- removePathnameTrailingSlash,
17
- urlToExtension,
18
- } from "@jsenv/urls";
19
- import { escapeRegexpSpecialChars } from "@jsenv/utils/src/string/escape_regexp_special_chars.js";
20
- import { readFileSync } from "node:fs";
21
- import { homedir } from "node:os";
22
- import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import { urlToExtension } from "@jsenv/urls";
23
15
  import stripAnsi from "strip-ansi";
16
+ import { createReplaceFilesystemWellKnownValues } from "./filesystem_well_known_values.js";
24
17
 
25
18
  export const replaceFluctuatingValues = (
26
19
  string,
27
20
  {
28
21
  stringType,
22
+ rootDirectoryUrl,
29
23
  fileUrl,
30
24
  removeAnsi = true,
31
- rootDirectoryUrl,
32
- // for unit tests
33
- ancestorPackagesDisabled,
34
- ancestorPackagesRootDirectoryUrl = "file:///",
35
- homedirDisabled,
36
- cwdPath = process.cwd(),
37
- cwdUrl,
38
- isWindows = process.platform === "win32",
25
+ // for unit test
26
+ replaceFilesystemWellKnownValues = createReplaceFilesystemWellKnownValues({
27
+ rootDirectoryUrl,
28
+ }),
39
29
  } = {},
40
30
  ) => {
41
- const wellKownUrlArray = [];
42
- const wellKnownPathArray = [];
43
- const addWellKnownFileUrl = (url, replacement) => {
44
- const urlWithoutTrailingSlash = removePathnameTrailingSlash(url);
45
- wellKownUrlArray.push({
46
- url: urlWithoutTrailingSlash,
47
- replacement,
48
- replace: (string) =>
49
- string.replaceAll(urlWithoutTrailingSlash, replacement),
50
- });
51
- const path =
52
- url === cwdUrl ? cwdPath : fileURLToPath(urlWithoutTrailingSlash);
53
- const windowPathRegex = new RegExp(
54
- `${escapeRegexpSpecialChars(path)}(((?:\\\\(?:[\\w !#()-]+|[.]{1,2})+)*)(?:\\\\)?)`,
55
- "gm",
56
- );
57
- const pathReplacement = replacement.startsWith("file:///")
58
- ? replacement.slice("file:///".length)
59
- : replacement;
60
- wellKnownPathArray.push({
61
- path,
62
- replacement: pathReplacement,
63
- replace: isWindows
64
- ? (string) => {
65
- return string.replaceAll(windowPathRegex, (match, after) => {
66
- return `${pathReplacement}${after.replaceAll("\\", "/")}`;
67
- });
68
- }
69
- : (string) => string.replaceAll(path, pathReplacement),
70
- });
71
- };
72
- if (rootDirectoryUrl) {
73
- addWellKnownFileUrl(rootDirectoryUrl, "file:///<root>");
74
- }
75
- /*
76
- * When running code inside a node project ancestor packages
77
- * should make things super predictible because
78
- * it will use a package.json name field
79
- * to replace files urls
80
- * And uses the highest ancestor package so that even if the file
81
- * is executed once within a package then outside that package
82
- * the replace value remains predictible as the highest package is used
83
- * The highest package is used because it's pushed first by
84
- * addWellKnownFileUrl
85
- */
86
- ancestor_packages: {
87
- if (ancestorPackagesDisabled) {
88
- break ancestor_packages;
89
- }
90
- const ancestorPackages = [];
91
- const cwd = cwdPath || process.cwd();
92
- const cwdUrl = ensurePathnameTrailingSlash(pathToFileURL(cwd));
93
- let currentUrl = cwdUrl;
94
- while (currentUrl.href !== ancestorPackagesRootDirectoryUrl) {
95
- const packageFileUrl = new URL("package.json", currentUrl);
96
- const packageDirectoryUrl = currentUrl;
97
- currentUrl = new URL(getParentUrl(currentUrl));
98
- let packageFileContent;
99
- try {
100
- packageFileContent = readFileSync(packageFileUrl);
101
- } catch (e) {
102
- if (e.code === "ENOENT") {
103
- continue;
104
- }
105
- throw e;
106
- }
107
- let packageObject;
108
- try {
109
- packageObject = JSON.parse(packageFileContent);
110
- } catch (e) {
111
- continue;
112
- }
113
- const packageName = packageObject.name;
114
- ancestorPackages.unshift({
115
- packageDirectoryUrl,
116
- packageName,
117
- });
118
- }
119
- for (const ancestorPackage of ancestorPackages) {
120
- addWellKnownFileUrl(
121
- ancestorPackage.packageDirectoryUrl,
122
- ancestorPackage.packageName,
123
- );
124
- }
125
- }
126
- home_dir: {
127
- if (!homedirDisabled) {
128
- const homedirPath = homedir();
129
- const homedirUrl = pathToFileURL(homedirPath);
130
- addWellKnownFileUrl(homedirUrl, "file:///~");
31
+ if (fileUrl && stringType === undefined) {
32
+ const extension = urlToExtension(fileUrl);
33
+ if (extension === ".html") {
34
+ stringType = "html";
35
+ } else if (extension === ".svg") {
36
+ stringType = "svg";
131
37
  }
132
38
  }
133
- process_cwd: {
134
- // we fallback on process.cwd()
135
- // but it's brittle because a file might be execute from anywhere
136
- // so it should be the last resort
137
- cwdUrl = cwdUrl || pathToFileURL(cwdPath);
138
- addWellKnownFileUrl(cwdUrl, "file:///cwd()");
139
- }
140
- const replaceFileUrls = (value) => {
141
- for (const wellKownUrl of wellKownUrlArray) {
142
- const replaceResult = wellKownUrl.replace(value);
143
- if (replaceResult !== value) {
144
- return replaceResult;
145
- }
146
- }
147
- return value;
148
- };
149
- const replaceFilePaths = (value) => {
150
- for (const wellKownPath of wellKnownPathArray) {
151
- const replaceResult = wellKownPath.replace(value);
152
- if (replaceResult !== value) {
153
- return replaceResult;
154
- }
155
- }
39
+ const replaceDurations = (value) => {
40
+ // https://stackoverflow.com/a/59202307/24573072
41
+ value = value.replace(
42
+ /(?<!\d|\.)\d+(?:\.\d+)?(\s*)(seconds|second|s)\b/g,
43
+ (match, space, unit) => {
44
+ if (unit === "seconds") unit = "second";
45
+ return `<X>${space}${unit}`;
46
+ },
47
+ );
156
48
  return value;
157
49
  };
158
50
  const replaceThings = (value) => {
51
+ if (stringType === "filesystem") {
52
+ return replaceFilesystemWellKnownValues(value);
53
+ }
159
54
  if (removeAnsi) {
160
55
  value = stripAnsi(value);
161
56
  }
162
- value = replaceFileUrls(value);
163
- value = replaceFilePaths(value);
57
+ value = replaceFilesystemWellKnownValues(value, {
58
+ willBeWrittenOnFilesystem: false,
59
+ });
164
60
  value = replaceHttpUrls(value);
61
+ value = replaceDurations(value);
165
62
  return value;
166
63
  };
167
-
168
- if (fileUrl && stringType === undefined) {
169
- const extension = urlToExtension(fileUrl);
170
- if (extension === ".html") {
171
- stringType = "html";
172
- } else if (extension === ".svg") {
173
- stringType = "svg";
174
- }
175
- }
176
64
  if (stringType === "html") {
177
65
  // do parse html
178
66
  const htmlAst =
@@ -204,27 +92,6 @@ export const replaceFluctuatingValues = (
204
92
  return replaceThings(string);
205
93
  };
206
94
 
207
- const getParentUrl = (url) => {
208
- url = String(url);
209
- // With node.js new URL('../', 'file:///C:/').href
210
- // returns "file:///C:/" instead of "file:///"
211
- const resource = url.slice("file://".length);
212
- const slashLastIndex = resource.lastIndexOf("/");
213
- if (slashLastIndex === -1) {
214
- return url;
215
- }
216
- const lastCharIndex = resource.length - 1;
217
- if (slashLastIndex === lastCharIndex) {
218
- const slashBeforeLastIndex = resource.lastIndexOf("/", slashLastIndex - 1);
219
- if (slashBeforeLastIndex === -1) {
220
- return url;
221
- }
222
- return `file://${resource.slice(0, slashBeforeLastIndex + 1)}`;
223
- }
224
-
225
- return `file://${resource.slice(0, slashLastIndex + 1)}`;
226
- };
227
-
228
95
  const replaceHttpUrls = (source) => {
229
96
  return source.replace(/(?:https?|ftp):\/\/\S+[\w/]/g, (match) => {
230
97
  const lastChar = match[match.length - 1];