Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
4ed1386b83 fix(test): collect test() calls inside setTimeout callbacks (#20087)
The test runner was transitioning from collection to execution phase
before setTimeout callbacks had a chance to fire, causing test() calls
inside setTimeout to be silently ignored. Now the collection phase
waits for pending setTimeout timers to complete before building the
execution plan, matching Node.js test runner behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 10:02:48 +00:00
5 changed files with 142 additions and 4 deletions

View File

@@ -20,6 +20,9 @@ pub const All = struct {
thread_id: std.Thread.Id,
timers: TimerHeap = .{ .context = {} },
active_timer_count: i32 = 0,
/// Tracks only setTimeout timers (not setInterval). Used by the test runner
/// to know when to finish the collection phase. See #20087.
active_set_timeout_count: i32 = 0,
uv_timer: if (Environment.isWindows) uv.Timer else void = if (Environment.isWindows) std.mem.zeroes(uv.Timer),
/// Whether we have emitted a warning for passing a negative timeout duration
warned_negative_number: bool = false,

View File

@@ -251,6 +251,12 @@ fn convertToInterval(this: *TimerObjectInternals, global: *JSGlobalObject, timer
const vm = global.bunVM();
// If this setTimeout was keeping the event loop alive, adjust the
// setTimeout-specific counter since the kind is changing to setInterval.
if (this.flags.is_keeping_event_loop_alive) {
vm.timer.active_set_timeout_count -= 1;
}
const new_interval: u31 = if (repeat.getNumber()) |num| if (num < 1 or num > std.math.maxInt(u31)) 1 else @intFromFloat(num) else 1;
// https://github.com/nodejs/node/blob/a7cbb904745591c9a9d047a364c2c188e5470047/lib/internal/timers.js#L613
@@ -429,7 +435,12 @@ fn setEnableKeepingEventLoopAlive(this: *TimerObjectInternals, vm: *VirtualMachi
}
this.flags.is_keeping_event_loop_alive = enable;
switch (this.flags.kind) {
.setTimeout, .setInterval => vm.timer.incrementTimerRef(if (enable) 1 else -1),
.setTimeout => {
const delta: i32 = if (enable) 1 else -1;
vm.timer.incrementTimerRef(delta);
vm.timer.active_set_timeout_count += delta;
},
.setInterval => vm.timer.incrementTimerRef(if (enable) 1 else -1),
// setImmediate has slightly different event loop logic
.setImmediate => vm.timer.incrementImmediateRef(if (enable) 1 else -1),

View File

@@ -545,8 +545,16 @@ pub const BunTest = struct {
min_timeout = bun.timespec.minIgnoreEpoch(min_timeout, waiting.timeout);
},
.complete => {
if (try this._advance(globalThis) == .exit) return;
this.addResult(.start);
switch (try this._advance(globalThis)) {
.exit => return,
.cont => this.addResult(.start),
.wait => {
// Waiting for pending timers to fire during collection.
// Return to the event loop; we'll be re-triggered when
// timers fire and test_command re-calls run().
return;
},
}
},
}
}
@@ -578,7 +586,7 @@ pub const BunTest = struct {
}
}
fn _advance(this: *BunTest, _: *jsc.JSGlobalObject) bun.JSError!enum { cont, exit } {
fn _advance(this: *BunTest, globalThis: *jsc.JSGlobalObject) bun.JSError!enum { cont, exit, wait } {
group.begin(@src());
defer group.end();
group.log("advance from {s}", .{@tagName(this.phase)});
@@ -586,6 +594,17 @@ pub const BunTest = struct {
switch (this.phase) {
.collection => {
// If there are pending setTimeout timers, wait for them to fire
// before transitioning to execution. This allows test() calls
// inside setTimeout callbacks to be collected. (See #20087)
// Note: we only check setTimeout (not setInterval) because
// setInterval timers repeat indefinitely and would block forever.
const vm = globalThis.bunVM();
if (vm.timer.active_set_timeout_count > 0) {
group.log("advance: waiting for {d} pending setTimeout(s) before finishing collection", .{vm.timer.active_set_timeout_count});
return .wait;
}
this.phase = .execution;
try debug.dumpDescribe(this.collection.root_scope);

View File

@@ -1973,6 +1973,17 @@ pub const TestCommand = struct {
if (buntest.phase == .done) break;
vm.eventLoop().tick();
// If still in collection phase (waiting for setTimeout callbacks
// to register tests), re-trigger the run loop after each event
// loop tick so we can check if collection is now complete.
// See: https://github.com/oven-sh/bun/issues/20087
if (buntest.phase == .collection) {
if (buntest.result_queue.readableLength() == 0) {
buntest.addResult(.start);
}
try bun.jsc.Jest.bun_test.BunTest.run(buntest_strong, vm.global);
}
while (prev_unhandled_count < vm.unhandled_error_counter) {
vm.global.handleRejectedPromises();
prev_unhandled_count = vm.unhandled_error_counter;

View File

@@ -0,0 +1,94 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
test("test() calls inside setTimeout are collected", async () => {
using dir = tempDir("issue-20087", {
"setTimeout.test.ts": `
import { test } from "bun:test";
setTimeout(() => {
test("test inside setTimeout", () => {
console.log("hello from setTimeout");
});
}, 100);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "setTimeout.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("hello from setTimeout");
expect(stderr).toContain("1 pass");
expect(stderr).not.toContain("0 pass");
expect(exitCode).toBe(0);
});
test("test() calls inside setTimeout with large delay are collected", async () => {
using dir = tempDir("issue-20087-large-delay", {
"setTimeout-large.test.ts": `
import { test } from "bun:test";
setTimeout(() => {
test("test inside setTimeout 1000ms", () => {
console.log("hello from 1000ms setTimeout");
});
}, 1000);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "setTimeout-large.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("hello from 1000ms setTimeout");
expect(stderr).toContain("1 pass");
expect(stderr).not.toContain("0 pass");
expect(exitCode).toBe(0);
});
test("mixed sync and setTimeout test() calls are all collected", async () => {
using dir = tempDir("issue-20087-mixed", {
"mixed.test.ts": `
import { test } from "bun:test";
test("sync test", () => {
console.log("sync");
});
setTimeout(() => {
test("async test", () => {
console.log("async");
});
}, 100);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "mixed.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("sync");
expect(stdout).toContain("async");
expect(stderr).toContain("2 pass");
expect(stderr).not.toContain("0 pass");
expect(exitCode).toBe(0);
});