@jsenv/snapshot 2.2.8 → 2.3.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": "2.2.8",
3
+ "version": "2.3.1",
4
4
  "description": "Snapshot testing",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -34,8 +34,9 @@
34
34
  "test": "node --conditions=development ./scripts/test.mjs"
35
35
  },
36
36
  "dependencies": {
37
- "@jsenv/assert": "4.1.5",
37
+ "@jsenv/assert": "4.1.6",
38
38
  "@jsenv/ast": "6.2.6",
39
+ "@jsenv/exception": "1.0.0",
39
40
  "@jsenv/filesystem": "4.9.2",
40
41
  "@jsenv/urls": "2.3.2",
41
42
  "@jsenv/utils": "2.1.1",
@@ -20,7 +20,7 @@ export const comparePngFiles = (actualData, expectData) => {
20
20
  );
21
21
  const diffRatio = numberOfPixelsConsideredAsDiff / numberOfPixels;
22
22
  const diffPercentage = diffRatio * 100;
23
- return diffPercentage <= 0.5;
23
+ return diffPercentage <= 1;
24
24
  };
25
25
 
26
26
  const getPngDimensions = (buffer) => {
@@ -0,0 +1,137 @@
1
+ import { createException } from "@jsenv/exception";
2
+ import { replaceFluctuatingValues } from "../replace_fluctuating_values.js";
3
+
4
+ const RETURN_PROMISE = {};
5
+
6
+ let functionExecutingCount = 0;
7
+
8
+ export const collectFunctionSideEffects = (
9
+ fn,
10
+ sideEffectDetectors,
11
+ { rootDirectoryUrl },
12
+ ) => {
13
+ const sideEffects = [];
14
+ const addSideEffect = (sideEffect) => {
15
+ sideEffects.push(sideEffect);
16
+ };
17
+ const finallyCallbackSet = new Set();
18
+ for (const sideEffectDetector of sideEffectDetectors) {
19
+ const uninstall = sideEffectDetector.install(addSideEffect);
20
+ finallyCallbackSet.add(() => {
21
+ uninstall();
22
+ });
23
+ }
24
+ if (functionExecutingCount) {
25
+ console.warn(
26
+ `collectFunctionSideEffects called while other function(s) side effects are collected`,
27
+ );
28
+ }
29
+ functionExecutingCount++;
30
+
31
+ const onCatch = (valueThrow) => {
32
+ sideEffects.push({
33
+ type: "throw",
34
+ value: valueThrow,
35
+ label: "throw",
36
+ text: renderValueThrownOrRejected(
37
+ createException(valueThrow, { rootDirectoryUrl }),
38
+ { rootDirectoryUrl },
39
+ ),
40
+ });
41
+ };
42
+ const onReturn = (valueReturned) => {
43
+ if (valueReturned === RETURN_PROMISE) {
44
+ sideEffects.push({
45
+ type: "return",
46
+ value: valueReturned,
47
+ label: "return promise",
48
+ text: null,
49
+ });
50
+ } else {
51
+ sideEffects.push({
52
+ type: "return",
53
+ value: valueReturned,
54
+ label: "return",
55
+ text: renderReturnValueOrResolveValue(valueReturned, {
56
+ rootDirectoryUrl,
57
+ }),
58
+ });
59
+ }
60
+ };
61
+ const onResolve = (value) => {
62
+ sideEffects.push({
63
+ type: "resolve",
64
+ value,
65
+ label: "resolve",
66
+ text: renderReturnValueOrResolveValue(value, { rootDirectoryUrl }),
67
+ });
68
+ };
69
+ const onReject = (reason) => {
70
+ sideEffects.push({
71
+ type: "reject",
72
+ value: reason,
73
+ label: "reject",
74
+ text: renderValueThrownOrRejected(
75
+ createException(reason, { rootDirectoryUrl }),
76
+ { rootDirectoryUrl },
77
+ ),
78
+ });
79
+ };
80
+ const onFinally = () => {
81
+ functionExecutingCount--;
82
+ for (const finallyCallback of finallyCallbackSet) {
83
+ finallyCallback();
84
+ }
85
+ finallyCallbackSet.clear();
86
+ };
87
+
88
+ let returnedPromise = false;
89
+ try {
90
+ const valueReturned = fn();
91
+ if (valueReturned && typeof valueReturned.then === "function") {
92
+ onReturn(RETURN_PROMISE);
93
+ returnedPromise = valueReturned.then(
94
+ (value) => {
95
+ onResolve(value);
96
+ onFinally();
97
+ return sideEffects;
98
+ },
99
+ (e) => {
100
+ onReject(e);
101
+ onFinally();
102
+ return sideEffects;
103
+ },
104
+ );
105
+ return returnedPromise;
106
+ }
107
+ onReturn(valueReturned);
108
+ return sideEffects;
109
+ } catch (e) {
110
+ onCatch(e);
111
+ return sideEffects;
112
+ } finally {
113
+ if (!returnedPromise) {
114
+ onFinally();
115
+ }
116
+ }
117
+ };
118
+
119
+ const renderReturnValueOrResolveValue = (value, { rootDirectoryUrl }) => {
120
+ if (value === undefined) {
121
+ return "undefined";
122
+ }
123
+ return replaceFluctuatingValues(JSON.stringify(value, null, " "), {
124
+ stringType: "json",
125
+ rootDirectoryUrl,
126
+ });
127
+ };
128
+
129
+ const renderValueThrownOrRejected = (value, { rootDirectoryUrl }) => {
130
+ return replaceFluctuatingValues(
131
+ value ? value.stack || value.message || value : String(value),
132
+ {
133
+ stringType: "error",
134
+ rootDirectoryUrl,
135
+ },
136
+ );
137
+ };
@@ -0,0 +1,18 @@
1
+ export const renderSideEffects = (sideEffects) => {
2
+ let string = "";
3
+ let index = 0;
4
+ for (const sideEffect of sideEffects) {
5
+ if (string) {
6
+ string += "\n\n";
7
+ }
8
+ let label = `${index + 1}. ${sideEffect.label}`;
9
+ let text = sideEffect.text;
10
+ string += label;
11
+ if (text) {
12
+ string += "\n";
13
+ string += text;
14
+ }
15
+ index++;
16
+ }
17
+ return string;
18
+ };
@@ -0,0 +1,153 @@
1
+ import { writeFileSync } from "@jsenv/filesystem";
2
+ import {
3
+ ensurePathnameTrailingSlash,
4
+ urlToFilename,
5
+ urlToRelativeUrl,
6
+ } from "@jsenv/urls";
7
+ import { takeDirectorySnapshot } from "../filesystem_snapshot.js";
8
+ import { replaceFluctuatingValues } from "../replace_fluctuating_values.js";
9
+ import { collectFunctionSideEffects } from "./function_side_effects_collector.js";
10
+ import { renderSideEffects } from "./function_side_effects_renderer.js";
11
+ import { spyConsoleCalls } from "./spy_console_calls.js";
12
+ import { spyFilesystemCalls } from "./spy_filesystem_calls.js";
13
+
14
+ export const snapshotFunctionSideEffects = (
15
+ fn,
16
+ fnFileUrl,
17
+ sideEffectDirectoryRelativeUrl = "./",
18
+ {
19
+ rootDirectoryUrl = new URL("./", fnFileUrl),
20
+ filesystemEffectsDirectory,
21
+ preventConsoleSideEffects = true,
22
+ undoFilesystemSideEffects = true,
23
+ } = {},
24
+ ) => {
25
+ if (filesystemEffectsDirectory === true) {
26
+ filesystemEffectsDirectory = "./fs/";
27
+ }
28
+ const sideEffectDirectoryUrl = new URL(
29
+ sideEffectDirectoryRelativeUrl,
30
+ fnFileUrl,
31
+ );
32
+ const sideEffectDirectorySnapshot = takeDirectorySnapshot(
33
+ sideEffectDirectoryUrl,
34
+ );
35
+ const sideEffectFilename = `${urlToFilename(sideEffectDirectoryUrl)}_side_effects.txt`;
36
+ const sideEffectFileUrl = new URL(sideEffectFilename, sideEffectDirectoryUrl);
37
+ const callbackSet = new Set();
38
+ const sideEffectDetectors = [
39
+ {
40
+ name: "console",
41
+ install: (addSideEffect) => {
42
+ const onConsole = (methodName, message) => {
43
+ addSideEffect({
44
+ type: `console:${methodName}`,
45
+ value: message,
46
+ label: `console.${methodName}`,
47
+ text: replaceFluctuatingValues(message, {
48
+ stringType: "console",
49
+ rootDirectoryUrl,
50
+ }),
51
+ });
52
+ };
53
+ const consoleSpy = spyConsoleCalls(
54
+ {
55
+ error: (message) => {
56
+ onConsole("error", message);
57
+ },
58
+ warn: (message) => {
59
+ onConsole("warn", message);
60
+ },
61
+ info: (message) => {
62
+ onConsole("info", message);
63
+ },
64
+ log: (message) => {
65
+ onConsole("log", message);
66
+ },
67
+ },
68
+ {
69
+ preventConsoleSideEffects,
70
+ },
71
+ );
72
+ return () => {
73
+ consoleSpy.restore();
74
+ };
75
+ },
76
+ },
77
+ {
78
+ name: "filesystem",
79
+ install: (addSideEffect) => {
80
+ const fsSideEffectDirectoryUrl = ensurePathnameTrailingSlash(
81
+ new URL(filesystemEffectsDirectory, sideEffectDirectoryUrl),
82
+ );
83
+ const fsSideEffectsDirectoryRelativeUrl = urlToRelativeUrl(
84
+ fsSideEffectDirectoryUrl,
85
+ sideEffectFileUrl,
86
+ );
87
+ const filesystemSpy = spyFilesystemCalls(
88
+ {
89
+ writeFile: (url, content) => {
90
+ const relativeUrl = urlToRelativeUrl(url, fnFileUrl);
91
+ const toUrl = new URL(relativeUrl, fsSideEffectDirectoryUrl);
92
+ if (filesystemEffectsDirectory) {
93
+ callbackSet.add(() => {
94
+ writeFileSync(toUrl, content);
95
+ });
96
+ addSideEffect({
97
+ type: "fs:write_file",
98
+ value: { relativeUrl, content },
99
+ label: `write file "${relativeUrl}" (see ./${fsSideEffectsDirectoryRelativeUrl}${relativeUrl})`,
100
+ text: null,
101
+ });
102
+ } else {
103
+ addSideEffect({
104
+ type: "fs:write_file",
105
+ value: { relativeUrl, content },
106
+ label: `write file "${relativeUrl}"`,
107
+ text: `--- content ---
108
+ ${content}
109
+ ---------------`,
110
+ });
111
+ }
112
+ },
113
+ writeDirectory: (url) => {
114
+ const relativeUrl = urlToRelativeUrl(url, fnFileUrl);
115
+ addSideEffect({
116
+ type: "fs:write_directory",
117
+ value: { relativeUrl },
118
+ label: `write directory "${relativeUrl}"`,
119
+ text: null,
120
+ });
121
+ },
122
+ },
123
+ {
124
+ undoFilesystemSideEffects,
125
+ },
126
+ );
127
+ return () => {
128
+ filesystemSpy.restore();
129
+ };
130
+ },
131
+ },
132
+ ];
133
+
134
+ const onSideEffectsCollected = (sideEffects) => {
135
+ for (const callback of callbackSet) {
136
+ callback();
137
+ }
138
+ callbackSet.clear();
139
+ writeFileSync(sideEffectFileUrl, renderSideEffects(sideEffects));
140
+ sideEffectDirectorySnapshot.compare();
141
+ };
142
+
143
+ const returnValue = collectFunctionSideEffects(fn, sideEffectDetectors, {
144
+ rootDirectoryUrl,
145
+ });
146
+ if (returnValue && typeof returnValue.then === "function") {
147
+ return returnValue.then((sideEffects) => {
148
+ onSideEffectsCollected(sideEffects);
149
+ });
150
+ }
151
+ onSideEffectsCollected(returnValue);
152
+ return undefined;
153
+ };
@@ -0,0 +1,53 @@
1
+ import { spyMethod } from "./spy_method.js";
2
+
3
+ export const spyConsoleCalls = (
4
+ { error, warn, info, log, trace },
5
+ { preventConsoleSideEffects },
6
+ ) => {
7
+ const restoreCallbackSet = new Set();
8
+ const errorSpy = spyMethod(console, "error", (message) => {
9
+ if (preventConsoleSideEffects) {
10
+ errorSpy.preventOriginalCall();
11
+ }
12
+ error(message);
13
+ });
14
+ const warnSpy = spyMethod(console, "warn", (message) => {
15
+ if (preventConsoleSideEffects) {
16
+ warnSpy.preventOriginalCall();
17
+ }
18
+ warn(message);
19
+ });
20
+ const infoSpy = spyMethod(console, "info", (message) => {
21
+ if (preventConsoleSideEffects) {
22
+ infoSpy.preventOriginalCall();
23
+ }
24
+ info(message);
25
+ });
26
+ const logSpy = spyMethod(console, "log", (message) => {
27
+ if (preventConsoleSideEffects) {
28
+ logSpy.preventOriginalCall();
29
+ }
30
+ log(message);
31
+ });
32
+ const traceSpy = spyMethod(console, "trace", (message) => {
33
+ if (preventConsoleSideEffects) {
34
+ traceSpy.preventOriginalCall();
35
+ }
36
+ trace(message);
37
+ });
38
+ restoreCallbackSet.add(() => {
39
+ errorSpy.remove();
40
+ warnSpy.remove();
41
+ infoSpy.remove();
42
+ logSpy.remove();
43
+ traceSpy.remove();
44
+ });
45
+ return {
46
+ restore: () => {
47
+ for (const restoreCallback of restoreCallbackSet) {
48
+ restoreCallback();
49
+ }
50
+ restoreCallbackSet.clear();
51
+ },
52
+ };
53
+ };
@@ -0,0 +1,202 @@
1
+ // https://github.com/antfu/fs-spy/blob/main/src/index.ts
2
+ // https://github.com/tschaub/mock-fs/tree/main
3
+
4
+ import { removeDirectorySync, removeFileSync } from "@jsenv/filesystem";
5
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { pathToFileURL } from "node:url";
7
+ import { spyMethod } from "./spy_method.js";
8
+
9
+ export const spyFilesystemCalls = (
10
+ {
11
+ writeFile = () => {},
12
+ writeDirectory = () => {},
13
+ removeFile = () => {},
14
+ // removeDirectory = () => {},
15
+ },
16
+ { undoFilesystemSideEffects } = {},
17
+ ) => {
18
+ const _internalFs = process.binding("fs");
19
+ const filesystemStateInfoMap = new Map();
20
+ const fileDescriptorPathMap = new Map();
21
+ const fileRestoreMap = new Map();
22
+ const onWriteFileDone = (fileUrl, stateBefore, stateAfter) => {
23
+ // we use same type because we don't want to differentiate between
24
+ // - writing file for the 1st time
25
+ // - updating file content
26
+ // the important part is the file content in the end of the function execution
27
+ if (
28
+ (!stateBefore.found && stateAfter.found) ||
29
+ stateBefore.content !== stateAfter.content ||
30
+ stateBefore.mtimeMs !== stateAfter.mtimeMs
31
+ ) {
32
+ if (undoFilesystemSideEffects && !fileRestoreMap.has(fileUrl)) {
33
+ if (stateBefore.found) {
34
+ fileRestoreMap.set(fileUrl, () => {
35
+ writeFileSync(fileUrl, stateBefore.content);
36
+ });
37
+ } else {
38
+ fileRestoreMap.set(fileUrl, () => {
39
+ removeFileSync(fileUrl, { allowUseless: true });
40
+ });
41
+ }
42
+ }
43
+ writeFile(fileUrl, stateAfter.content);
44
+ return;
45
+ }
46
+ // file is exactly the same
47
+ // function did not have any effect on the file
48
+ };
49
+ const onWriteDirectoryDone = (directoryUrl, stateBefore) => {
50
+ if (stateBefore.found) {
51
+ return;
52
+ }
53
+ if (undoFilesystemSideEffects && !fileRestoreMap.has(directoryUrl)) {
54
+ fileRestoreMap.set(directoryUrl, () => {
55
+ removeDirectorySync(directoryUrl, {
56
+ allowUseless: true,
57
+ recursive: true,
58
+ });
59
+ });
60
+ }
61
+ writeDirectory(directoryUrl);
62
+ };
63
+ const restoreCallbackSet = new Set();
64
+ const mkdirSpy = spyMethod(
65
+ _internalFs,
66
+ "mkdir",
67
+ (directoryPath, mode, recursive, callback) => {
68
+ const directoryUrl = pathToFileURL(directoryPath);
69
+ if (callback) {
70
+ const stateBefore = getDirectoryState(directoryPath);
71
+ const oncomplete = callback.oncomplete;
72
+ callback.oncomplete = (error, fd) => {
73
+ if (error) {
74
+ oncomplete(error);
75
+ } else {
76
+ fileDescriptorPathMap.set(fd, directoryPath);
77
+ oncomplete();
78
+ onWriteDirectoryDone(directoryUrl, stateBefore, { found: true });
79
+ }
80
+ };
81
+ return mkdirSpy.callOriginal();
82
+ }
83
+ const stateBefore = getDirectoryState(directoryPath);
84
+ mkdirSpy.callOriginal();
85
+ onWriteDirectoryDone(directoryUrl, stateBefore, { found: true });
86
+ return undefined;
87
+ },
88
+ );
89
+ const openSpy = spyMethod(
90
+ _internalFs,
91
+ "open",
92
+ (filePath, flags, mode, callback) => {
93
+ if (callback) {
94
+ const stateBefore = getFileState(filePath);
95
+ filesystemStateInfoMap.set(filePath, stateBefore);
96
+ const oncomplete = callback.oncomplete;
97
+ callback.oncomplete = (error, fd) => {
98
+ if (error) {
99
+ oncomplete(error);
100
+ } else {
101
+ fileDescriptorPathMap.set(fd, filePath);
102
+ oncomplete(error, fd);
103
+ }
104
+ };
105
+ return openSpy.callOriginal();
106
+ }
107
+ const stateBefore = getFileState(filePath);
108
+ filesystemStateInfoMap.set(filePath, stateBefore);
109
+ const fd = openSpy.callOriginal();
110
+ fileDescriptorPathMap.set(fd, filePath);
111
+ return fd;
112
+ },
113
+ );
114
+ const closeSpy = spyMethod(_internalFs, "close", (fileDescriptor) => {
115
+ const filePath = fileDescriptorPathMap.get(fileDescriptor);
116
+ if (!filePath) {
117
+ return closeSpy.callOriginal();
118
+ }
119
+ const openInfo = filesystemStateInfoMap.get(filePath);
120
+ if (!openInfo) {
121
+ const returnValue = closeSpy.callOriginal();
122
+ fileDescriptorPathMap.delete(fileDescriptor);
123
+ return returnValue;
124
+ }
125
+ const fileUrl = pathToFileURL(filePath);
126
+ const nowState = getFileState(fileUrl);
127
+ onWriteFileDone(fileUrl, openInfo, nowState);
128
+ const returnValue = closeSpy.callOriginal();
129
+ fileDescriptorPathMap.delete(fileDescriptor);
130
+ filesystemStateInfoMap.delete(filePath);
131
+ return returnValue;
132
+ });
133
+ const writeFileUtf8Spy = spyMethod(
134
+ _internalFs,
135
+ "writeFileUtf8",
136
+ (filePath) => {
137
+ const fileUrl = pathToFileURL(filePath);
138
+ const stateBefore = getFileState(fileUrl);
139
+ writeFileUtf8Spy.callOriginal();
140
+ const stateAfter = getFileState(fileUrl);
141
+ onWriteFileDone(fileUrl, stateBefore, stateAfter);
142
+ },
143
+ );
144
+ const unlinkSpy = spyMethod(_internalFs, "unlink", (filePath) => {
145
+ unlinkSpy.callOriginal();
146
+ removeFile(filePath); // TODO eventually split in removeFile/removeDirectory
147
+ });
148
+ restoreCallbackSet.add(() => {
149
+ mkdirSpy.remove();
150
+ openSpy.remove();
151
+ closeSpy.remove();
152
+ writeFileUtf8Spy.remove();
153
+ unlinkSpy.remove();
154
+ });
155
+ return {
156
+ restore: () => {
157
+ for (const restoreCallback of restoreCallbackSet) {
158
+ restoreCallback();
159
+ }
160
+ restoreCallbackSet.clear();
161
+ for (const [, restore] of fileRestoreMap) {
162
+ restore();
163
+ }
164
+ fileRestoreMap.clear();
165
+ },
166
+ };
167
+ };
168
+
169
+ const getFileState = (file) => {
170
+ try {
171
+ const fileContent = readFileSync(file);
172
+ const { mtimeMs } = statSync(file);
173
+ return {
174
+ found: true,
175
+ mtimeMs,
176
+ content: String(fileContent),
177
+ };
178
+ } catch (e) {
179
+ if (e.code === "ENOENT") {
180
+ return {
181
+ found: false,
182
+ };
183
+ }
184
+ throw e;
185
+ }
186
+ };
187
+
188
+ const getDirectoryState = (directory) => {
189
+ try {
190
+ statSync(directory);
191
+ return {
192
+ found: true,
193
+ };
194
+ } catch (e) {
195
+ if (e.code === "ENOENT") {
196
+ return {
197
+ found: false,
198
+ };
199
+ }
200
+ throw e;
201
+ }
202
+ };
@@ -0,0 +1,111 @@
1
+ const jsenvSpySymbol = Symbol.for("jsenv_spy");
2
+
3
+ export const spyMethod = (object, method, spyCallback) => {
4
+ const current = object[method];
5
+ const jsenvSpySymbolValue = current[jsenvSpySymbol];
6
+ let addCallback;
7
+ let removeCallback;
8
+ let callOriginal;
9
+ let onOriginalCall;
10
+ let preventOriginalCall;
11
+ if (jsenvSpySymbolValue) {
12
+ addCallback = jsenvSpySymbolValue.addCallback;
13
+ removeCallback = jsenvSpySymbolValue.removeCallback;
14
+ callOriginal = jsenvSpySymbolValue.callOriginal;
15
+ onOriginalCall = jsenvSpySymbolValue.onOriginalCall;
16
+ preventOriginalCall = jsenvSpySymbolValue.preventOriginalCall;
17
+ } else {
18
+ const original = current;
19
+ let currentArgs;
20
+ let originalCalled = false;
21
+ let originalReturnValue;
22
+ let preventOriginalCallCalled;
23
+ let spyExecuting;
24
+ let someSpyUsedCallOriginal;
25
+ let allSpyUsedPreventOriginalCall;
26
+ onOriginalCall = (returnValue) => {
27
+ originalCalled = true;
28
+ originalReturnValue = returnValue;
29
+ };
30
+ callOriginal = () => {
31
+ if (someSpyUsedCallOriginal) {
32
+ return originalReturnValue;
33
+ }
34
+ someSpyUsedCallOriginal = true;
35
+ onOriginalCall(original(...currentArgs));
36
+ return originalReturnValue;
37
+ };
38
+ preventOriginalCall = () => {
39
+ preventOriginalCallCalled = true;
40
+ };
41
+ const spyCallbackSet = new Set();
42
+ const spy = (...args) => {
43
+ if (spyExecuting) {
44
+ // when a spy is executing
45
+ // if it calls the method himself
46
+ // then we want this call to go trough
47
+ // and others spy should not know about it
48
+ onOriginalCall(original(...args));
49
+ return originalReturnValue;
50
+ }
51
+ spyExecuting = true;
52
+ originalCalled = false;
53
+ currentArgs = args;
54
+ someSpyUsedCallOriginal = false;
55
+ allSpyUsedPreventOriginalCall = true;
56
+ for (const spyCallback of spyCallbackSet) {
57
+ try {
58
+ spyCallback(...args);
59
+ } finally {
60
+ if (preventOriginalCallCalled) {
61
+ preventOriginalCallCalled = false;
62
+ } else {
63
+ allSpyUsedPreventOriginalCall = false;
64
+ }
65
+ }
66
+ }
67
+ spyExecuting = false;
68
+ if (!someSpyUsedCallOriginal && !allSpyUsedPreventOriginalCall) {
69
+ callOriginal();
70
+ }
71
+ currentArgs = null;
72
+ if (originalCalled) {
73
+ originalCalled = false;
74
+ const value = originalReturnValue;
75
+ originalReturnValue = undefined;
76
+ return value;
77
+ }
78
+ return undefined;
79
+ };
80
+ addCallback = (spyCallback) => {
81
+ if (spyCallbackSet.size === 0) {
82
+ object[method] = spy;
83
+ }
84
+ spyCallbackSet.add(spyCallback);
85
+ };
86
+ removeCallback = (spyCallback) => {
87
+ spyCallbackSet.delete(spyCallback);
88
+ if (spyCallbackSet.size === 0) {
89
+ object[method] = original;
90
+ }
91
+ };
92
+ spy[jsenvSpySymbol] = {
93
+ addCallback,
94
+ removeCallback,
95
+ original,
96
+ callOriginal,
97
+ onOriginalCall,
98
+ preventOriginalCall,
99
+ };
100
+ object[method] = spy;
101
+ }
102
+ addCallback(spyCallback);
103
+ const spyHooks = {
104
+ callOriginal,
105
+ preventOriginalCall,
106
+ remove: () => {
107
+ removeCallback(spyCallback);
108
+ },
109
+ };
110
+ return spyHooks;
111
+ };
package/src/main.js CHANGED
@@ -2,5 +2,5 @@ export {
2
2
  takeDirectorySnapshot,
3
3
  takeFileSnapshot,
4
4
  } from "./filesystem_snapshot.js";
5
- export { snapshotFunctionSideEffects } from "./function_snapshot.js";
5
+ export { snapshotFunctionSideEffects } from "./function_side_effects/function_side_effects_snapshot.js";
6
6
  export { replaceFluctuatingValues } from "./replace_fluctuating_values.js";
@@ -1,256 +0,0 @@
1
- import {
2
- readEntryStatSync,
3
- readFileSync,
4
- removeFileSync,
5
- writeFileSync,
6
- } from "@jsenv/filesystem";
7
- import {
8
- ensurePathnameTrailingSlash,
9
- urlToFilename,
10
- urlToRelativeUrl,
11
- } from "@jsenv/urls";
12
- import { takeDirectorySnapshot } from "./filesystem_snapshot.js";
13
- import { replaceFluctuatingValues } from "./replace_fluctuating_values.js";
14
-
15
- const consoleSpySymbol = Symbol.for("console_spy_for_jsenv_snapshot");
16
-
17
- export const snapshotFunctionSideEffects = (
18
- fn,
19
- fnFileUrl,
20
- sideEffectDirectoryRelativeUrl = "./",
21
- {
22
- rootDirectoryUrl = new URL("./", fnFileUrl),
23
- captureConsole = true,
24
- filesystemEffects,
25
- filesystemEffectsDirectory,
26
- restoreFilesystem = true,
27
- } = {},
28
- ) => {
29
- if (filesystemEffectsDirectory === true) {
30
- filesystemEffectsDirectory = "./fs/";
31
- }
32
- const sideEffectDirectoryUrl = new URL(
33
- sideEffectDirectoryRelativeUrl,
34
- fnFileUrl,
35
- );
36
- const sideEffectDirectorySnapshot = takeDirectorySnapshot(
37
- sideEffectDirectoryUrl,
38
- );
39
- const sideEffectFilename = `${urlToFilename(sideEffectDirectoryUrl)}_side_effects.txt`;
40
- const sideEffectFileUrl = new URL(sideEffectFilename, sideEffectDirectoryUrl);
41
- const sideEffects = [];
42
- const finallyCallbackSet = new Set();
43
- const onError = (e, isAsync) => {
44
- sideEffects.push({
45
- type: isAsync ? "reject" : "throw",
46
- value: e,
47
- });
48
- };
49
- const onResult = (result, isAsync) => {
50
- sideEffects.push({
51
- type: isAsync ? "resolve" : "return",
52
- value: result,
53
- });
54
- };
55
- const onFinally = () => {
56
- for (const finallyCallback of finallyCallbackSet) {
57
- finallyCallback();
58
- }
59
- writeFileSync(
60
- sideEffectFileUrl,
61
- stringifySideEffects(sideEffects, {
62
- rootDirectoryUrl,
63
- filesystemEffectsDirectory,
64
- }),
65
- );
66
- sideEffectDirectorySnapshot.compare();
67
- };
68
- if (captureConsole) {
69
- const installConsoleSpy = (methodName) => {
70
- const methodSpied = console[methodName];
71
- if (consoleSpySymbol in methodSpied) {
72
- throw new Error("snapshotFunctionSideEffects already running");
73
- }
74
- const methodSpy = (message) => {
75
- sideEffects.push({
76
- type: `console.${methodName}`,
77
- value: message,
78
- });
79
- };
80
- methodSpy[consoleSpySymbol] = true;
81
- console[methodName] = methodSpy;
82
- finallyCallbackSet.add(() => {
83
- console[methodName] = methodSpied;
84
- });
85
- };
86
- installConsoleSpy("error");
87
- installConsoleSpy("warn");
88
- installConsoleSpy("info");
89
- installConsoleSpy("log");
90
- }
91
- if (filesystemEffects) {
92
- const fsSideEffectDirectoryUrl = ensurePathnameTrailingSlash(
93
- new URL(filesystemEffectsDirectory, sideEffectDirectoryUrl),
94
- );
95
- const fsSideEffectsDirectoryRelativeUrl = urlToRelativeUrl(
96
- fsSideEffectDirectoryUrl,
97
- sideEffectFileUrl,
98
- );
99
- for (const filesystemEffect of filesystemEffects) {
100
- const from = new URL(filesystemEffect, fnFileUrl);
101
- const relativeUrl = urlToRelativeUrl(from, fnFileUrl);
102
- const toUrl = new URL(relativeUrl, fsSideEffectDirectoryUrl);
103
- const atStartState = getFileState(from);
104
- const onFileSystemSideEffect = (fsSideEffect) => {
105
- const last = sideEffects.pop();
106
- sideEffects.push(fsSideEffect);
107
- sideEffects.push(last);
108
- };
109
- finallyCallbackSet.add(() => {
110
- const nowState = getFileState(from);
111
- if (atStartState.found && !nowState.found) {
112
- onFileSystemSideEffect({
113
- type: `remove file "${relativeUrl}"`,
114
- value: atStartState.content,
115
- });
116
- if (restoreFilesystem) {
117
- writeFileSync(from, atStartState.content);
118
- }
119
- return;
120
- }
121
- // we use same type because we don't want to differentiate between
122
- // - writing file for the 1st time
123
- // - updating file content
124
- // the important part is the file content in the end of the function execution
125
- if (
126
- (!atStartState.found && nowState.found) ||
127
- atStartState.content !== nowState.content ||
128
- atStartState.mtimeMs !== nowState.mtimeMs
129
- ) {
130
- if (filesystemEffectsDirectory) {
131
- writeFileSync(toUrl, nowState.content);
132
- onFileSystemSideEffect({
133
- type: `write file "${relativeUrl}" (see ./${fsSideEffectsDirectoryRelativeUrl}${relativeUrl})`,
134
- value: nowState.content,
135
- });
136
- } else {
137
- onFileSystemSideEffect({
138
- type: `write file "${relativeUrl}"`,
139
- value: nowState.content,
140
- });
141
- }
142
- if (restoreFilesystem) {
143
- if (atStartState.found) {
144
- if (atStartState.content !== nowState.content) {
145
- writeFileSync(from, atStartState.content);
146
- }
147
- } else {
148
- removeFileSync(from);
149
- }
150
- }
151
- return;
152
- }
153
- // file is exactly the same
154
- // function did not have any effect on the file
155
- });
156
- }
157
- }
158
- let returnedPromise = false;
159
- try {
160
- const returnValue = fn();
161
- if (returnValue && returnValue.then) {
162
- returnedPromise = returnValue.then(
163
- (value) => {
164
- onResult(value, true);
165
- onFinally();
166
- },
167
- (e) => {
168
- onError(e, true);
169
- onFinally();
170
- },
171
- );
172
- return returnedPromise;
173
- }
174
- onResult(returnValue);
175
- return null;
176
- } catch (e) {
177
- onError(e);
178
- return null;
179
- } finally {
180
- if (returnedPromise) {
181
- return returnedPromise;
182
- }
183
- onFinally();
184
- }
185
- };
186
-
187
- const stringifySideEffects = (
188
- sideEffects,
189
- { rootDirectoryUrl, filesystemEffectsDirectory },
190
- ) => {
191
- let string = "";
192
- let index = 0;
193
- for (const sideEffect of sideEffects) {
194
- if (string) {
195
- string += "\n\n";
196
- }
197
- string += `${index + 1}. ${sideEffect.type}`;
198
- let value = sideEffect.value;
199
- if (sideEffect.type.startsWith("console.")) {
200
- value = replaceFluctuatingValues(value, {
201
- stringType: "console",
202
- rootDirectoryUrl,
203
- });
204
- string += "\n";
205
- string += value;
206
- } else if (
207
- sideEffect.type.startsWith("remove file") ||
208
- sideEffect.type.startsWith("write file")
209
- ) {
210
- if (!filesystemEffectsDirectory) {
211
- string += "\n";
212
- string += value;
213
- }
214
- } else if (sideEffect.type === "throw" || sideEffect.type === "reject") {
215
- value = replaceFluctuatingValues(value.stack, {
216
- stringType: "error",
217
- });
218
- string += "\n";
219
- string += value;
220
- } else if (sideEffect.type === "return" || sideEffect.type === "resolve") {
221
- value =
222
- value === undefined
223
- ? undefined
224
- : replaceFluctuatingValues(JSON.stringify(value, null, " "), {
225
- stringType: "json",
226
- rootDirectoryUrl,
227
- });
228
- string += "\n";
229
- string += value;
230
- } else {
231
- string += "\n";
232
- string += value;
233
- }
234
- index++;
235
- }
236
- return string;
237
- };
238
-
239
- const getFileState = (fileUrl) => {
240
- try {
241
- const fileContent = readFileSync(fileUrl);
242
- const { mtimeMs } = readEntryStatSync(fileUrl);
243
- return {
244
- found: true,
245
- mtimeMs,
246
- content: String(fileContent),
247
- };
248
- } catch (e) {
249
- if (e.code === "ENOENT") {
250
- return {
251
- found: false,
252
- };
253
- }
254
- throw e;
255
- }
256
- };