test_runner: automatically wait for subtests to finish

This commit updates the test runner to automatically wait for
subtests to finish. This makes the experience more consistent
with suites and removes the need to await anything.

PR-URL: https://github.com/nodejs/node/pull/58800
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Pietro Marchini <pietro.marchini94@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
This commit is contained in:
cjihrig 2025-01-19 17:46:37 -05:00 committed by RafaelGSS
parent c9dc0a8903
commit a1529d5d99
No known key found for this signature in database
GPG key ID: 8BEAB4DFCF555EF4
11 changed files with 56 additions and 84 deletions

View file

@ -28,6 +28,8 @@ const {
setupGlobalSetupTeardownFunctions,
} = require('internal/test_runner/utils');
const { queueMicrotask } = require('internal/process/task_queues');
const { TIMEOUT_MAX } = require('internal/timers');
const { clearInterval, setInterval } = require('timers');
const { bigint: hrtime } = process.hrtime;
const testResources = new SafeMap();
let globalRoot;
@ -228,11 +230,20 @@ function setupProcessState(root, globalOptions) {
const rejectionHandler =
createProcessEventHandler('unhandledRejection', root);
const coverage = configureCoverage(root, globalOptions);
const exitHandler = async () => {
const exitHandler = async (kill) => {
if (root.subtests.length === 0 && (root.hooks.before.length > 0 || root.hooks.after.length > 0)) {
// Run global before/after hooks in case there are no tests
await root.run();
}
if (kill !== true && root.subtestsPromise !== null) {
// Wait for all subtests to finish, but keep the process alive in case
// there are no ref'ed handles left.
const keepAlive = setInterval(() => {}, TIMEOUT_MAX);
await root.subtestsPromise.promise;
clearInterval(keepAlive);
}
root.postRun(new ERR_TEST_FAILURE(
'Promise resolution is still pending but the event loop has already resolved',
kCancelledByParent));
@ -252,8 +263,8 @@ function setupProcessState(root, globalOptions) {
}
};
const terminationHandler = () => {
exitHandler();
const terminationHandler = async () => {
await exitHandler(true);
process.exit();
};

View file

@ -651,6 +651,8 @@ class Test extends AsyncResource {
this.activeSubtests = 0;
this.pendingSubtests = [];
this.readySubtests = new SafeMap();
this.unfinishedSubtests = new SafeSet();
this.subtestsPromise = null;
this.subtests = [];
this.waitingOn = 0;
this.finished = false;
@ -754,6 +756,11 @@ class Test extends AsyncResource {
addReadySubtest(subtest) {
this.readySubtests.set(subtest.childNumber, subtest);
if (this.unfinishedSubtests.delete(subtest) &&
this.unfinishedSubtests.size === 0) {
this.subtestsPromise.resolve();
}
}
processReadySubtestRange(canSend) {
@ -815,6 +822,7 @@ class Test extends AsyncResource {
if (parent.waitingOn === 0) {
parent.waitingOn = test.childNumber;
parent.subtestsPromise = PromiseWithResolvers();
}
if (preventAddingSubtests) {
@ -937,6 +945,7 @@ class Test extends AsyncResource {
// If there is enough available concurrency to run the test now, then do
// it. Otherwise, return a Promise to the caller and mark the test as
// pending for later execution.
this.parent.unfinishedSubtests.add(this);
this.reporter.enqueue(this.nesting, this.loc, this.name, this.reportedType);
if (this.root.harness.buildPromise || !this.parent.hasConcurrency()) {
const deferred = PromiseWithResolvers();
@ -1061,6 +1070,10 @@ class Test extends AsyncResource {
this[kShouldAbort]();
if (this.subtestsPromise !== null) {
await SafePromiseRace([this.subtestsPromise.promise, stopPromise]);
}
if (this.plan !== null) {
const planPromise = this.plan?.check();
// If the plan returns a promise, it means that it is waiting for more assertions to be made before

View file

@ -3,13 +3,13 @@
[90m﹣ should skip [90m(*ms)[39m # SKIP[39m
▶ parent
[31m✖ should fail [90m(*ms)[39m[39m
[31m✖ should pass but parent fail [90m(*ms)[39m[39m
[32m✔ should pass but parent fail [90m(*ms)[39m[39m
[31m✖ parent [90m(*ms)[39m[39m
[34m tests 6[39m
[34m suites 0[39m
[34m pass 1[39m
[34m pass 2[39m
[34m fail 3[39m
[34m cancelled 1[39m
[34m cancelled 0[39m
[34m skipped 1[39m
[34m todo 0[39m
[34m duration_ms *[39m
@ -40,7 +40,3 @@
*[39m
*[39m
*[39m
*
[31m✖ should pass but parent fail [90m(*ms)[39m[39m
[32m'test did not finish before its parent and was cancelled'[39m

View file

@ -1,5 +1,5 @@
..XX...X..XXX.X.....
XXX.....X..X...X....
XXX............X....
.....X...XXX.XX.....
XXXXXXX...XXXXX
@ -93,10 +93,6 @@ Failed tests:
'1 subtest failed'
✖ sync throw non-error fail (*ms)
Symbol(thrown symbol from sync throw non-error fail)
✖ +long running (*ms)
'test did not finish before its parent and was cancelled'
✖ top level (*ms)
'1 subtest failed'
✖ sync skip option is false fail (*ms)
Error: this should be executed
*

View file

@ -186,12 +186,8 @@ Error [ERR_TEST_FAILURE]: thrown from subtest sync throw fail
<testcase name="level 1c" time="*" classname="test"/>
<testcase name="level 1d" time="*" classname="test"/>
</testsuite>
<testsuite name="top level" time="*" disabled="0" errors="0" tests="2" failures="1" skipped="0" hostname="HOSTNAME">
<testcase name="+long running" time="*" classname="test" failure="test did not finish before its parent and was cancelled">
<failure type="cancelledByParent" message="test did not finish before its parent and was cancelled">
[Error [ERR_TEST_FAILURE]: test did not finish before its parent and was cancelled] { code: 'ERR_TEST_FAILURE', failureType: 'cancelledByParent', cause: 'test did not finish before its parent and was cancelled' }
</failure>
</testcase>
<testsuite name="top level" time="*" disabled="0" errors="0" tests="2" failures="0" skipped="0" hostname="HOSTNAME">
<testcase name="+long running" time="*" classname="test"/>
<testsuite name="+short running" time="*" disabled="0" errors="0" tests="1" failures="0" skipped="0" hostname="HOSTNAME">
<testcase name="++short running" time="*" classname="test"/>
</testsuite>
@ -519,9 +515,9 @@ Error [ERR_TEST_FAILURE]: test could not be started because its parent finished
<!-- Error: Test "callback async throw after done" at test/fixtures/test-runner/output/output.js:269:1 generated asynchronous activity after the test ended. This activity created the error "Error: thrown from callback async throw after done" and would have caused the test to fail, but instead triggered an uncaughtException event. -->
<!-- tests 75 -->
<!-- suites 0 -->
<!-- pass 34 -->
<!-- fail 25 -->
<!-- cancelled 3 -->
<!-- pass 36 -->
<!-- fail 24 -->
<!-- cancelled 2 -->
<!-- skipped 9 -->
<!-- todo 4 -->
<!-- duration_ms * -->

View file

@ -1,35 +1,23 @@
TAP version 13
# Subtest: does not keep event loop alive
# Subtest: +does not keep event loop alive
not ok 1 - +does not keep event loop alive
ok 1 - +does not keep event loop alive
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/no_refs.js:(LINE):11'
failureType: 'cancelledByParent'
error: 'Promise resolution is still pending but the event loop has already resolved'
code: 'ERR_TEST_FAILURE'
stack: |-
*
...
1..1
not ok 1 - does not keep event loop alive
ok 1 - does not keep event loop alive
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/no_refs.js:(LINE):1'
failureType: 'cancelledByParent'
error: 'Promise resolution is still pending but the event loop has already resolved'
code: 'ERR_TEST_FAILURE'
stack: |-
*
...
1..1
# tests 2
# suites 0
# pass 0
# pass 2
# fail 0
# cancelled 2
# cancelled 0
# skipped 0
# todo 0
# duration_ms *

View file

@ -288,14 +288,10 @@ ok 23 - level 0a
...
# Subtest: top level
# Subtest: +long running
not ok 1 - +long running
ok 1 - +long running
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/output.js:(LINE):5'
failureType: 'cancelledByParent'
error: 'test did not finish before its parent and was cancelled'
code: 'ERR_TEST_FAILURE'
...
# Subtest: +short running
# Subtest: ++short running
@ -311,14 +307,10 @@ ok 23 - level 0a
type: 'test'
...
1..2
not ok 24 - top level
ok 24 - top level
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/output.js:(LINE):1'
failureType: 'subtestsFailed'
error: '1 subtest failed'
code: 'ERR_TEST_FAILURE'
...
# Subtest: invalid subtest - pass but subtest fails
ok 25 - invalid subtest - pass but subtest fails
@ -787,9 +779,9 @@ not ok 62 - invalid subtest fail
# Error: Test "callback async throw after done" at test/fixtures/test-runner/output/output.js:(LINE):1 generated asynchronous activity after the test ended. This activity created the error "Error: thrown from callback async throw after done" and would have caused the test to fail, but instead triggered an uncaughtException event.
# tests 75
# suites 0
# pass 34
# fail 25
# cancelled 3
# pass 36
# fail 24
# cancelled 2
# skipped 9
# todo 4
# duration_ms *

View file

@ -288,14 +288,10 @@ ok 23 - level 0a
...
# Subtest: top level
# Subtest: +long running
not ok 1 - +long running
ok 1 - +long running
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/output.js:(LINE):5'
failureType: 'cancelledByParent'
error: 'test did not finish before its parent and was cancelled'
code: 'ERR_TEST_FAILURE'
...
# Subtest: +short running
# Subtest: ++short running
@ -311,14 +307,10 @@ ok 23 - level 0a
type: 'test'
...
1..2
not ok 24 - top level
ok 24 - top level
---
duration_ms: *
type: 'test'
location: '/test/fixtures/test-runner/output/output.js:(LINE):1'
failureType: 'subtestsFailed'
error: '1 subtest failed'
code: 'ERR_TEST_FAILURE'
...
# Subtest: invalid subtest - pass but subtest fails
ok 25 - invalid subtest - pass but subtest fails
@ -801,9 +793,9 @@ ok 63 - last test
1..63
# tests 77
# suites 0
# pass 36
# fail 25
# cancelled 3
# pass 38
# fail 24
# cancelled 2
# skipped 9
# todo 4
# duration_ms *

View file

@ -90,9 +90,9 @@
Error: Test "callback async throw after done" at test/fixtures/test-runner/output/output.js:269:1 generated asynchronous activity after the test ended. This activity created the error "Error: thrown from callback async throw after done" and would have caused the test to fail, but instead triggered an uncaughtException event.
tests 75
suites 0
pass 34
fail 25
cancelled 3
pass 36
fail 24
cancelled 2
skipped 9
todo 4
duration_ms *
@ -203,10 +203,6 @@
sync throw non-error fail (*ms)
Symbol(thrown symbol from sync throw non-error fail)
*
+long running (*ms)
'test did not finish before its parent and was cancelled'
*
sync skip option is false fail (*ms)
Error: this should be executed

View file

@ -93,9 +93,9 @@
Error: Test "callback async throw after done" at test/fixtures/test-runner/output/output.js:269:1 generated asynchronous activity after the test ended. This activity created the error "Error: thrown from callback async throw after done" and would have caused the test to fail, but instead triggered an uncaughtException event.
tests 76
suites 0
pass 35
fail 25
cancelled 3
pass 37
fail 24
cancelled 2
skipped 9
todo 4
duration_ms *
@ -206,10 +206,6 @@
sync throw non-error fail (*ms)
Symbol(thrown symbol from sync throw non-error fail)
*
+long running (*ms)
'test did not finish before its parent and was cancelled'
*
sync skip option is false fail (*ms)
Error: this should be executed

View file

@ -215,10 +215,6 @@ const tests = [
name: 'test-runner/output/unfinished-suite-async-error.js',
flags: ['--test-reporter=tap'],
},
{
name: 'test-runner/output/unresolved_promise.js',
flags: ['--test-reporter=tap'],
},
{ name: 'test-runner/output/default_output.js', transform: specTransform, tty: true },
{
name: 'test-runner/output/arbitrary-output.js',