Skip to content

node:test: subtests, plan, mock timers, getTestContext (+11 tests) - #32631

Merged
cirospaciari merged 29 commits into
mainfrom
claude/node-test-runner-v26
Jul 17, 2026
Merged

node:test: subtests, plan, mock timers, getTestContext (+11 tests)#32631
cirospaciari merged 29 commits into
mainfrom
claude/node-test-runner-v26

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jun 23, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Brings the node:test in-process API much closer to Node v26.3.0 so that the upstream test_runner suite can be vendored. 20 of Node v26.3.0's test_runner tests now pass and are vendored, up from 7 (the previously vendored 7 keep passing; test-runner-aliases.js and test-runner-typechecking.js are refreshed to verbatim upstream copies).

Runtime changes (src/js/node/test.ts, new src/js/internal/test_runner/mock_timers.ts)

  • Subtests: t.test() / t.describe() and global test() / describe() called inside a running test now execute as inline subtests (previously they threw NotImplementedError). The parent waits for awaited and unawaited subtests, and fails with N subtests failed when one fails. Top-level test() returns a promise that resolves undefined when the test completes, like Node.
  • Hooks: beforeEach / afterEach registered through node:test are now run by the module itself so they receive Node's context arguments (the running test's TestContext) and follow Node's inheritance order (ancestors first for beforeEach, nearest scope first for afterEach). before / after still register through bun:test beforeAll / afterAll but receive the owning suite/root context. t.before/t.after/t.beforeEach/t.afterEach apply to the test's subtests. Hook { timeout, signal } options are validated like Node (ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE) and enforced when the hook runs (timeout race + abort listener). before hooks created on an already-running test run exactly once and their outcome is memoized like Node's runOnce: a failing before hook fails the owning test and every later subtest without running their bodies, and a failing before hook of an inline suite fails that suite. Test bodies that throw or reject with a nullish value fail (Node does). Inline describe() callbacks are awaited like Node's suite build: an async callback that rejects fails the suite, a synchronous throw still accounts for the children it already registered, and inline-suite children run serially after previously scheduled subtests. Hooks accept Node's (ctx, done) callback signature, and done-style functions follow Node exactly: a callback function that also returns a promise fails (passed a callback but also returned a Promise) after the body settles, a done() invoked twice fails (callback invoked multiple times), and the callback is only passed for an arity of exactly 2. { timeout: Infinity } is forwarded to bun:test so an explicitly untimed test overrides the runner's per-test default. A runtime t.skip()/t.todo() suppresses a later failure (Node reports SKIP/TODO and the run passes), inline describe.todo() failures do not fail the owning test, collection-phase todo bodies are registered for real so bun test --todo runs them, and the runner's own timers (timeout race, t.plan wait, t.waitFor) are captured at module load so mock.timers cannot fake them.
  • New API surface: getTestContext() (tracked with AsyncLocalStorage, so it works across await and setImmediate), t.plan() including the wait option, t.waitFor(), runtime t.skip() / t.todo(), test tags (validation, case-insensitive dedup, parent-first inheritance, frozen arrays, one-shot ExperimentalWarning), SuiteContext for suite callbacks/hooks, fullName / filePath / passed / attempt, custom assertions via assert.register() (bound to the TestContext, counted by the plan), done-callback test functions ((t, done) => {}), and test() / suite() option validation matching Node's error codes (timeout, concurrency, tags). t.assert is built as a prototype-less namespace like Node's, and the internal mock/assertion registries never go through user-patchable Map.prototype methods.
  • Mocks: t.mock is now a per-test MockTracker that is reset automatically when the test finishes. Adds mock.property() (port of Node's MockPropertyContext) and mock.timers — a port of Node's MockTimers + PriorityQueue (src/js/internal/test_runner/mock_timers.ts) covering setTimeout/setInterval/setImmediate, node:timers, timers/promises (including the async-iterator setInterval), Date, scheduler.wait, and AbortSignal.timeout.
  • Timeouts: the node-style timeout option is enforced by the shim with Node's semantics (a 1ms timeout with a synchronous body still passes); only timeouts above bun:test's 5s default are forwarded to bun:test so its watchdog does not kill them early.
  • only: test.only() / describe.only() and { only: true } register ordinary tests/suites, matching Node's runner semantics (only does nothing without --test-only, which bun has no equivalent for). They are intentionally not routed to bun:test's only(), which both skips siblings and throws in CI environments — routing them was what made the verbatim test-runner-typechecking.js fail on every CI shard. This means bun test --only does not select node:test only-marked tests.
  • node:net (1 line): a custom lookup callback that yields a non-string address now emits ERR_INVALID_IP_ADDRESS like Node (typeof ip !== "string" guard from upstream lib/net.js), instead of connecting to the stringified value. This gap was exposed because test-net-connect-custom-lookup-non-string-address.mjs actually waits for its done callback now.

Per-file isolation and timeout forwarding

  • File-level beforeEach/afterEach (and assert.register() / the module-level mock tracker) no longer leak across files sharing one bun test process: the root test node is now per entry file, matching Node's process-per-file isolation (fixtures 14-root-hooks-a/b.js run as one multi-file invocation).
  • bunTestOptions() forwards every finite timeout as max(timeout, 5s) so a lower bun test --timeout cannot cut a node-style timeout short (fixture 11-timeout-overrides.js, run with --timeout 100).
  • The node-test harness internal/timers shim exports TIMEOUT_MAX (2 ** 31 - 1, same as Node), so the vendored test-runner-mock-timers.js case for the > TIMEOUT_MAX clamp exercises the real threshold.

Stop-clock, file boundaries, and outcome reporting

  • One stop clock per test. executeTestNode arms a single timeout before the body and races the body, the subtest drain, and plan.check() against it, matching Node's stopTest. Previously only the body was bounded, so t.plan(n, { wait: true }) could never reject and a { timeout: 100 } test hung until bun:test's watchdog (fixture 16b-plan-wait-timeout.js).
  • File boundary comes from the runner, not Bun.main. BunTestRoot::file_generation is bumped in enter_file and read from JS, so per-file state (root hooks, the module-level mock tracker, assert.register()) resets on --rerun-each iterations of the same path, where Bun.main never changes (fixture 17-rerun-mock-reset.mjs). It is a private $newRustFunction binding, not a new export on the public bun:test module object; the host_fn goes through Jest::runner_ptr() so it never materializes an exclusive &mut TestRunner while the runner holds a live pointer into it.
  • t.passed / t.error in afterEach / after. TestNode.error was added (initialized null, like Node) and both fields are written before the hook loops. TestContext.error no longer hardcodes undefined (fixture 15-outcome-in-hooks.js).
  • Plan captured once, option gated on truthiness, applied before beforeEach. t.assert destructures the plan at first access and closes over it (Node does), so the { plan: N } option must exist before a hook can touch t.assert and is only installed for a truthy N{ plan: 0 } installs no plan (fixtures 16-plan-and-late-subtest.js, 19-plan-option-order.js).
  • Subtests created after their parent finished match Node: the late subtest is failed with ERR_TEST_FAILURE / failureType: 'parentAlreadyFinished' for the reporter, while the promise t.test() returned resolves with undefined (checked against the v26.3.0 binary; fixture 16-plan-and-late-subtest.js). Previously this fell through to bun:test's "Cannot call test() inside a test".
  • t.assert.ok is installed outside the generic wrapper and passes itself to innerOk as the stackStartFn (nodejs/node@028c5864), so the stack starts at the caller; a registered assert.register("ok", fn) still wins (fixture 21-register-ok.js). The message stays generic until internal/assert/utils.getErrMessage is implemented — the only remaining blocker for the test-runner-assert.js FIXME.
  • __proto__: null on every Object.defineProperty descriptor literal in test.ts (matching mock.js and this PR's own mock_timers.ts), TestContext.workerId, and the contextNode WeakMap replaced with a plain #node private field on both contexts.

Known divergences from Node (deliberate)

  • mock.timers.tick(-1) / setTime(-1) reproduce Node's swapped-argument ERR_INVALID_ARG_VALUE message verbatim ("The argument 'time' -1. Received 'positive integer'", from mock_timers.js:558).
  • setInterval(fn, 0) under mock.timers re-fires inside a single tick() until the callback clears it, because Node's mock timers clamp only the upper bound. Clamping to 1ms (as real timers do) would change a case Node passes: a self-clearing zero-delay interval fires 4x on tick(1) in Node and 2x under a clamp. Pinned by 18-mock-timers-interval-zero.js.
  • bun:test's own watchdog is kept as a floor at its 5s default even when a finite node-style {timeout: N} is set (bunTestOptions() forwards max(N, 5s)). Node only observes the timeout after the body yields, so a synchronous body that runs past 5s passes under Node with any timeout value but is killed by bun:test's watchdog here (test('x', {timeout: 50}, () => { /* spin 8s */ }) -> Node pass, Bun "timed out after 5000ms"). Forwarding Infinity instead would disable the watchdog for every node-style timed test and let a genuinely hung one block the whole run; deferred to a follow-up.

Test changes

  • Vendors 13 more upstream Node v26.3.0 files into test/js/node/test/parallel/: aftereach-runtime-skip, custom-assertions, get-test-context, mock-timers, mock-timers-date, mock-timers-scheduler, mocking, option-validation, tags-inheritance, test-filepath, test-fullname, wait-for, and test-mock-timers-abortsignal-timeout. All are verbatim upstream copies except test-runner-mocking.js, where two assertions on V8-specific engine error text (Cannot read private member, Cannot redefine property) are loosened to match on TypeError since JSC's wording differs; and test-runner-mock-timers.js, where one upstream comment line is dropped.
  • test/js/node/test_runner/fixtures/05-test-in-test.js previously asserted that subtests throw NotImplementedError; it now asserts subtests run inline, return promises, and that global test()/describe() inside a running test become subtests.
  • Quarantines test-set-http-max-http-headers.js in test/expectations.txt: it only passed because callback-style node:test functions never waited for their callback under the old shim. It spawns test-http-max-http-headers.js, which is not vendored, so now that the callbacks actually run it fails 2/4 with "Module not found". The file and its no-validate-leaksan.txt entry are left as-is.
  • Updates the node:test entry in docs/runtime/nodejs-compat.mdx to describe what is and is not supported.

Not covered (still missing, unchanged behavior)

run(), node:test/reporters, snapshot testing (t.assert.snapshot), mock.module(), code coverage, watch mode, and Node's --test CLI runner mode. The remaining upstream test_runner tests depend on these and are not vendored. Test-level signal, t.signal abort, and concurrency are validated but not enforced -- hook-level signal is (fixture 20-hook-signal-and-assert-ok.js) and subtests always run serially. test.only() / {only: true} are accepted but do not filter (no --test-only equivalent). A beforeEach/afterEach registered from a --preload module applies only to the first file of a multi-file bun test run: the preload module is cached and never re-evaluated, and each file's fresh root drops the previous one's hooks (before/after are unaffected -- they route through bun:test's own beforeAll/afterAll, which persist). The fix needs a preload-scope root above every per-file root and is deferred. A global test() called from an inline suite's after hook runs but its failure is dropped: Bun reports 1 pass, 0 fail where node v26.3.0 reports pass 2, fail 1 (the late test is counted as its own failure; neither runtime fails the owning test). Surfacing it needs the late subtest registered with bun:test after the file's tests are built, so it is deferred. Hook errors surface as the thrown error rather than Node's failureType: 'hookFailed' wrapper, and a collection-phase hook timeout larger than bun:test's per-test watchdog is still bounded by that watchdog.

How did you verify your code works?

  • All 18 vendored test-runner-* files pass when run the same way CI runs them (bun test --config=bunfig.node-test.toml <file> with a debug build).
  • test/js/node/test_runner/node-test.test.ts (20 tests) passes, including the rewritten subtest fixture, the existing mock/hook/variation fixtures, and two new fixtures: 06-hook-semantics.js (before hooks run once, hook option validation, mock registries immune to Map.prototype tampering), 07-failing-hooks.js (nullish body rejections, an async inline describe rejection, a failing inline-suite before hook, a hook timeout, and a subtest after a failed before hook must all fail), 08-only-no-op.js (run with CI=1: only registers ordinary tests and never trips bun:test's CI-only guard), 09-inline-suites.js (inline-suite ordering and async describe callbacks), 10-done-callbacks.js (done-callback tests and hooks), 11-timeout-overrides.js (run with --timeout 100: an Infinity timeout must override the runner default), 12-runtime-todo-and-mock-timers.js (runtime skip/todo suppression; runner timers stay real under mock timers), and 13-todo-bodies.js (run with --todo: todo bodies actually run, both spellings).
  • The hook/timeout/subtest semantics above were cross-checked against the real node v26.3.0 binary on identical inputs (same files run under both runtimes).
  • All 59 vendored node tests that import node:test were re-run with the debug build; the only failure modes seen were pre-existing tmpdir collisions when run outside the CI runner (they pass when run individually).
  • Affected bun-authored tests pass: test/regression/issue/19412, 23077, 24338, test/js/node/module/node-module-module.test.js.
  • bun run lint and the ban-words test pass; changed files are formatted with prettier.

[review] gate passed · iteration 13 · 50 files touched

fails on main (without fix)
ASAN without fix: 20 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 (e8ab4ad77)

test/js/node/test_runner/node-test.test.ts:
(pass) node:test > should run basic tests [2332.40ms]
(pass) node:test > should run hooks in the right order [2368.75ms]
(pass) node:test > should run tests with different variations [2234.31ms]
(pass) node:test > should run async tests [2208.05ms]
(pass) node:test > should run all tests from multiple files [2516.61ms]
45 |     });
46 |   });
47 | 
48 |   test("should run test() and describe() called inside another test() as subtests", async () => {
49 |     const { exitCode, stderr } = await runTests(["05-test-in-test.js"]);
50 |     expect({ exitCode, stderr }).toMatchObject({
                                      ^
error: expect(received).toMatchObject(expected)

  
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (fa8b9698e)

test/js/node/test_runner/node-test.test.ts:
(pass) node:test > should run basic tests [51.44ms]
(pass) node:test > should run hooks in the right order [70.81ms]
(pass) node:test > should run tests with different variations [53.40ms]
(pass) node:test > should run async tests [50.22ms]
(pass) node:test > should run all tests from multiple files [72.03ms]
(pass) node:test > should run test() and describe() called inside another test() as subtests [48.81ms]
(pass) node:test > should run before hooks created on a running test once and validate hook options [48.14ms]
(pass) node:test > should fail tests whose hooks, bodies, or inline suite callbacks fail [52.58ms]
(pass) node:test > should support done callbacks in tests and hooks [44.10ms]
(pass) node:test > should count runtime t.todo()/t.skip() as todo/skip and keep runner timers real under mock timers [51.75ms]
 96 |   test("should count runtime t.todo()/t.skip() as todo/skip under --concurrent too", async () => {
 97 |     // markCurrentResult's microtask-drain fallback could not name a sequence
 98 |     // inside a concurrent group, so the skip/todo mark was dropped and both
 9
... (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 (e8ab4ad77)

test/js/node/test_runner/node-test.test.ts:
(pass) node:test > should run basic tests [3034.84ms]
(pass) node:test > should run hooks in the right order [3186.25ms]
(pass) node:test > should run tests with different variations [2593.80ms]
(pass) node:test > should run async tests [2599.43ms]
(pass) node:test > should run all tests from multiple files [3819.22ms]
(pass) node:test > should run test() and describe() called inside another test() as subtests [2634.84ms]
(pass) node:test > should run before hooks created on a running test once and validate hook options [2622.01ms]
(pass) node:test > should fail tests whose hooks, bodies, or inline suite callbacks fail [2782.44ms]
(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 734ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] 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/22] gen cpp.rs (cppbind)
[3/22] gen JS modules (bundle-modules)
Preprocess modules (7362ms)
Bundle modules (45ms)
Postprocesss modules (178ms)
Bundle Functions (764ms)
Generate Code (91ms)

[8.45s] Bundled "src/js" for production
  2035 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/10] 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 rus
... (truncated)
diff hotspot
.claude/skills/verify/SKILL.md                     |    6 +
 docs/runtime/nodejs-compat.mdx                     |    2 +-
 src/codegen/generate-js2native.ts                  |    1 +
 src/js/internal/test_runner/mock_timers.ts         |  756 ++++++++
 src/js/node/net.ts                                 |    2 +-
 src/js/node/test.ts                                | 1988 ++++++++++++++++----
 src/jsc/bindings/bindings.cpp                      |    7 +
 src/runtime/test_runner/Execution.rs               |   24 +-
 src/runtime/test_runner/bun_test.rs                |    6 +
 src/runtime/test_runner/jest.rs                    |   71 +-
 test/expectations.txt                              |    8 +
 test/js/node/test/common/index.js                  |    4 +-
 .../test-mock-timers-abortsignal-timeout.js        |   21 +
 .../parallel/test-runner-aftereach-runtime-skip.js |   36 +
 test/js/node/test/parallel/test-runner-aliases.js  |    8 +-
 .../test/parallel/test-runner-custom-assertions.js |   63 +
 .../test/parallel/test-runner-get-test-context.js  |  120 ++
 .../test/parallel/test-runner-mock-timers-date.js  |  130 ++
 .../parallel/test-runner-mock-timers-scheduler.js  |  122 ++
 .../node/test/parallel/test-runner-mock-timers.js  |  905 +++++++++
 test/js/node/test/parallel/test-runner-mocking.js  | 1286 +++++++++++++
 .../test/parallel/test-runner-option-validation.js |   26 +
 .../test/parallel/test-runner-tags-inheritance.mjs |  118 ++
 .../test/parallel/test-runner-test-filepath.js     |   52 +
 .../test/parallel/test-runner-test-fullname.js     |   48 +
 .../node/test/parallel/test-runner-typechecking.js |   28 +-
 test/js/node/test/parallel/test-runner-wait-for.js |  124 ++
 .../node/test_runner/fixtures/05-test-in-test.js   |   67 +-
 .../node/test_runner/fixtures/06-hook-semantics.js |   66 +
 .../node/test_runner/fixtures/07-failing-hooks.js  |   67 +
 test/js/node/test_runner/fixtures/08-only-no-op.js |   21 +
 .../node/test_runner/fixtures/09-i
... (truncated)

gate history · 8 passed · 2 rejected · iteration 13

evidence per changed file
file                                                      reads  edits  tests
.claude/skills/verify/SKILL.md                                0      0      0
docs/runtime/nodejs-compat.mdx                                0      0      0
src/codegen/generate-js2native.ts                             0      0      0
src/js/internal/test_runner/mock_timers.ts                    1      0      0
src/js/node/net.ts                                            0      0      0
src/js/node/test.ts                                           8     10      0
src/jsc/bindings/bindings.cpp                                 0      0      0
src/runtime/test_runner/Execution.rs                          4      3      0
src/runtime/test_runner/bun_test.rs                           4      0      0
src/runtime/test_runner/jest.rs                               1      1      0
test/expectations.txt                                         2      1      0
test/js/node/test/common/index.js                             0      0      0
…e/test/parallel/test-mock-timers-abortsignal-timeout.js      1      0      0
…ode/test/parallel/test-runner-aftereach-runtime-skip.js      0      0      0
test/js/node/test/parallel/test-runner-aliases.js             0      0      0
…/js/node/test/parallel/test-runner-custom-assertions.js      0      0      0
(+ 34 more files)

@robobun

robobun commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:05 AM PT - Jul 17th, 2026

@cirospaciari, your commit e8ab4ad77b9ecb4fffae8da4c616e56e21e201e6 passed in Build #74414! 🎉


🧪   To try this PR locally:

bunx bun-pr 32631

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

bun-32631 --bun

@mintlify

mintlify Bot commented Jun 23, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 23, 2026, 4:14 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. Support the node:test built-in API #5090 - Umbrella issue for node:test support; this PR substantially advances it with subtests, hooks, mocking, mock timers, plan, waitFor, done-callback, tags, and more
  2. node:test does not implement done #32527 - Implements the missing done callback (t, done) => {} pattern
  3. Support node:test mock #24255 - Adds per-test MockTracker (t.mock) with mock.fn, mock.property, mock.reset, and mock timers
  4. node:test argument t of test is undefined #26170 - Fixes t argument being undefined in hook callbacks by properly passing TestContext to hooks
  5. node:test async tests exceeding 5s timeout fail in Bun but pass in Node #27422 - Fixes timeout handling to match Node.js behavior (proper timeout option enforcement)
  6. node:test: assertion failures inside async callback (done-style test) are ignored and test incorrectly passes #28501 - Fixes assertion failures inside done-style async callbacks being silently ignored

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #5090
Fixes #32527
Fixes #24255
Fixes #26170
Fixes #27422
Fixes #28501

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:test: support the (t, done) callback signature in tests and hooks #28502 - Also modifies src/js/node/test.ts to fix done-callback support in node:test, which this PR implements as part of its broader feature set
  2. TestContext not passed to hooks in node:test #26185 - Also modifies src/js/node/test.ts to pass TestContext to beforeEach/afterEach hooks, which this PR implements as part of its hooks overhaul

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

node:test harness expansion

Layer / File(s) Summary
MockTimers implementation
src/js/internal/test_runner/mock_timers.ts
Adds virtual timers, mocked Date and scheduler APIs, validation, lifecycle controls, and the MockTimers export.
MockTracker and property mocking
src/js/node/test.ts, test/js/node/test/parallel/test-runner-mocking.js
Refactors mocking state, adds property mocking and lazy timer access, and covers spies, descriptors, call tracking, replacement implementations, and restoration.
Assertions and test-context state
src/js/node/test.ts, test/js/node/test/parallel/test-runner-*.js
Adds custom assertions, context-bound assertions, async test-context lookup, hooks, tags, option validation, metadata, and waitFor() behavior.
MockTimers validation and API coverage
test/js/node/test/parallel/test-runner-mock-timers*.js
Covers global timers, promise timers, intervals, Date, scheduler waits, abort handling, reset behavior, and timer method compatibility.
Nested test behavior and runtime integration
test/js/node/test_runner/..., src/runtime/test_runner/..., test/js/node/test_runner/node-test.test.ts
Updates nested tests to execute as subtests, tracks callback execution state for runtime skip/todo handling, expands integration runner controls, and updates verification guidance and expectations.
Compatibility documentation
docs/runtime/nodejs-compat.mdx
Documents supported node:test behavior under bun test, remaining unsupported features, and the recommendation to use bun:test.

DNS IP validation

Layer / File(s) Summary
Non-string IP rejection
src/js/node/net.ts
Requires DNS lookup results to be strings before applying IP validation.

Possibly related PRs

  • oven-sh/bun#33832: Updates related runtime test-runner tracking for the currently executing callback entry.

Suggested reviewers: jarred-sumner, robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly names the main node:test additions and the added test coverage, matching the changeset.
Description check ✅ Passed The description matches the required template and includes both the PR purpose and verification details.

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

@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: 10

🤖 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 `@src/js/internal/test_runner/mock_timers.ts`:
- Around line 745-749: The runAll() method incorrectly uses peekBottom() to find
the maximum runAt time in a min-heap, but peekBottom() does not reliably return
the longest timer in a min-heap structure, causing runAll() to potentially stop
early and miss pending timers. Additionally, this approach can compute a
negative delta when only immediate timers are present, causing tick() to fail
argument validation. Instead of using peekBottom(), find the actual maximum
runAt value from all timers in the execution queue, and ensure the calculated
delta is non-negative before passing it to the tick() method to handle edge
cases like immediate-only queues correctly.
- Around line 107-117: The issue is that after reset() is called, previously
returned Timeout objects retain their stale priorityQueuePosition values. When
those Timeout objects are later disposed via close() or Symbol.dispose, it
causes removeAt() to be invoked with invalid indices that can drive `#size`
negative and corrupt the heap. To fix this, in the reset() method, before
clearing the heap array, iterate through all Timeout handles that are being
removed and clear their priorityQueuePosition property to undefined so that
subsequent close() or dispose() calls won't attempt to remove them from the
heap.
- Around line 684-691: Duplicate entries in the internalOptions.apis array can
cause the `#storeOriginal`*() methods to capture mocked descriptors instead of
original ones, breaking restoration on reset(). Add validation to ensure no
duplicate API names exist in the internalOptions.apis array before assigning it
to this.#timersInContext. This can be done by converting the array to a Set and
comparing sizes, or by tracking seen APIs during the existing validation loop
and throwing an error if a duplicate is encountered.

In `@src/js/node/test.ts`:
- Around line 217-231: The methods in getAccessValue and mockImplementationOnce
directly call Map.prototype methods (.has(), .get(), .delete(), .set()) on the
this.#onceValues Map object, which can be overridden by users and break the
functionality. Replace all Map access and mutation calls on this.#onceValues
with Bun's private Map intrinsics (using the $ prefix) in both the
getAccessValue method (where .has(), .get(), and .delete() are called) and the
mockImplementationOnce method (where .set() is called). Apply the same
tamper-resistant pattern to the other internal registries mentioned in lines
540-578.
- Around line 1147-1149: The createHook function is accepting and returning hook
options like signal and timeout from parseHookArgs, but these options are being
silently dropped by all callers who only extract the fn property, making
unsupported options accepted without any effect or error. Either wire these
options into the actual hook execution logic throughout the codebase to make
them functional, or reject them upfront by checking if options contains signal
or timeout properties and throwing an error indicating these options are not
supported, rather than silently accepting and ignoring them.
- Around line 171-180: The restore logic is spreading an accessor descriptor's
get/set properties along with a value property, which creates an invalid
descriptor that causes Object.defineProperty() to throw. When restoring the
property in the Object.defineProperty() call, check if the original descriptor
is an accessor descriptor by testing for get/set properties. If it is an
accessor descriptor, only include get, set, configurable, and enumerable in the
new descriptor while excluding value and writable. If it is a data descriptor,
include value, writable, configurable, and enumerable. Apply the same fix to all
property restoration points including the ones around lines 238-242.
- Around line 1425-1434: The branch that handles inline subtests created inside
a running test is not incrementing the parent plan count, which causes
inconsistent plan validation between global test() calls and t.test() calls.
Call planCount(runningNode) in this branch (where runningNode is defined and
runningNode.isRunning() is true) before scheduling the subtest with
scheduleSubtest to ensure the parent's plan count is incremented consistently
regardless of which spelling (t.test() or global test()) created the inline
subtest.
- Around line 708-710: The getTestContext() function always returns
node.getCtx() regardless of whether the current node is a suite or a test. When
a suite is the active node, the function should return the SuiteContext via
node.getSuiteCtx() instead of the TestContext. Modify getTestContext() to check
if the current node is a suite node and conditionally return either
node.getSuiteCtx() for suite nodes or node.getCtx() for test nodes.
- Around line 1345-1351: When `runOwnBeforeHooks(suite)` throws an error, the
catch block in the `run` async function is silently swallowing the error without
incrementing `suite.failedSubtests` to track the failure. Inside the catch
block, add logic to increment `suite.failedSubtests` so that before hook
failures are properly recorded and the parent suite can accurately track that
the setup failed.
- Around line 367-399: The restore function in this mocking logic always defines
the descriptor back onto objectOrFunction using Object.defineProperty, but when
the descriptor was originally found on a prototype (inherited property rather
than an own property), this creates an unwanted own property shadow. To fix
this, check whether the descriptor was originally an own property of
objectOrFunction before the while loop, and in the restore function,
conditionally either delete the own property (if it was inherited) or redefine
it (if it was an own property). This ensures the object is restored to its
original shape after restoreAll is called.
🪄 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: 0ec46dab-8954-4fa9-a64e-2d9fe5469646

📥 Commits

Reviewing files that changed from the base of the PR and between 98094dc and adfaf69.

📒 Files selected for processing (20)
  • docs/runtime/nodejs-compat.mdx
  • src/js/internal/test_runner/mock_timers.ts
  • src/js/node/net.ts
  • src/js/node/test.ts
  • test/js/node/test/parallel/test-runner-aftereach-runtime-skip.js
  • test/js/node/test/parallel/test-runner-aliases.js
  • test/js/node/test/parallel/test-runner-custom-assertions.js
  • test/js/node/test/parallel/test-runner-get-test-context.js
  • test/js/node/test/parallel/test-runner-mock-timers-date.js
  • test/js/node/test/parallel/test-runner-mock-timers-scheduler.js
  • test/js/node/test/parallel/test-runner-mock-timers.js
  • test/js/node/test/parallel/test-runner-option-validation.js
  • test/js/node/test/parallel/test-runner-tags-inheritance.mjs
  • test/js/node/test/parallel/test-runner-test-filepath.js
  • test/js/node/test/parallel/test-runner-test-fullname.js
  • test/js/node/test/parallel/test-runner-typechecking.js
  • test/js/node/test/parallel/test-runner-wait-for.js
  • test/js/node/test/parallel/test-set-http-max-http-headers.js
  • test/js/node/test_runner/fixtures/05-test-in-test.js
  • test/js/node/test_runner/node-test.test.ts
💤 Files with no reviewable changes (1)
  • test/js/node/test/parallel/test-set-http-max-http-headers.js

Comment thread src/js/internal/test_runner/mock_timers.ts
Comment thread src/js/internal/test_runner/mock_timers.ts
Comment thread src/js/internal/test_runner/mock_timers.ts
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts
@lenovouser

Copy link
Copy Markdown
Contributor

Awesome addition - have been waiting a while for this! ❤️

Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread test/js/node/test/parallel/test-runner-mock-timers.js
Comment thread src/js/node/test.ts Outdated
Reimplements the node:test shim so that the in-process API matches Node:

- t.test()/t.describe() and global test()/describe() called inside a
  running test now run as inline subtests (previously NotImplementedError);
  the parent waits for them and fails when a subtest fails.
- beforeEach/afterEach are run by the module itself with Node's context
  arguments and inheritance order; before/after still map to bun:test
  beforeAll/afterAll but receive the owning suite/root context.
- New: getTestContext(), t.plan() (including the wait option), t.waitFor(),
  runtime t.skip()/t.todo(), test tags (validation, case-insensitive
  dedup, inheritance, one-shot ExperimentalWarning), SuiteContext,
  fullName/filePath/passed/attempt, custom assertions via assert.register(),
  done-callback test functions, and top-level test() returning a promise.
- t.mock is now a per-test MockTracker that resets after the test;
  mock.property() and mock.timers (a port of Node's MockTimers and
  PriorityQueue, in src/js/internal/test_runner/mock_timers.ts) are added.
- Per-test timeouts are enforced by the shim with Node semantics; only
  timeouts above bun:test's 5s default are forwarded to bun:test.

node:net: a custom lookup callback that yields a non-string address now
emits ERR_INVALID_IP_ADDRESS like Node instead of connecting to the
stringified value.

Tests:
- Vendors 11 more Node v26.3.0 test_runner tests (18 total now pass) and
  refreshes test-runner-aliases.js / test-runner-typechecking.js to
  verbatim upstream copies.
- Rewrites the 05-test-in-test.js fixture to assert subtests work instead
  of asserting they throw NotImplementedError.
- Removes test-set-http-max-http-headers.js: it only passed because
  callback-style node:test functions never waited for their callback; it
  spawns test-http-max-http-headers.js, which is not vendored, so it
  cannot pass meaningfully.
- Updates the node:test entry in docs/runtime/nodejs-compat.mdx.

@sosukesuzuki sosukesuzuki left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Runtime t.skip()/t.todo() gets reported as pass under --concurrent:

    // repro.js
    const test = require("node:test");
    test("alpha", (t) => { t.skip("runtime skip"); });
    test("beta",  (t) => { t.todo("runtime todo"); });
    $ bun bd test --config=bunfig.node-test.toml ./repro.js
     0 pass / 1 skip / 1 todo
    $ bun bd test --config=bunfig.node-test.toml --concurrent ./repro.js
     2 pass / 0 fail

Deterministic, and reachable via concurrentTestGlob in bunfig.toml too. Looks like
get_current_state_data() returns entry_data: None for a concurrent group
(bun_test.rs:704), so the mark gets dropped at jest.rs:582 and
createTopLevelTestRunner falls through to done(undefined).

Your own test-runner-aftereach-runtime-skip.js goes 1 pass/2 skip -> 2 pass/1 skip
under --concurrent and still exits 0.

@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/parallel/test-runner-mocking.js`:
- Around line 587-589: Remove the Bun/JSC-specific private-field and
defineProperty assertion changes from the vendored test-runner mocking test,
restoring parity with Node v26.3.0 and its original assertions. Move equivalent
coverage for JSC-specific error behavior into Bun-owned tests instead, leaving
test/js/node/test/parallel/test-runner-mocking.js vendor-aligned.
🪄 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: fdadf401-f868-485d-ad62-34d9f5a617b9

📥 Commits

Reviewing files that changed from the base of the PR and between eb655a2 and 884abfb.

📒 Files selected for processing (3)
  • src/js/node/test.ts
  • test/expectations.txt
  • test/js/node/test/parallel/test-runner-mocking.js

Comment thread test/js/node/test/parallel/test-runner-mocking.js
@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt

Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread test/js/node/test/parallel/test-runner-mocking.js
…root, oldRoot.mockTracker reset

markCurrentResult's fallback for the 'not yet stamped' DoneCallback window
(when .then() fires inside run_test_callback's microtask drain) used
get_current_state_data(), which can't name a sequence inside a concurrent
group, so the skip/todo mark was dropped and the test was reported pass.
Execution now stashes the callback's own EntryData alongside on_stack_entry
(set/restored around run_test_callback), and js_node_test_mark_result reads
that instead. New --concurrent case in node-test.test.ts pins it.

getTestContext() now checks node.parent !== undefined like hookArgFor()
does, so a file-level before/after hook (root has isSuite=true,
parent=undefined; Node's root is a Test) gets a TestContext.

getRootNode()'s isNewFile branch now also resets the outgoing root's own
mockTracker (reachable via a file-level before hook's t.mock, distinct from
the module-level mock export).
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

@sosukesuzuki good catch, thank you. Fixed in cd40cb3: your diagnosis was exactly right. get_current_state_data() cannot name a sequence inside a concurrent group, and js_node_test_mark_result hits that fallback when .then() fires during run_test_callback's microtask drain (before the DoneCallback is stamped). Execution now stashes the callback's own EntryData in on_stack_entry_data (set/restored alongside on_stack_entry around run_test_callback), and the mark reads that instead.

Verified with your repro:

$ bun bd test --config=bunfig.node-test.toml --concurrent ./repro.js
 0 pass / 1 skip / 1 todo / 0 fail

test-runner-aftereach-runtime-skip.js under --concurrent is now 1 pass / 2 skip as well. Pinned by a new --concurrent case in node-test.test.ts (fail-before: 5 pass; after: 3 pass / 1 skip / 1 todo).

@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 97-99: Remove the implementation-history comment describing
markCurrentResult's microtask-drain fallback and the dropped skip/todo mark;
leave the test setup, actions, test name, and assertions unchanged.
🪄 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: c0fffd91-cbf0-481d-b81c-5282de1efcde

📥 Commits

Reviewing files that changed from the base of the PR and between 884abfb and e8ab4ad.

📒 Files selected for processing (4)
  • src/js/node/test.ts
  • src/runtime/test_runner/Execution.rs
  • src/runtime/test_runner/jest.rs
  • test/js/node/test_runner/node-test.test.ts

Comment thread test/js/node/test_runner/node-test.test.ts

@sosukesuzuki sosukesuzuki left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, great work.

But one comment about current limitation I found.

bun:test 5s watchdog is still a floor, so a sync block past it diverges from node even with a timeout set.

    test("x", { timeout: 50 }, () => { const e = Date.now()+8000; while (Date.now()<e) {} });
    node: pass    bun: fail "timed out after 5000ms"

I think this rarely happens. So it's enough to note it in the PR description and follow up later.

@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

@sosukesuzuki thanks, added to the Known divergences section of the PR body. You are right about the mechanism: bunTestOptions() forwards max(N, 5s) to bun:test's watchdog so a lower --timeout cannot cut a node-style timeout short, and that 5s floor is what kills the sync-spin case even though the shim's own createStopController race would pass it (its timer cannot fire until the body yields, same as Node). Forwarding Infinity instead would disable the watchdog for every node-style timed test and let a genuinely hung one block the whole run, so I left it as a deliberate trade-off for now and noted it as follow-up.

@cirospaciari
cirospaciari merged commit 1b2bb4e into main Jul 17, 2026
79 checks passed
@cirospaciari
cirospaciari deleted the claude/node-test-runner-v26 branch July 17, 2026 18:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants