Skip to content

Commit 77e2bf0

Browse files
cjihrigmarco-ippolito
authored andcommitted
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 PR-URL: #52038 Reviewed-By: Moshe Atlow <[email protected]> Reviewed-By: Chemi Atlow <[email protected]> Reviewed-By: Raz Luvaton <[email protected]> Reviewed-By: Benjamin Gruenbaum <[email protected]>
1 parent b7bc635 commit 77e2bf0

File tree

15 files changed

+173
-11
lines changed

15 files changed

+173
-11
lines changed

doc/api/cli.md

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

1842+
### `--test-force-exit`
1843+
1844+
<!-- YAML
1845+
added: REPLACEME
1846+
-->
1847+
1848+
Configures the test runner to exit the process once all known tests have
1849+
finished executing even if the event loop would otherwise remain active.
1850+
18421851
### `--test-name-pattern`
18431852

18441853
<!-- YAML

doc/api/test.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1149,7 +1149,12 @@ added:
11491149
- v18.9.0
11501150
- v16.19.0
11511151
changes:
1152-
- version: v20.1.0
1152+
- version: REPLACEME
1153+
pr-url: https://github.com/nodejs/node/pull/52038
1154+
description: Added the `forceExit` option.
1155+
- version:
1156+
- v20.1.0
1157+
- v18.17.0
11531158
pr-url: https://github.com/nodejs/node/pull/47628
11541159
description: Add a testNamePatterns option.
11551160
-->
@@ -1165,6 +1170,9 @@ changes:
11651170
**Default:** `false`.
11661171
* `files`: {Array} An array containing the list of files to run.
11671172
**Default** matching files from [test runner execution model][].
1173+
* `forceExit`: {boolean} Configures the test runner to exit the process once
1174+
all known tests have finished executing even if the event loop would
1175+
otherwise remain active. **Default:** `false`.
11681176
* `inspectPort` {number|Function} Sets inspector port of test child process.
11691177
This can be a number, or a function that takes no arguments and returns a
11701178
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
@@ -425,6 +425,10 @@ Starts the Node.js command line test runner.
425425
The maximum number of test files that the test runner CLI will execute
426426
concurrently.
427427
.
428+
.It Fl -test-force-exit
429+
Configures the test runner to exit the process once all known tests have
430+
finished executing even if the event loop would otherwise remain active.
431+
.
428432
.It Fl -test-name-pattern
429433
A regular expression that configures the test runner to only execute tests
430434
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
@@ -207,6 +207,7 @@ function setup(root) {
207207
},
208208
counters: null,
209209
shouldColorizeTestFiles: false,
210+
teardown: exitHandler,
210211
};
211212
root.harness.resetCounters();
212213
root.startTime = hrtime();

lib/internal/test_runner/runner.js

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

159-
function getRunArgs(path, { inspectPort, testNamePatterns, only }) {
159+
function getRunArgs(path, { forceExit, inspectPort, testNamePatterns, only }) {
160160
const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv);
161+
if (forceExit === true) {
162+
ArrayPrototypePush(argv, '--test-force-exit');
163+
}
161164
if (isUsingInspector()) {
162165
ArrayPrototypePush(argv, `--inspect-port=${getInspectPort(inspectPort)}`);
163166
}
@@ -490,14 +493,33 @@ function run(options) {
490493
options = kEmptyObject;
491494
}
492495
let { testNamePatterns, shard } = options;
493-
const { concurrency, timeout, signal, files, inspectPort, watch, setup, only } = options;
496+
const {
497+
concurrency,
498+
timeout,
499+
signal,
500+
files,
501+
forceExit,
502+
inspectPort,
503+
watch,
504+
setup,
505+
only,
506+
} = options;
494507

495508
if (files != null) {
496509
validateArray(files, 'options.files');
497510
}
498511
if (watch != null) {
499512
validateBoolean(watch, 'options.watch');
500513
}
514+
if (forceExit != null) {
515+
validateBoolean(forceExit, 'options.forceExit');
516+
517+
if (forceExit && watch) {
518+
throw new ERR_INVALID_ARG_VALUE(
519+
'options.forceExit', watch, 'is not supported with watch mode',
520+
);
521+
}
522+
}
501523
if (only != null) {
502524
validateBoolean(only, 'options.only');
503525
}
@@ -553,7 +575,15 @@ function run(options) {
553575

554576
let postRun = () => root.postRun();
555577
let filesWatcher;
556-
const opts = { __proto__: null, root, signal, inspectPort, testNamePatterns, only };
578+
const opts = {
579+
__proto__: null,
580+
root,
581+
signal,
582+
inspectPort,
583+
testNamePatterns,
584+
only,
585+
forceExit,
586+
};
557587
if (watch) {
558588
filesWatcher = watchFiles(testFiles, opts);
559589
postRun = undefined;

lib/internal/test_runner/test.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,12 @@ const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']);
7878
const kUnwrapErrors = new SafeSet()
7979
.add(kTestCodeFailure).add(kHookFailure)
8080
.add('uncaughtException').add('unhandledRejection');
81-
const { sourceMaps, testNamePatterns, testOnlyFlag } = parseCommandLine();
81+
const {
82+
forceExit,
83+
sourceMaps,
84+
testNamePatterns,
85+
testOnlyFlag,
86+
} = parseCommandLine();
8287
let kResistStopPropagation;
8388
let findSourceMap;
8489

@@ -722,6 +727,16 @@ class Test extends AsyncResource {
722727
// This helps catch any asynchronous activity that occurs after the tests
723728
// have finished executing.
724729
this.postRun();
730+
} else if (forceExit) {
731+
// This is the root test, and all known tests and hooks have finished
732+
// executing. If the user wants to force exit the process regardless of
733+
// any remaining ref'ed handles, then do that now. It is theoretically
734+
// possible that a ref'ed handle could asynchronously create more tests,
735+
// but the user opted into this behavior.
736+
this.reporter.once('close', () => {
737+
process.exit();
738+
});
739+
this.harness.teardown();
725740
}
726741
}
727742

@@ -770,12 +785,11 @@ class Test extends AsyncResource {
770785
if (this.parent === this.root &&
771786
this.root.activeSubtests === 0 &&
772787
this.root.pendingSubtests.length === 0 &&
773-
this.root.readySubtests.size === 0 &&
774-
this.root.hooks.after.length > 0) {
775-
// This is done so that any global after() hooks are run. At this point
776-
// all of the tests have finished running. However, there might be
777-
// ref'ed handles keeping the event loop alive. This gives the global
778-
// after() hook a chance to clean them up.
788+
this.root.readySubtests.size === 0) {
789+
// At this point all of the tests have finished running. However, there
790+
// might be ref'ed handles keeping the event loop alive. This gives the
791+
// global after() hook a chance to clean them up. The user may also
792+
// want to force the test runner to exit despite ref'ed handles.
779793
this.root.run();
780794
}
781795
} else if (!this.reported) {

lib/internal/test_runner/utils.js

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

200200
const isTestRunner = getOptionValue('--test');
201201
const coverage = getOptionValue('--experimental-test-coverage');
202+
const forceExit = getOptionValue('--test-force-exit');
202203
const sourceMaps = getOptionValue('--enable-source-maps');
203204
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
204205
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
@@ -251,6 +252,7 @@ function parseCommandLine() {
251252
__proto__: null,
252253
isTestRunner,
253254
coverage,
255+
forceExit,
254256
sourceMaps,
255257
testOnlyFlag,
256258
testNamePatterns,

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
}
@@ -617,6 +620,9 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
617620
AddOption("--test-concurrency",
618621
"specify test runner concurrency",
619622
&EnvironmentOptions::test_runner_concurrency);
623+
AddOption("--test-force-exit",
624+
"force test runner to exit upon completion",
625+
&EnvironmentOptions::test_runner_force_exit);
620626
AddOption("--test-timeout",
621627
"specify test runner timeout",
622628
&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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
setTimeout(() => {
23+
throw new Error('this should not have a chance to be thrown');
24+
}, 1000);
25+
});
26+
27+
test('also passes');

0 commit comments

Comments
 (0)