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
7 changes: 6 additions & 1 deletion src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2793,7 +2793,6 @@ impl TestCommand {
index: jest::FileMap::default(),
default_timeout_override: u32::MAX,
vi_config: Default::default(),
vi_config_of_preload: Default::default(),
// SAFETY: lifetime-erase to `'static`; `ctx` is the
// process-lifetime CLI context and `exec()` never returns.
test_options: unsafe { bun_ptr::detach_lifetime_ref(&ctx.test_options) },
Expand Down Expand Up @@ -4012,6 +4011,8 @@ impl TestCommand {
reporter.jest.snapshots.reset_counts();
}

// Test isolation drops the preloads' config after a file is left, and runs that file's handlers.
crate::test_runner::vi_utils::restore_config_of_preload(&mut reporter.jest);
let bun_test_root = &mut jest::Jest::runner().unwrap().bun_test_root;
// Determine if this file should run tests concurrently based on glob pattern
let should_run_concurrent = reporter.jest.should_file_run_concurrently(file_id);
Expand Down Expand Up @@ -4287,6 +4288,10 @@ impl Drop for EnteredFile<'_> {
// A mock.module() patch still pending must not hold up the next file.
bun_jsc::cpp::JSMock__forgetPendingModulePatches(self.global);
bun_jsc::cpp::JSMock__undoModuleMocksOfTestFile(self.global, self.next_load_shares_global);
// A hook registered between two test files belongs to the preloads.
if let Some(runner) = jest::Jest::runner() {
crate::test_runner::vi_utils::restore_config_of_preload(runner);
}
}
}

Expand Down
7 changes: 5 additions & 2 deletions src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ pub(crate) struct BunTestRoot {
// gpa dropped — global mimalloc
pub(crate) active_file: BunTestPtrOptional,
pub(crate) hook_scope: Box<DescribeScope>,
/// What the preloads set with `vi.setConfig()`. Lives as long as `hook_scope`.
pub(crate) vi_config_of_preload: super::vi_utils::Config,
/// `RefData` pointers handed to `Promise.then()`. Tracked here (process-lifetime)
/// so a never-settled promise's `+1` stays reachable; do not free orphans —
/// a queued reaction may still consume them.
Expand All @@ -558,15 +560,16 @@ impl BunTestRoot {
BunTestRoot {
active_file: None,
hook_scope,
vi_config_of_preload: Default::default(),
pending_then_refs: std::cell::RefCell::new(Vec::new()),
file_generation: 0,
}
}

/// Drop preload-level hooks registered in the previous global. The next
/// file's `loadPreloads()` re-registers them against the fresh global.
/// Drop the preloads' hooks and their `vi.setConfig()`. The next file's `loadPreloads()` registers both again.
pub(crate) fn reset_hook_scope_for_test_isolation(&mut self) {
debug_assert!(self.hook_scope.entries.is_empty());
self.vi_config_of_preload = Default::default();
// drop old, create fresh
self.hook_scope = DescribeScope::create(BaseScope {
parent: None,
Expand Down
1 change: 0 additions & 1 deletion src/runtime/test_runner/jest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ pub(crate) struct TestRunner<'a> {
pub(crate) default_timeout_override: u32,

pub(crate) vi_config: vi_utils::Config,
pub(crate) vi_config_of_preload: vi_utils::Config,

pub(crate) test_options: &'a TestOptions,

Expand Down
18 changes: 8 additions & 10 deletions src/runtime/test_runner/vi_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use bun_jsc::bindgen::{JsLocal, JsScope};
use bun_jsc::{JSFunction, JSGlobalObject, JSHostFn, JSValue, JsResult};
use bun_options_types::context::RESETS_BEFORE_EACH_TEST;

use super::jest::Jest;
use super::jest::{Jest, TestRunner};
use crate::generated_bindgen::vi_utils as generated;

unsafe extern "C" {
Expand Down Expand Up @@ -66,14 +66,12 @@ extern "C" fn ViUtils__isLoadingPreload(global: &JSGlobalObject) -> bool {
global.bun_vm().is_in_preload
}

/// If `next_load_shares_global` is false, `global` is thrown away and the preloads run again, so there's nothing to undo.
pub(crate) fn restore_config_of_preload(runner: &mut TestRunner<'_>) {
runner.vi_config = runner.bun_test_root.vi_config_of_preload;
}

/// If `next_load_shares_global` is false, `global` is thrown away, so there's nothing to undo.
pub(crate) fn on_test_file_end(global: &JSGlobalObject, next_load_shares_global: bool) {
if let Some(runner) = Jest::runner() {
if !next_load_shares_global {
runner.vi_config_of_preload = Config::default();
}
runner.vi_config = runner.vi_config_of_preload;
}
if !next_load_shares_global {
return;
}
Expand Down Expand Up @@ -108,12 +106,12 @@ fn number_option(
}))
}

/// Config set by a preload applies to every test file, like config set by a Vitest setup file.
/// A preload's config lasts as long as its hooks: for every test file, or under test isolation for one.
fn commit(global: &JSGlobalObject, config: Config) {
if let Some(runner) = Jest::runner() {
runner.vi_config = config;
if global.bun_vm().is_in_preload {
runner.vi_config_of_preload = config;
runner.bun_test_root.vi_config_of_preload = config;
}
}
}
Expand Down
263 changes: 263 additions & 0 deletions test/js/bun/test/vi-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,269 @@ describe.concurrent("vi.setConfig", () => {
},
);

// With --parallel=2 one worker takes every file, so the worker's own swap runs between them.
const oneWorker = { BUN_TEST_PARALLEL_SCALE_MS: "60000" };

describe("what is set after the tests of a file have ended doesn't reach the next file", () => {
async function seenWith(flags: string, fileThatSetsItLate: string) {
const { stdout, stderr, exitCode } = await run(
["test", ...flags.split(" ")],
{
"a.test.ts": fileThatSetsItLate,
"b.test.ts": `
import { test } from "vitest";
test("b", ({ task }) => console.log("testTimeout of b:", task.timeout));
`,
},
oneWorker,
);
return {
seen: (stdout + stderr).split("\n").filter(line => /^(set after the tests of a|testTimeout of b:)/.test(line)),
failed: results(stderr).filter(line => !line.startsWith("(pass)")),
exitCode,
};
}

test.each(["--isolate", "--parallel=2"])("by a socket's close handler: bun test %s", async flags => {
const leavesSocketOpen = `
import { test, vi } from "bun:test";
// Left open and reachable, so that only the runner closes them: after this file.
let server, client;
test("a", async () => {
server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
client = await Bun.connect({
hostname: "127.0.0.1",
port: server.port,
socket: {
data() {},
close() {
console.log("set after the tests of a");
vi.setConfig({ testTimeout: 54321 });
},
},
});
});
`;
expect(await seenWith(flags, leavesSocketOpen)).toEqual({
seen: ["set after the tests of a", "testTimeout of b: 5000"],
failed: [],
exitCode: 0,
});
});

test.each(["--no-isolate", "--rerun-each=2"])(
"by a Proxy trap when the runner restores a spy: bun test %s",
async flags => {
const spiesOnProxy = `
import { test, vi } from "bun:test";
let ended = false;
function trap(name) {
return (...args) => {
if (ended) {
ended = false;
console.log("set after the tests of a");
vi.setConfig({ testTimeout: 54321 });
}
return Reflect[name](...args);
};
}
const proxy = new Proxy({ method() {} }, { defineProperty: trap("defineProperty"), set: trap("set") });
test("a", () => {
vi.spyOn(proxy, "method");
ended = true;
});
`;
const runs = flags.includes("--rerun-each=2") ? 2 : 1;
expect(await seenWith(flags, spiesOnProxy)).toEqual({
seen: [...Array(runs).fill("set after the tests of a"), ...Array(runs).fill("testTimeout of b: 5000")],
failed: [],
exitCode: 0,
});
},
);
});

test("a hook registered between two test files doesn't get the hookTimeout of the file before it", async () => {
const { stdout, stderr, exitCode } = await run(
["test", "--parallel=2", "--no-isolate"],
{
"a.test.ts": `
import { beforeEach, describe, test, vi } from "bun:test";
vi.setConfig({ hookTimeout: 10 });
test("a", () => {
// Polls until no test file is active. The worker exits after its last file, so the poll ends with the run.
const poll = () => {
try {
describe.skip("probe", () => {});
} catch (error) {
if (String(error).includes("outside of a test file")) {
beforeEach(async () => {
await Bun.sleep(40);
console.log("the hook ran");
});
console.log("registered between two files");
return;
}
}
setImmediate(poll);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
setImmediate(poll);
});
`,
"b.test.ts": `import { test } from "bun:test"; test("b", () => console.log("b runs"));`,
},
oneWorker,
);
expect({
seen: (stdout + stderr)
.split("\n")
.filter(line => /^(registered between two files|the hook ran|b runs)$/.test(line)),
results: results(stderr),
exitCode,
}).toEqual({
seen: ["registered between two files", "the hook ran", "b runs"],
results: ["(pass) a", "(pass) b"],
exitCode: 0,
});
});

describe("a preload's config lasts as long as the preload's hooks", () => {
// Prints what the file's tests get, then sets what no later file may get.
const observes = `
import { describe, test, vi } from "vitest";
import { basename } from "node:path";
const seen = {};
const fn = vi.fn(() => "original");
const object = { method: () => "original" };
test("first", ({ task }) => {
seen.testTimeout = task.timeout;
fn();
fn.mockReturnValue("changed");
vi.spyOn(object, "method").mockReturnValue("spied");
vi.stubEnv("VI_UTILS_STUBBED", "1");
vi.stubGlobal("viUtilsStubbed", 1);
});
test("second", () => {
seen.calls = fn.mock.calls.length;
seen.returns = fn();
seen.method = object.method();
seen.env = process.env.VI_UTILS_STUBBED ?? null;
seen.global = typeof viUtilsStubbed;
});
describe.concurrent("concurrent", () => {
let started = 0;
for (let i = 0; i < 2; i++) {
test("test " + i, async () => {
started++;
await new Promise(resolve => setImmediate(resolve));
if (i === 0) seen.startedTogether = started;
});
}
});
test("last", () => {
console.log(basename(Bun.main) + ":", JSON.stringify(seen));
vi.resetConfig();
vi.setConfig({ testTimeout: 55, maxConcurrency: 55 });
});
`;
const files = {
"preload.ts": `
import { afterAll, vi } from "bun:test";
import { basename } from "node:path";
const file = basename(Bun.main);
console.log("preload for", file);
// Registered before the preload sets a hookTimeout, and takes longer than the one it sets for a.test.ts.
afterAll(() => Bun.sleep(40));
if (file === "a.test.ts") vi.setConfig({ testTimeout: 54321, hookTimeout: 10, maxConcurrency: 1, clearMocks: false, mockReset: true, restoreMocks: true, unstubEnvs: true, unstubGlobals: true });
`,
"a.test.ts": observes,
"b.test.ts": observes,
};
const defaults = {
testTimeout: 5000,
calls: 0,
returns: "changed",
method: "spied",
env: "1",
global: "number",
startedTogether: 2,
};
// mockReset clears the calls that clearMocks: false keeps.
const ofPreload = {
testTimeout: 54321,
calls: 0,
returns: "original",
method: "original",
env: null,
global: "undefined",
startedTogether: 1,
};

async function seenWith(flags: string, fixture: Record<string, string> = files) {
const { stdout, stderr, exitCode } = await run(
["test", "--preload=./preload.ts", ...flags.split(" ")],
fixture,
oneWorker,
);
const lines = (stdout + stderr).split("\n");
return {
preloadedFor: lines.filter(line => line.startsWith("preload for ")).map(line => line.slice(12)),
seen: lines.filter(line => /^[ab]\.test\.ts: \{/.test(line)).map(line => [line[0], JSON.parse(line.slice(11))]),
errors: lines.filter(line => line.startsWith("error: ")),
failed: results(stderr).filter(line => !line.startsWith("(pass)")),
exitCode,
};
}

async function isGoneInTheNextFile(flags: string) {
expect(await seenWith(flags)).toEqual({
preloadedFor: ["a.test.ts", "b.test.ts"],
seen: [
["a", ofPreload],
["b", defaults],
],
errors: [],
failed: [],
exitCode: 0,
});
}

// A debug build takes seconds to start with --isolate=modules, so these run one at a time.
test.serial.each(["--isolate=modules", "--parallel=2 --isolate=modules"])(
"until the next file: bun test %s",
isGoneInTheNextFile,
);
test.each(["--isolate", "--parallel=2"])("until the next file: bun test %s", isGoneInTheNextFile);

test.each(["--rerun-each=2", "--parallel=2 --no-isolate"])("for every file: bun test %s", async flags => {
const runs = flags.includes("--rerun-each=2") ? 2 : 1;
expect(await seenWith(flags)).toEqual({
preloadedFor: ["a.test.ts"],
seen: ["a", "b"].flatMap(file => Array(runs).fill([file, ofPreload])),
errors: [],
failed: [],
exitCode: 0,
});
});

test.each(["--isolate --rerun-each=2", "--parallel=2 --rerun-each=2"])(
"until the next file, also when the file fails to load: bun test %s",
async flags => {
const seen = await seenWith(flags, { ...files, "a.test.ts": `throw new Error("does not load");` });
expect(seen).toEqual({
preloadedFor: ["a.test.ts", "b.test.ts"],
seen: [
["b", defaults],
["b", defaults],
],
errors: ["error: does not load"],
failed: [],
exitCode: 1,
});
},
);
});

test("rejects a config that is not an object and a testTimeout that is not a number", () => {
// @ts-expect-error
expect(() => vi.setConfig()).toThrow('The "config" argument must be of type object. Received undefined');
Expand Down
Loading