Skip to content

Commit 0eaeb45

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

24 files changed

+603
-13
lines changed

doc/api/cli.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2614,6 +2614,23 @@ 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: v26.0.0
2621+
-->
2622+
2623+
Stops the test runner after the first test failure.
2624+
2625+
When `--test-isolation=process` (the default), no new test files are started
2626+
after the first failure, but test files that have already started are allowed to
2627+
finish.
2628+
2629+
When `--test-isolation=none`, no new tests are started and any queued tests are
2630+
cancelled.
2631+
2632+
This flag cannot be combined with `--watch`.
2633+
26172634
### `--test-concurrency`
26182635

26192636
<!-- YAML
@@ -3679,6 +3696,7 @@ one is included in the list below.
36793696
* `--secure-heap-min`
36803697
* `--secure-heap`
36813698
* `--snapshot-blob`
3699+
* `--test-bail`
36823700
* `--test-coverage-branches`
36833701
* `--test-coverage-exclude`
36843702
* `--test-coverage-functions`

doc/api/test.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,14 @@ 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, but test files that have already started continue running.
619+
* In `'none'` isolation, no new tests are started and queued tests are
620+
cancelled.
621+
614622
#### Child process option inheritance
615623

616624
When running tests in process isolation mode (the default), spawned child processes
@@ -1505,6 +1513,13 @@ changes:
15051513
* `argv` {Array} An array of CLI flags to pass to each test file when spawning the
15061514
subprocesses. This option has no effect when `isolation` is `'none'`.
15071515
**Default:** `[]`.
1516+
* `bail` {boolean} Stops the test run after the first failure.
1517+
If `isolation` is `'process'`, no new test files are started after the
1518+
first failure, but files that have already started continue running.
1519+
If `isolation` is `'none'`, no new tests are started and queued tests are
1520+
cancelled.
1521+
This option cannot be used together with `watch`.
1522+
**Default:** `false`.
15081523
* `signal` {AbortSignal} Allows aborting an in-progress test execution.
15091524
* `testNamePatterns` {string|RegExp|Array} A String, RegExp or a RegExp Array,
15101525
that can be used to only run tests whose name matches the provided pattern.
@@ -3232,6 +3247,22 @@ are defined, while others are emitted in the order that the tests execute.
32323247

32333248
Emitted when code coverage is enabled and all tests have completed.
32343249

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

32373268
* `data` {Object}
@@ -4096,6 +4127,7 @@ Can be used to abort test subtasks when the test has been aborted.
40964127
[`--experimental-test-module-mocks`]: cli.md#--experimental-test-module-mocks
40974128
[`--import`]: cli.md#--importmodule
40984129
[`--no-strip-types`]: cli.md#--no-strip-types
4130+
[`--test-bail`]: cli.md#--test-bail
40994131
[`--test-concurrency`]: cli.md#--test-concurrency
41004132
[`--test-coverage-exclude`]: cli.md#--test-coverage-exclude
41014133
[`--test-coverage-include`]: cli.md#--test-coverage-include

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
}

lib/internal/test_runner/utils.js

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

213213
const isTestRunner = getOptionValue('--test');
214+
const bail = getOptionValue('--test-bail');
214215
const coverage = getOptionValue('--experimental-test-coverage');
215216
const forceExit = getOptionValue('--test-force-exit');
216217
const sourceMaps = getOptionValue('--enable-source-maps');
@@ -341,6 +342,7 @@ function parseCommandLine() {
341342
globalTestOptions = {
342343
__proto__: null,
343344
isTestRunner,
345+
bail,
344346
concurrency,
345347
coverage,
346348
coverageExcludeGlobs,

src/node_options.cc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,12 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
861861
kDisallowedInEnvvar,
862862
false,
863863
OptionNamespaces::kTestRunnerNamespace);
864+
AddOption("--test-bail",
865+
"abort test execution after first failure",
866+
&EnvironmentOptions::test_runner_bail,
867+
kDisallowedInEnvvar,
868+
false,
869+
OptionNamespaces::kTestRunnerNamespace);
864870
AddOption("--test-concurrency",
865871
"specify test runner concurrency",
866872
&EnvironmentOptions::test_runner_concurrency,

0 commit comments

Comments
 (0)