node:test: republish run() child events at the child's nesting

RO
3mo ago
+104-11

Problem

  • Under run({ files }) from node:test, every republished test:pass / test:fail event carries a nesting one higher than node's: a file's top-level tests come out at nesting 1 and their subtests at nesting 2. Node v26.3.0 reports 0 and 1, exactly what the file reports when run on its own.
  • Cause: republishChildEvent() in src/js/node/test.ts did data.nesting = (data.nesting ?? 0) + 1 on every event it forwarded from the child process. Node's FileTest#handleReportItem (lib/internal/test_runner/runner.js) forwards the child's nesting unchanged.
  • The file node that run() reports for a file (its test:enqueue / test:dequeue, and its verdict when the file had nothing else to report, failed, or was cancelled) always carried testId: 1 and testNumber: 1, whatever its position in the run. Node gives the second file 2, and so on.

Fix

  • republishChildEvent() forwards the child's nesting as-is.
  • runFiles() passes each file's 1-based position to runOneFile() / reportCancelledFile(), which use it as the file node's testId and testNumber.
  • Why this is correct: it matches node. Node's root creates one FileTest per file and numbers them in order; under process isolation it does not treat the file node as the parent of the file's tests, so the child's nesting values are the run's nesting values. Verified against node v26.3.0 on the test's files: the verdict sequence with its nesting column, and empty.js reporting as testNumber: 2, testId: 2, are node's output line for line (details below).
  • Counting is unchanged: topLevel and the run-level test:plan still count one item per file. Node counts the file's nesting-0 verdicts instead, but bun's child does not yet report top-level describe() suites (#39331 adds that), so counting nesting-0 verdicts here would report 0 top-level items for a describe-only file; that recount belongs with #39331. Subtest testNumber (node forwards the child's value; bun's child sends 0) is also unchanged.
  • Verified: test/js/node/test_runner/node-test.test.ts, new node:test run() block (two cases: a two-file run pinning the nesting column and the file node's number, and a run aborted before it starts, pinning both cancelled file nodes' numbers). Both fail on main (nesting 2/1/1/1, file node number 1) and pass with the fix. The other 45 cases in the file and the vendored run() tests (test-runner-todo-skip-tests.js, test-runner-expect-error.js, test-runner-expect-error-but-pass.js, test-runner-filetest-location.js, test-runner-tags-experimental-warning.mjs) still pass.
  • #34515 rewrites this code path as part of a much larger change and also drops the +1; this is the standalone fix for the bug on main today.

Background

  • run() is node:test's programmatic runner. With the default isolation: 'process' it runs each file in a child process; the child reports its tests as events and the parent republishes them on the TestsStream that run() returned, then appends the run-level test:plan, diagnostics and test:summary. Bun's run() does the same with a bun test child that prints one JSON event per line.
  • nesting is a test's depth in the event stream: a file's top-level tests are 0, t.test() subtests inside them are 1, and so on. Reporters indent by it. Under process isolation node keeps the child's values; the file node that the parent synthesizes for each file also sits at nesting 0.
  • The file node is node's FileTest: a test-shaped entry per file, enqueued and dequeued as the file starts, and given a verdict of its own only when the file had nothing else to report or the process itself failed (FileTest#skipReporting); a file whose tests merely failed is represented by those tests. Its testId and testNumber are its position among the run's files.
node v26.3.0 vs bun for the files used in the test

nested.js (top with subtest sub, a failing test, a skipped test) and empty.js (no tests), run in that order. Columns: event, name, nesting.

node v26.3.0                    bun main                        bun with this change
test:pass  sub        1         test:pass  sub        2         test:pass  sub        1
test:pass  top        0         test:pass  top        1         test:pass  top        0
test:fail  failing    0         test:fail  failing    1         test:fail  failing    0
test:pass  skipped    0         test:pass  skipped    1         test:pass  skipped    0
test:pass  empty.js   0         test:pass  empty.js   0         test:pass  empty.js   0
  (testNumber 2, testId 2)        (testNumber 1, testId 1)        (testNumber 2, testId 2)
Earlier revision of this PR

The first push also moved topLevel / testNumber onto node's model on the parent side: each nesting-0 verdict counted as a top-level item and was numbered across files, and the file node only counted when reported. That is right once the child reports every top-level item, but today a top-level describe() emits no verdict (#39331 adds it), so a describe-only file would have reported test:plan count 0 and topLevel: 0 where main and node both report 1. That part was dropped from this PR; the counting on main is unchanged.