Skip to content

Commit 6a2fd8f

Browse files
committed
test_runner: support programmatically running --test
1 parent d6e626d commit 6a2fd8f

File tree

6 files changed

+192
-149
lines changed

6 files changed

+192
-149
lines changed

doc/api/test.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,29 @@ Otherwise, the test is considered to be a failure. Test files must be
316316
executable by Node.js, but are not required to use the `node:test` module
317317
internally.
318318

319+
## `runFiles([options])`
320+
321+
<!-- YAML
322+
added: REPLACEME
323+
-->
324+
325+
* `options` {Object} Configuration options for running test files. The following
326+
properties are supported:
327+
* `concurrency` {number|boolean} If a number is provided,
328+
then that many files would run in parallel.
329+
If truthy, it would run (number of cpu cores - 1)
330+
files in parallel.
331+
If falsy, it would only run one file at a time.
332+
If unspecified, subtests inherit this value from their parent.
333+
**Default:** `true`.
334+
* `files`: {Array} An array containing the list of files to run.
335+
**Default** matching files from [test runner execution model][].
336+
* `signal` {AbortSignal} Allows aborting an in-progress test file.
337+
* `timeout` {number} A number of milliseconds the test file will fail after.
338+
If unspecified, subtests inherit this value from their parent.
339+
**Default:** `Infinity`.
340+
* Returns: {Promise} Resolved with `undefined` once all test files complete.
341+
319342
## `test([name][, options][, fn])`
320343

321344
<!-- YAML

lib/internal/main/test_runner.js

Lines changed: 2 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,11 @@
11
'use strict';
2-
const {
3-
ArrayFrom,
4-
ArrayPrototypeFilter,
5-
ArrayPrototypeIncludes,
6-
ArrayPrototypeJoin,
7-
ArrayPrototypePush,
8-
ArrayPrototypeSlice,
9-
ArrayPrototypeSort,
10-
SafePromiseAll,
11-
SafeSet,
12-
} = primordials;
132
const {
143
prepareMainThreadExecution,
154
markBootstrapComplete
165
} = require('internal/process/pre_execution');
17-
const { spawn } = require('child_process');
18-
const { readdirSync, statSync } = require('fs');
19-
const console = require('internal/console/global');
20-
const {
21-
codes: {
22-
ERR_TEST_FAILURE,
23-
},
24-
} = require('internal/errors');
25-
const { test } = require('internal/test_runner/harness');
26-
const { kSubtestsFailed } = require('internal/test_runner/test');
27-
const {
28-
isSupportedFileType,
29-
doesPathMatchFilter,
30-
} = require('internal/test_runner/utils');
31-
const { basename, join, resolve } = require('path');
32-
const { once } = require('events');
33-
const kFilterArgs = ['--test'];
6+
const { runFiles } = require('internal/test_runner/runner');
347

358
prepareMainThreadExecution(false);
369
markBootstrapComplete();
3710

38-
// TODO(cjihrig): Replace this with recursive readdir once it lands.
39-
function processPath(path, testFiles, options) {
40-
const stats = statSync(path);
41-
42-
if (stats.isFile()) {
43-
if (options.userSupplied ||
44-
(options.underTestDir && isSupportedFileType(path)) ||
45-
doesPathMatchFilter(path)) {
46-
testFiles.add(path);
47-
}
48-
} else if (stats.isDirectory()) {
49-
const name = basename(path);
50-
51-
if (!options.userSupplied && name === 'node_modules') {
52-
return;
53-
}
54-
55-
// 'test' directories get special treatment. Recursively add all .js,
56-
// .cjs, and .mjs files in the 'test' directory.
57-
const isTestDir = name === 'test';
58-
const { underTestDir } = options;
59-
const entries = readdirSync(path);
60-
61-
if (isTestDir) {
62-
options.underTestDir = true;
63-
}
64-
65-
options.userSupplied = false;
66-
67-
for (let i = 0; i < entries.length; i++) {
68-
processPath(join(path, entries[i]), testFiles, options);
69-
}
70-
71-
options.underTestDir = underTestDir;
72-
}
73-
}
74-
75-
function createTestFileList() {
76-
const cwd = process.cwd();
77-
const hasUserSuppliedPaths = process.argv.length > 1;
78-
const testPaths = hasUserSuppliedPaths ?
79-
ArrayPrototypeSlice(process.argv, 1) : [cwd];
80-
const testFiles = new SafeSet();
81-
82-
try {
83-
for (let i = 0; i < testPaths.length; i++) {
84-
const absolutePath = resolve(testPaths[i]);
85-
86-
processPath(absolutePath, testFiles, { userSupplied: true });
87-
}
88-
} catch (err) {
89-
if (err?.code === 'ENOENT') {
90-
console.error(`Could not find '${err.path}'`);
91-
process.exit(1);
92-
}
93-
94-
throw err;
95-
}
96-
97-
return ArrayPrototypeSort(ArrayFrom(testFiles));
98-
}
99-
100-
function filterExecArgv(arg) {
101-
return !ArrayPrototypeIncludes(kFilterArgs, arg);
102-
}
103-
104-
function runTestFile(path) {
105-
return test(path, async (t) => {
106-
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
107-
ArrayPrototypePush(args, path);
108-
109-
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' });
110-
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
111-
// instead of just displaying it all if the child fails.
112-
let err;
113-
114-
child.on('error', (error) => {
115-
err = error;
116-
});
117-
118-
const { 0: { 0: code, 1: signal }, 1: stdout, 2: stderr } = await SafePromiseAll([
119-
once(child, 'exit', { signal: t.signal }),
120-
child.stdout.toArray({ signal: t.signal }),
121-
child.stderr.toArray({ signal: t.signal }),
122-
]);
123-
124-
if (code !== 0 || signal !== null) {
125-
if (!err) {
126-
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
127-
err.exitCode = code;
128-
err.signal = signal;
129-
err.stdout = ArrayPrototypeJoin(stdout, '');
130-
err.stderr = ArrayPrototypeJoin(stderr, '');
131-
// The stack will not be useful since the failures came from tests
132-
// in a child process.
133-
err.stack = undefined;
134-
}
135-
136-
throw err;
137-
}
138-
});
139-
}
140-
141-
(async function main() {
142-
const testFiles = createTestFileList();
143-
144-
for (let i = 0; i < testFiles.length; i++) {
145-
runTestFile(testFiles[i]);
146-
}
147-
})();
11+
runFiles();

lib/internal/test_runner/harness.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const {
33
ArrayPrototypeForEach,
44
FunctionPrototypeBind,
55
SafeMap,
6+
SafeWeakMap,
67
} = primordials;
78
const {
89
createHook,
@@ -19,7 +20,11 @@ const { kCancelledByParent, Test, ItTest, Suite } = require('internal/test_runne
1920
const isTestRunner = getOptionValue('--test');
2021
const testResources = new SafeMap();
2122
const root = new Test({ __proto__: null, name: '<root>' });
22-
let wasRootSetup = false;
23+
const wasRootSetup = new SafeWeakMap();
24+
25+
function createTestTree(options = {}) {
26+
return setup(new Test({ __proto__: null, ...options, name: '<root>' }));
27+
}
2328

2429
function createProcessEventHandler(eventName, rootTest) {
2530
return (err) => {
@@ -48,7 +53,7 @@ function createProcessEventHandler(eventName, rootTest) {
4853
}
4954

5055
function setup(root) {
51-
if (wasRootSetup) {
56+
if (wasRootSetup.has(root)) {
5257
return root;
5358
}
5459
const hook = createHook({
@@ -146,7 +151,7 @@ function setup(root) {
146151
root.reporter.pipe(process.stdout);
147152
root.reporter.version();
148153

149-
wasRootSetup = true;
154+
wasRootSetup.set(root, true);
150155
return root;
151156
}
152157

@@ -184,6 +189,7 @@ function hook(hook) {
184189
}
185190

186191
module.exports = {
192+
createTestTree,
187193
test: FunctionPrototypeBind(test, root),
188194
describe: runInParentContext(Suite),
189195
it: runInParentContext(ItTest),

lib/internal/test_runner/runner.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use strict';
2+
const {
3+
ArrayFrom,
4+
ArrayPrototypeFilter,
5+
ArrayPrototypeIncludes,
6+
ArrayPrototypeJoin,
7+
ArrayPrototypeMap,
8+
ArrayPrototypePush,
9+
ArrayPrototypeSlice,
10+
ArrayPrototypeSort,
11+
SafePromiseAll,
12+
SafeSet,
13+
} = primordials;
14+
15+
const { spawn } = require('child_process');
16+
const { readdirSync, statSync } = require('fs');
17+
const console = require('internal/console/global');
18+
const {
19+
codes: {
20+
ERR_TEST_FAILURE,
21+
},
22+
} = require('internal/errors');
23+
const { validateArray } = require('internal/validators');
24+
const { kEmptyObject } = require('internal/util');
25+
const { createTestTree } = require('internal/test_runner/harness');
26+
const { kSubtestsFailed, Test } = require('internal/test_runner/test');
27+
const {
28+
isSupportedFileType,
29+
doesPathMatchFilter,
30+
} = require('internal/test_runner/utils');
31+
const { basename, join, resolve } = require('path');
32+
const { once } = require('events');
33+
34+
const kFilterArgs = ['--test'];
35+
36+
// TODO(cjihrig): Replace this with recursive readdir once it lands.
37+
function processPath(path, testFiles, options) {
38+
const stats = statSync(path);
39+
40+
if (stats.isFile()) {
41+
if (options.userSupplied ||
42+
(options.underTestDir && isSupportedFileType(path)) ||
43+
doesPathMatchFilter(path)) {
44+
testFiles.add(path);
45+
}
46+
} else if (stats.isDirectory()) {
47+
const name = basename(path);
48+
49+
if (!options.userSupplied && name === 'node_modules') {
50+
return;
51+
}
52+
53+
// 'test' directories get special treatment. Recursively add all .js,
54+
// .cjs, and .mjs files in the 'test' directory.
55+
const isTestDir = name === 'test';
56+
const { underTestDir } = options;
57+
const entries = readdirSync(path);
58+
59+
if (isTestDir) {
60+
options.underTestDir = true;
61+
}
62+
63+
options.userSupplied = false;
64+
65+
for (let i = 0; i < entries.length; i++) {
66+
processPath(join(path, entries[i]), testFiles, options);
67+
}
68+
69+
options.underTestDir = underTestDir;
70+
}
71+
}
72+
73+
function createTestFileList() {
74+
const cwd = process.cwd();
75+
const hasUserSuppliedPaths = process.argv.length > 1;
76+
const testPaths = hasUserSuppliedPaths ?
77+
ArrayPrototypeSlice(process.argv, 1) : [cwd];
78+
const testFiles = new SafeSet();
79+
80+
try {
81+
for (let i = 0; i < testPaths.length; i++) {
82+
const absolutePath = resolve(testPaths[i]);
83+
84+
processPath(absolutePath, testFiles, { userSupplied: true });
85+
}
86+
} catch (err) {
87+
if (err?.code === 'ENOENT') {
88+
console.error(`Could not find '${err.path}'`);
89+
process.exit(1);
90+
}
91+
92+
throw err;
93+
}
94+
95+
return ArrayPrototypeSort(ArrayFrom(testFiles));
96+
}
97+
98+
function filterExecArgv(arg) {
99+
return !ArrayPrototypeIncludes(kFilterArgs, arg);
100+
}
101+
102+
function runTestFile(path, root) {
103+
const subtest = root.createSubtest(Test, path, async (t) => {
104+
const args = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
105+
ArrayPrototypePush(args, path);
106+
107+
const child = spawn(process.execPath, args, { signal: t.signal, encoding: 'utf8' });
108+
// TODO(cjihrig): Implement a TAP parser to read the child's stdout
109+
// instead of just displaying it all if the child fails.
110+
let err;
111+
112+
child.on('error', (error) => {
113+
err = error;
114+
});
115+
116+
const { 0: { 0: code, 1: signal }, 1: stdout, 2: stderr } = await SafePromiseAll([
117+
once(child, 'exit', { signal: t.signal }),
118+
child.stdout.toArray({ signal: t.signal }),
119+
child.stderr.toArray({ signal: t.signal }),
120+
]);
121+
122+
if (code !== 0 || signal !== null) {
123+
if (!err) {
124+
err = new ERR_TEST_FAILURE('test failed', kSubtestsFailed);
125+
err.exitCode = code;
126+
err.signal = signal;
127+
err.stdout = ArrayPrototypeJoin(stdout, '');
128+
err.stderr = ArrayPrototypeJoin(stderr, '');
129+
// The stack will not be useful since the failures came from tests
130+
// in a child process.
131+
err.stack = undefined;
132+
}
133+
134+
throw err;
135+
}
136+
});
137+
return subtest.start();
138+
}
139+
140+
async function runFiles(options) {
141+
if (options === null || typeof options !== 'object') {
142+
options = kEmptyObject;
143+
}
144+
const { concurrency, timeout, signal, files } = options;
145+
const root = createTestTree({ concurrency, timeout, signal });
146+
147+
if (files !== undefined) {
148+
validateArray(options, 'options.files');
149+
}
150+
151+
await ArrayPrototypeMap(files ?? createTestFileList(), (path) => runTestFile(path, root));
152+
}
153+
154+
module.exports = { runFiles };

lib/internal/test_runner/test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ class Test extends AsyncResource {
176176

177177
case 'boolean':
178178
if (concurrency) {
179-
this.concurrency = isTestRunner ? MathMax(cpus().length - 1, 1) : Infinity;
179+
this.concurrency = parent === null ? MathMax(cpus().length - 1, 1) : Infinity;
180180
} else {
181181
this.concurrency = 1;
182182
}

0 commit comments

Comments
 (0)