Skip to content

Commit 805361e

Browse files
committed
test_runner: add initial CLI runner
This commit introduces an initial version of a CLI-based test runner.
1 parent 3ac7f86 commit 805361e

22 files changed

+571
-128
lines changed

doc/api/cli.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,15 @@ minimum allocation from the secure heap. The minimum value is `2`.
10521052
The maximum value is the lesser of `--secure-heap` or `2147483647`.
10531053
The value given must be a power of two.
10541054

1055+
### `--test`
1056+
1057+
<!-- YAML
1058+
added: REPLACEME
1059+
-->
1060+
1061+
Starts the Node.js command line test runner. See the documentation on
1062+
[running tests from the command line][] for more details.
1063+
10551064
### `--test-only`
10561065

10571066
<!-- YAML
@@ -2033,6 +2042,7 @@ $ node --max-old-space-size=1536 index.js
20332042
[jitless]: https://v8.dev/blog/jitless
20342043
[libuv threadpool documentation]: https://docs.libuv.org/en/latest/threadpool.html
20352044
[remote code execution]: https://www.owasp.org/index.php/Code_Injection
2045+
[running tests from the command line]: test.md#running-tests-from-the-command-line
20362046
[security warning]: #warning-binding-inspector-to-a-public-ipport-combination-is-insecure
20372047
[timezone IDs]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
20382048
[ways that `TZ` is handled in other environments]: https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html

doc/api/test.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,49 @@ test('a test that creates asynchronous activity', (t) => {
219219
});
220220
```
221221

222+
## Running tests from the command line
223+
224+
The Node.js test runner can be invoked from the command line by passing the
225+
[`--test`][] flag. By default, Node.js will recursively search the current
226+
directory for test files:
227+
228+
```bash
229+
node --test
230+
```
231+
232+
Alternatively, one or more paths can be provided as the final argument(s) to
233+
the Node.js command, as shown below.
234+
235+
```bash
236+
node --test test1.js test2.mjs test/
237+
```
238+
239+
In this example, the test runner will execute the files `test1.js` and
240+
`test2.mjs`. The test runner will also recursively search the `test/` directory
241+
for test files to execute.
242+
243+
When searching for test files to execute, the test runner behaves as follows:
244+
245+
* Any files explicitly provided by the user are executed.
246+
* `node_modules` directories are skipped unless explicitly provided by the
247+
user.
248+
* `.js`, `.cjs`, and `.mjs` filenames matching the following patterns are
249+
treated as test files:
250+
* `test` - Files whose basename is the string `'test'`. Examples: `test.js`,
251+
`test.cjs`, `test.mjs`.
252+
* `test-.+` - Files whose basename starts with the string `'test-'` followed
253+
by one or more characters. Examples: `test-example.js`,
254+
`test-another-example.mjs`.
255+
* `.+[\.\-\_]test` - Files whose basename ends with one or more characters
256+
followed by `.test`, `-test`, or `_test`. Examples: `example.test.js`,
257+
`example-test.cjs`, `example_test.mjs`.
258+
259+
Each matching test file is executed in a separate child process. If the child
260+
process finishes with an exit code of 0, the test is considered passing.
261+
Otherwise, the test is considered to be a failure. Test files must be
262+
executable by Node.js, but are not required to use the `node:test` module
263+
internally.
264+
222265
## `test([name][, options][, fn])`
223266

224267
<!-- YAML
@@ -368,5 +411,6 @@ behaves in the same fashion as the top level [`test()`][] function.
368411

369412
[TAP]: https://testanything.org/
370413
[`--test-only`]: cli.md#--test-only
414+
[`--test`]: cli.md#--test
371415
[`TestContext`]: #class-testcontext
372416
[`test()`]: #testname-options-fn

doc/node.1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@ the secure heap. The default is 0. The value must be a power of two.
381381
.It Fl -secure-heap-min Ns = Ns Ar n
382382
Specify the minimum allocation from the OpenSSL secure heap. The default is 2. The value must be a power of two.
383383
.
384+
.It Fl -test
385+
Starts the Node.js command line test runner.
386+
.
384387
.It Fl -test-only
385388
Configures the test runner to only execute top level tests that have the `only`
386389
option set.

lib/internal/errors.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,16 @@ function isErrorStackTraceLimitWritable() {
207207
desc.set !== undefined;
208208
}
209209

210+
function inspectWithNoCustomRetry(obj, options) {
211+
const utilInspect = lazyInternalUtilInspect();
212+
213+
try {
214+
return utilInspect.inspect(obj, options);
215+
} catch {
216+
return utilInspect.inspect(obj, { ...options, customInspect: false });
217+
}
218+
}
219+
210220
// A specialized Error that includes an additional info property with
211221
// additional information about the error condition.
212222
// It has the properties present in a UVException but with a custom error
@@ -862,6 +872,7 @@ module.exports = {
862872
getMessage,
863873
hideInternalStackFrames,
864874
hideStackFrames,
875+
inspectWithNoCustomRetry,
865876
isErrorStackTraceLimitWritable,
866877
isStackOverflowError,
867878
kEnhanceStackBeforeInspector,
@@ -1549,11 +1560,14 @@ E('ERR_TEST_FAILURE', function(error, failureType) {
15491560
assert(typeof failureType === 'string',
15501561
"The 'failureType' argument must be of type string.");
15511562

1552-
const msg = error?.message ?? lazyInternalUtilInspect().inspect(error);
1563+
let msg = error?.message ?? error;
15531564

1554-
this.failureType = error?.failureType ?? failureType;
1555-
this.cause = error;
1565+
if (typeof msg !== 'string') {
1566+
msg = inspectWithNoCustomRetry(msg);
1567+
}
15561568

1569+
this.failureType = failureType;
1570+
this.cause = error;
15571571
return msg;
15581572
}, Error);
15591573
E('ERR_TLS_CERT_ALTNAME_FORMAT', 'Invalid subject alternative name string',

lib/internal/main/test_runner.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use strict';
2+
const {
3+
ArrayFrom,
4+
ArrayPrototypeFilter,
5+
ArrayPrototypeIncludes,
6+
ArrayPrototypePush,
7+
ArrayPrototypeSlice,
8+
ArrayPrototypeSort,
9+
Promise,
10+
SafeSet,
11+
StringPrototypeEndsWith,
12+
} = primordials;
13+
const {
14+
prepareMainThreadExecution,
15+
} = require('internal/bootstrap/pre_execution');
16+
const { spawn } = require('child_process');
17+
const { readdirSync, statSync } = require('fs');
18+
const console = require('internal/console/global');
19+
const {
20+
codes: {
21+
ERR_TEST_FAILURE,
22+
},
23+
} = require('internal/errors');
24+
const test = require('internal/test_runner/harness');
25+
const { kSubtestsFailed } = require('internal/test_runner/test');
26+
const { doesPathMatchFilter } = require('internal/test_runner/utils');
27+
const { join, resolve } = require('path');
28+
const kFilterArgs = ['--test'];
29+
30+
prepareMainThreadExecution(false);
31+
markBootstrapComplete();
32+
33+
// TODO(cjihrig): Replace this with recursive readdir once it lands.
34+
function processPath(path, testFiles, userSupplied = false) {
35+
const stats = statSync(path);
36+
37+
if (stats.isFile()) {
38+
if (userSupplied || doesPathMatchFilter(path)) {
39+
testFiles.add(path);
40+
}
41+
} else if (stats.isDirectory()) {
42+
if (!userSupplied && StringPrototypeEndsWith(path, 'node_modules')) {
43+
return;
44+
}
45+
46+
const entries = readdirSync(path);
47+
48+
for (let i = 0; i < entries.length; i++) {
49+
processPath(join(path, entries[i]), testFiles);
50+
}
51+
}
52+
}
53+
54+
function createTestFileList() {
55+
const testPaths = process.argv.length > 1 ?
56+
ArrayPrototypeSlice(process.argv, 1) : ['.'];
57+
const testFiles = new SafeSet();
58+
59+
try {
60+
for (let i = 0; i < testPaths.length; i++) {
61+
const absolutePath = resolve(testPaths[i]);
62+
63+
processPath(absolutePath, testFiles, true);
64+
}
65+
} catch (err) {
66+
if (err?.code === 'ENOENT') {
67+
console.error(`Could not find '${err.path}'`);
68+
process.exit(1);
69+
}
70+
71+
throw err;
72+
}
73+
74+
return ArrayPrototypeSort(ArrayFrom(testFiles));
75+
}
76+
77+
function runTestFile(path) {
78+
return test(path, () => {
79+
return new Promise((resolve, reject) => {
80+
const args = ArrayPrototypeFilter(process.execArgv, (arg) => {
81+
return !ArrayPrototypeIncludes(kFilterArgs, arg);
82+
});
83+
ArrayPrototypePush(args, path);
84+
85+
const child = spawn(process.execPath, args);
86+
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
87+
// instead of just displaying it all if the child fails.
88+
let stdout = '';
89+
let stderr = '';
90+
let err;
91+
92+
child.on('error', (error) => {
93+
err = error;
94+
});
95+
96+
child.stdout.setEncoding('utf8');
97+
child.stderr.setEncoding('utf8');
98+
99+
child.stdout.on('data', (chunk) => {
100+
stdout += chunk;
101+
});
102+
103+
child.stderr.on('data', (chunk) => {
104+
stderr += chunk;
105+
});
106+
107+
child.once('exit', (code, signal) => {
108+
if (code !== 0 || signal !== null) {
109+
if (!err) {
110+
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
111+
err.exitCode = code;
112+
err.signal = signal;
113+
err.stdout = stdout;
114+
err.stderr = stderr;
115+
// The stack will not be useful since the failures came from tests
116+
// in a child process.
117+
err.stack = undefined;
118+
}
119+
120+
return reject(err);
121+
}
122+
123+
resolve();
124+
});
125+
});
126+
});
127+
}
128+
129+
(async function main() {
130+
const testFiles = createTestFileList();
131+
132+
for (let i = 0; i < testFiles.length; i++) {
133+
runTestFile(testFiles[i]);
134+
}
135+
})();

0 commit comments

Comments
 (0)