node:test: run(), expectFailure, and Node v26.3.0 skip/todo semantics
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: aReadablein objectMode with Node's buffering, emitting each message as both an event and a stream chunk.- Each file runs in its own
bun testchild, spawned withNODE_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-leveltest: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:dequeueup front,test:completeat the end, andtest:failwithfailureType: 'testCodeFailure'when the file itself dies (a top-level throw, a missing file). When the file's tests fail instead, the completion carriessubtestsFailedand notest:fail— which is what Node does. - Skip and todo directive events, which bun never emitted at all:
bun testdoesn't invoke those bodies, so nothing reported them. details.type, suites counted only insuites, andfailureTypepreserved 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 checksskipfirst, 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, andcommon.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-listenerandtest-net-server-async-dispose). Off by default — bun suites keep exit-after-tests. events.addAbortListenernow survivesstopImmediatePropagation: it registers a native abort algorithm (runs inrunAbortSteps()before event dispatch) instead of an'abort'listener, since the native EventTarget drops node's[kResistStopPropagation]option.node:eventshad a duplicate inline implementation bypassinginternal/abort_listener; it now delegates.Server[Symbol.asyncDispose]resolves when not listening (node'slib/net.jsguard); a second dispose used to reject withERR_SERVER_NOT_RUNNINGand 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