From 21722da927ab5de41745454d857b1c9c5a764540 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:09:54 +0000 Subject: [PATCH 1/2] node:test: hand run({ argv }) to the test file in the child's environment run() started each file as `bun test ...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. --- docs/runtime/nodejs-compat.mdx | 2 +- src/bun_core/env_var.rs | 2 + src/js/node/test.ts | 33 ++- src/runtime/cli/test/parallel/runner.rs | 6 + src/runtime/cli/test_command.rs | 41 +++ .../node/test_runner/fixtures/32-run-argv.mjs | 158 ++++++++++++ test/js/node/test_runner/node-test.test.ts | 235 +++++++++++++++++- 7 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 test/js/node/test_runner/fixtures/32-run-argv.mjs diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 2331b3a5a030..3810f7e8f2d3 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -177,7 +177,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:test`](https://nodejs.org/api/test.html) -🟑 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, `expectFailure`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). `run()` requires an explicit `files` list and runs each file in a `bun test` child process. Most of its options (`globPatterns`, `watch`, `coverage`, `shard`, `only`, `testNamePatterns`, ...) throw `ERR_NOT_IMPLEMENTED`. Missing `node:test/reporters`, snapshot testing, `mock.module()`, `t.runOnly()`, code coverage, `--test-only`, test-level `signal` abort, and Node's `--test` CLI runner mode. `test.only()` / `{only: true}` are accepted but do not filter. `concurrency` is validated but subtests always run serially. Use [`bun:test`](/test) instead. +🟑 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, `expectFailure`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). `run()` requires an explicit `files` list and runs each file in a `bun test` child process. Its `argv` option is the `process.argv.slice(2)` of each file. Most of its options (`globPatterns`, `watch`, `coverage`, `shard`, `only`, `testNamePatterns`, ...) throw `ERR_NOT_IMPLEMENTED`. Missing `node:test/reporters`, snapshot testing, `mock.module()`, `t.runOnly()`, code coverage, `--test-only`, test-level `signal` abort, and Node's `--test` CLI runner mode. `test.only()` / `{only: true}` are accepted but do not filter. `concurrency` is validated but subtests always run serially. Use [`bun:test`](/test) instead. ### [`node:trace_events`](https://nodejs.org/api/tracing.html) diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 78e86cefe1c9..97da93518d37 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -194,6 +194,8 @@ new!(pub BUN_INTERNAL_BUNX_INSTALL: string, "BUN_INTERNAL_BUNX_INSTALL", {}); new!(pub BUN_INTERNAL_SCRIPTS_OF_COMMANDS: string, "BUN_INTERNAL_SCRIPTS_OF_COMMANDS", {}); // What a Bun that has `bun check` and not the other two sets in its place. Only read. new!(pub BUN_INTERNAL_CHECK_SCRIPTS: string, "BUN_INTERNAL_CHECK_SCRIPTS", {}); +// Set by `run()` of `node:test` for the `bun test` that it starts for a file: that file and the `argv` option. +new!(pub BUN_INTERNAL_NODE_TEST_ARGV: string, "BUN_INTERNAL_NODE_TEST_ARGV", {}); // Set by HostProcess.rs when spawning the WebView host subprocess. The // child's CLI entrypoint checks this before anything else and hands off to // C++ Bun__WebView__hostMain. Never returns β€” no JSC, no VM. diff --git a/src/js/node/test.ts b/src/js/node/test.ts index f99f5b0b3f1b..181203a50d08 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -49,6 +49,7 @@ const kJoinSeparator = " > "; // spawned child, so use node's variable and value rather than a bun-specific one. const kRunChildEnv = "NODE_TEST_CONTEXT"; const kRunChildEnvValue = "child-v8"; +const kRunChildArgvEnv = "BUN_INTERNAL_NODE_TEST_ARGV"; const kRunEventPrefix = "\0bun:test:run\0"; // Created lazily on the first run() call so the common test()/describe() @@ -442,6 +443,25 @@ function reportCancelledFile( counts.topLevel++; } +// Bun.spawn checks the environment before the command line, so a NUL is reported here, as the argument that Node.js has it in. +function validateRunChildArg(arg: string, index: number) { + if (arg.includes("\0")) throw $ERR_INVALID_ARG_VALUE(`args[${index}]`, arg, "must be a string without null bytes"); +} + +// undefined for an empty argv, which also keeps a value that this process inherited out of the child. +function encodeRunChildArgv(args: string[], argv: string[]) { + if (argv.length === 0) return undefined; + for (let i = 0; i < args.length; i++) validateRunChildArg(`${args[i]}`, i); + const file = args[args.length - 1]; + let encoded = `${Buffer.byteLength(file)}:${file}`; + for (let i = 0; i < argv.length; i++) { + const value = `${argv[i]}`; + validateRunChildArg(value, args.length + i); + encoded += `${Buffer.byteLength(value)}:${value}`; + } + return encoded; +} + async function runOneFile( file: string, opts: ReturnType, @@ -450,10 +470,8 @@ async function runOneFile( ) { const path = require("node:path"); const absolute = path.resolve(opts.cwd as string, file); - // Node's getRunArgs builds [...execArgv, path, ...argv] so runtime flags land - // in the child's process.execArgv; bun's CLI likewise takes runtime flags - // before the `test` keyword and user args after the path. - const args = [process.execPath, ...(opts.execArgv as string[]), "test", absolute, ...(opts.argv as string[])]; + // Node's getRunArgs has argv after the path. `bun test` takes every token there for its own, so argv goes in kRunChildArgvEnv. + const args = [process.execPath, ...(opts.execArgv as string[]), "test", absolute]; const fileStarted = Date.now(); const fileCounts = makeRunCounts(); @@ -476,7 +494,12 @@ async function runOneFile( const proc = Bun.spawn({ cmd: args, cwd: opts.cwd as string, - env: { ...(opts.env ?? process.env), BUN_TEST_DRAIN_EVENT_LOOP: "1", [kRunChildEnv]: kRunChildEnvValue }, + env: { + ...(opts.env ?? process.env), + BUN_TEST_DRAIN_EVENT_LOOP: "1", + [kRunChildEnv]: kRunChildEnvValue, + [kRunChildArgvEnv]: encodeRunChildArgv(args, opts.argv as string[]), + }, stdout: "pipe", stderr: "pipe", signal: opts.signal, diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 455852e1824e..0d6389fc06a7 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -60,6 +60,12 @@ pub(crate) fn run_as_coordinator( return Ok(false); } + // A worker takes `BUN_INTERNAL_NODE_TEST_ARGV` with no file to check it against, so it only gets the value that this process took. + // SAFETY: see vm_ptr note above. + if unsafe { (*vm_ptr).argv.is_empty() } { + env.map.remove(b"BUN_INTERNAL_NODE_TEST_ARGV"); + } + // Workers' stderr is a pipe; have them format with ANSI when we will be // rendering to a color terminal so streamed lines match serial output. if Output::enable_ansi_colors_stderr() { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 2171aead50c6..487b22f18134 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -930,6 +930,41 @@ fn should_drain_event_loop() -> bool { env_var::BUN_TEST_DRAIN_EVENT_LOOP.get().unwrap_or(false) } +/// `process.argv.slice(2)` in a child of `run()` of `node:test`, whose `argv` cannot be on a command line that `bun test` parses. +#[cold] +fn node_test_argv(encoded: &[u8], ctx: &Command::ContextData) -> Vec> { + let entries = node_test_argv_entries(encoded); + let Some((file, argv)) = entries.as_deref().and_then(<[_]>::split_first) else { + Output::err_generic( + "Invalid value for BUN_INTERNAL_NODE_TEST_ARGV. run() of node:test sets it for the test files that it starts\n", + (), + ); + Global::exit(1); + }; + // The variable is inherited: only the process that `run()` started for `file`, and its `--parallel` workers, take the values. + if !ctx.test_options.test_worker && !ctx.positionals.iter().any(|it| **it == **file) { + return Vec::new(); + } + argv.iter().map(|value| Box::from(*value)).collect() +} + +/// The entries of `BUN_INTERNAL_NODE_TEST_ARGV`, the file and then the values: each is a length, `:` and as many bytes. +fn node_test_argv_entries(mut encoded: &[u8]) -> Option> { + let mut entries = Vec::new(); + while !encoded.is_empty() { + let colon = strings::index_of_char_usize(encoded, b':')?; + let len = &encoded[..colon]; + if !len.iter().all(u8::is_ascii_digit) { + return None; + } + let len = bun_fmt::parse_unsigned::(len, 10).ok()?; + let (entry, rest) = encoded[colon + 1..].split_at_checked(len)?; + entries.push(entry); + encoded = rest; + } + Some(entries) +} + /// jest and vitest never run a test file's `process.on('exit')` listeners; node's test harness asserts from them. pub(crate) fn skip_exit_listeners(reporter: &CommandLineReporter) -> bool { !(reporter.jest.node_test_used || should_drain_event_loop()) @@ -1964,6 +1999,12 @@ impl TestCommand { })? }; vm.argv = core::mem::take(&mut ctx.passthrough); + // `run()` of `node:test` sets both. Every start already reads the first, so a plain `bun test` does not look up the second. + if should_drain_event_loop() + && let Some(encoded) = env_var::BUN_INTERNAL_NODE_TEST_ARGV.get_not_empty() + { + vm.argv = node_test_argv(encoded, ctx); + } // Clone (not take): build_worker_argv reads ctx.preloads to forward --preload. vm.preload = ctx.preloads.clone(); vm.transpiler.options.rewrite_jest_for_tests = true; diff --git a/test/js/node/test_runner/fixtures/32-run-argv.mjs b/test/js/node/test_runner/fixtures/32-run-argv.mjs new file mode 100644 index 000000000000..3ddb9fdcb320 --- /dev/null +++ b/test/js/node/test_runner/fixtures/32-run-argv.mjs @@ -0,0 +1,158 @@ +// run({ argv }) gives argv to each test file, and the runtime that starts the file applies none of it. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L182-L256 +// +// This file passes under `bun test` and, as it is, under `node`. node-test.test.ts runs both. +import assert from "node:assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, run, test } from "node:test"; + +const root = mkdtempSync(join(tmpdir(), "node-test-run-argv-")); +after(() => rmSync(root, { recursive: true, force: true })); +// Not ASCII, so that a path has more bytes than characters. +const dir = join(root, "Γ© δΈ­πŸ˜€"); +mkdirSync(dir); + +const files = { + // The name of the one test is what the file read in process.argv. + "a.test.mjs": ` + import { test } from "node:test"; + test(JSON.stringify(process.argv.slice(2)), () => {}); + `, + // Never in `files`: it runs only if the child takes a value of argv for a path. + "b.test.mjs": ` + import { test } from "node:test"; + test("b.test.mjs ran", () => {}); + `, + "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", () => {}); }); + `, + "early.test.cjs": ` + const early = process.argv.slice(2); + const { test } = require("node:test"); + const inExecArgv = early.some(value => process.execArgv.includes(value)); + test(JSON.stringify({ preload: globalThis.preloadArgv, early, inExecArgv }), () => {}); + `, + "preload.cjs": `globalThis.preloadArgv = process.argv.slice(2);`, + // Starts the file below as a test file, the way its own runtime does, and names its test after what both read. + "outer.test.mjs": ` + import { test } from "node:test"; + import { spawnSync } from "node:child_process"; + import { readFileSync } from "node:fs"; + import { fileURLToPath } from "node:url"; + const inner = fileURLToPath(new URL("./inner.mjs", import.meta.url)); + spawnSync(process.execPath, [...(process.versions.bun ? ["test"] : []), inner], { stdio: "ignore" }); + const nested = JSON.parse(readFileSync(inner + ".json", "utf8")); + test(JSON.stringify({ own: process.argv.slice(2), nested }), () => {}); + `, + "inner.mjs": ` + import { test } from "node:test"; + import { writeFileSync } from "node:fs"; + writeFileSync(import.meta.filename + ".json", JSON.stringify(process.argv.slice(2))); + test("inner", () => {}); + `, + // node's test/fixtures/test-runner/print-arguments.js + "print-arguments.js": ` + const { test } = require('node:test'); + + test('process.argv is setup', (t) => { + t.assert.deepStrictEqual(process.argv.slice(2), ['--a-custom-argument']); + }); + `, +}; +for (const [name, source] of Object.entries(files)) writeFileSync(join(dir, name), source); + +/** What the children of one run() reported, as " ". A failure has its error too. */ +async function reported(stream) { + const events = []; + for await (const { type, data } of stream) { + if ((type !== "test:pass" && type !== "test:fail") || data.details.type !== "test") continue; + events.push(type === "test:pass" ? `${type} ${data.name}` : `${type} ${data.name}: ${data.details.error?.message}`); + } + return events; +} + +// A child of a debug build takes seconds to start. +const timeout = 60_000; + +test("the test file reads argv in process.argv and nothing applies it", { timeout }, async () => { + const cases = { + "no argv": {}, + "flags of the runtime and of its test runner": { + argv: ["--only", "--todo", "-t", "zzz", "--timeout", "1", "--bail", "--help", "--watch", "-x", "--preload", "./nope.js"], + }, + "paths of other test files": { argv: [".", "b.test.mjs", "./b.test.mjs"] }, + "values that a parser or a shell would change": { + argv: ["", "--", "--only", "a b", "a=b", `q"uo'te`, "back\\slash", "-"], + }, + // Bun hands the values on as ":". + "values that are not ASCII, or read like how Bun hands them on": { argv: ["Γ©πŸ˜€", "\ud800", "1:x", ":", "0:"] }, + "argv behind a `--` in execArgv": { execArgv: ["--"], argv: ["x", "--only"] }, + }; + const names = Object.keys(cases); + const results = await Promise.all( + names.map(name => reported(run({ ...cases[name], files: ["a.test.mjs"], cwd: dir }))), + ); + assert.deepStrictEqual( + Object.fromEntries(names.map((name, i) => [name, results[i]])), + // A lone surrogate reaches the child as U+FFFD. + Object.fromEntries( + names.map(name => [name, [`test:pass ${JSON.stringify((cases[name].argv ?? []).map(value => value.toWellFormed()))}`]]), + ), + ); +}); + +test("argv --only and --test-only do not make the child skip the tests without `only`", { timeout }, async () => { + const stream = run({ files: ["only.test.mjs"], argv: ["--only", "--test-only"], cwd: dir }); + assert.deepStrictEqual(await reported(stream), [ + "test:pass focused", + "test:pass unfocused", + "test:pass in-only-suite", + "test:pass plain-in-suite", + ]); +}); + +test("a preload and the first statement of a CommonJS file read argv, and process.execArgv does not have it", { timeout }, async () => { + const argv = ["--timeout", "1"]; + const stream = run({ + files: ["early.test.cjs"], + argv, + execArgv: [`--require=${join(dir, "preload.cjs")}`], + cwd: dir, + }); + assert.deepStrictEqual(await reported(stream), [ + `test:pass ${JSON.stringify({ preload: argv, early: argv, inExecArgv: false })}`, + ]); +}); + +test("each file gets argv as it is when the files start", { timeout }, async () => { + const argv = ["at-call"]; + const stream = run({ + files: ["a.test.mjs", "early.test.cjs"], + argv, + setup: () => void argv.push("in-setup"), + cwd: dir, + }); + argv.push("after-call"); + const seen = ["at-call", "after-call", "in-setup"]; + assert.deepStrictEqual( + (await reported(stream)).sort(), + [`test:pass ${JSON.stringify(seen)}`, `test:pass ${JSON.stringify({ early: seen, inExecArgv: false })}`].sort(), + ); +}); + +test("a test file that the test file starts gets no argv of run()", { timeout }, async () => { + const stream = run({ files: ["outer.test.mjs"], argv: ["x"], cwd: dir }); + assert.deepStrictEqual(await reported(stream), [`test:pass ${JSON.stringify({ own: ["x"], nested: [] })}`]); +}); + +// node's test/parallel/test-runner-run.mjs +test("should allow custom arguments via argv", { timeout }, async () => { + const stream = run({ files: [join(dir, "print-arguments.js")], argv: ["--a-custom-argument"] }); + assert.deepStrictEqual(await reported(stream), ["test:pass process.argv is setup"]); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 2a27963203c0..061988c7c446 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -1,7 +1,8 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; -import { join } from "node:path"; +import { bunEnv, bunExe, nodeExeAtLeast, tempDir } from "harness"; +import { dirname, join } from "node:path"; +import { inspect } from "node:util"; describe("node:test", () => { // These three drive the largest fixtures (01-harness has 32 node:test cases); @@ -521,3 +522,233 @@ test("mock.property/mock.method survive a polluted Object.prototype", async () = const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), stderr, exitCode }).toMatchObject({ stdout: "ok", exitCode: 0 }); }); + +describe("node:test run({ argv })", () => { + const { run } = require("node:test"); + + // The cases that must hold in Node.js too are in the fixture. It passes under `node` as it is. + test.concurrent( + "gives argv to each test file and applies none of it", + async () => { + const { exitCode, stderr } = await runTests(["32-run-argv.mjs"]); + expect(stderr).toContain("6 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining("0 fail"), + }); + }, + 120_000, + ); + + const node = nodeExeAtLeast("26.3.0"); + (node ? test.concurrent : test.skip)("the same cases pass in Node.js", async () => { + await using proc = Bun.spawn({ + cmd: [node!, join(import.meta.dirname, "fixtures", "32-run-argv.mjs")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, output: exitCode === 0 ? "" : stdout + stderr }).toEqual({ exitCode: 0, output: "" }); + }); + + /** What the children of one run() reported, as " ". A failure has its error too. */ + async function reported(stream: AsyncIterable<{ type: string; data: any }>) { + const events: string[] = []; + for await (const { type, data } of stream) { + if ((type !== "test:pass" && type !== "test:fail") || data.details.type !== "test") continue; + events.push( + type === "test:pass" ? `${type} ${data.name}` : `${type} ${data.name}: ${data.details.error?.message}`, + ); + } + return events; + } + + // A preload from bunfig.toml loads before one from `--preload`, so only the runtime can have set process.argv for it. + test.concurrent( + "a preload from bunfig.toml already reads argv", + async () => { + using dir = tempDir("node-test-run-argv-bunfig", { + "bunfig.toml": `[test]\npreload = ["./preload.cjs"]\n`, + "preload.cjs": `globalThis.preloadArgv = process.argv.slice(2);`, + "a.test.mjs": ` + import { test } from "node:test"; + test(JSON.stringify(globalThis.preloadArgv), () => {}); + `, + }); + const argv = ["--timeout", "1"]; + const stream = run({ files: ["a.test.mjs"], argv, cwd: String(dir), env: bunEnv }); + expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify(argv)}`]); + }, + 30_000, + ); + + test.concurrent( + "the workers of a `--parallel` child give argv to their files and apply none of it", + async () => { + // With `--only` applied, a worker does not run this test and the file is not written. + const record = ` + import { test } from "node:test"; + import { writeFileSync } from "node:fs"; + test("record", () => writeFileSync(import.meta.filename + ".json", JSON.stringify(process.argv.slice(2)))); + `; + using dir = tempDir("node-test-run-argv-parallel", { + "tests/one.test.mjs": record, + "tests/two.test.mjs": record, + }); + const argv = ["--only", "", "x"]; + const stream = run({ files: ["tests"], argv, execArgv: ["--parallel=2"], cwd: String(dir), env: bunEnv }); + expect(await reported(stream)).not.toContainEqual(expect.stringContaining("test:fail")); + expect({ + one: await Bun.file(join(String(dir), "tests/one.test.mjs.json")).json(), + two: await Bun.file(join(String(dir), "tests/two.test.mjs.json")).json(), + }).toEqual({ one: argv, two: argv }); + }, + 30_000, + ); + + // The first argument of Node's command line that has a NUL, with Node's message. Node.js reports the error as the + // `test:fail` of the file. Bun ends the stream with it, as for every child that it cannot start. + test.concurrent.each([ + ["argv", { files: ["a.test.mjs"], argv: ["x", "a\0b"] }, "args[4]", "a\0b"], + ["the file", { files: ["a\0b.test.mjs"], argv: ["x"] }, "args[2]", "a\0b.test.mjs"], + ["execArgv and argv", { files: ["a.test.mjs"], execArgv: ["e\0e"], argv: ["a\0b"] }, "args[1]", "e\0e"], + ])("a NUL in %s is the error of that argument of the command line", async (_, options, argument, value) => { + using dir = tempDir("node-test-run-argv-nul", { "a.test.mjs": `` }); + const received = argument === "args[2]" ? join(String(dir), value) : value; + const stream = run({ ...options, cwd: String(dir), env: bunEnv }); + await expect(reported(stream)).rejects.toMatchObject({ + code: "ERR_INVALID_ARG_VALUE", + message: `The argument '${argument}' must be a string without null bytes. Received ${inspect(received)}`, + }); + }); + + // run() hands argv to its child in BUN_INTERNAL_NODE_TEST_ARGV: the file that it starts the child for, then the + // values, each as ":". The lines below are a child of run(), written out by hand. + const kArgv = "BUN_INTERNAL_NODE_TEST_ARGV"; + const encode = (...entries: string[]) => entries.map(entry => `${Buffer.byteLength(entry)}:${entry}`).join(""); + // The name of the one test is what the file read in process.argv, and what it has of the variable. + const readsArgv = ` + import { test } from "node:test"; + test(JSON.stringify([process.argv.slice(2), process.env.${kArgv} ?? null]), () => {}); + `; + const asRunChild = { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: "1", NODE_TEST_CONTEXT: "child-v8" }; + + /** Runs `bun test ` in the directory of the file, with `env`. */ + async function bunTest(file: string, env: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", file], + env, + cwd: dirname(file), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.concurrent( + "a run() without argv keeps the variable that its own process has out of the child", + async () => { + using dir = tempDir("node-test-run-argv-stale", { "a.test.mjs": readsArgv }); + const file = join(String(dir), "a.test.mjs"); + const stream = run({ files: [file], env: { ...bunEnv, [kArgv]: encode(file, "stale") } }); + expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify([[], null])}`]); + }, + 30_000, + ); + + test.concurrent( + "the variable is for the file that run() names in it", + async () => { + using dir = tempDir("node-test-run-argv-bound", { "a.test.mjs": readsArgv, "b.test.mjs": readsArgv }); + const [a, b] = [join(String(dir), "a.test.mjs"), join(String(dir), "b.test.mjs")]; + const value = encode(a, "x", ""); + const [forA, forB] = await Promise.all([ + bunTest(a, { ...asRunChild, [kArgv]: value }), + bunTest(b, { ...asRunChild, [kArgv]: value }), + ]); + expect(forA.stderr).toContain(`(pass) ${JSON.stringify([["x", ""], value])}`); + expect(forB.stderr).toContain(`(pass) ${JSON.stringify([[], value])}`); + expect({ a: forA.exitCode, b: forB.exitCode }).toEqual({ a: 0, b: 0 }); + }, + 30_000, + ); + + test.concurrent( + "a `bun test` that run() did not start does not read the variable", + async () => { + using dir = tempDir("node-test-run-argv-plain", { + "a.test.mjs": readsArgv, + // An env file is not the environment that run() gives its child. + ".env.test": `BUN_TEST_DRAIN_EVENT_LOOP=1\n${kArgv}=not what run() writes\n`, + }); + const file = join(String(dir), "a.test.mjs"); + const plain = { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: undefined, [kArgv]: "not what run() writes" }; + const { stderr, exitCode } = await bunTest(file, plain); + expect(stderr).toContain(`(pass) ${JSON.stringify([[], "not what run() writes"])}`); + expect(exitCode).toBe(0); + }, + 30_000, + ); + + // A worker has no file on its command line to check the variable against. It gets the variable only from a + // coordinator that took it: not from an env file, and not from a coordinator that was started for another file. + test.concurrent.each([ + ["from an env file", { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: undefined, [kArgv]: undefined }], + ["of another file", { ...asRunChild, [kArgv]: encode("/another/file.test.mjs", "x") }], + ])( + "the workers of `--parallel` do not read the variable %s", + async (_, env) => { + const record = ` + import { test } from "node:test"; + import { writeFileSync } from "node:fs"; + test("record", () => writeFileSync(import.meta.filename + ".json", JSON.stringify(process.argv.slice(2)))); + `; + using dir = tempDir("node-test-run-argv-workers", { + "tests/one.test.mjs": record, + "tests/two.test.mjs": record, + ".env.test": `BUN_TEST_DRAIN_EVENT_LOOP=1\n${kArgv}=not what run() writes\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2", join(String(dir), "tests")], + env, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("2 pass"); + expect({ + one: await Bun.file(join(String(dir), "tests/one.test.mjs.json")).json(), + two: await Bun.file(join(String(dir), "tests/two.test.mjs.json")).json(), + }).toEqual({ one: [], two: [] }); + expect(exitCode).toBe(0); + }, + 30_000, + ); + + test.concurrent( + "an empty variable is no variable", + async () => { + using dir = tempDir("node-test-run-argv-empty", { "a.test.mjs": readsArgv }); + const { stderr, exitCode } = await bunTest(join(String(dir), "a.test.mjs"), { ...asRunChild, [kArgv]: "" }); + expect(stderr).toContain(`(pass) ${JSON.stringify([[], ""])}`); + expect(exitCode).toBe(0); + }, + 30_000, + ); + + // Not a length, a length past the end, no length, a length that is not only digits, bytes left over. + test.concurrent.each(["x", "2:x", ":x", "0_1:x", "1:xy"])( + "a child of run() with the variable set to %j fails, and does not run the file", + async value => { + using dir = tempDir("node-test-run-argv-invalid", { "a.test.mjs": readsArgv }); + const { stderr, exitCode } = await bunTest(join(String(dir), "a.test.mjs"), { ...asRunChild, [kArgv]: value }); + expect(stderr).toContain(`error: Invalid value for ${kArgv}`); + expect(stderr).not.toContain("a.test.mjs"); + expect(exitCode).toBe(1); + }, + 30_000, + ); +}); From 709dea3d4143d3d005e482db4712e72228360150 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:25:44 +0000 Subject: [PATCH 2/2] node:test: take the argv variable of run() out of the child's environment 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. --- docs/runtime/nodejs-compat.mdx | 2 +- src/js/node/test.ts | 5 +- src/runtime/cli/test/parallel/runner.rs | 10 +- src/runtime/cli/test_command.rs | 19 +- .../node/test_runner/fixtures/32-run-argv.mjs | 158 ---------- .../node/test_runner/fixtures/41-run-argv.mjs | 147 +++++++++ test/js/node/test_runner/node-test.test.ts | 284 +++++++++--------- 7 files changed, 305 insertions(+), 320 deletions(-) delete mode 100644 test/js/node/test_runner/fixtures/32-run-argv.mjs create mode 100644 test/js/node/test_runner/fixtures/41-run-argv.mjs diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 3810f7e8f2d3..9929addab195 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -177,7 +177,7 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:test`](https://nodejs.org/api/test.html) -🟑 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, `expectFailure`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). `run()` requires an explicit `files` list and runs each file in a `bun test` child process. Its `argv` option is the `process.argv.slice(2)` of each file. Most of its options (`globPatterns`, `watch`, `coverage`, `shard`, `only`, `testNamePatterns`, ...) throw `ERR_NOT_IMPLEMENTED`. Missing `node:test/reporters`, snapshot testing, `mock.module()`, `t.runOnly()`, code coverage, `--test-only`, test-level `signal` abort, and Node's `--test` CLI runner mode. `test.only()` / `{only: true}` are accepted but do not filter. `concurrency` is validated but subtests always run serially. Use [`bun:test`](/test) instead. +🟑 Partially implemented. The in-process API works when test files run under `bun test`: tests, suites, subtests, hooks, `t.plan()`, `t.assert`, `assert.register()`, `t.waitFor()`, `getTestContext()`, `expectFailure`, and `t.mock` (function/method/getter/setter/property mocks and mock timers). `run()` requires an explicit `files` list and runs each file in a `bun test` child process. Its `argv` option is the `process.argv.slice(2)` of each file, and `bun test` does not read it. Flags for that `bun test` go in `execArgv`, each as one element (`--bail`, `--timeout=60000`). Most of its options (`globPatterns`, `watch`, `coverage`, `shard`, `only`, `testNamePatterns`, ...) throw `ERR_NOT_IMPLEMENTED`. Missing `node:test/reporters`, snapshot testing, `mock.module()`, `t.runOnly()`, code coverage, `--test-only`, test-level `signal` abort, and Node's `--test` CLI runner mode. `test.only()` / `{only: true}` are accepted but do not filter. `concurrency` is validated but subtests always run serially. Use [`bun:test`](/test) instead. ### [`node:trace_events`](https://nodejs.org/api/tracing.html) diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 181203a50d08..07aac70b36ff 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -449,10 +449,9 @@ function validateRunChildArg(arg: string, index: number) { } // undefined for an empty argv, which also keeps a value that this process inherited out of the child. -function encodeRunChildArgv(args: string[], argv: string[]) { +function encodeRunChildArgv(file: string, args: string[], argv: string[]) { if (argv.length === 0) return undefined; for (let i = 0; i < args.length; i++) validateRunChildArg(`${args[i]}`, i); - const file = args[args.length - 1]; let encoded = `${Buffer.byteLength(file)}:${file}`; for (let i = 0; i < argv.length; i++) { const value = `${argv[i]}`; @@ -498,7 +497,7 @@ async function runOneFile( ...(opts.env ?? process.env), BUN_TEST_DRAIN_EVENT_LOOP: "1", [kRunChildEnv]: kRunChildEnvValue, - [kRunChildArgvEnv]: encodeRunChildArgv(args, opts.argv as string[]), + [kRunChildArgvEnv]: encodeRunChildArgv(absolute, args, opts.argv as string[]), }, stdout: "pipe", stderr: "pipe", diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 0d6389fc06a7..ab204285821c 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -60,10 +60,14 @@ pub(crate) fn run_as_coordinator( return Ok(false); } - // A worker takes `BUN_INTERNAL_NODE_TEST_ARGV` with no file to check it against, so it only gets the value that this process took. + // `bun test` has `process.argv` values only from `run()` of `node:test`, for the one process that it starts. A worker does not get them. // SAFETY: see vm_ptr note above. - if unsafe { (*vm_ptr).argv.is_empty() } { - env.map.remove(b"BUN_INTERNAL_NODE_TEST_ARGV"); + if !unsafe { (*vm_ptr).argv.is_empty() } { + Output::err_generic( + "run() of node:test cannot give `argv` to the workers of --parallel. Name each test file in `files`, or remove --parallel from `execArgv`\n", + (), + ); + Global::exit(1); } // Workers' stderr is a pipe; have them format with ANSI when we will be diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 487b22f18134..e72e2481839d 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -941,8 +941,8 @@ fn node_test_argv(encoded: &[u8], ctx: &Command::ContextData) -> Vec> ); Global::exit(1); }; - // The variable is inherited: only the process that `run()` started for `file`, and its `--parallel` workers, take the values. - if !ctx.test_options.test_worker && !ctx.positionals.iter().any(|it| **it == **file) { + // The variable can reach another `bun test`: through a `Worker` of the test file, or through a child of `run()` that is not `bun test`. + if !ctx.positionals.iter().skip(1).any(|it| **it == **file) { return Vec::new(); } argv.iter().map(|value| Box::from(*value)).collect() @@ -1999,12 +1999,6 @@ impl TestCommand { })? }; vm.argv = core::mem::take(&mut ctx.passthrough); - // `run()` of `node:test` sets both. Every start already reads the first, so a plain `bun test` does not look up the second. - if should_drain_event_loop() - && let Some(encoded) = env_var::BUN_INTERNAL_NODE_TEST_ARGV.get_not_empty() - { - vm.argv = node_test_argv(encoded, ctx); - } // Clone (not take): build_worker_argv reads ctx.preloads to forward --preload. vm.preload = ctx.preloads.clone(); vm.transpiler.options.rewrite_jest_for_tests = true; @@ -2029,6 +2023,15 @@ impl TestCommand { vm.transpiler.configure_defines()?; + // `run()` of `node:test` sets both. Every start already reads the first, so a plain `bun test` does not look up the second. + if should_drain_event_loop() + && let Some(encoded) = env_var::BUN_INTERNAL_NODE_TEST_ARGV.get_not_empty() + { + vm.argv = node_test_argv(encoded, ctx); + // Out of the map of this VM: `process.env` and what the main thread starts do not have it. A `Worker` loads the process environment again. + env_loader.map.remove(b"BUN_INTERNAL_NODE_TEST_ARGV"); + } + vm.load_extra_env_and_source_code_printer(); vm.is_main_thread = true; VirtualMachine::set_is_main_thread_vm(true); diff --git a/test/js/node/test_runner/fixtures/32-run-argv.mjs b/test/js/node/test_runner/fixtures/32-run-argv.mjs deleted file mode 100644 index 3ddb9fdcb320..000000000000 --- a/test/js/node/test_runner/fixtures/32-run-argv.mjs +++ /dev/null @@ -1,158 +0,0 @@ -// run({ argv }) gives argv to each test file, and the runtime that starts the file applies none of it. -// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L182-L256 -// -// This file passes under `bun test` and, as it is, under `node`. node-test.test.ts runs both. -import assert from "node:assert"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { after, run, test } from "node:test"; - -const root = mkdtempSync(join(tmpdir(), "node-test-run-argv-")); -after(() => rmSync(root, { recursive: true, force: true })); -// Not ASCII, so that a path has more bytes than characters. -const dir = join(root, "Γ© δΈ­πŸ˜€"); -mkdirSync(dir); - -const files = { - // The name of the one test is what the file read in process.argv. - "a.test.mjs": ` - import { test } from "node:test"; - test(JSON.stringify(process.argv.slice(2)), () => {}); - `, - // Never in `files`: it runs only if the child takes a value of argv for a path. - "b.test.mjs": ` - import { test } from "node:test"; - test("b.test.mjs ran", () => {}); - `, - "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", () => {}); }); - `, - "early.test.cjs": ` - const early = process.argv.slice(2); - const { test } = require("node:test"); - const inExecArgv = early.some(value => process.execArgv.includes(value)); - test(JSON.stringify({ preload: globalThis.preloadArgv, early, inExecArgv }), () => {}); - `, - "preload.cjs": `globalThis.preloadArgv = process.argv.slice(2);`, - // Starts the file below as a test file, the way its own runtime does, and names its test after what both read. - "outer.test.mjs": ` - import { test } from "node:test"; - import { spawnSync } from "node:child_process"; - import { readFileSync } from "node:fs"; - import { fileURLToPath } from "node:url"; - const inner = fileURLToPath(new URL("./inner.mjs", import.meta.url)); - spawnSync(process.execPath, [...(process.versions.bun ? ["test"] : []), inner], { stdio: "ignore" }); - const nested = JSON.parse(readFileSync(inner + ".json", "utf8")); - test(JSON.stringify({ own: process.argv.slice(2), nested }), () => {}); - `, - "inner.mjs": ` - import { test } from "node:test"; - import { writeFileSync } from "node:fs"; - writeFileSync(import.meta.filename + ".json", JSON.stringify(process.argv.slice(2))); - test("inner", () => {}); - `, - // node's test/fixtures/test-runner/print-arguments.js - "print-arguments.js": ` - const { test } = require('node:test'); - - test('process.argv is setup', (t) => { - t.assert.deepStrictEqual(process.argv.slice(2), ['--a-custom-argument']); - }); - `, -}; -for (const [name, source] of Object.entries(files)) writeFileSync(join(dir, name), source); - -/** What the children of one run() reported, as " ". A failure has its error too. */ -async function reported(stream) { - const events = []; - for await (const { type, data } of stream) { - if ((type !== "test:pass" && type !== "test:fail") || data.details.type !== "test") continue; - events.push(type === "test:pass" ? `${type} ${data.name}` : `${type} ${data.name}: ${data.details.error?.message}`); - } - return events; -} - -// A child of a debug build takes seconds to start. -const timeout = 60_000; - -test("the test file reads argv in process.argv and nothing applies it", { timeout }, async () => { - const cases = { - "no argv": {}, - "flags of the runtime and of its test runner": { - argv: ["--only", "--todo", "-t", "zzz", "--timeout", "1", "--bail", "--help", "--watch", "-x", "--preload", "./nope.js"], - }, - "paths of other test files": { argv: [".", "b.test.mjs", "./b.test.mjs"] }, - "values that a parser or a shell would change": { - argv: ["", "--", "--only", "a b", "a=b", `q"uo'te`, "back\\slash", "-"], - }, - // Bun hands the values on as ":". - "values that are not ASCII, or read like how Bun hands them on": { argv: ["Γ©πŸ˜€", "\ud800", "1:x", ":", "0:"] }, - "argv behind a `--` in execArgv": { execArgv: ["--"], argv: ["x", "--only"] }, - }; - const names = Object.keys(cases); - const results = await Promise.all( - names.map(name => reported(run({ ...cases[name], files: ["a.test.mjs"], cwd: dir }))), - ); - assert.deepStrictEqual( - Object.fromEntries(names.map((name, i) => [name, results[i]])), - // A lone surrogate reaches the child as U+FFFD. - Object.fromEntries( - names.map(name => [name, [`test:pass ${JSON.stringify((cases[name].argv ?? []).map(value => value.toWellFormed()))}`]]), - ), - ); -}); - -test("argv --only and --test-only do not make the child skip the tests without `only`", { timeout }, async () => { - const stream = run({ files: ["only.test.mjs"], argv: ["--only", "--test-only"], cwd: dir }); - assert.deepStrictEqual(await reported(stream), [ - "test:pass focused", - "test:pass unfocused", - "test:pass in-only-suite", - "test:pass plain-in-suite", - ]); -}); - -test("a preload and the first statement of a CommonJS file read argv, and process.execArgv does not have it", { timeout }, async () => { - const argv = ["--timeout", "1"]; - const stream = run({ - files: ["early.test.cjs"], - argv, - execArgv: [`--require=${join(dir, "preload.cjs")}`], - cwd: dir, - }); - assert.deepStrictEqual(await reported(stream), [ - `test:pass ${JSON.stringify({ preload: argv, early: argv, inExecArgv: false })}`, - ]); -}); - -test("each file gets argv as it is when the files start", { timeout }, async () => { - const argv = ["at-call"]; - const stream = run({ - files: ["a.test.mjs", "early.test.cjs"], - argv, - setup: () => void argv.push("in-setup"), - cwd: dir, - }); - argv.push("after-call"); - const seen = ["at-call", "after-call", "in-setup"]; - assert.deepStrictEqual( - (await reported(stream)).sort(), - [`test:pass ${JSON.stringify(seen)}`, `test:pass ${JSON.stringify({ early: seen, inExecArgv: false })}`].sort(), - ); -}); - -test("a test file that the test file starts gets no argv of run()", { timeout }, async () => { - const stream = run({ files: ["outer.test.mjs"], argv: ["x"], cwd: dir }); - assert.deepStrictEqual(await reported(stream), [`test:pass ${JSON.stringify({ own: ["x"], nested: [] })}`]); -}); - -// node's test/parallel/test-runner-run.mjs -test("should allow custom arguments via argv", { timeout }, async () => { - const stream = run({ files: [join(dir, "print-arguments.js")], argv: ["--a-custom-argument"] }); - assert.deepStrictEqual(await reported(stream), ["test:pass process.argv is setup"]); -}); diff --git a/test/js/node/test_runner/fixtures/41-run-argv.mjs b/test/js/node/test_runner/fixtures/41-run-argv.mjs new file mode 100644 index 000000000000..100b48fba2dd --- /dev/null +++ b/test/js/node/test_runner/fixtures/41-run-argv.mjs @@ -0,0 +1,147 @@ +// run({ argv }) gives argv to each test file, and the runtime that starts the file applies none of it. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/test_runner/runner.js#L182-L256 +// +// This file passes under `bun test` and, as it is, under `node`. node-test.test.ts runs both. +import assert from "node:assert"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, run, test } from "node:test"; + +const root = mkdtempSync(join(tmpdir(), "node-test-run-argv-")); +after(() => rmSync(root, { recursive: true, force: true })); +// Not ASCII, so that a path has more bytes than characters. +const dir = join(root, "Γ© δΈ­πŸ˜€"); +mkdirSync(dir); + +const files = { + // The name of the one test is what the file read in process.argv. + "a.test.mjs": ` + import { test } from "node:test"; + test(JSON.stringify(process.argv.slice(2)), () => {}); + `, + // Never in `files`: it runs only if the child takes a value of argv for a path. + "b.test.mjs": ` + import { test } from "node:test"; + test("b.test.mjs ran", () => {}); + `, + "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", () => {}); }); + `, + "early.test.cjs": ` + const early = process.argv.slice(2); + const { test } = require("node:test"); + const inExecArgv = early.some(value => process.execArgv.includes(value)); + test(JSON.stringify({ preload: globalThis.preloadArgv, early, inExecArgv }), () => {}); + `, + "preload.cjs": `globalThis.preloadArgv = process.argv.slice(2);`, + // Starts itself once more as a test file, the way its own runtime does, and names its test after what both read. + "again.test.mjs": ` + import { test } from "node:test"; + import { spawnSync } from "node:child_process"; + import { readFileSync, writeFileSync } from "node:fs"; + const self = import.meta.filename; + if (process.env.RUN_ARGV_AGAIN) { + writeFileSync(self + ".json", JSON.stringify(process.argv.slice(2))); + test("again", () => {}); + } else { + spawnSync(process.execPath, [...(process.versions.bun ? ["test"] : []), self], { + stdio: "ignore", + env: { ...process.env, RUN_ARGV_AGAIN: "1" }, + }); + const again = JSON.parse(readFileSync(self + ".json", "utf8")); + test(JSON.stringify({ own: process.argv.slice(2), again }), () => {}); + } + `, + // node's test/fixtures/test-runner/print-arguments.js + "print-arguments.js": ` + const { test } = require('node:test'); + + test('process.argv is setup', (t) => { + t.assert.deepStrictEqual(process.argv.slice(2), ['--a-custom-argument']); + }); + `, +}; +for (const [name, source] of Object.entries(files)) writeFileSync(join(dir, name), source); + +/** What the children of one run() reported, as " ". A failure has its error too. */ +async function reported(stream) { + const events = []; + for await (const { type, data } of stream) { + if ((type !== "test:pass" && type !== "test:fail") || data.details.type !== "test") continue; + events.push(type === "test:pass" ? `${type} ${data.name}` : `${type} ${data.name}: ${data.details.error?.message}`); + } + return events; +} + +/** What a.test.mjs reports for argv. A lone surrogate reaches the child as U+FFFD. */ +const reads = argv => [`test:pass ${JSON.stringify(argv.map(value => value.toWellFormed()))}`]; + +/** Calls run() for each case at once, with its options and `cwd: dir`, and compares what the children of each report. */ +async function check(cases) { + const names = Object.keys(cases); + const reports = await Promise.all(names.map(name => reported(run({ ...cases[name][0], cwd: dir })))); + assert.deepStrictEqual( + Object.fromEntries(names.map((name, i) => [name, reports[i]])), + Object.fromEntries(names.map(name => [name, cases[name][1]])), + ); +} + +// A child of a debug build takes seconds to start, and one of these children starts a child. +const timeout = 30_000; + +test("the test file reads argv in process.argv and nothing applies it", { timeout }, async () => { + const flags = "--only --todo -t zzz --timeout 1 --bail --help --watch -x --preload ./nope.js".split(" "); + const paths = [".", "b.test.mjs", "./b.test.mjs"]; + // Bun hands the values on as ":", so the last five are not ASCII or read like that. + const changed = ["", "--", "--only", "a b", "a=b", `q"uo'te`, "back\\slash", "-", "Γ©πŸ˜€", "\ud800", "1:x", ":", "0:"]; + await check({ + "no argv": [{ files: ["a.test.mjs"] }, reads([])], + "flags of the runtime and of its test runner": [{ files: ["a.test.mjs"], argv: flags }, reads(flags)], + "paths of other test files": [{ files: ["a.test.mjs"], argv: paths }, reads(paths)], + "values that a parser, a shell or an encoding would change": [ + { files: ["a.test.mjs"], argv: changed }, + reads(changed), + ], + "argv behind a `--` in execArgv": [ + { files: ["a.test.mjs"], execArgv: ["--"], argv: ["x", "--only"] }, + reads(["x", "--only"]), + ], + "--only and --test-only do not make the child skip the tests without `only`": [ + { files: ["only.test.mjs"], argv: ["--only", "--test-only"] }, + ["test:pass focused", "test:pass unfocused", "test:pass in-only-suite", "test:pass plain-in-suite"], + ], + // node's test/parallel/test-runner-run.mjs + "should allow custom arguments via argv": [ + { files: [join(dir, "print-arguments.js")], argv: ["--a-custom-argument"] }, + ["test:pass process.argv is setup"], + ], + }); +}); + +test("argv is set before a preload, read when the files start, and only for those files", { timeout }, async () => { + const early = ["--timeout", "1"]; + const late = ["at-call"]; + const seen = ["at-call", "after-call", "in-setup"]; + const checked = check({ + "a preload and the first statement of a CommonJS file read argv, and process.execArgv does not have it": [ + { files: ["early.test.cjs"], argv: early, execArgv: [`--require=${join(dir, "preload.cjs")}`] }, + [`test:pass ${JSON.stringify({ preload: early, early, inExecArgv: false })}`], + ], + "each file gets argv as it is when the files start": [ + { files: ["a.test.mjs", "early.test.cjs"], argv: late, setup: () => void late.push("in-setup") }, + [...reads(seen), `test:pass ${JSON.stringify({ early: seen, inExecArgv: false })}`], + ], + "a test file that starts itself once more gets no argv of run() there": [ + { files: ["again.test.mjs"], argv: ["x"] }, + [`test:pass ${JSON.stringify({ own: ["x"], again: [] })}`], + ], + }); + // check() has called run(), and no file has started. + late.push("after-call"); + await checked; +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 061988c7c446..ad43fb60f03d 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -1,7 +1,9 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, nodeExeAtLeast, tempDir } from "harness"; +import { readdirSync } from "node:fs"; import { dirname, join } from "node:path"; +import { run } from "node:test"; import { inspect } from "node:util"; describe("node:test", () => { @@ -524,26 +526,24 @@ test("mock.property/mock.method survive a polluted Object.prototype", async () = }); describe("node:test run({ argv })", () => { - const { run } = require("node:test"); + // A debug build needs more than a second for a `bun test` that runs a file. So the tests here whose child runs one are + // not concurrent: each then has the default timeout to itself. The fixture starts 12 such children, and has its own + // timeout. // The cases that must hold in Node.js too are in the fixture. It passes under `node` as it is. - test.concurrent( - "gives argv to each test file and applies none of it", - async () => { - const { exitCode, stderr } = await runTests(["32-run-argv.mjs"]); - expect(stderr).toContain("6 pass"); - expect({ exitCode, stderr }).toMatchObject({ - exitCode: 0, - stderr: expect.stringContaining("0 fail"), - }); - }, - 120_000, - ); + test("gives argv to each test file and applies none of it", async () => { + const { exitCode, stderr } = await runTests(["41-run-argv.mjs"]); + expect(stderr).toContain(" 2 pass"); + expect({ exitCode, stderr }).toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining(" 0 fail"), + }); + }, 30_000); const node = nodeExeAtLeast("26.3.0"); - (node ? test.concurrent : test.skip)("the same cases pass in Node.js", async () => { + (node ? test : test.skip)("the same cases pass in Node.js", async () => { await using proc = Bun.spawn({ - cmd: [node!, join(import.meta.dirname, "fixtures", "32-run-argv.mjs")], + cmd: [node!, join(import.meta.dirname, "fixtures", "41-run-argv.mjs")], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -565,47 +565,38 @@ describe("node:test run({ argv })", () => { } // A preload from bunfig.toml loads before one from `--preload`, so only the runtime can have set process.argv for it. - test.concurrent( - "a preload from bunfig.toml already reads argv", - async () => { - using dir = tempDir("node-test-run-argv-bunfig", { - "bunfig.toml": `[test]\npreload = ["./preload.cjs"]\n`, - "preload.cjs": `globalThis.preloadArgv = process.argv.slice(2);`, - "a.test.mjs": ` - import { test } from "node:test"; - test(JSON.stringify(globalThis.preloadArgv), () => {}); - `, - }); - const argv = ["--timeout", "1"]; - const stream = run({ files: ["a.test.mjs"], argv, cwd: String(dir), env: bunEnv }); - expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify(argv)}`]); - }, - 30_000, - ); + test("a preload from bunfig.toml already reads argv", async () => { + using dir = tempDir("node-test-run-argv-bunfig", { + "bunfig.toml": `[test]\npreload = ["./preload.cjs"]\n`, + "preload.cjs": `globalThis.preloadArgv = process.argv.slice(2);`, + "a.test.mjs": ` + import { test } from "node:test"; + test(JSON.stringify(globalThis.preloadArgv), () => {}); + `, + }); + const argv = ["--timeout", "1"]; + const stream = run({ files: ["a.test.mjs"], argv, cwd: String(dir), env: bunEnv }); + expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify(argv)}`]); + }); - test.concurrent( - "the workers of a `--parallel` child give argv to their files and apply none of it", - async () => { - // With `--only` applied, a worker does not run this test and the file is not written. - const record = ` + test("a flag of `bun test` acts on the child from execArgv, and is a value for the file from argv", async () => { + using dir = tempDir("node-test-run-argv-bail", { + "fail.test.mjs": ` import { test } from "node:test"; - import { writeFileSync } from "node:fs"; - test("record", () => writeFileSync(import.meta.filename + ".json", JSON.stringify(process.argv.slice(2)))); - `; - using dir = tempDir("node-test-run-argv-parallel", { - "tests/one.test.mjs": record, - "tests/two.test.mjs": record, - }); - const argv = ["--only", "", "x"]; - const stream = run({ files: ["tests"], argv, execArgv: ["--parallel=2"], cwd: String(dir), env: bunEnv }); - expect(await reported(stream)).not.toContainEqual(expect.stringContaining("test:fail")); - expect({ - one: await Bun.file(join(String(dir), "tests/one.test.mjs.json")).json(), - two: await Bun.file(join(String(dir), "tests/two.test.mjs.json")).json(), - }).toEqual({ one: argv, two: argv }); - }, - 30_000, - ); + test("one " + JSON.stringify(process.argv.slice(2)), () => { throw new Error("failed"); }); + test("two", () => { throw new Error("failed"); }); + `, + }); + const options = { files: ["fail.test.mjs"], cwd: String(dir), env: bunEnv }; + const [fromExecArgv, fromArgv] = await Promise.all([ + reported(run({ ...options, execArgv: ["--bail"] })), + reported(run({ ...options, argv: ["--bail"] })), + ]); + expect({ fromExecArgv, fromArgv }).toEqual({ + fromExecArgv: ["test:fail one []: failed"], + fromArgv: [`test:fail one ["--bail"]: failed`, "test:fail two: failed"], + }); + }); // The first argument of Node's command line that has a NUL, with Node's message. Node.js reports the error as the // `test:fail` of the file. Bun ends the stream with it, as for every child that it cannot start. @@ -624,14 +615,19 @@ describe("node:test run({ argv })", () => { }); // run() hands argv to its child in BUN_INTERNAL_NODE_TEST_ARGV: the file that it starts the child for, then the - // values, each as ":". The lines below are a child of run(), written out by hand. + // values, each as ":". The child takes the values, and takes the variable out of its + // environment. const kArgv = "BUN_INTERNAL_NODE_TEST_ARGV"; const encode = (...entries: string[]) => entries.map(entry => `${Buffer.byteLength(entry)}:${entry}`).join(""); - // The name of the one test is what the file read in process.argv, and what it has of the variable. + // The name of the one test is what the file read in process.argv, what process.env has of the variable, and what a + // process that the file starts with no `env` has of it. Bun.spawn does not take that environment from process.env. const readsArgv = ` import { test } from "node:test"; - test(JSON.stringify([process.argv.slice(2), process.env.${kArgv} ?? null]), () => {}); + const started = Bun.spawnSync({ cmd: [process.execPath, "-p", "JSON.stringify(process.env.${kArgv} ?? null)"] }); + const has = [process.env.${kArgv} ?? null, JSON.parse(started.stdout.toString())]; + test(JSON.stringify([process.argv.slice(2), ...has]), () => {}); `; + // The environment of a child of run(), for a child that a test starts by hand. const asRunChild = { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: "1", NODE_TEST_CONTEXT: "child-v8" }; /** Runs `bun test ` in the directory of the file, with `env`. */ @@ -647,97 +643,69 @@ describe("node:test run({ argv })", () => { return { stdout, stderr, exitCode }; } - test.concurrent( - "a run() without argv keeps the variable that its own process has out of the child", - async () => { - using dir = tempDir("node-test-run-argv-stale", { "a.test.mjs": readsArgv }); - const file = join(String(dir), "a.test.mjs"); - const stream = run({ files: [file], env: { ...bunEnv, [kArgv]: encode(file, "stale") } }); - expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify([[], null])}`]); - }, - 30_000, - ); - - test.concurrent( - "the variable is for the file that run() names in it", - async () => { - using dir = tempDir("node-test-run-argv-bound", { "a.test.mjs": readsArgv, "b.test.mjs": readsArgv }); - const [a, b] = [join(String(dir), "a.test.mjs"), join(String(dir), "b.test.mjs")]; - const value = encode(a, "x", ""); - const [forA, forB] = await Promise.all([ - bunTest(a, { ...asRunChild, [kArgv]: value }), - bunTest(b, { ...asRunChild, [kArgv]: value }), - ]); - expect(forA.stderr).toContain(`(pass) ${JSON.stringify([["x", ""], value])}`); - expect(forB.stderr).toContain(`(pass) ${JSON.stringify([[], value])}`); - expect({ a: forA.exitCode, b: forB.exitCode }).toEqual({ a: 0, b: 0 }); - }, - 30_000, - ); - - test.concurrent( - "a `bun test` that run() did not start does not read the variable", - async () => { - using dir = tempDir("node-test-run-argv-plain", { - "a.test.mjs": readsArgv, - // An env file is not the environment that run() gives its child. - ".env.test": `BUN_TEST_DRAIN_EVENT_LOOP=1\n${kArgv}=not what run() writes\n`, - }); - const file = join(String(dir), "a.test.mjs"); - const plain = { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: undefined, [kArgv]: "not what run() writes" }; - const { stderr, exitCode } = await bunTest(file, plain); - expect(stderr).toContain(`(pass) ${JSON.stringify([[], "not what run() writes"])}`); - expect(exitCode).toBe(0); - }, - 30_000, - ); - - // A worker has no file on its command line to check the variable against. It gets the variable only from a - // coordinator that took it: not from an env file, and not from a coordinator that was started for another file. - test.concurrent.each([ - ["from an env file", { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: undefined, [kArgv]: undefined }], - ["of another file", { ...asRunChild, [kArgv]: encode("/another/file.test.mjs", "x") }], - ])( - "the workers of `--parallel` do not read the variable %s", - async (_, env) => { - const record = ` + test("a run() without argv keeps the variable that its own process has out of the child", async () => { + using dir = tempDir("node-test-run-argv-stale", { "a.test.mjs": readsArgv }); + const file = join(String(dir), "a.test.mjs"); + const stream = run({ files: [file], cwd: String(dir), env: { ...bunEnv, [kArgv]: encode(file, "stale") } }); + expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify([[], null, null])}`]); + }); + + test("the variable is for the file that run() names in it, and no test file has it", async () => { + using dir = tempDir("node-test-run-argv-bound", { "a.test.mjs": readsArgv, "b.test.mjs": readsArgv }); + const [a, b] = [join(String(dir), "a.test.mjs"), join(String(dir), "b.test.mjs")]; + const value = encode(a, "x", ""); + const [forA, forB, forCommand] = await Promise.all([ + bunTest(a, { ...asRunChild, [kArgv]: value }), + bunTest(b, { ...asRunChild, [kArgv]: value }), + // `test` is on the command line of `bun test ` too, and is not a file of it. + bunTest(a, { ...asRunChild, [kArgv]: encode("test", "x") }), + ]); + expect(forA.stderr).toContain(`(pass) ${JSON.stringify([["x", ""], null, null])}`); + expect(forB.stderr).toContain(`(pass) ${JSON.stringify([[], null, null])}`); + expect(forCommand.stderr).toContain(`(pass) ${JSON.stringify([[], null, null])}`); + expect({ a: forA.exitCode, b: forB.exitCode, command: forCommand.exitCode }).toEqual({ a: 0, b: 0, command: 0 }); + }); + + // A `Worker` loads the environment of the process again (`Loader::clone_for_worker`), so it still has the variable. + // #41932 changes that loader. + test.failing("a Worker of the test file does not have the variable", async () => { + using dir = tempDir("node-test-run-argv-worker", { + "a.test.mjs": ` import { test } from "node:test"; - import { writeFileSync } from "node:fs"; - test("record", () => writeFileSync(import.meta.filename + ".json", JSON.stringify(process.argv.slice(2)))); - `; - using dir = tempDir("node-test-run-argv-workers", { - "tests/one.test.mjs": record, - "tests/two.test.mjs": record, - ".env.test": `BUN_TEST_DRAIN_EVENT_LOOP=1\n${kArgv}=not what run() writes\n`, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--parallel=2", join(String(dir), "tests")], - env, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).toContain("2 pass"); - expect({ - one: await Bun.file(join(String(dir), "tests/one.test.mjs.json")).json(), - two: await Bun.file(join(String(dir), "tests/two.test.mjs.json")).json(), - }).toEqual({ one: [], two: [] }); - expect(exitCode).toBe(0); - }, - 30_000, - ); + const worker = new Worker("data:text/javascript,postMessage(process.env.${kArgv} ?? null)"); + const inWorker = await new Promise(resolve => (worker.onmessage = event => resolve(event.data))); + worker.terminate(); + test(JSON.stringify({ inWorker }), () => {}); + `, + }); + const file = join(String(dir), "a.test.mjs"); + const { stderr } = await bunTest(file, { ...asRunChild, [kArgv]: encode(file, "x") }); + const inWorker = /\(pass\) \{"inWorker":(.*)\}/.exec(stderr)?.[1]; + // With no report from the Worker, a failure here is not the one that this test waits for. + if (inWorker === undefined) return; + expect(inWorker).toBe("null"); + }); - test.concurrent( - "an empty variable is no variable", - async () => { - using dir = tempDir("node-test-run-argv-empty", { "a.test.mjs": readsArgv }); - const { stderr, exitCode } = await bunTest(join(String(dir), "a.test.mjs"), { ...asRunChild, [kArgv]: "" }); - expect(stderr).toContain(`(pass) ${JSON.stringify([[], ""])}`); - expect(exitCode).toBe(0); - }, - 30_000, - ); + test("a `bun test` that run() did not start does not read the variable", async () => { + using dir = tempDir("node-test-run-argv-plain", { + "a.test.mjs": readsArgv, + // An env file is not the environment that run() gives its child. + ".env.test": `BUN_TEST_DRAIN_EVENT_LOOP=1\n${kArgv}=not what run() writes\n`, + }); + const file = join(String(dir), "a.test.mjs"); + const plain = { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: undefined, [kArgv]: "not what run() writes" }; + const { stderr, exitCode } = await bunTest(file, plain); + const value = "not what run() writes"; + expect(stderr).toContain(`(pass) ${JSON.stringify([[], value, value])}`); + expect(exitCode).toBe(0); + }); + + test("an empty variable is no variable", async () => { + using dir = tempDir("node-test-run-argv-empty", { "a.test.mjs": readsArgv }); + const { stderr, exitCode } = await bunTest(join(String(dir), "a.test.mjs"), { ...asRunChild, [kArgv]: "" }); + expect(stderr).toContain(`(pass) ${JSON.stringify([[], "", ""])}`); + expect(exitCode).toBe(0); + }); // Not a length, a length past the end, no length, a length that is not only digits, bytes left over. test.concurrent.each(["x", "2:x", ":x", "0_1:x", "1:xy"])( @@ -749,6 +717,28 @@ describe("node:test run({ argv })", () => { expect(stderr).not.toContain("a.test.mjs"); expect(exitCode).toBe(1); }, - 30_000, ); + + test("with `--parallel` and one test file, the file gets argv", async () => { + using dir = tempDir("node-test-run-argv-parallel-one", { "a.test.mjs": readsArgv }); + const argv = ["--only", "", "x"]; + const stream = run({ files: ["a.test.mjs"], argv, execArgv: ["--parallel=2"], cwd: String(dir), env: bunEnv }); + expect(await reported(stream)).toEqual([`test:pass ${JSON.stringify([argv, null, null])}`]); + }); + + // A worker of `--parallel` is not the process that run() starts, and run() gets no test events from a worker. + test("with `--parallel` and two test files, the child starts no worker and says why", async () => { + const ran = ` + import { test } from "node:test"; + import { writeFileSync } from "node:fs"; + writeFileSync(import.meta.filename + ".ran", ""); + test("ran", () => {}); + `; + using dir = tempDir("node-test-run-argv-parallel", { "tests/one.test.mjs": ran, "tests/two.test.mjs": ran }); + const stream = run({ files: ["tests"], argv: ["x"], execArgv: ["--parallel=2"], cwd: String(dir), env: bunEnv }); + expect(await reported(stream)).toEqual([ + "test:fail tests: error: run() of node:test cannot give `argv` to the workers of --parallel. Name each test file in `files`, or remove --parallel from `execArgv`", + ]); + expect(readdirSync(join(String(dir), "tests")).sort()).toEqual(["one.test.mjs", "two.test.mjs"]); + }); });