Skip to content

node:test: hand run({ argv }) to the test file in the child's environment - #46847

Open
robobun wants to merge 2 commits into
mainfrom
robobun/8e81202b/node-test-run-argv
Open

robobun wants to merge 2 commits into
mainfrom
robobun/8e81202b/node-test-run-argv

Conversation

@robobun

@robobun robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • run({ files, argv }) of node:test must give argv to each test file. The child bun test parses it, and the file reads []. argv: ["--only"] runs 0 of 4 tests and passes.
  • The cause is runOneFile (src/js/node/test.ts:456): [execPath, ...execArgv, "test", file, ...argv].
  • No user reported it.

Fix

  • runOneFile puts the file path and the values in the child's BUN_INTERNAL_NODE_TEST_ARGV, not on its command line.
  • TestCommand::exec (src/runtime/cli/test_command.rs) decodes it into process.argv.slice(2) before a preload runs, then removes it from process.env. Correct because bun test parses no caller value.
  • Verified: test/js/node/test_runner/node-test.test.ts (19 new tests, 15 fail on main), the vendored test-runner-* files and test/cli/test/parallel.test.ts. Node.js v26.3.0 passes the shared fixture.
  • Self-reviewed: 72 concerns raised, 69 addressed. Rejected: a shared entry reader (the others skip bad entries), worker: resolve fetch() proxy env (and the rest of the env loader) from the worker's own environment #41932's Worker loader change (its own PR), the gate below.

Background

  • run() starts each file as a bun test child, which parses every token after the file. Node.js starts node <file> ...argv.
  • Weighed a one-line ERR_NOT_IMPLEMENTED gate for argv: it ends the false pass and delivers nothing. Take it if you do not want the variable. A count flag and bun test -- <args> (bun test: forward args after -- to process.argv #35622) change the command line.

Downsides

  • argv: ["--bail"] no longer reaches bun test. Use execArgv with --flag or --flag=value.
  • Each bun test start: +13 instructions, 0 new getenv calls. .text grows 1,536 B.
  • All of argv shares one environment string: 128 KiB on Linux, where Node.js allows that for each value. argv with --parallel workers is an error.
Notes

What the child is given

  • run({ files: ["a.test.mjs"], argv: ["--only", "x"] }) starts bun test /abs/a.test.mjs, as main does for an empty argv. The environment of the child has BUN_INTERNAL_NODE_TEST_ARGV=15:/abs/a.test.mjs6:--only1:x. Each entry is its UTF-8 byte length, : and the bytes. The first entry is the file, from the path that run() resolved.
  • bun test reads the variable after it has loaded its environment, before bunfig preloads, --preload and the file. The values are not in process.execArgv. A value that does not decode prints an error and exits 1. An empty value is no value.
  • bun test then removes the variable from its environment map. process.env and Bun.env of the test file do not have it. A process that the main thread starts does not have it, through Bun.spawn without env, node:child_process or Bun.$. So a test file that starts bun test on its own path reads [] there, as in Node.js.
  • argv is read when each file starts, as in Node.js. A value pushed after run() returns, or in setup, reaches the files.
  • With an empty argv, runOneFile sets the variable to undefined. So a variable that the caller's own process has does not reach the child.
  • A bun test reads the variable only when BUN_TEST_DRAIN_EVENT_LOOP is set, which run() sets in the same object. Every bun test already reads that flag once, so a plain start does not look the new variable up. Both are read from the process environment, not from .env files.
  • The variable names its file. A bun test takes the values only when that file is one of the paths on its command line. This is the second check, for a process that still inherits the variable (see Limits).
  • A NUL in execArgv, the file or argv throws ERR_INVALID_ARG_VALUE for args[N], for the first argument of the command line that has one, as on main. The name of the variable is not in a message.

Node.js and Bun on the same file

$ node test/js/node/test_runner/fixtures/41-run-argv.mjs      # v26.3.0
✔ the test file reads argv in process.argv and nothing applies it
✔ argv is set before a preload, read when the files start, and only for those files
ℹ pass 2
ℹ fail 0

$ bun test ./test/js/node/test_runner/fixtures/41-run-argv.mjs  # this branch
 2 pass
 0 fail

$ bun test ./test/js/node/test_runner/fixtures/41-run-argv.mjs  # main
 0 pass
 2 fail
  • The two tests call run() 10 times. That starts 12 processes, one of them from a test file. The cases: no argv, flags of bun and bun test, paths of other test files, values that a parser or a shell changes, -- in execArgv, --only and --test-only on a file with 4 tests, a preload and a CommonJS file, two files with argv changed after the call, a file that starts itself once more, and the case of Node's test-runner-run.mjs.
  • The repro of the report: run({ files: [only.test.mjs], argv: ["--only"] }) gives 4 test events under Node.js, 1 on main, 4 on this branch. For ["--test-only"]: 4, 4, 4.
  • On main, a value of argv is an option of the child bun test. --preload x loads a module, --watch never ends, . runs every test file of the directory, --only and --help report a pass with 0 tests run.
  • The one upstream test that calls run({ argv }), test/parallel/test-runner-run.mjs, imports node:test/reporters, which Bun does not have. So this PR adds no passing Node.js test file. Its case should allow custom arguments via argv is in the fixture with its file print-arguments.js.
  • One difference stays in the NUL case: Node.js reports the error as the test:fail of the file and goes on. Bun ends the stream with it, on main too, as for every child that it cannot start.

Who this is for

Measurements (release builds, linux-x64, merge base 5e18294 against this branch)

  • libc getenv calls on bun test ./one.test.ts: 61 to 61. The new variable is not read. A run() child without argv: 61 to 62. A plain --parallel coordinator: 60 to 60 (gdb).
  • Instructions added to a plain bun test start: +13. TestCommand::exec itself runs 5 more from its entry to its call of run_all_tests (1,030 to 1,035, calls stepped over), and one more cached read of the flag is 8 (gdb).
  • A plain --parallel run: +2 instructions in run_as_coordinator (113 to 115 up to the first worker environment).
  • Startup symbols byte-identical: 9 of 9 (objdump with addresses masked: Command::start, which, create_context_data, arguments::parse, ComptimeClap::parse_with_table, StreamingClap::next, exec_auto_or_run, write_context_no_parse, exec_test).
  • Release binary: +1,536 B .text (82,164,617 to 82,166,153), +0 B .rodata, equal file size. The new cold function is 979 B, TestCommand::exec grows 273 B and run_as_coordinator 80 B. The accessor adds 16 B of statics (size, nm).
  • A run() child with 3 values: 4 allocator entries and 1 free in the decoder (gdb).
  • In the caller, for each file: 5 to 4 cells for the command line and the environment object when argv is omitted. 5 to 21 with 8 values, of which 17 are strings (heapStats, N=2000, JIT off, measured on the old and the new expressions outside run()).
  • The command line of the child: the same entries with and without argv (/proc/self/cmdline read by the test file).
  • argv shapes with the same events as Node.js v26.3.0: 47 of 48 (main: 0 of 48).
  • Tools not installed here: perf, valgrind, strace, bloaty.

Differences from Node.js v26.3.0

  • argv: ["--env-file=nope"] is the one shape of the 48 that differs. node reads --env-file at any position of its command line and fails the file. Bun gives the value to the file.
  • On main the 48 shapes end like this: 31 run with [], 9 fail the file or the test, 4 also run a second test file, 2 pass with 0 tests run, 1 hangs, 1 runs the test twice.

Limits left

  • Size. All values share one environment string. Linux allows 128 KiB for one string. Measured with Node.js v26.3.0: for two values of 70,000 bytes, Node.js gives both to the file, main runs the file with [], and this branch ends the stream with E2BIG: argument list too long. For one value of 200,000 bytes all three fail with E2BIG. macOS has no limit for one string. On Windows one variable has the limit that the whole command line of Node.js has (32,767 characters). One variable for each value removes the limit. It needs a set of numbered names that the writer, the reader and the removal each walk. I kept one variable.

  • --parallel. A child of run() that has argv and starts workers exits 1 with run() of node:test cannot give argv to the workers of --parallel. A worker is not the process that run() starts, and run() gets no test events from a worker (the same on main). This needs a directory or a name filter in files, and --parallel in execArgv. Node.js fails a directory in files. With one test file there is no worker, and the file gets argv.

  • Worker threads. The removal is from the map of the main thread's VM. A Worker loads the environment of the process again (Loader::clone_for_worker, src/dotenv/env_loader.rs), so it still has the variable:

    In the test file process.env child of Bun.spawnSync, no env child of node:child_process
    main thread not set not set not set
    new Worker(url) set set set
    new Worker(url, {}) not set set not set
    node:worker_threads not set set not set

    One result changes with it: a bun test that a Worker starts for the same path reads the values of run(). worker: resolve fetch() proxy env (and the rest of the env loader) from the worker's own environment #41932 changes that loader (a Worker keeps the map of its parent). It is open and has conflicts. A test.failing case marks the first Worker row.

  • Of the three variables that run() sets, this PR removes one from the child's environment. NODE_TEST_CONTEXT is inherited as in Node.js. BUN_TEST_DRAIN_EVENT_LOOP still reaches the test file, its children and --parallel workers, as on main.

  • execArgv: ["-e", code] or -p: the child is not bun test. On main its script reads test, the file and the values as arguments. Now it reads test and the file, and its children inherit the variable.

  • A flag of bun test in execArgv must be one element: --timeout=60000. ["--timeout", "60000"] fails with Script not found "60000", on main too. For --preload and the other runtime flags that take a value, that is cli: take flag arity from the auto table when the subcommand is classified #41511.

  • execArgv: ["--only"] on a node:test file still reports a pass with 0 tests run. That is the --only rule of bun test (node:test: filter on only under --test-only #46797), not argv.

  • The other effects of the child line bun <flags> test <file> are not changed: the caller's runtime flags are not handed on (node:test: give each file of run() the runtime flags of its caller #45549), --eval= and --print= in execArgv do not evaluate, and a compiled executable starts itself again.

Seen in CI with the first commit, not changed here

  • The first commit gave argv to the workers of a --parallel child. Its three tests for that passed on Linux and macOS and timed out after 30 s on Windows 11 aarch64 and Windows 2019 x64 (build 132400). The other 60 tests of the file passed there.
  • In all three, a worker had BUN_TEST_DRAIN_EVENT_LOOP=1. In one of them no code of this PR ran in the worker past one lookup that found nothing.
  • My reading of the code, not run on Windows: with that flag a worker calls vm.on_before_exit() after each file (test_command.rs), which loops while the event loop is alive. On Windows the worker's pipe to the coordinator is a uv::Pipe that reads and is never unref'd (test/parallel/Channel.rs), so the loop stays alive and the worker does not report the file.
  • On Linux, main waits in the same place: BUN_TEST_DRAIN_EVENT_LOOP=1 bun test --parallel=2 does not end while a file has a timer, which is what the flag asks for. By my reading, on Windows the worker's own pipe has the same effect. run() sets the flag for its child on main, and the coordinator hands its environment to the workers.
  • This PR no longer starts workers with argv, so it has no test on that path. No issue tracks the hang.

Other designs

  • ERR_NOT_IMPLEMENTED for a non-empty argv: one line beside the other gates of run() (src/js/node/test.ts:320-332) and one test. It ends every false pass from argv with no native code and no variable. It breaks argv: ["--bail"] in the same way as this PR, and the test file still gets nothing. If you do not want to own the variable yet, I will send that.
  • A count flag. runOneFile appended argv and --node-test-argc=<n>, and exec_test cut that tail off the argv view before the parse, as boot_standalone does. I built and measured it: +16 to +26 instructions per bun test start, 47 of 48 shapes. The self-review found two defects that no small change removes. The flag is a legal value of another flag in the last position, so bun test f -t --node-test-argc=1 lost its name filter with no run() involved. A flag that is not last is skipped as unknown, and the values are flags of bun test again.
  • A declared flag for each value (--node-test-argv=<value>): the parser still reads the caller's bytes, and with execArgv: ["--"] they are file filters.
  • bun test <file> -- <args> (Unable to pass arguments to tests by using -- to separate args #9162, bun test: forward args after -- to process.argv #35622): a change of the command line for every user. This PR does not touch the command line, so it neither needs nor blocks that decision. If it lands, run() can use it and the variable can go.
  • A child in the shape of Node.js (bun <file> ...argv): node:test: --test CLI mode, node:test/reporters, standalone execution, and reporter-output parity (+18 tests, test_runner 32%→55%) #34515 lets bun <file> run a node:test file, and keeps bun test as the child of run(). A bun <file> child is a separate decision: it fails files that use bun:test, and it ignores bun test flags in execArgv and [test] of bunfig. The variable stays for as long as the child is bun test.
  • A parser stop after the file, as bun run <file> <args> has: it changes bun test a.test.ts --timeout 5000 unless the stop depends on the environment.
  • A carrier in JavaScript only (--preload=node:test and the variable): a preload from bunfig.toml loads before it and reads [].
  • A skip of the variable in Loader::load_process, as for NODE_CHANNEL_FD: it also hides the variable from Workers. It adds one comparison for each environment entry to every start of every command.

Questions for a maintainer

  1. Do you want argv delivered now, or the ERR_NOT_IMPLEMENTED gate until bun test has a place for arguments of the file?
  2. Is an environment variable the carrier you want for the options of run()? Three open PRs touch the same object: node:test: enforce run({ timeout }) as the inherited per-test timeout #36449 (BUN_NODE_TEST_RUN_TIMEOUT), node:test run(): report child events in byte frames that printed text cannot produce #45003 (BUN_NODE_TEST_RUN_FRAMES) and node:test: filter on only under --test-only #46797 (a --test-only flag on the command line). The names can follow whichever you pick.

Other open PRs

Tests

  • New: the fixture 41-run-argv.mjs (2 node:test tests, for both runtimes) and 19 tests in describe("node:test run({ argv })"). On a release build of main 15 of the 19 fail. The four that pass on both are the Node.js run of the fixture, the marker for the Worker limit, and two cases where a bun test must not take the variable.
  • One test has a timeout of its own, 30 s, as the three fixture tests at the top of the file: the fixture starts 12 bun test children and takes 7 s to 10 s on a debug build. The tests whose child runs a test file are not concurrent, so each has the default timeout for its own child (1.2 s to 2.0 s on a debug build). As concurrent tests they took 2.2 s to 4.8 s, and 1 run of 5 went past 5 s.
  • Debug build, this branch: test/js/node/test_runner/node-test.test.ts 64 pass. The vendored test-runner-* files, run as CI runs them: 25 of 25. test/cli/test/parallel.test.ts and parallel-startup-failure.test.ts: 49 pass, 1 skip.
  • One test of that file, each worker has a unique JEST_WORKER_ID and BUN_TEST_WORKER_ID, depends on timing in a debug build. In 16 runs each it passed 7 times on the first commit and 9 times on main.
  • test/cli/test/{bun-test,isolation,rerun-each,pass-with-no-tests,test-shard}.test.ts: 197 pass, 6 todo, 2 fail. test/internal/source-lints/: 181 pass, 2 fail. The four failures are tests that need more than the default 5 s on a debug build (two nested too deeply to print cases, and two of sema-parser-recursion.test.ts). They pass on a release build of this branch.
  • bun run rust:check-all: 12 of 12 targets.
  • Windows and macOS: not run here. CI ran the first commit on both. The second commit has no platform branch.

Self-review

  • First review, of the count flag: 36 concerns raised, 36 addressed. The largest one replaced the design.
  • Second review, of the environment variable: 16 concerns raised, 15 addressed. Not done: one reader for this list and for the two lists in script_or_command.rs. Those two stop at the first entry that does not decode, and this one must fail there.
  • Third review, after the first CI run: 20 concerns raised, 18 addressed. The --parallel worker path is gone, the file comes from the resolved path, the two-file case is back, the docs say where flags of bun test go, a test reads the environment that Bun.spawn gives, and this body says who asked.
  • Not done: the one-line change of Loader::clone_for_worker that closes the Worker limit. It changes what every Worker loads, and worker: resolve fetch() proxy env (and the rest of the env loader) from the worker's own environment #41932 has it.
  • Not done: the ERR_NOT_IMPLEMENTED gate in place of this change. It is the first question above.

…ment

run() started each file as `bun test <file> ...argv`, and `bun test` reads
every token after the keyword as its own flag or file filter. The values never
reached process.argv of the test file.

The child's command line no longer has argv. run() puts the file and the values
in BUN_INTERNAL_NODE_TEST_ARGV, and `bun test` decodes them into
process.argv.slice(2) before a preload runs. A `bun test` that inherits the
variable and is started for another file ignores it.
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 179664e and 709dea3.

Files selected for processing: 7
  • docs/runtime/nodejs-compat.mdx
  • src/bun_core/env_var.rs
  • src/js/node/test.ts
  • src/runtime/cli/test/parallel/runner.rs
  • src/runtime/cli/test_command.rs
  • test/js/node/test_runner/fixtures/41-run-argv.mjs
  • test/js/node/test_runner/node-test.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Walkthrough

node:test’s run() now forwards each file’s argv through an internal environment variable. Bun decodes the arguments for the matching test file. Multi-file parallel runs reject nonempty arguments.

Changes

node:test argv forwarding

Layer / File(s) Summary
Encode and forward run() arguments
docs/runtime/nodejs-compat.mdx, src/bun_core/env_var.rs, src/js/node/test.ts, test/js/node/test_runner/fixtures/41-run-argv.mjs, test/js/node/test_runner/node-test.test.ts
run() encodes the file path and arguments in a length-prefixed value passed through the child environment. NUL bytes produce an invalid-argument error. The documentation and tests cover argv values, execArgv options, and child behavior.
Decode arguments for the matching test VM
src/runtime/cli/test_command.rs, test/js/node/test_runner/node-test.test.ts
The test command parses the encoded value and applies arguments only when its file matches a test positional. It removes the internal variable from the VM environment. Tests cover malformed, empty, matching, and inherited values.
Handle parallel test runs
src/runtime/cli/test/parallel/runner.rs, test/js/node/test_runner/node-test.test.ts
The parallel coordinator exits with status 1 when VM arguments are nonempty. Tests cover single-file and multi-file --parallel behavior.

Suggested reviewers: cirospaciari, jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · up to 709de

The reviewed argument-forwarding change has no established merge-blocking issue; it is ready for normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the main change: node:test run({ argv }) now delivers argv to the test file through the child's environment. It matches the changeset, which adds BUN_INTERNAL_NODE_TEST_ARGV, decodes i…
Description check Passed The description covers the problem, the fix, verification, and downsides. It also gives extra background, measurements, and open questions. The template has two sections, 'What does this PR do?' and '…
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.
Full details: Title check

Explanation

The title names the main change: node:test run({ argv }) now delivers argv to the test file through the child's environment. It matches the changeset, which adds BUN_INTERNAL_NODE_TEST_ARGV, decodes it in TestCommand::exec, and stops passing argv on the bun test command line. It is specific and concise.

Full details: Description check

Explanation

The description covers the problem, the fix, verification, and downsides. It also gives extra background, measurements, and open questions. The template has two sections, 'What does this PR do?' and 'How did you verify your code works?'. The description uses Problem, Fix, and Background headings instead. It does not use the template headings, but it contains the same information: what changes, and how it was verified. Verification is detailed with test counts, platforms, and Node.js comparison. Windows and macOS were not run, and the description says so.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:16 AM PT - Oct 25th, 2026

✅ @robobun, your commit 709dea3d4143d3d005e482db4712e72228360150 passed in Build #132614! 🎉


🧪   To try this PR locally:

bunx bun-pr 46847

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

bun-46847 --bun

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction, with two files:

// only.test.mjs
import { test, describe } from "node:test";
test.only("focused", () => {});
test("unfocused", () => {});
describe("d", { only: true }, () => { test("in-only-suite", () => {}); });
describe("e", () => { test("plain-in-suite", () => {}); });

// run-argv.mjs
import { run } from "node:test";
let n = 0;
for await (const { type, data } of run({ files: [process.argv[2]], argv: [process.argv[3]] })) {
  if ((type === "test:pass" || type === "test:fail") && data.details.type === "test") n++;
}
console.log("test events:", n);
  • node run-argv.mjs ./only.test.mjs --only (v26.3.0) prints test events: 4.
  • bun run-argv.mjs ./only.test.mjs --only on main prints test events: 1. The child bun test prints Ran 0 tests across 1 file.
  • This branch prints test events: 4 for --only and for --test-only.

PR: #46847

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the BUN_INTERNAL_NODE_TEST_ARGV decoder in src/runtime/cli/test_command.rs for adversarial lengths — parse_unsigned(...).ok()? and split_at_checked(len)? turn an oversized or non-numeric prefix into the exit-1 error rather than a panic or out-of-bounds read, and the gate on BUN_TEST_DRAIN_EVENT_LOOP means a plain bun test never reads the variable. The coordinator-side strip in runner.rs only drops the variable when the coordinator itself rejected it, so workers cannot pick up a value the coordinator did not accept.

Extended reasoning...

The change moves node:test run({ argv }) off the child's command line into a length-prefixed env var decoded in Rust before preloads run, touching src/js/node/test.ts, test_command.rs, the parallel coordinator, and new tests. The decoder parses untrusted env bytes; its length arithmetic uses checked parsing and split_at_checked, so malformed input exits with a message rather than panicking. The inline findings (env-string size limit, grandchild re-spawn seeing argv, test convention nits) are behavioral trade-offs a human should weigh, so this is not approved.

Comment thread src/js/node/test.ts Resolved
Comment thread src/runtime/cli/test_command.rs Resolved
…ment

The `bun test` that run() starts removes BUN_INTERNAL_NODE_TEST_ARGV from
its environment map when it has read the values. `process.env` of the test
file does not have the variable, and neither does a process that its main
thread starts. A test file that starts itself once more reads no argv
there, as in Node.js. A Worker thread still has the variable, because a
Worker loads the environment of the process again.

A `--parallel` child of run() that has argv exits with an error before it
starts workers: a worker is not the process that run() starts. With one
test file there is no worker, and the file gets argv.

run() takes the file name for the variable from the path that it resolved,
not from the last argument of the command line. The file is compared with
the paths that `bun test` was given, not with the command name.

Docs: flags for the child `bun test` go in execArgv, each as one element.

Tests: the ones whose child runs a test file are not concurrent, and use
the default timeout.

This branch has not been deployed

No deployments
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