node:test: report a verdict for each suite registered with bun:test under run() - #39331
node:test: report a verdict for each suite registered with bun:test under run()#39331robobun wants to merge 1 commit into
Conversation
… run() Under run(), a describe()/suite() block registered with bun:test emitted no test:pass/test:fail of its own, so `suites` undercounted and a failure of the suite itself (a failing before()/after() hook or describe callback) was only visible as a synthesized file-level failure. In a run() child, the describe callback wrapper now brackets the block with a beforeAll/afterAll pair: the afterAll runs the suite's after() hooks and reports the suite once its children have reported. Top-level tests and nested suites roll their verdicts up into the enclosing suite the way node's Test.postRun() does; before() hook failures and a throwing or rejecting describe callback are recorded against the suite, and a todo suite's own failures are reported on it without failing the file, as in node. The inline suite path shares the verdict and event code. On the parent side, run() tracks success the way node's harness does, so a file whose only failure is a suite reports success:false with `failed 0`, and a nonzero child exit that a reported suite failure already explains no longer produces a file-level test:fail.
|
Warning Review limit reached
Next review available in: 15 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status Reproduced on bun 1.4.0 and main with the two-file repro from the report (a top-level Fix and tests are in this PR; the five new cases in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. It's a substantial addition to the node:test compat layer (~200 new lines in src/js/node/test.ts implementing suite-verdict reporting via injected beforeAll/afterAll bracketing, plus a parent-side RunTally refactor), so a human look would still be worthwhile.
What was reviewed:
- Checked that every new code path (
buildReportedSuite,rollUpIntoEnclosingSuite, theafter()diversion,before()failure recording) is gated onrunChildReporterEnabled/isReportedSuite, so plainbun teston a node:test file is unaffected. - Verified
concludeSuitereadssuite.error === nullagainstTestNode.error'snullinitializer, and thatfailSuiteBuild's rethrow preserves bun:test's scope-drop behavior for non-todo suites. - Confirmed the
RunTallyrefactor preserves the previouscounts.cancelledcontribution to run failure viareportCancelledFilesettingrun.success = false, and thatrepublishChildEventmatches node'scountCompletedTest(failed non-todo suite →success = false, no test-counter bump).
Extended reasoning...
Overview
This PR makes node:test's run() report a test:pass/test:fail verdict for each describe()/suite() block registered with bun:test, matching Node v26.3.0. Previously only inline suites (declared inside a running test) were reported; top-level and nested-in-suite blocks were silent, so counts.suites undercounted and suite-level hook failures surfaced only as a synthesized file-level failure.
The child-side fix (buildReportedSuite) wraps each describe callback so that, in a run() child, it registers a beforeAll (starts the clock) and an afterAll (settleReportedSuite: runs the suite's collected after() hooks, records the first failure as hookFailed, then concludeSuite → reportSuiteToRunParent). rollUpIntoEnclosingSuite propagates child failures into the parent suite's failedSubtests. before() failures are additionally recorded via failSuiteFromHook; after() hooks in reported suites are collected on the node instead of registered directly with bun:test. Todo suites' own failures are recorded but not handed to bun:test (advisory in Node). A throwing/rejecting describe callback (failSuiteBuild) reports at declaration time and rethrows so bun:test still drops the scope.
The parent side replaces the counts-only accounting with RunTally = { counts, success }, so a failed non-todo suite (which never bumps failed) can still make test:summary.success false and suppress the file-level synthesized verdict. republishChildEvent, runOneFile, runFiles, and reportCancelledFile are updated to thread success through.
Five new test.concurrent cases in node-test.test.ts pin exact verdict sequences, error messages/failureTypes, the suites N diagnostic, and per-file/run-level summary fields against Node v26.3.0.
Security risks
None. This is test-runner event reporting; no auth, crypto, filesystem-path handling, or untrusted-input parsing beyond what already existed. The child→parent JSON channel and rebuildError are unchanged.
Level of scrutiny
Medium-high. The change is entirely inside the Node.js compat layer for node:test's programmatic run(), but it's ~200 lines of new control flow with several subtle interactions: the done-arity workaround for async-context-wrapped hooks, todo-suite advisory-failure semantics, the failSuiteBuild rethrow (non-todo) vs. record-only (todo) split, diverting after() to run inside the closing afterAll, and using beforeAll/afterAll ordering as the bracket. The PR description is exceptionally thorough and cites Node source for each decision, and the tests pin exact verdict sequences, but the design (hook injection to observe bun:test's block boundaries) is the kind of approach a maintainer should sign off on.
Other factors
- All new paths are gated on
runChildReporterEnabled(set only whenNODE_TEST_CONTEXTis in the env), sobun test <node-test-file>withoutrun()is untouched — I traced each new call site and confirmed the guards. reportDirectiveOnlyNode→reportSkippedNodenarrowing is correct: the todo path now goes throughbuildReportedSuite's closing hook (or stays withdescribe.todooutside a run() child), and every remaining call site passes a skip.- The inline-suite path (
scheduleSuiteSubtest) now sharesconcludeSuite/reportSuiteToRunParentwith the new path; the only observable changes there are a realduration_msand acauseonsubtestsFailed, both improvements toward Node parity. - Tests follow harness conventions (
tempDir,await using, concurrent pipe drain,test.concurrent, exact assertions) and each pins a Node-verified expectation with documented remaining differences. - No prior human or bot review on this PR; CI build was still in progress at review time.
|
Note from #39284 (the // republishChildEvent, for test:pass / test:fail
if (data.nesting === 0) {
fileCounts.topLevel++;
data.testNumber = runCounts.topLevel + fileCounts.topLevel; // runCounts: files already finished
}
// runOneFile: move the unconditional fileCounts.topLevel++ under reportFileNodeReference values from node v26.3.0 for a file with one |
Problem
node:test'srun(), adescribe()/suite()block declared at the top level of a file (or nested in another one) never gets atest:pass/test:failevent of its own. Node v26.3.0 reports one after the suite's children. Only suites declared inside a running test (inline suites) were reported. Same on 1.4.0 and main; never worked.run()consumers: thesuites Ndiagnostic andcounts.suitesundercount (the file in the test below reportssuites 3on main, node sayssuites 7), and a failure of the suite itself is invisible: a failing suite-levelafter()hook, or adescribe()callback that throws, only surfaced as a synthesizedtest:failfor the whole file carrying the child's stderr, where node reports the suite as failed (failed running after hook,hookFailed) and nothing for the file.src/js/node/test.ts:addSuite()registers such a suite as a bun:testdescribeblock and nothing reports it when the block is done;reportNodeToRunParent()only handles tests, and the suite event was emitted fromscheduleSuiteSubtest()alone, the inline path. On the parent side,runOneFile()derived the per-filesuccessand the "did the file itself die" check fromcounts.failed, which counts tests only, so a failed suite could not make a file unsuccessful and would not have stopped the file-level verdict either.Fix
run()child (NODE_TEST_CONTEXTset): the describe callback wrapper (buildReportedSuite()) registers a bun:testbeforeAll/afterAllpair in the block before running the callback. bun:test runs a block'sbeforeAllhooks before and itsafterAllhooks after everything declared in the block, nested blocks included, so thebeforeAllstarts the suite's clock and theafterAll(settleReportedSuite()) runs the suite'safter()hooks, stopping at the first failure like node'srunHook(), and reports the verdict. Nested suites therefore report before their parent, and children before their suite, which is node's order.Suite.run()+Test.postRun(), inconcludeSuite(): a failure recorded against the suite itself wins (abefore()/after()hook failure, recorded asfailed running <kind> hook/hookFailedwith the hook's error as cause, or a throwing / rejecting describe callback), otherwise children that finished without passing and are not todo fail it withN subtests failed/subtestsFailed.rollUpIntoEnclosingSuite()is the roll-up for tests and suites registered with bun:test; inline subtests keep theirs. Underrun(),after()hooks of such a suite are collected on the node for the closing hook to run (their failure still reaches bun:test through the hook'sdone(err), exactly whatafter()handed it before, so the child's exit status is unchanged);before()hooks keep registering with bun:test and additionally record their failure on the suite.countCompletedTestleavessuccessalone, the process exits 0): they are recorded so the suite reportstest:failwith thetododirective, and not handed to bun:test. The same path now reports todo suites in general, which were previously reported as passed at declaration time, before their children; skipped suites are unchanged (reportSkippedNode(), formerlyreportDirectiveOnlyNode(), is now only ever called for skips).scheduleSuiteSubtest()) now goes through the sameconcludeSuite()/reportSuiteToRunParent(), so both kinds of suite produce the same event shape; its only visible changes are a realduration_msinstead of 0 and the first failure attached as the cause ofsubtestsFailed, whichexecuteTestNode()already did for tests (makeSubtestsFailedError()is the shared helper).runFiles()/runOneFile()carry node'sharness.successnext to the counters (RunTally).republishChildEvent()clears it for a failed or cancelled test and for a failed non-todo suite, as node'scountCompletedTest()does; the per-file and run-leveltest:summaryreport it, and the file-level verdict is now synthesized only for a nonzero exit that no reported failure accounts for (previously: no failed test), so a suite-only failure yieldssuccess: false,fail 0, and no file-level event, as in node.details.type, directives, error messages and failure types), on thesuitesdiagnostic and on everytest:summaryfield the change touches; the comparison is in the details block. The known remaining differences are listed there too; none of them changes a verdict or a count.done, like every other callback this module hands to bun:test: bun:test derives "waits for done()" from the callback's arity, and cannot read it through the async-context wrapper a callback registered under an activeAsyncLocalStoragecontext gets, which is the case for every nested describe callback (it runs inside its parent'srunWithNode()). A zero-arity hook there waits 5s for adone()nobody calls and, for abeforeAll, skips the block's tests. jsc: get_length returns 0 for objects without a length property; bun:test reads callback arity before wrapping it #38910 fixes that in bun:test; the code here is right either way.bun teston a node:test file is untouched: every new code path is behindrunChildReporterEnabled, androllUpIntoEnclosingSuite()returns immediately outside arun()child.test/js/node/test_runner/node-test.test.ts, newnode:test run()block (5 cases: the suite sequence above, suite-only after-hook failure, todo suites' own failures, before-hook failure, throwing and rejecting describe callbacks). All 5 fail on the unfixed build (no suite verdicts,suites 3instead of 7, file-level verdicts instead of suite ones) and pass with it; the 45 existing cases in the file still pass. The portedtest/js/node/test/parallel/test-runner-*files behave as on main (test-runner-todo-skip-tests.js, which pins a skipped suite's single event, included;test-runner-mock-timers-scheduler.jsfails its 100ms wall-clock bound on the unmodified debug build too).test/regression/issue/{19111,19412,24147,24338,24339,24374,26915,28431}.test.tspass.Background
run()is node:test's programmatic runner. With the default process isolation it runs each file in a child process; the child reports its tests as events, and the parent republishes them on the streamrun()returned and appends the run-level plan, diagnostics andtest:summary. Bun'srun()spawnsbun teston each file withNODE_TEST_CONTEXTset; in that modenode:testprints one JSON event per verdict, which the parent rebuilds.test:passortest:failwhosedetails.typeis"suite".countCompletedTest()(node'stest_runner/utils.js) counts such an event only insuites, never intests/passed/failed, and clears the harness'ssuccessflag when the suite failed and is not todo. That flag is whattest:summaryreports assuccess; it is the only place a suite failure shows up in the totals.FileTestsynthesizes a verdict for the file itself only when the child exited nonzero without any of its own top-level verdicts explaining it (a file that failed to load, a root-level hook failure); a file whose tests or suites failed is represented by those verdicts.node:testregisters top-level tests and suites with bun:test (Bun.jest), which then drives execution: tests through a runner callback, suites asdescribeblocks. Suites declared while a test is running are executed by the module itself ("inline" suites). bun:test orders a block's hooks around its contents, so abeforeAll/afterAllpair registered from the describe callback observes the start and the end of everything in the block.node v26.3.0 vs. this branch on the test fixtures, and the remaining differences
Verdicts as
name (details.type, directive), in stream order, forsuites.jsfrom the first test. The left column is node v26.3.0 and is also, line for line, what this branch emits:after-hook.js(one suite whoseafter()throws, one passing child):todo-suites.js(todo suites whosebefore(),after(), and callback fail): node and this branch both report the three suites astest:failwith thetododirective and the hook / callback error,success: true,todo 3,suites 3, no file-level verdict. Main reported the suites as passed at declaration time plus a file-level failure.Remaining differences, all pre-existing or noted in the code:
before()hook or callback failed: node cancels them (cancelledByParent); here bun:test decides what happens to them. After a failedbefore()they currently run and pass (bun:test only prints an error passed to a hook'sdone(); once it fails the hook instead, bun test: fail the test or hook whose done() received an error #39112, it will skip them), so the before-hook test only pins the suite's verdict and the summary fields that hold either way. After a throwing callback bun:test drops them (no events), except in a todo suite, where the error is not rethrown and they run; there the counts agree with node, which counts the cancelled children as todo as well.testCodeFailure(pre-existing for tests, same for describe callbacks here), andsubtestsFailederrors carry the first failure ascausewhere node repeats the message.nesting/testNumber/topLevelare node:test: republish run() child events at the child's nesting #39284's.N subtests failed(pre-existing); the sharedconcludeSuite()would take a recorded error there too.