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
17 changes: 17 additions & 0 deletions docs/test/runtime-behavior.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ test("passing test", () => {
Promise.reject(new Error("Unhandled rejection"));
```

### Files without tests

A file that registers no tests, `describe` blocks, or lifecycle hooks runs as a script. After evaluating the file, `bun test` keeps running its timers and I/O until they finish, for at most the test timeout (`--timeout`). An error thrown or a promise rejected while it waits fails the run:

```ts title="script.test.ts" icon="/icons/typescript.svg"
import assert from "node:assert";
import { readFile } from "node:fs/promises";

// No test() calls. bun test waits for this function to finish.
(async () => {
const source = await readFile(import.meta.path, "utf8");
assert.ok(source.includes("assert")); // A failed assertion here fails the run
})();
```

`bun test` skips this wait when a preload script registers hooks, or when an earlier file or preload script left a timer or connection open.

### Custom Error Handling

You can set up custom error handlers in your test setup:
Expand Down
29 changes: 17 additions & 12 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,18 +1188,11 @@ impl VirtualMachine {

pub fn is_event_loop_alive_excluding_immediates(&self) -> bool {
let el = self.event_loop_shared();
let active = self
.platform_loop_opt()
.map(|h| h.is_active())
.unwrap_or(false);
self.unhandled_error_counter == 0
&& ((active as usize)
+ self.active_tasks
+ el.tasks.readable_length()
+ el.yield_tasks.len()
+ (!el.concurrent_tasks.is_empty() as usize)
+ (el.has_pending_refs() as usize)
> 0)
&& (self.has_keep_alives()
|| el.tasks.readable_length() > 0
|| !el.yield_tasks.is_empty()
|| !el.concurrent_tasks.is_empty())
}

pub fn is_event_loop_alive(&self) -> bool {
Expand All @@ -1209,6 +1202,13 @@ impl VirtualMachine {
|| !el.next_immediate_tasks.is_empty()
}

/// The ref'd-handle terms of `is_event_loop_alive()`, without its task queues or error gate.
pub fn has_keep_alives(&self) -> bool {
self.platform_loop_opt().is_some_and(|h| h.is_active())
|| self.active_tasks > 0
|| self.event_loop_shared().has_pending_refs()
}

pub fn wakeup(&mut self) {
self.event_loop_mut().wakeup();
}
Expand Down Expand Up @@ -4829,6 +4829,7 @@ impl VirtualMachine {
pub(crate) fn reload_entry_point_for_test_runner(
&mut self,
entry_path: &[u8],
after_preloads: impl FnOnce(&Self),
) -> crate::CrateResult<*mut JSInternalPromise> {
self.has_loaded = false;
self.set_main(entry_path);
Expand All @@ -4855,6 +4856,8 @@ impl VirtualMachine {
}
}

after_preloads(self);

// Note: reshaped for borrowck.
let global = self.global;
let main_str = bun_core::String::from_bytes(self.main());
Expand Down Expand Up @@ -4888,11 +4891,13 @@ impl VirtualMachine {
}

/// Loads a test-file entry point and waits for the load promise to settle.
/// `after_preloads` runs once preloads have finished, before the file is evaluated.
pub fn load_entry_point_for_test_runner(
&mut self,
entry_path: &[u8],
after_preloads: impl FnOnce(&Self),
) -> crate::CrateResult<*mut JSInternalPromise> {
let promise = self.reload_entry_point_for_test_runner(entry_path)?;
let promise = self.reload_entry_point_for_test_runner(entry_path, after_preloads)?;

// pending_internal_promise can change if hot module reloading is enabled
if self.is_watcher_enabled() {
Expand Down
42 changes: 41 additions & 1 deletion src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,37 @@ fn should_drain_event_loop() -> bool {
env_var::BUN_TEST_DRAIN_EVENT_LOOP.get().unwrap_or(false)
}

/// Runs a bare file's timers and I/O like `bun <file>` until they finish, throw, or the test timeout passes.
fn drain_script_file(
reporter: &CommandLineReporter,
buntest: &bun_test::BunTestPtr,
vm: &mut VirtualMachine,
) {
let errors_before = vm.unhandled_error_counter;
let timeout_ms = match reporter.jest.default_timeout_override {
u32::MAX => reporter.jest.default_timeout_ms,
override_ms => override_ms,
};
let deadline = (timeout_ms != 0).then(|| {
bun::Timespec::now(bun::TimespecMockMode::ForceRealTime).add_ms(i64::from(timeout_ms))
});
if let Some(deadline) = &deadline {
// So the poll below wakes at the deadline rather than at the next unrelated timer.
buntest.get().update_min_timeout(vm.global(), deadline);
}
while vm.unhandled_error_counter == errors_before
&& (vm.has_keep_alives() || vm.event_loop_shared().has_pending_tasks())
&& deadline.is_none_or(|deadline| {
bun::Timespec::now(bun::TimespecMockMode::ForceRealTime)
.order(&deadline)
.is_lt()
})
{
vm.event_loop_ref().auto_tick();
vm.event_loop_ref().tick();
}
}

/// 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 @@ -3276,7 +3307,11 @@ impl TestCommand {
}
// need to wake up so autoTick() doesn't wait for 16-100ms after loading the entrypoint
vm.wakeup();
let promise = vm.load_entry_point_for_test_runner(file_path)?;
// When nothing was alive here, whatever drain_script_file() waits on is this file's.
let mut idle_after_preloads = false;
let promise = vm.load_entry_point_for_test_runner(file_path, |vm| {
idle_after_preloads = !vm.has_keep_alives();
})?;
// Only count the file once, not once per repeat
if repeat_index == 0 {
reporter.summary().files += 1;
Expand Down Expand Up @@ -3378,6 +3413,11 @@ impl TestCommand {
// here since such a file already failed. Opt-in; one file per process.
if should_drain_event_loop() {
vm.on_before_exit();
} else if idle_after_preloads
&& buntest.collection.root_scope.is_bare()
&& buntest.bun_test_root.get().hook_scope.is_bare()
{
drain_script_file(reporter, &buntest_strong, vm);
}
drop(buntest_strong);
}
Expand Down
12 changes: 11 additions & 1 deletion src/runtime/test_runner/bun_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,8 @@ impl BunTest {
Ok(())
}

fn update_min_timeout(&mut self, global_this: &JSGlobalObject, min_timeout: &Timespec) {
/// Arms `self.timer` for `min_timeout` unless a sooner deadline is armed; a fire in `Phase::Done` only wakes the loop.
pub(crate) fn update_min_timeout(&mut self, global_this: &JSGlobalObject, min_timeout: &Timespec) {
let _g = group_begin!();
let _ = global_this;
// only set the timer if the new timeout is sooner than the current timeout. this unfortunately means that we can't unset an unnecessary timer.
Expand Down Expand Up @@ -1771,6 +1772,15 @@ pub struct DescribeScope {
}

impl DescribeScope {
/// True iff no test(), describe() or lifecycle hook was registered here.
pub(crate) fn is_bare(&self) -> bool {
self.entries.is_empty()
&& self.before_all.is_empty()
&& self.before_each.is_empty()
&& self.after_each.is_empty()
&& self.after_all.is_empty()
}

pub(crate) fn create(base: BaseScope) -> Box<DescribeScope> {
Box::new(DescribeScope {
base,
Expand Down
197 changes: 197 additions & 0 deletions test/cli/test/bun-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1750,6 +1750,203 @@ describe("bun test", () => {
});
});

// https://github.com/oven-sh/bun/issues/34859
describe.concurrent("script files with no test() registrations", () => {
async function runScripts(files: Record<string, string>, args: string[] = Object.keys(files), env = bunEnv) {
using dir = tempDir("bun-test-script-file", files);
await using proc = Bun.spawn({
cmd: [bunExe(), "test", ...args],
env,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

const rejectsAfterTimer = (message: string) => `
(async () => {
await new Promise(resolve => setTimeout(resolve, 20));
throw new Error(${JSON.stringify(message)});
})();
`;

test("fails on a rejection that happens after a timer", async () => {
const { stderr, exitCode } = await runScripts({ "script.test.js": rejectsAfterTimer("rejected after a timer") });
expect(stderr).toContain("Unhandled error between tests");
expect(stderr).toContain("rejected after a timer");
expect(stderr).toContain("1 error");
expect(exitCode).toBe(1);
});

test("fails on an exception thrown from a timer callback", async () => {
const { stderr, exitCode } = await runScripts({
"script.test.js": `setTimeout(() => { throw new Error("thrown from a timer"); }, 20);`,
});
expect(stderr).toContain("Unhandled error between tests");
expect(stderr).toContain("thrown from a timer");
expect(exitCode).toBe(1);
});

test("fails on a timer armed by a setImmediate callback", async () => {
const { stderr, exitCode } = await runScripts({
"script.test.js": `setImmediate(() => setTimeout(() => { throw new Error("thrown after an immediate"); }, 20));`,
});
expect(stderr).toContain("Unhandled error between tests");
expect(stderr).toContain("thrown after an immediate");
expect(exitCode).toBe(1);
});

// The shape of the vendored node tests from the issue: a check made after a child process replies.
test("fails on an error thrown after a child process replies", async () => {
const { stderr, exitCode } = await runScripts({
"script.test.js": `
const { spawn } = require("node:child_process");
const { once } = require("node:events");
(async () => {
const child = spawn(process.execPath, ["-e", "process.send(1, () => process.disconnect())"], {
stdio: ["ignore", "ignore", "ignore", "ipc"],
});
const [received] = await once(child, "message");
if (received !== 2) throw new Error("child replied with " + received);
})();
`,
});
expect(stderr).toContain("Unhandled error between tests");
expect(stderr).toContain("child replied with 1");
expect(exitCode).toBe(1);
});

test("lets the file's async work finish before moving on", async () => {
const { stdout, stderr, exitCode } = await runScripts({
"script.test.js": `
(async () => {
await new Promise(resolve => setTimeout(resolve, 20));
await Bun.file(__filename).text();
console.log("async work finished");
})();
`,
});
expect(stdout).toContain("async work finished");
expect(stderr).toContain("0 fail");
expect(exitCode).toBe(0);
});

test("still fails on a rejection that is pending when the module finishes evaluating", async () => {
const { stderr, exitCode } = await runScripts({
"script.test.js": `(async () => { throw new Error("rejected during evaluation"); })();`,
});
expect(stderr).toContain("Unhandled error between tests");
expect(stderr).toContain("rejected during evaluation");
expect(exitCode).toBe(1);
});

test("drains each script file separately", async () => {
const { stderr, exitCode } = await runScripts({
"a.test.js": rejectsAfterTimer("file A rejected"),
"b.test.js": rejectsAfterTimer("file B rejected"),
});
expect(stderr).toContain("file A rejected");
expect(stderr).toContain("file B rejected");
expect(stderr).toContain("2 errors");
expect(exitCode).toBe(1);
});

test("drains with --timeout=0", async () => {
const { stderr, exitCode } = await runScripts({ "script.test.js": rejectsAfterTimer("rejected after a timer") }, [
"--timeout=0",
"script.test.js",
]);
expect(stderr).toContain("rejected after a timer");
expect(exitCode).toBe(1);
});

test("gives up after the test timeout on work the file never finishes", async () => {
const { stdout, stderr, exitCode } = await runScripts(
{
"script.test.js": `
setTimeout(() => console.log("timer ran"), 20);
setInterval(() => {}, 60_000);
`,
},
["--timeout=200", "script.test.js"],
);
expect(stdout).toContain("timer ran");
expect(stderr).toContain("0 fail");
expect(exitCode).toBe(0);
});

test("fails the same way under BUN_TEST_DRAIN_EVENT_LOOP", async () => {
const { stderr, exitCode } = await runScripts(
{ "script.test.js": rejectsAfterTimer("rejected after a timer") },
["script.test.js"],
{ ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: "1" },
);
expect(stderr).toContain("Unhandled error between tests");
expect(stderr).toContain("rejected after a timer");
expect(exitCode).toBe(1);
});

// Only the drain runs timers once a file is done, so a run that correctly skips
// it exits without printing this. The script files below load nothing, so the
// timer cannot fire while one of them is still being loaded either.
const lateTimer = `setTimeout(() => console.log("late timer ran"), 2_000);`;
const scriptFile = `globalThis.loaded = true;`;

test("does not wait on a file that registered a test()", async () => {
const { stdout, stderr, exitCode } = await runScripts({
"with.test.js": `
const { test } = require("bun:test");
test("leaves a timer behind", () => { ${lateTimer} });
`,
});
expect(stdout).not.toContain("late timer ran");
expect(stderr).toContain("1 pass");
expect(exitCode).toBe(0);
});

test("does not wait when a prior file left a ref'd handle", async () => {
const { stdout, stderr, exitCode } = await runScripts({
"a.test.js": `
const { test } = require("bun:test");
test("leaves a timer behind", () => { ${lateTimer} });
`,
"b.test.js": scriptFile,
});
expect(stdout).not.toContain("late timer ran");
expect(stderr).toContain("1 pass");
expect(stderr).toContain("0 fail");
expect(exitCode).toBe(0);
});

test("does not wait when a preload left a ref'd handle", async () => {
const { stdout, stderr, exitCode } = await runScripts({ "setup.js": lateTimer, "script.test.js": scriptFile }, [
"--preload",
"./setup.js",
"script.test.js",
]);
expect(stdout).not.toContain("late timer ran");
expect(stderr).toContain("0 fail");
expect(exitCode).toBe(0);
});

test("does not wait when a preload registered hooks", async () => {
const { stdout, stderr, exitCode } = await runScripts(
{
"setup.js": `
import { beforeAll } from "bun:test";
beforeAll(() => { ${lateTimer} });
`,
"script.test.js": scriptFile,
},
["--preload", "./setup.js", "script.test.js"],
);
expect(stdout).not.toContain("late timer ran");
expect(stderr).toContain("0 fail");
expect(exitCode).toBe(0);
});
});

function createTest(input?: string | (string | { filename: string; contents: string })[], filename?: string): string {
const cwd = tmpdirSync();
const inputs = Array.isArray(input) ? input : [input ?? ""];
Expand Down
Loading