Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
33 changes: 21 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,16 @@ impl VirtualMachine {
|| !el.next_immediate_tasks.is_empty()
}

/// Whether something ref'd (a platform-loop handle or ref'd timer, a
/// `Ref`-holding object, a queued `ref_keep_alive`) still holds the loop
/// open. Unlike `is_event_loop_alive()` this ignores the task queues and
/// `unhandled_error_counter`, which `bun test` accumulates across files.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +4832,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 +4859,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 +4894,14 @@ impl VirtualMachine {
}

/// Loads a test-file entry point and waits for the load promise to settle.
/// `after_preloads` runs between preload completion and entry-point
/// evaluation so the caller can observe preload-created handles.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
56 changes: 51 additions & 5 deletions src/runtime/cli/test_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,42 @@ fn should_drain_event_loop() -> bool {
env_var::BUN_TEST_DRAIN_EVENT_LOOP.get().unwrap_or(false)
}

/// A file that registered nothing with `bun:test` is a plain script: as under
/// `bun <file>`, run its timers and I/O until nothing is left or one of them throws
/// or rejects, which the usual between-tests reporting has already counted (#34859).
/// The caller checked that the loop was idle before the file ran, so everything
/// waited on here is the file's own. Bounded by the test timeout (0 = unbounded) so a
/// leaked server or interval cannot hang the run; the file's `BunTest` timer is armed
/// at that deadline so the poll wakes up for it.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 {
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 +3312,12 @@ 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)?;
// Sampled before the file's own top level runs: when nothing was alive
// then, whatever drain_script_file() (below) waits on is this file's own.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 @@ -3372,12 +3413,17 @@ impl TestCommand {
// SAFETY: el is the VM-owned event loop; vm is passed back as *mut.
unsafe { (*el).tick_immediate_tasks(vm) };

// Node parity: a node test file exits only when its loop drains.
// on_before_exit() drains and dispatches 'beforeExit' like `bun run`;
// it early-returns when unhandled_error_counter > 0, which is fine
// here since such a file already failed. Opt-in; one file per process.
if should_drain_event_loop() {
// Node parity: a node test file exits only when its loop drains.
// on_before_exit() drains and dispatches 'beforeExit' like `bun run`;
// it early-returns when unhandled_error_counter > 0, which is fine
// here since such a file already failed. Opt-in; one file per process.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
14 changes: 13 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,10 @@ impl BunTest {
Ok(())
}

fn update_min_timeout(&mut self, global_this: &JSGlobalObject, min_timeout: &Timespec) {
/// Arms `self.timer` for `min_timeout` unless an earlier deadline is armed. Once the
/// file is `Phase::Done`, firing only wakes the event loop (`bun_test_timeout_callback`),
/// which is how `test_command.rs` bounds its script drain; `Drop` removes an unfired timer.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +1774,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
Loading
Loading