Skip to content

Commit a82c365

Browse files
committed
test_runner: support forced exit
This commit updates the test runner to allow a forced exit once all known tests have finished running. Fixes: #49925
1 parent 1f19316 commit a82c365

File tree

13 files changed

+138
-10
lines changed

13 files changed

+138
-10
lines changed

doc/api/cli.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,15 @@ added:
18661866
The maximum number of test files that the test runner CLI will execute
18671867
concurrently. The default value is `os.availableParallelism() - 1`.
18681868

1869+
### `--test-force-exit`
1870+
1871+
<!-- YAML
1872+
added: REPLACEME
1873+
-->
1874+
1875+
Configures the test runner to exit the process once all known tests have
1876+
finished executing even if the event loop would otherwise remain active.
1877+
18691878
### `--test-name-pattern`
18701879

18711880
<!-- YAML

doc/api/test.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,6 +1119,9 @@ added:
11191119
- v18.9.0
11201120
- v16.19.0
11211121
changes:
1122+
- version: REPLACEME
1123+
pr-url: https://github.com/nodejs/node/pull/52038
1124+
description: Added the `forceExit` option.
11221125
- version:
11231126
- v20.1.0
11241127
- v18.17.0
@@ -1137,6 +1140,9 @@ changes:
11371140
**Default:** `false`.
11381141
* `files`: {Array} An array containing the list of files to run.
11391142
**Default** matching files from [test runner execution model][].
1143+
* `forceExit`: {boolean} Configures the test runner to exit the process once
1144+
all known tests have finished executing even if the event loop would
1145+
otherwise remain active. **Default:** `false`.
11401146
* `inspectPort` {number|Function} Sets inspector port of test child process.
11411147
This can be a number, or a function that takes no arguments and returns a
11421148
number. If a nullish value is provided, each process gets its own port,

doc/node.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,10 @@ Starts the Node.js command line test runner.
422422
The maximum number of test files that the test runner CLI will execute
423423
concurrently.
424424
.
425+
.It Fl -test-force-exit
426+
Configures the test runner to exit the process once all known tests have
427+
finished executing even if the event loop would otherwise remain active.
428+
.
425429
.It Fl -test-name-pattern
426430
A regular expression that configures the test runner to only execute tests
427431
whose name matches the provided pattern.

lib/internal/test_runner/harness.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ function setup(root) {
195195
suites: 0,
196196
},
197197
shouldColorizeTestFiles: false,
198+
teardown: exitHandler,
198199
};
199200
root.startTime = hrtime();
200201
return root;

lib/internal/test_runner/runner.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,11 @@ function filterExecArgv(arg, i, arr) {
112112
!ArrayPrototypeSome(kFilterArgValues, (p) => arg === p || (i > 0 && arr[i - 1] === p) || StringPrototypeStartsWith(arg, `${p}=`));
113113
}
114114

115-
function getRunArgs(path, { inspectPort, testNamePatterns, only }) {
115+
function getRunArgs(path, { forceExit, inspectPort, testNamePatterns, only }) {
116116
const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
117+
if (forceExit === true) {
118+
ArrayPrototypePush(argv, '--test-force-exit');
119+
}
117120
if (isUsingInspector()) {
118121
ArrayPrototypePush(argv, `--inspect-port=${getInspectPort(inspectPort)}`);
119122
}
@@ -440,14 +443,33 @@ function run(options = kEmptyObject) {
440443
validateObject(options, 'options');
441444

442445
let { testNamePatterns, shard } = options;
443-
const { concurrency, timeout, signal, files, inspectPort, watch, setup, only } = options;
446+
const {
447+
concurrency,
448+
timeout,
449+
signal,
450+
files,
451+
forceExit,
452+
inspectPort,
453+
watch,
454+
setup,
455+
only,
456+
} = options;
444457

445458
if (files != null) {
446459
validateArray(files, 'options.files');
447460
}
448461
if (watch != null) {
449462
validateBoolean(watch, 'options.watch');
450463
}
464+
if (forceExit != null) {
465+
validateBoolean(forceExit, 'options.forceExit');
466+
467+
if (forceExit && watch) {
468+
throw new ERR_INVALID_ARG_VALUE(
469+
'options.forceExit', watch, 'is not supported with watch mode',
470+
);
471+
}
472+
}
451473
if (only != null) {
452474
validateBoolean(only, 'options.only');
453475
}
@@ -501,7 +523,15 @@ function run(options = kEmptyObject) {
501523

502524
let postRun = () => root.postRun();
503525
let filesWatcher;
504-
const opts = { __proto__: null, root, signal, inspectPort, testNamePatterns, only };
526+
const opts = {
527+
__proto__: null,
528+
root,
529+
signal,
530+
inspectPort,
531+
testNamePatterns,
532+
only,
533+
forceExit,
534+
};
505535
if (watch) {
506536
filesWatcher = watchFiles(testFiles, opts);
507537
postRun = undefined;

lib/internal/test_runner/test.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']);
7676
const kUnwrapErrors = new SafeSet()
7777
.add(kTestCodeFailure).add(kHookFailure)
7878
.add('uncaughtException').add('unhandledRejection');
79-
const { testNamePatterns, testOnlyFlag } = parseCommandLine();
79+
const { forceExit, testNamePatterns, testOnlyFlag } = parseCommandLine();
8080
let kResistStopPropagation;
8181

8282
function stopTest(timeout, signal) {
@@ -722,6 +722,16 @@ class Test extends AsyncResource {
722722
// This helps catch any asynchronous activity that occurs after the tests
723723
// have finished executing.
724724
this.postRun();
725+
} else if (forceExit) {
726+
// This is the root test, and all known tests and hooks have finished
727+
// executing. If the user wants to force exit the process regardless of
728+
// any remaining ref'ed handles, then do that now. It is theoretically
729+
// possible that a ref'ed handle could asynchronously create more tests,
730+
// but the user opted into this behavior.
731+
this.reporter.once('close', () => {
732+
process.exit();
733+
});
734+
this.harness.teardown();
725735
}
726736
}
727737

@@ -772,12 +782,11 @@ class Test extends AsyncResource {
772782
if (this.parent === this.root &&
773783
this.root.activeSubtests === 0 &&
774784
this.root.pendingSubtests.length === 0 &&
775-
this.root.readySubtests.size === 0 &&
776-
this.root.hooks.after.length > 0) {
777-
// This is done so that any global after() hooks are run. At this point
778-
// all of the tests have finished running. However, there might be
779-
// ref'ed handles keeping the event loop alive. This gives the global
780-
// after() hook a chance to clean them up.
785+
this.root.readySubtests.size === 0) {
786+
// At this point all of the tests have finished running. However, there
787+
// might be ref'ed handles keeping the event loop alive. This gives the
788+
// global after() hook a chance to clean them up. The user may also
789+
// want to force the test runner to exit despite ref'ed handles.
781790
this.root.run();
782791
}
783792
} else if (!this.reported) {

lib/internal/test_runner/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ function parseCommandLine() {
193193

194194
const isTestRunner = getOptionValue('--test');
195195
const coverage = getOptionValue('--experimental-test-coverage');
196+
const forceExit = getOptionValue('--test-force-exit');
196197
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
197198
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
198199
let destinations;
@@ -244,6 +245,7 @@ function parseCommandLine() {
244245
__proto__: null,
245246
isTestRunner,
246247
coverage,
248+
forceExit,
247249
testOnlyFlag,
248250
testNamePatterns,
249251
reporters,

src/node_options.cc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
179179
} else if (force_repl) {
180180
errors->push_back("either --watch or --interactive "
181181
"can be used, not both");
182+
} else if (test_runner_force_exit) {
183+
errors->push_back("either --watch or --test-force-exit "
184+
"can be used, not both");
182185
} else if (!test_runner && (argv->size() < 1 || (*argv)[1].empty())) {
183186
errors->push_back("--watch requires specifying a file");
184187
}
@@ -616,6 +619,9 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
616619
AddOption("--test-concurrency",
617620
"specify test runner concurrency",
618621
&EnvironmentOptions::test_runner_concurrency);
622+
AddOption("--test-force-exit",
623+
"force test runner to exit upon completion",
624+
&EnvironmentOptions::test_runner_force_exit);
619625
AddOption("--test-timeout",
620626
"specify test runner timeout",
621627
&EnvironmentOptions::test_runner_timeout);

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ class EnvironmentOptions : public Options {
166166
uint64_t test_runner_concurrency = 0;
167167
uint64_t test_runner_timeout = 0;
168168
bool test_runner_coverage = false;
169+
bool test_runner_force_exit = false;
169170
std::vector<std::string> test_name_pattern;
170171
std::vector<std::string> test_reporter;
171172
std::vector<std::string> test_reporter_destination;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Flags: --test-force-exit --test-reporter=spec
2+
'use strict';
3+
const { after, afterEach, before, beforeEach, test } = require('node:test');
4+
5+
before(() => {
6+
console.log('BEFORE');
7+
});
8+
9+
beforeEach(() => {
10+
console.log('BEFORE EACH');
11+
});
12+
13+
after(() => {
14+
console.log('AFTER');
15+
});
16+
17+
afterEach(() => {
18+
console.log('AFTER EACH');
19+
});
20+
21+
test('passes but oops', () => {
22+
setInterval(() => {}, 5000);
23+
});
24+
25+
test('also passes');

0 commit comments

Comments
 (0)