Skip to content

Commit de4c8cb

Browse files
committed
test_runner: support runner bailout at files level
1 parent 720feff commit de4c8cb

26 files changed

+617
-13
lines changed

doc/api/cli.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2614,6 +2614,21 @@ Starts the Node.js command line test runner. This flag cannot be combined with
26142614
See the documentation on [running tests from the command line][]
26152615
for more details.
26162616

2617+
### `--test-bail`
2618+
2619+
<!-- YAML
2620+
added: REPLACEME
2621+
-->
2622+
2623+
> Stability: 1.0 - Early development
2624+
2625+
Stops the test runner after the first test failure.
2626+
2627+
Behavior depends on `--test-isolation`.
2628+
See the [test runner execution model][] section for details.
2629+
2630+
This flag cannot be combined with `--watch`.
2631+
26172632
### `--test-concurrency`
26182633

26192634
<!-- YAML
@@ -3679,6 +3694,7 @@ one is included in the list below.
36793694
* `--secure-heap-min`
36803695
* `--secure-heap`
36813696
* `--snapshot-blob`
3697+
* `--test-bail`
36823698
* `--test-coverage-branches`
36833699
* `--test-coverage-exclude`
36843700
* `--test-coverage-functions`

doc/api/test.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,16 @@ each other in ways that are not possible when isolation is enabled. For example,
611611
if a test relies on global state, it is possible for that state to be modified
612612
by a test originating from another file.
613613

614+
When bail is enabled (via [`--test-bail`][] or `run({ bail: true })`),
615+
isolation mode changes how execution stops:
616+
617+
* In `'process'` isolation, no new test files are started after the first
618+
failure, and test files that have already started are not forcibly
619+
terminated. Within already-started files, tests that have not started yet
620+
may still be aborted by bailout.
621+
* In `'none'` isolation, no new tests are started and queued tests are
622+
cancelled.
623+
614624
#### Child process option inheritance
615625

616626
When running tests in process isolation mode (the default), spawned child processes
@@ -1505,6 +1515,15 @@ changes:
15051515
* `argv` {Array} An array of CLI flags to pass to each test file when spawning the
15061516
subprocesses. This option has no effect when `isolation` is `'none'`.
15071517
**Default:** `[]`.
1518+
* `bail` {boolean} Stops the test run after the first failure.
1519+
If `isolation` is `'process'`, no new test files are started after the
1520+
first failure, and files that have already started are not forcibly
1521+
terminated. Within already-started files, tests that have not started yet
1522+
may still be aborted by bailout.
1523+
If `isolation` is `'none'`, no new tests are started and queued tests are
1524+
cancelled.
1525+
This option cannot be used together with `watch`.
1526+
**Default:** `false`.
15081527
* `signal` {AbortSignal} Allows aborting an in-progress test execution.
15091528
* `testNamePatterns` {string|RegExp|Array} A String, RegExp or a RegExp Array,
15101529
that can be used to only run tests whose name matches the provided pattern.
@@ -3232,6 +3251,22 @@ are defined, while others are emitted in the order that the tests execute.
32323251

32333252
Emitted when code coverage is enabled and all tests have completed.
32343253

3254+
### Event: `'test:bail'`
3255+
3256+
* `data` {Object}
3257+
* `column` {number|undefined} The column number where the bailout originated,
3258+
or `undefined` if it was run through the REPL.
3259+
* `file` {string|undefined} The path of the test file, `undefined` if test
3260+
was run through the REPL.
3261+
* `line` {number|undefined} The line number where the bailout originated, or
3262+
`undefined` if it was run through the REPL.
3263+
* `nesting` {number} The nesting level of the test.
3264+
* `test` {string} The bailout message.
3265+
3266+
Emitted when bail is enabled and the first failure triggers bailout behavior.
3267+
In `'process'` isolation this means no new test files are started, while in
3268+
`'none'` isolation no new tests are started and queued tests are cancelled.
3269+
32353270
### Event: `'test:complete'`
32363271

32373272
* `data` {Object}
@@ -4096,6 +4131,7 @@ Can be used to abort test subtasks when the test has been aborted.
40964131
[`--experimental-test-module-mocks`]: cli.md#--experimental-test-module-mocks
40974132
[`--import`]: cli.md#--importmodule
40984133
[`--no-strip-types`]: cli.md#--no-strip-types
4134+
[`--test-bail`]: cli.md#--test-bail
40994135
[`--test-concurrency`]: cli.md#--test-concurrency
41004136
[`--test-coverage-exclude`]: cli.md#--test-coverage-exclude
41014137
[`--test-coverage-include`]: cli.md#--test-coverage-include

doc/node-config-schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,9 @@
671671
"test": {
672672
"type": "boolean"
673673
},
674+
"test-bail": {
675+
"type": "boolean"
676+
},
674677
"test-concurrency": {
675678
"type": "number"
676679
},

doc/node.1

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,15 @@ Specify the minimum allocation from the OpenSSL secure heap. The default is 2. T
471471
.It Fl -test
472472
Starts the Node.js command line test runner.
473473
.
474+
.It Fl -test-bail
475+
Stops the test runner after the first test failure.
476+
This option is in early development.
477+
Behavior depends on the selected
478+
.Fl -test-isolation
479+
mode.
480+
This flag cannot be combined with
481+
.Fl -watch .
482+
.
474483
.It Fl -test-concurrency
475484
The maximum number of test files that the test runner CLI will execute
476485
concurrently.

lib/internal/test_runner/harness.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ function createTestTree(rootTestOptions, globalOptions) {
5252
buildSuites: [],
5353
isWaitingForBuildPhase: false,
5454
watching: false,
55+
bail: globalOptions.bail,
56+
bailedOut: false,
5557
config: globalOptions,
5658
coverage: null,
5759
resetCounters() {

lib/internal/test_runner/reporter/spec.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const {
1111
const assert = require('assert');
1212
const Transform = require('internal/streams/transform');
1313
const colors = require('internal/util/colors');
14-
const { kSubtestsFailed } = require('internal/test_runner/test');
14+
const { kSubtestsFailed, kBailedOut } = require('internal/test_runner/test');
1515
const { getCoverageReport } = require('internal/test_runner/utils');
1616
const { relative } = require('path');
1717
const {
@@ -78,8 +78,10 @@ class SpecReporter extends Transform {
7878
}
7979
#handleEvent({ type, data }) {
8080
switch (type) {
81+
case 'test:bail':
82+
return `${reporterColorMap['test:bail']}${reporterUnicodeSymbolMap[type]}Bailing out!${colors.white}\n`;
8183
case 'test:fail':
82-
if (data.details?.error?.failureType !== kSubtestsFailed) {
84+
if (data.details?.error?.failureType !== kSubtestsFailed && data.details?.error?.failureType !== kBailedOut) {
8385
ArrayPrototypePush(this.#failedTests, data);
8486
}
8587
return this.#handleTestReportEvent(type, data);

lib/internal/test_runner/reporter/utils.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const reporterUnicodeSymbolMap = {
2424
'test:coverage': '\u2139 ',
2525
'arrow:right': '\u25B6 ',
2626
'hyphen:minus': '\uFE63 ',
27+
'test:bail': '\u26A0 ',
2728
};
2829

2930
const reporterColorMap = {
@@ -37,6 +38,9 @@ const reporterColorMap = {
3738
get 'test:diagnostic'() {
3839
return colors.blue;
3940
},
41+
get 'test:bail'() {
42+
return colors.yellow;
43+
},
4044
get 'info'() {
4145
return colors.blue;
4246
},

lib/internal/test_runner/runner.js

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const {
2222
SafePromiseAll,
2323
SafePromiseAllReturnVoid,
2424
SafePromiseAllSettledReturnVoid,
25+
SafePromisePrototypeFinally,
26+
SafePromiseRace,
2527
SafeSet,
2628
StringPrototypeIndexOf,
2729
StringPrototypeSlice,
@@ -147,6 +149,7 @@ function getRunArgs(path, { forceExit,
147149
testNamePatterns,
148150
testSkipPatterns,
149151
only,
152+
bail,
150153
argv: suppliedArgs,
151154
execArgv,
152155
rerunFailuresFilePath,
@@ -185,6 +188,9 @@ function getRunArgs(path, { forceExit,
185188
if (only === true) {
186189
ArrayPrototypePush(runArgs, '--test-only');
187190
}
191+
if (bail === true) {
192+
ArrayPrototypePush(runArgs, '--test-bail');
193+
}
188194
if (timeout != null) {
189195
ArrayPrototypePush(runArgs, `--test-timeout=${timeout}`);
190196
}
@@ -271,9 +277,13 @@ class FileTest extends Test {
271277
this.reporter[kEmitMessage](item.type, item.data);
272278
}
273279
#accumulateReportItem(item) {
274-
if (item.type !== 'test:pass' && item.type !== 'test:fail') {
280+
if (item.type !== 'test:pass' && item.type !== 'test:fail' && item.type !== 'test:bail') {
275281
return;
276282
}
283+
// If a test failure occurred and bail is enabled, emit a bail event after reporting the failure
284+
if (item.type === 'test:bail' && this.root.harness?.bail && !this.root.harness.bailedOut) {
285+
this.root.harness.bailedOut = true;
286+
}
277287
this.#reportedChildren++;
278288
if (item.data.nesting === 0 && item.type === 'test:fail') {
279289
this.failedSubtests = true;
@@ -604,6 +614,7 @@ function run(options = kEmptyObject) {
604614
} = options;
605615
const {
606616
concurrency,
617+
bail,
607618
timeout,
608619
signal,
609620
files,
@@ -747,7 +758,9 @@ function run(options = kEmptyObject) {
747758
functionCoverage: functionCoverage,
748759
cwd,
749760
globalSetupPath,
761+
bail,
750762
};
763+
751764
const root = createTestTree(rootTestOptions, globalOptions);
752765
let testFiles = files ?? createTestFileList(globPatterns, cwd);
753766
const { isTestRunner } = globalOptions;
@@ -756,10 +769,18 @@ function run(options = kEmptyObject) {
756769
testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
757770
}
758771

772+
if (bail) {
773+
validateBoolean(bail, 'options.bail');
774+
if (watch) {
775+
throw new ERR_INVALID_ARG_VALUE('options.bail', watch, 'bail not supported with watch mode');
776+
}
777+
}
778+
759779
let teardown;
760780
let postRun;
761781
let filesWatcher;
762782
let runFiles;
783+
763784
const opts = {
764785
__proto__: null,
765786
root,
@@ -770,6 +791,7 @@ function run(options = kEmptyObject) {
770791
hasFiles: files != null,
771792
globPatterns,
772793
only,
794+
bail,
773795
forceExit,
774796
cwd,
775797
isolation,
@@ -792,15 +814,53 @@ function run(options = kEmptyObject) {
792814
teardown = () => root.harness.teardown();
793815
}
794816

795-
runFiles = () => {
796-
root.harness.bootstrapPromise = null;
797-
root.harness.buildPromise = null;
798-
return SafePromiseAllSettledReturnVoid(testFiles, (path) => {
799-
const subtest = runTestFile(path, filesWatcher, opts);
800-
filesWatcher?.runningSubtests.set(path, subtest);
801-
return subtest;
802-
});
803-
};
817+
if (bail) {
818+
runFiles = async () => {
819+
root.harness.bootstrapPromise = null;
820+
root.harness.buildPromise = null;
821+
822+
const running = new SafeSet();
823+
let index = 0;
824+
825+
const shouldBail = () => bail && root.harness.bailedOut;
826+
827+
const enqueueNext = () => {
828+
if (index < testFiles.length && !shouldBail()) {
829+
const path = testFiles[index++];
830+
const subtest = runTestFile(path, filesWatcher, opts);
831+
filesWatcher?.runningSubtests.set(path, subtest);
832+
running.add(subtest);
833+
SafePromisePrototypeFinally(subtest, () => running.delete(subtest));
834+
}
835+
};
836+
837+
// Fill initial pool up to root test concurrency
838+
// We use root test concurrency here because concurrency logic is handled at test level.
839+
while (running.size < root.concurrency && index < testFiles.length && !shouldBail()) {
840+
enqueueNext();
841+
}
842+
843+
// As each test completes, enqueue the next one
844+
while (running.size > 0) {
845+
await SafePromiseRace([...running]);
846+
847+
// Refill pool after completion(s)
848+
while (running.size < root.concurrency && index < testFiles.length && !shouldBail()) {
849+
enqueueNext();
850+
}
851+
}
852+
};
853+
} else {
854+
runFiles = () => {
855+
root.harness.bootstrapPromise = null;
856+
root.harness.buildPromise = null;
857+
return SafePromiseAllSettledReturnVoid(testFiles, (path) => {
858+
const subtest = runTestFile(path, filesWatcher, opts);
859+
filesWatcher?.runningSubtests.set(path, subtest);
860+
return subtest;
861+
});
862+
};
863+
}
804864
} else if (isolation === 'none') {
805865
if (watch) {
806866
const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));

lib/internal/test_runner/test.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const { bigint: hrtime } = process.hrtime;
7878
const kCallbackAndPromisePresent = 'callbackAndPromisePresent';
7979
const kCancelledByParent = 'cancelledByParent';
8080
const kAborted = 'testAborted';
81+
const kBailedOut = 'bailedOut';
8182
const kParentAlreadyFinished = 'parentAlreadyFinished';
8283
const kSubtestsFailed = 'subtestsFailed';
8384
const kTestCodeFailure = 'testCodeFailure';
@@ -580,7 +581,7 @@ class Test extends AsyncResource {
580581
}
581582
}
582583

583-
switch (typeof concurrency) {
584+
switch (typeof concurrency) { // <-- here we are overriding this.concurrency with the value from options!
584585
case 'number':
585586
validateUint32(concurrency, 'options.concurrency', true);
586587
this.concurrency = concurrency;
@@ -780,6 +781,10 @@ class Test extends AsyncResource {
780781
*/
781782
async processPendingSubtests() {
782783
while (this.pendingSubtests.length > 0 && this.hasConcurrency()) {
784+
if (this.root.harness?.bailedOut) {
785+
queueMicrotask( () => this.postRun(new ERR_TEST_FAILURE("Test was aborted due to bailout", kBailedOut)));
786+
break;
787+
}
783788
const deferred = ArrayPrototypeShift(this.pendingSubtests);
784789
const test = deferred.test;
785790
test.reporter.dequeue(test.nesting, test.loc, test.name, this.reportedType);
@@ -1382,6 +1387,10 @@ class Test extends AsyncResource {
13821387
this.reporter.ok(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive);
13831388
} else {
13841389
this.reporter.fail(this.nesting, this.loc, this.testNumber, this.name, report.details, report.directive);
1390+
if (this.root.harness?.bail && !this.root.harness.bailedOut) {
1391+
this.reporter.bail(this.nesting, this.loc, 'bailing out due to test failure');
1392+
this.root.harness.bailedOut = true;
1393+
}
13851394
}
13861395

13871396
for (let i = 0; i < this.diagnostics.length; i++) {
@@ -1558,6 +1567,7 @@ module.exports = {
15581567
kTestCodeFailure,
15591568
kTestTimeoutFailure,
15601569
kAborted,
1570+
kBailedOut,
15611571
kUnwrapErrors,
15621572
Suite,
15631573
Test,

lib/internal/test_runner/tests_stream.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ class TestsStream extends Readable {
149149
});
150150
}
151151

152+
bail(nesting, loc, test) {
153+
this[kEmitMessage]('test:bail', {
154+
__proto__: null,
155+
nesting,
156+
test,
157+
...loc,
158+
});
159+
}
160+
152161
end() {
153162
this.#tryPush(null);
154163
}

0 commit comments

Comments
 (0)