Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/runtime/nodejs-compat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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)

Expand Down
2 changes: 2 additions & 0 deletions src/bun_core/env_var.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 27 additions & 5 deletions src/js/node/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -442,6 +443,24 @@ 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(file: string, args: string[], argv: string[]) {
if (argv.length === 0) return undefined;
for (let i = 0; i < args.length; i++) validateRunChildArg(`${args[i]}`, i);
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<typeof validateRunOptions>,
Expand All @@ -450,10 +469,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();

Expand All @@ -476,7 +493,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(absolute, args, opts.argv as string[]),
},
stdout: "pipe",
stderr: "pipe",
signal: opts.signal,
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/cli/test/parallel/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ pub(crate) fn run_as_coordinator(
return Ok(false);
}

// `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() } {
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
// rendering to a color terminal so streamed lines match serial output.
if Output::enable_ansi_colors_stderr() {
Expand Down
44 changes: 44 additions & 0 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<[u8]>> {
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 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()
}

/// 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<Vec<&[u8]>> {
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::<usize>(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())
Expand Down Expand Up @@ -1988,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);
Expand Down
147 changes: 147 additions & 0 deletions test/js/node/test_runner/fixtures/41-run-argv.mjs
Original file line number Diff line number Diff line change
@@ -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 "<event> <test name>". 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 "<byte length>:<bytes>", 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;
});
Loading
Loading