node:test: run(), expectFailure, and Node v26.3.0 skip/todo semantics

Merged
CI
4mo ago
+1474-63

Implements node:test's run() and raises the surrounding node:test behavior to Node v26.3.0. Stacked on #34443 (its first commit), which is landable on its own.

Every behavior below was diffed against the real node v26.3.0 binary, not read off the source.

run()

  • Node-exact option validation in Node's order (runner.js:731-909), including the mutually-exclusive pairs: forceExit×watch, shard×watch, globPatterns×files, env×isolation:'none'.
  • TestsStream: a Readable in objectMode with Node's buffering, emitting each message as both an event and a stream chunk.
  • Each file runs in its own bun test child, spawned with NODE_TEST_CONTEXT (Node's own variable — upstream tests branch on it to tell parent from child). The child streams one JSON event per line; the parent republishes, aggregates, and emits a per-file and a run-level test:summary.
  • Node's recursion guard, so a file calling run() on itself doesn't fork forever.
  • Options that can't be honored yet (watch, coverage, shard, isolation:'none', globPatterns, globalSetupPath) throw rather than being silently ignored.

Event fidelity added in this round, each verified against Node:

  • The file-level test node that process isolation reports: test:enqueue/test:dequeue up front, test:complete at the end, and test:fail with failureType: 'testCodeFailure' when the file itself dies (a top-level throw, a missing file). When the file's tests fail instead, the completion carries subtestsFailed and no test:fail — which is what Node does.
  • Skip and todo directive events, which bun never emitted at all: bun test doesn't invoke those bodies, so nothing reported them.
  • details.type, suites counted only in suites, and failureType preserved across the child process boundary.

expectFailure (xfail)

Node v26's expectFailure was missing entirely. Adds the option parser (string label, function/RegExp validator, object form with label/match, and the empty-object rejection), the inverted verdict — a failing body is the expected outcome, a passing one fails with failureType: 'expectedFailure' — and the expectFailure field on the reported event.

Two Node divergences reachable from plain bun test

Not run()-specific; these affect anyone using node:test under bun today.

  • A skipped suite ran its callback. Node never invokes it, so its children are never declared and its side effects never happen. bun ran the body and registered the children.
  • { skip: true, todo: true } was treated as a todo. Node checks skip first, for both tests and suites.

Test-runner harness

A vendored test that only drives run() is the parent of that run, not a test file — Node executes it as a plain script, and under bun test a file that registers no tests of its own exits before its run() finishes, delivering zero events. The runner now picks bun run for those, gated so that a file with any unindented registration of its own keeps bun test rather than silently "passing" having tested nothing. 4 of the 88 vendored node:test files change subcommand, all of them added by this PR.

Coverage

Vendors 5 upstream tests — test-runner-expect-error, test-runner-expect-error-but-pass, test-runner-todo-skip-tests, test-runner-filetest-location, test-runner-tags-experimental-warning — taking the upstream test_runner suite from 20 to 26 of 81. bun's own node-test.test.ts goes 40 → 44 (new fixtures cover expectFailure and the suite-skip semantics). All 20 previously-vendored test-runner-* files still pass.

Still draft: the reporting seam

A test whose failure is decided after its body resolves is still reported as passing by run(). Repro: test/fixtures/test-runner/plan/timeout-basic.mjs — run directly under bun test it is correctly 1 pass / 1 fail (bun's plan logic is fine), but the JS layer has already returned by the time the native runner attributes the async throw, so run() sees two passes.

I confirmed the JS layer cannot close this: bun's bun test intercepts in-process throws natively and attributes them itself, so process.on('uncaughtException') — the mechanism Node's own harness.js:255 uses — never fires for an in-test throw. A faithful run() needs a reporter hook in the native runner, emitting where bun actually decides pass/fail (Execution::on_sequence_completed, src/runtime/test_runner/Execution.rs, already the single fan-out point for the CLI reporter, the junit writer and the inspector agent).

That's a maintainer design call, which is why this stays draft. Happy to implement it in this PR or a follow-up — it's also the unlock for --test-style CLI emulation and node:test/reporters, which together account for ~25 of the remaining 55 upstream test_runner files.

Update (2026-07-17)

Three follow-up commits landed on this branch after review:

  • Event-loop drain for node tests (BUN_TEST_DRAIN_EVENT_LOOP=1, set by the vendored-test runner; also on for run() children): a node test process only exits when its loop drains, and common.mustCall() verifies counts in 'exit' handlers. This fixes the five vendored tests that started failing once exit handlers ran (test-worker-arraybuffer-zerofill, test-file-write-stream5, test-tls-psk-alpn-callback-exception-handling, and with the two fixes below, test-events-add-abort-listener and test-net-server-async-dispose). Off by default — bun suites keep exit-after-tests.
  • events.addAbortListener now survives stopImmediatePropagation: it registers a native abort algorithm (runs in runAbortSteps() before event dispatch) instead of an 'abort' listener, since the native EventTarget drops node's [kResistStopPropagation] option. node:events had a duplicate inline implementation bypassing internal/abort_listener; it now delegates.
  • Server[Symbol.asyncDispose] resolves when not listening (node's lib/net.js guard); a second dispose used to reject with ERR_SERVER_NOT_RUNNING and re-emit 'close'.

Each verified byte-identical to the node v26.3.0 binary on minimal repros.


no test proof · iteration 26 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/test/bun-test.test.ts