Skip to content

Commit c2dc969

Browse files
committed
test: unify assertSnapshot stacktrace transform
The snapshotted stack frames should hide node internal stack frames so that general node core development does not need updating the snapshot. For userland stack frames, they are highly fixture related and any fixture change should reflect in a change of the snapshot. Additionally, the line and column number are highly relevant to the correctness of the snapshot, these should not be redacted. A change in node core that affects userland stack frames should be alarming and be reflected in the snapshots. Features like test runner and source map support both should snapshot userland stack frames to ensure that userland code locations are computed correctly.
1 parent b864049 commit c2dc969

File tree

77 files changed

+1511
-2701
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+1511
-2701
lines changed

test/common/assertSnapshot.js

Lines changed: 87 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,30 @@ const common = require('.');
33
const path = require('node:path');
44
const test = require('node:test');
55
const fs = require('node:fs/promises');
6+
const { realpathSync } = require('node:fs');
67
const assert = require('node:assert/strict');
78
const { pathToFileURL } = require('node:url');
89
const { hostname } = require('node:os');
910

10-
const stackFramesRegexp = /(?<=\n)(\s+)((.+?)\s+\()?(?:\(?(.+?):(\d+)(?::(\d+))?)\)?(\s+\{)?(\[\d+m)?(\n|$)/g;
11+
/* eslint-disable @stylistic/js/max-len,no-control-regex */
12+
/**
13+
* Group 1: Line start (including color codes and escapes)
14+
* Group 2: Function name
15+
* Group 3: Filename
16+
* Group 4: Line number
17+
* Group 5: Column number
18+
* Group 6: Line end (including color codes and `{` which indicates the start of an error object details)
19+
*/
20+
// Mappings: (g1 ) (g2 ) (g3 ) (g4 ) (g5 ) (g6 )
21+
const internalStackFramesRegexp = /(?<=\n)(\s*(?:\x1b?\[\d+m\s+)?(?:at\s)?)(?:(.+?)\s+\()?(?:(node:.+?):(\d+)(?::(\d+))?)\)?((?:\x1b?\[\d+m)?\s*{?\n|$)/g;
22+
/**
23+
* Group 1: Filename
24+
* Group 2: Line number
25+
* Group 3: Line end and source code line
26+
*/
27+
const internalErrorSourceLines = /(?<=\n|^)(node:.+?):(\d+)(\n.*\n\s*\^(?:\n|$))/g;
28+
/* eslint-enable @stylistic/js/max-len,no-control-regex */
29+
1130
const windowNewlineRegexp = /\r/g;
1231

1332
// Replaces the current Node.js executable version strings with a
@@ -17,14 +36,33 @@ function replaceNodeVersion(str) {
1736
return str.replaceAll(`Node.js ${process.version}`, 'Node.js <node-version>');
1837
}
1938

20-
function replaceStackTrace(str, replacement = '$1*$7$8\n') {
21-
return str.replace(stackFramesRegexp, replacement);
39+
// Collapse consecutive identical lines containing the keyword into
40+
// one single line. The `str` should have been processed by `replaceWindowsLineEndings`.
41+
function foldIdenticalLines(str, keyword) {
42+
const lines = str.split('\n');
43+
const folded = lines.filter((line, idx) => {
44+
if (idx === 0) {
45+
return true;
46+
}
47+
if (line.includes(keyword) && line === lines[idx - 1]) {
48+
return false;
49+
}
50+
return true;
51+
});
52+
return folded.join('\n');
2253
}
2354

55+
const kInternalFrame = '<node-internal-frames>';
56+
// Replace non-internal frame `at TracingChannel.traceSync (node:diagnostics_channel:328:14)`
57+
// as well as `at node:internal/main/run_main_module:33:47` with `at <node-internal-frames>`.
58+
// Also replaces error source line like:
59+
// node:internal/mod.js:44
60+
// throw err;
61+
// ^
2462
function replaceInternalStackTrace(str) {
25-
// Replace non-internal frame `at TracingChannel.traceSync (node:diagnostics_channel:328:14)`
26-
// as well as `at node:internal/main/run_main_module:33:47` with `*`.
27-
return str.replaceAll(/(\W+).*[(\s]node:.*/g, '$1*');
63+
const result = str.replaceAll(internalErrorSourceLines, `$1:<line>$3`)
64+
.replaceAll(internalStackFramesRegexp, `$1${kInternalFrame}$6`);
65+
return foldIdenticalLines(result, kInternalFrame);
2866
}
2967

3068
// Replaces Windows line endings with posix line endings for unified snapshots
@@ -55,30 +93,53 @@ function replaceWarningPid(str) {
5593
return str.replaceAll(/\(node:\d+\)/g, '(node:<pid>)');
5694
}
5795

58-
// Replaces path strings representing the nodejs/node repo full project root with
59-
// `<project-root>`. Also replaces file URLs containing the full project root path.
60-
// The project root path may contain unicode characters.
61-
function transformProjectRoot(replacement = '<project-root>') {
62-
const projectRoot = path.resolve(__dirname, '../..');
96+
// Replaces a path with a placeholder. The path can be a platform specific path
97+
// or a file URL.
98+
function transformPath(dirname, replacement) {
6399
// Handles output already processed by `replaceWindowsPaths`.
64-
const winPath = replaceWindowsPaths(projectRoot);
65-
// Handles URL encoded project root in file URL strings as well.
66-
const urlEncoded = pathToFileURL(projectRoot).pathname;
100+
const winPath = replaceWindowsPaths(dirname);
101+
// Handles URL encoded path in file URL strings as well.
102+
const urlEncoded = pathToFileURL(dirname).pathname;
67103
// On Windows, paths are case-insensitive, so we need to use case-insensitive
68104
// regex replacement to handle cases where the drive letter case differs.
69105
const flags = common.isWindows ? 'gi' : 'g';
70106
const urlEncodedRegex = new RegExp(RegExp.escape(urlEncoded), flags);
71-
const projectRootRegex = new RegExp(RegExp.escape(projectRoot), flags);
107+
const dirnameRegex = new RegExp(RegExp.escape(dirname), flags);
72108
const winPathRegex = new RegExp(RegExp.escape(winPath), flags);
73109
return (str) => {
74110
return str.replaceAll('\\\'', "'")
75111
// Replace fileUrl first as `winPath` could be a substring of the fileUrl.
76112
.replaceAll(urlEncodedRegex, replacement)
77-
.replaceAll(projectRootRegex, replacement)
113+
.replaceAll(dirnameRegex, replacement)
78114
.replaceAll(winPathRegex, replacement);
79115
};
80116
}
81117

118+
// Replaces path strings representing the nodejs/node repo full project root with
119+
// `<project-root>`. Also replaces file URLs containing the full project root path.
120+
// The project root path may contain unicode characters.
121+
const kProjectRoot = '<project-root>';
122+
function transformProjectRoot() {
123+
const projectRoot = path.resolve(__dirname, '../..');
124+
if (process.env.NODE_TEST_DIR) {
125+
const testDir = realpathSync(process.env.NODE_TEST_DIR);
126+
// On Jenkins CI, the test dir may be overridden by `NODE_TEST_DIR`.
127+
return transform(
128+
transformPath(projectRoot, kProjectRoot),
129+
transformPath(testDir, `${kProjectRoot}/test`),
130+
// TODO(legendecas): test-runner may print relative paths to the test relative to cwd.
131+
// It will be better if we could distinguish them from the project root.
132+
transformPath(path.relative(projectRoot, testDir), 'test'),
133+
);
134+
}
135+
return transformPath(projectRoot, kProjectRoot);
136+
}
137+
138+
// Replaces tmpdirs created by `test/common/tmpdir.js`.
139+
function transformTmpDir(str) {
140+
return str.replaceAll(/\/\.tmp\.\d+\//g, '/<tmpdir>/');
141+
}
142+
82143
function transform(...args) {
83144
return (str) => args.reduce((acc, fn) => fn(acc), str);
84145
}
@@ -149,33 +210,24 @@ function replaceTestDuration(str) {
149210
}
150211

151212
const root = path.resolve(__dirname, '..', '..');
152-
const color = '(\\[\\d+m)';
153-
const stackTraceBasePath = new RegExp(`${color}\\(${RegExp.escape(root)}/?${color}(.*)${color}\\)`, 'g');
154-
155213
function replaceSpecDuration(str) {
156214
return str
157215
.replaceAll(/[0-9.]+ms/g, '*ms')
158-
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *')
159-
.replace(stackTraceBasePath, '$3');
216+
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *');
160217
}
161218

162219
function replaceJunitDuration(str) {
163220
return str
164221
.replaceAll(/time="[0-9.]+"/g, 'time="*"')
165222
.replaceAll(/duration_ms [0-9.]+/g, 'duration_ms *')
166223
.replaceAll(`hostname="${hostname()}"`, 'hostname="HOSTNAME"')
167-
.replaceAll(/file="[^"]*"/g, 'file="*"')
168-
.replace(stackTraceBasePath, '$3');
224+
.replaceAll(/file="[^"]*"/g, 'file="*"');
169225
}
170226

171227
function removeWindowsPathEscaping(str) {
172228
return common.isWindows ? str.replaceAll(/\\\\/g, '\\') : str;
173229
}
174230

175-
function replaceTestLocationLine(str) {
176-
return str.replaceAll(/(js:)(\d+)(:\d+)/g, '$1(LINE)$3');
177-
}
178-
179231
// The Node test coverage returns results for all files called by the test. This
180232
// will make the output file change if files like test/common/index.js change.
181233
// This transform picks only the first line and then the lines from the test
@@ -194,9 +246,11 @@ function pickTestFileFromLcov(str) {
194246
}
195247

196248
// Transforms basic patterns like:
197-
// - platform specific path and line endings,
198-
// - line trailing spaces,
199-
// - executable specific path and versions.
249+
// - platform specific path and line endings
250+
// - line trailing spaces
251+
// - executable specific path and versions
252+
// - project root path and tmpdir
253+
// - node internal stack frames
200254
const basicTransform = transform(
201255
replaceWindowsLineEndings,
202256
replaceTrailingSpaces,
@@ -205,29 +259,25 @@ const basicTransform = transform(
205259
replaceNodeVersion,
206260
generalizeExeName,
207261
replaceWarningPid,
262+
transformProjectRoot(),
263+
transformTmpDir,
264+
replaceInternalStackTrace,
208265
);
209266

210267
const defaultTransform = transform(
211268
basicTransform,
212-
replaceStackTrace,
213-
transformProjectRoot(),
214269
replaceTestDuration,
215-
replaceTestLocationLine,
216270
);
217271
const specTransform = transform(
218272
replaceSpecDuration,
219273
basicTransform,
220-
replaceStackTrace,
221274
);
222275
const junitTransform = transform(
223276
replaceJunitDuration,
224277
basicTransform,
225-
replaceStackTrace,
226278
);
227279
const lcovTransform = transform(
228280
basicTransform,
229-
replaceStackTrace,
230-
transformProjectRoot(),
231281
pickTestFileFromLcov,
232282
);
233283

@@ -246,7 +296,6 @@ module.exports = {
246296
assertSnapshot,
247297
getSnapshotPath,
248298
replaceNodeVersion,
249-
replaceStackTrace,
250299
replaceInternalStackTrace,
251300
replaceWindowsLineEndings,
252301
replaceWindowsPaths,
Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
11
Trace: foo
2-
at *
3-
at *
4-
at *
5-
at *
6-
at *
7-
at *
8-
at *
9-
at *
2+
at Object.<anonymous> (<project-root>/test/fixtures/console/console.js:5:9)
3+
at <node-internal-frames>

test/fixtures/console/stack_overflow.snapshot

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
before
2-
<project-root>/test/fixtures/console/stack_overflow.js:*
2+
<project-root>/test/fixtures/console/stack_overflow.js:39
33
JSON.stringify(array);
44
^
55

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Error: test
22
at one (<project-root>/test/fixtures/async-error.js:4:9)
33
at two (<project-root>/test/fixtures/async-error.js:17:9)
4-
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
4+
at <node-internal-frames>
55
at async three (<project-root>/test/fixtures/async-error.js:20:3)
66
at async four (<project-root>/test/fixtures/async-error.js:24:3)
77
at async main (<project-root>/test/fixtures/errors/async_error_nexttick_main.js:7:5)
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
node:punycode:54
1+
node:punycode:<line>
22
throw new RangeError(errors[type]);
33
^
44

55
RangeError: Invalid input
6-
at error (node:punycode:54:8)
7-
at Object.decode (node:punycode:247:5)
6+
at <node-internal-frames>
87
at Object.<anonymous> (<project-root>/test/fixtures/errors/core_line_numbers.js:13:10)
98

109
Node.js <node-version>

test/fixtures/errors/error_aggregateTwoErrors.snapshot

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:*
1+
<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:15
22
throw aggregateTwoErrors(err, originalError);
33
^
44

55
AggregateError: original
6-
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:*:*) {
6+
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:15:7) {
77
code: 'ERR0',
88
[errors]: [
99
Error: original
10-
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:*:*) {
10+
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:9:23) {
1111
code: 'ERR0'
1212
},
1313
Error: second error
14-
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:*:*) {
14+
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_aggregateTwoErrors.js:10:13) {
1515
code: 'ERR1'
1616
}
1717
]

test/fixtures/errors/error_exit.snapshot

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
Exiting with code=1
2-
node:internal/assert/utils:*
2+
node:internal/assert/utils:<line>
33
throw error;
44
^
55

66
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
77

88
1 !== 2
99

10-
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_exit.js:*:*) {
10+
at Object.<anonymous> (<project-root>/test/fixtures/errors/error_exit.js:32:8) {
1111
generatedMessage: true,
1212
code: 'ERR_ASSERTION',
1313
actual: 1,
1 Byte
Binary file not shown.
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
node:events:*
1+
node:events:<line>
22
throw er; // Unhandled 'error' event
33
^
44

55
Error: foo:bar
6-
at bar (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:*:*)
7-
at foo (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:*:*)
6+
at bar (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:9:12)
7+
at foo (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:12:10)
88
Emitted 'error' event at:
9-
at quux (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:*:*)
10-
at Object.<anonymous> (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:*:*)
9+
at quux (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:19:6)
10+
at Object.<anonymous> (<project-root>/test/fixtures/errors/events_unhandled_error_common_trace.js:22:1)
1111

1212
Node.js <node-version>
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
node:events:*
1+
node:events:<line>
22
throw er; // Unhandled 'error' event
33
^
44

55
Error
6-
at Object.<anonymous> (<project-root>/test/fixtures/errors/events_unhandled_error_nexttick.js:*:*)
6+
at Object.<anonymous> (<project-root>/test/fixtures/errors/events_unhandled_error_nexttick.js:6:12)
77
Emitted 'error' event at:
8-
at <project-root>/test/fixtures/errors/events_unhandled_error_nexttick.js:*:*
8+
at <project-root>/test/fixtures/errors/events_unhandled_error_nexttick.js:8:22
99

1010
Node.js <node-version>

0 commit comments

Comments
 (0)