Skip to content

node:test: report t.test() after parent finished as a parentAlreadyFinished failure - #34583

Open
robobun wants to merge 10 commits into
mainfrom
claude/farm-7db298bf-node-test-late-subtest-failure
Open

node:test: report t.test() after parent finished as a parentAlreadyFinished failure#34583
robobun wants to merge 10 commits into
mainfrom
claude/farm-7db298bf-node-test-late-subtest-failure

Conversation

@robobun

@robobun robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

When t.test() is called after its parent test already finished (the typical forgot-to-await shape), Node runs the body, resolves the returned promise with undefined, and records the late subtest as a failed test with failureType: 'parentAlreadyFinished' so the run exits 1. Bun resolved the promise but dropped the late subtest entirely, so a suite that Node fails red would pass green.

Repro

// bun test file.mjs   vs   node --test file.mjs
import { test } from 'node:test';
test('parent', async (t) => {
  setTimeout(async () => {
    const p = t.test('late subtest', () => { console.log('LOG:late-body-ran'); });
    try { await p; console.log('LOG:await=resolved'); }
    catch (e) { console.log('LOG:await=rejected code=' + e.code); }
  }, 30);
});
test('keepalive', async () => { await new Promise(r => setTimeout(r, 150)); });

Node v26.3.0: LOG:late-body-ran, ✖ late subtest with test could not be started because its parent finished, pass 2 / fail 1, exit 1.
Bun (before): LOG:await=resolved, body never runs, 2 pass / 0 fail, exit 0.

Cause

addTest() in src/js/node/test.ts returned Promise.resolve(undefined) when runningNode.finished without booking the failure into the run results. The adjacent comment noted Node fails the late subtest but nothing recorded it.

Fix

addTest()/addSuite() now route the late subtest through runLateSubtest(), which runs the body (as Node does), discards its outcome, and calls a new native hook js_node_test_report_late_failure in jest.rs to print a (fail) <name> line with the error and bump the reporter's fail counter so the run exits 1. Late skip/todo subtests are left alone (Node exits 0 for those too).

Verification

Bun (after):

(pass) parent
(fail) parent > late subtest
error: test could not be started because its parent finished
 failureType: "parentAlreadyFinished",
       code: "ERR_TEST_FAILURE"
LOG:late-body-ran
LOG:await=resolved
(pass) keepalive
 2 pass
 1 fail
EXIT=1

New test/js/node/test_runner/fixtures/25-late-subtest-failure.js covers body-runs + promise-resolves + fail-count + exit-1. All 41 cases in node-test.test.ts pass.


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/test_runner/node-test.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (b7234910c)

test/js/node/test_runner/node-test.test.ts:
killed 1 dangling process
(fail) node:test > should run basic tests [5000.64ms]
  ^ this test timed out after 5000ms.

# Unhandled error between tests
-------------------------------
4 | import { join } from "node:path";
5 | 
6 | describe("node:test", () => {
7 |   test("should run basic tests", async () => {
8 |     const { exitCode, stderr } = await runTests(["01-harness.js"]);
9 |     expect({ exitCode, stderr }).toMatchObject({
                                     ^
error: expect(received).toMatchObject(expected)

  {
-   "exitCode": 0,
-   "stderr": StringContaining "0 fail",
+   "exitCode": 143,
+   "stderr": 
+ "
+ test/js/node/test_runner/fixtures/01-harness.js
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (274b5fe9f)

test/js/node/test_runner/node-test.test.ts:
(pass) node:test > should run basic tests [98.21ms]
(pass) node:test > should run hooks in the right order [76.39ms]
(pass) node:test > should run tests with different variations [52.63ms]
(pass) node:test > should run async tests [116.00ms]
(pass) node:test > should run all tests from multiple files [123.06ms]
(pass) node:test > should run test() and describe() called inside another test() as subtests [49.95ms]
(pass) node:test > should run before hooks created on a running test once and validate hook options [56.80ms]
(pass) node:test > should fail tests whose hooks, bodies, or inline suite callbacks fail [56.08ms]
(pass) node:test > should support done callbacks in tests and hooks [51.40ms]
(pass) node:test > should count runtime t.todo()/t.skip() as todo/skip and keep runner timers real under mock timers [55.59ms]
(pass) node:test > should count runtime t.todo()/t.skip() as todo/skip under --concurrent too [68.62ms]
(pass) node:test > should run todo bodies under --todo instead of registering an empty function [54.48ms]
(pass) node:test > should forward Infinity and finite timeouts
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/test_runner/node-test.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (b7234910c)

test/js/node/test_runner/node-test.test.ts:
(pass) node:test > should run basic tests [4672.57ms]
(pass) node:test > should run hooks in the right order [4918.87ms]
(pass) node:test > should run tests with different variations [4565.82ms]
(pass) node:test > should run async tests [3597.10ms]
(pass) node:test > should run all tests from multiple files [4552.20ms]
(pass) node:test > should run test() and describe() called inside another test() as subtests [3770.41ms]
(pass) node:test > should run before hooks created on a running test once and validate hook options [4488.83ms]
(pass) node:test > should fail tests whose hooks, bodies, or inline suite callbacks fail [4206.45ms]
(pass) node:test > should support done
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 1285ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[2/21] gen JS modules (bundle-modules)
Preprocess modules (8041ms)
Bundle modules (68ms)
Postprocesss modules (140ms)
Bundle Functions (1142ms)
Generate Code (111ms)

[9.52s] Bundled "src/js" for production
  2037 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nig
... (truncated)
diff hotspot
src/js/node/test.ts                                | 62 ++++++++++++++++++++--
 src/runtime/test_runner/jest.rs                    | 59 ++++++++++++++++++++
 .../fixtures/16-plan-and-late-subtest.js           |  6 ++-
 .../fixtures/25-late-subtest-failure.js            | 54 +++++++++++++++++++
 test/js/node/test_runner/node-test.test.ts         | 22 ++++++++
 5 files changed, 197 insertions(+), 6 deletions(-)

gate history · 2 passed · 1 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/js/node/test.ts                                          16     11      0
src/runtime/test_runner/jest.rs                              11      8      0
…s/node/test_runner/fixtures/16-plan-and-late-subtest.js      1      1      0
…js/node/test_runner/fixtures/25-late-subtest-failure.js      3      4      0
test/js/node/test_runner/node-test.test.ts                    6      6      0

…nished failure

When t.test() is called after its parent test already finished (the
typical forgot-to-await shape), Node runs the body, resolves the
returned promise with undefined, and records the late subtest as a
failed test with failureType parentAlreadyFinished so the run exits 1.

Bun previously resolved the promise but dropped the late subtest
entirely, so a suite that Node fails red would pass green.

addTest()/addSuite() now route the late subtest through
runLateSubtest(), which runs the body, discards its outcome, and calls
a new native hook (js_node_test_report_late_failure) to print a
'(fail) <name>' line with the error and bump the reporter's fail
counter so the run exits 1. Late skip/todo subtests are left alone
(Node does not count those as failures either).
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Late node:test subtests and suites now report parentAlreadyFinished failures after their parent completes. Skip and todo cases remain non-failing, while Rust reporting updates output, counters, error handling, and exit status. Fixtures and integration tests cover the behavior.

Late subtest failure handling

Layer / File(s) Summary
Route late tests and suites through failure handling
src/js/node/test.ts
Late t.test() and t.describe() calls use runLateSubtest, which creates failures, preserves skip/todo semantics, executes bodies, and resolves promises.
Report late failures through the test runner
src/runtime/test_runner/jest.rs
Late failures are formatted and emitted through the reporter, summary counters are updated, and supplied errors reach the VM error handler.
Validate late subtest outcomes
test/js/node/test_runner/fixtures/*late-subtest*, test/js/node/test_runner/node-test.test.ts
Tests verify late body execution, promise resolution, skip/todo handling, failure output, aggregate counts, and exit status.

Possibly related PRs

  • oven-sh/bun#32631: Extends the node:test result-reporting pipeline and uses the same execution context for skip/todo handling.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The linked issue is about Node.js build-output compatibility, but this PR only changes node:test late-subtest handling. Either link this PR to the relevant node:test issue or add changes that address the build-output blockers in #39.
Out of Scope Changes check ⚠️ Warning The node:test late-subtest failure work is unrelated to the linked build-output objectives, so the changes are out of scope. Move this work to a PR with a matching issue, or revise the PR to address the Node.js build-output requirements in #39.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change to node:test late-subtest handling.
Description check ✅ Passed The description explains the change and includes repro and verification details, covering the template's intent.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:42 AM PT - Jul 18th, 2026

@robobun, your commit b723491 has 1 failures in Build #75407 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34583

That installs a local version of the PR into your bun-34583 executable, so you can run:

bun-34583 --bun

Comment thread test/js/node/test_runner/fixtures/25-late-subtest-failure.js
Comment thread src/runtime/test_runner/jest.rs Outdated
Comment thread src/js/node/test.ts Outdated
… cover late describe

Route the late-failure status line through worker_emit_test_done when
running as a --parallel worker, matching handle_test_completed's sibling
path instead of writing to stderr unconditionally.

Pass a no-op done callback when a late subtest body declares two
parameters so (t, done) => { done(); ... } does not throw on undefined().

Extend fixture 25 to cover saved.describe('late-suite', ...) (the
isSuite=true branch of runLateSubtest) and the (t, done) body shape.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/test_runner/node-test.test.ts`:
- Around line 258-265: Update the late-subtest assertions in the node test
runner test to require the `late-done` reporting variant, while explicitly
asserting that `late-skip` and `late-todo` are absent from stderr. Keep the
existing checks for `late`, `late-suite`, the parent-finished message, and the
expected failure count.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fe1b138e-938c-491a-8c48-bcbfdb575199

📥 Commits

Reviewing files that changed from the base of the PR and between 4cf2d50 and 8832af3.

📒 Files selected for processing (5)
  • src/js/node/test.ts
  • src/runtime/test_runner/jest.rs
  • test/js/node/test_runner/fixtures/16-plan-and-late-subtest.js
  • test/js/node/test_runner/fixtures/25-late-subtest-failure.js
  • test/js/node/test_runner/node-test.test.ts

Comment thread test/js/node/test_runner/node-test.test.ts Outdated
Comment thread src/runtime/test_runner/jest.rs Outdated
The two existing serial summary().fail increments use an == bail check,
so an increment that skips the check lets fail step past bail without it
ever firing. Mirror the check here so a late subtest that is the Nth
failure under --bail=N terminates the run like any other failure.

Also note the intentional omission of a JUnit entry: maybe_print_junit_line
needs a bun:test sequence/entry that a late subtest does not have (same
as unhandled_errors_between_tests).
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
robobun added 3 commits July 18, 2026 10:13
…ock tracker

Node's Test constructor replaces a {skip:true} body with a noop, so a
late {skip:true} subtest's body never runs; pre-PR Bun also never ran it.
Gate the body invocation on !child.skipped ({todo:true} bodies still run,
matching Node).

Reset child.mockTracker after the body settles, mirroring executeTestNode,
so a late body's t.mock.method() does not leak into later tests.
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
…t() runs as a subtest

With child.finished set before the body ran, a t.test() inside the late
body saw its own parent as finished and recursively fired a second
spurious parentAlreadyFinished failure. Node runs the nested call as an
ordinary subtest of the late test.

child.finished is now set in settle(), and drainSubtestChain(child) runs
first so nested subtests the body scheduled are awaited before the outer
promise resolves. isExecutionPhase=true already makes isRunning() true
for the duration, so the nested call falls through to scheduleSubtest.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found, but I'd like a human to look at this one — it adds a second body-running path in src/js/node/test.ts that intentionally diverges from executeTestNode (timeout race and after-hooks deferred to a follow-up per the thread), plus a new native hook in jest.rs that directly mutates CommandLineReporter state under unsafe and mirrors the --bail exit path.

What was reviewed:

  • runLateSubtest lifecycle vs. executeTestNode: nested-subtest routing (child.finished deferred to settle), {skip:true} body gate, mockTracker.reset() — all now match Node's postRun.
  • js_node_test_report_late_failure: --bail mirror, worker-IPC branch, failures_to_repeat_buf gating on !dots && !only_failures matches handle_test_completed.
  • Fixture 16's {skip:true} addition keeps its exit-0 contract; fixture 25 pins body-ran/skip/todo variants and the 3 fail count.
Extended reasoning...

Overview

Fixes a Node-compat gap where t.test() called after its parent finished was silently dropped (Bun exited 0; Node exits 1 with parentAlreadyFinished). Touches src/js/node/test.ts (new runLateSubtest path wired into addTest/addSuite, ~50 lines), src/runtime/test_runner/jest.rs (new js_node_test_report_late_failure native hook, ~60 lines with unsafe reporter mutation, worker-IPC branch, and a mirrored --bail exit), one existing fixture tweak, one new fixture, and new assertions in node-test.test.ts.

Security risks

None identified. No untrusted-input parsing; the only new surface is a $newRustFunction binding reachable from the built-in node:test module. The unsafe blocks follow the same buntest_as_mut / reporter-pointer provenance pattern as the adjacent js_node_test_mark_result.

Level of scrutiny

Medium-high. This is Node-compat runtime code (not sandbox), and the Rust side adds a third site that increments summary().fail and can Global::exit(1). The JS side adds a second body-running path that partially replicates executeTestNode's lifecycle — the kind of parallel-implementation shape REVIEW.md flags ("parameterize the existing path rather than cloning a parallel branch"). The author acknowledged this and deferred routing through executeTestNode to a follow-up; a maintainer should confirm that tradeoff is acceptable.

Other factors

The PR went through seven rounds of review-driven fixes (done-callback, worker-IPC, --bail mirror, {skip:true} body gate, mockTracker.reset(), nested-subtest recursion, plus test-assertion tightening), all resolved. One raised item — honoring {timeout: N} on a late body — was intentionally left unaddressed with a stated rationale (triple edge case, no false-green, bounded by bun:test's watchdog). All 41 tests in node-test.test.ts pass on both debug/ASAN and release per the evidence block. Given the accumulated surface and the deferred-refactor decision, this is past the "simple and self-contained" bar for auto-approval.

Comment thread src/runtime/test_runner/jest.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No further findings after the earlier rounds — all six prior threads are addressed (or explicitly deferred). Leaving the final call to a human given the new unsafe reporter-write hook in jest.rs and runLateSubtest being a second body-execution path parallel to executeTestNode.

Checked: summary().fail sibling side-effects (worker-IPC, --bail mirrored; JUnit omission commented with the unhandled_errors_between_tests precedent); runLateSubtest vs executeTestNode parity — skip-body gating, mockTracker.reset(), nested t.test() via deferred child.finished, done-callback shape; fail glyph now via write_test_status_line. The {timeout: N} race on a never-settling late body was intentionally left for a follow-up (no false-green).

Extended reasoning...

Overview

Node-compat fix for node:test: when t.test() is called after its parent finished, book a parentAlreadyFinished failure so the run exits 1 (previously silently exited 0). Touches src/js/node/test.ts (new runLateSubtest path wired into addTest/addSuite), src/runtime/test_runner/jest.rs (new js_node_test_report_late_failure native hook that writes a fail line, bumps summary().fail, and mirrors the --bail exit), fixture 16 (adjusted to keep exit 0), a new fixture 25, and one new case in node-test.test.ts.

Security risks

None identified. The new native hook is only reachable via $newRustFunction from the bundled node:test builtin, takes a name string and an error value, and writes to the test reporter. No untrusted-input parsing, no filesystem/network surface.

Level of scrutiny

Medium-high. This is not a mechanical change: it adds a new unsafe { &mut *rep.as_ptr() } write path into CommandLineReporter from a JS-reachable hook, and runLateSubtest is a second body-running path alongside executeTestNode — the six earlier review rounds each surfaced a sibling invariant it was missing (worker-IPC, --bail, {skip:true} body, mockTracker.reset(), nested-subtest child.finished ordering, glyph duplication). All were addressed, and one (per-late-subtest {timeout: N} race) was reasonably deferred with a stated follow-up plan to route through executeTestNode. That architectural question — parallel path now vs. reuse executeTestNode — is worth a human's eye.

Other factors

CI evidence in the PR body shows the new test failing on main (both ASAN debug and release-canary) and passing with the fix. Fixture 25 pins body-runs, promise-resolves-undefined, done-callback, late-suite, late-skip body not run, late-todo body run, 3 fail / 2 pass, and exit 1; fixture 16 was updated so its own exit code stays 0. All prior inline threads (mine and coderabbit's) are marked resolved.

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green: node-test.test.ts passes on all lanes, and clippy/Format/Lint all pass. The remaining red lanes are unrelated to this change:

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js (debian x64-asan): pre-existing JSC getOwnPropertyDescriptor exception-scope assertion in worker_threads; also failing on main.
  • test/js/node/test/parallel/test-repl-close.js (windows x64): flaky EPIPE on write, passes on retry on other lanes.

Neither touches node:test, src/js/node/test.ts, or src/runtime/test_runner/jest.rs. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant