diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 36e29978e0..65544c457e 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -191,6 +191,31 @@ declare module "bun:test" { useFakeTimers: typeof jest.useFakeTimers; useRealTimers: typeof jest.useRealTimers; runAllTicks: typeof jest.runAllTicks; + /** + * Advances all timers to the next timer (earliest scheduled timer). + * Useful for stepping through timers one at a time. + * + * @returns this (the vi object) for chaining + * + * @example + * ```ts + * import { vi, test, expect } from "bun:test"; + * + * test("vi.advanceTimersToNextTimer", () => { + * vi.useFakeTimers(); + * + * let i = 0; + * setInterval(() => console.log(++i), 50); + * + * vi.advanceTimersToNextTimer() // log: 1 + * .advanceTimersToNextTimer() // log: 2 + * .advanceTimersToNextTimer(); // log: 3 + * + * vi.useRealTimers(); + * }); + * ``` + */ + advanceTimersToNextTimer(): typeof vi; }; interface FunctionLike { diff --git a/src/bun.js/api/Timer.zig b/src/bun.js/api/Timer.zig index 9af35c6338..3a89d693df 100644 --- a/src/bun.js/api/Timer.zig +++ b/src/bun.js/api/Timer.zig @@ -578,6 +578,35 @@ pub const All = struct { return .js_undefined; } + /// Internal function that advances to the next timer in the vi_timers heap. + /// Returns true if a timer was fired, false if no timers remain. + fn advanceToNextViTimer(timer: *All, vm: *VirtualMachine) bool { + // Get the next timer from the heap + timer.lock.lock(); + const event_timer = timer.vi_timers.deleteMin(); + timer.lock.unlock(); + + if (event_timer) |et| { + // Set the virtual time to this timer's scheduled time + // This ensures nested timers scheduled during callbacks get the correct base time + timer.vi_current_time = et.next; + + // Fire the timer at its scheduled time + const arm_result = et.fire(&et.next, vm); + + // If the timer wants to rearm (like setInterval), reinsert it + if (arm_result == .rearm) { + timer.lock.lock(); + timer.vi_timers.insert(et); + timer.lock.unlock(); + } + + return true; + } + + return false; + } + pub fn runAllTimers( globalThis: *JSGlobalObject, _: *jsc.CallFrame, @@ -588,27 +617,7 @@ pub const All = struct { // Keep firing timers until there are no more // We need to keep checking the heap because firing timers can schedule new timers - while (true) { - timer.lock.lock(); - const event_timer = timer.vi_timers.deleteMin(); - timer.lock.unlock(); - - if (event_timer == null) break; - - // Set the virtual time to this timer's scheduled time - // This ensures nested timers scheduled during callbacks get the correct base time - timer.vi_current_time = event_timer.?.next; - - // Fire the timer at its scheduled time - const arm_result = event_timer.?.fire(&event_timer.?.next, vm); - - // If the timer wants to rearm (like setInterval), reinsert it - if (arm_result == .rearm) { - timer.lock.lock(); - timer.vi_timers.insert(event_timer.?); - timer.lock.unlock(); - } - } + while (advanceToNextViTimer(timer, vm)) {} // Clear the virtual time when we're done timer.vi_current_time = null; @@ -616,6 +625,28 @@ pub const All = struct { return .js_undefined; } + pub fn advanceTimersToNextTimer( + globalThis: *JSGlobalObject, + callframe: *jsc.CallFrame, + ) JSError!JSValue { + jsc.markBinding(@src()); + const vm = globalThis.bunVM(); + const timer = &vm.timer; + + // Advance to the next timer (if any) + _ = advanceToNextViTimer(timer, vm); + + // Keep the virtual time set for subsequent calls (don't clear it) + // Return the 'this' value (the vi object) for chaining + return callframe.this(); + } + + export fn Bun__Timer__initViTime(vm: *VirtualMachine) callconv(.C) void { + const timer = &vm.timer; + // Initialize virtual time to zero so timers are scheduled relative to time 0 + timer.vi_current_time = timespec{ .sec = 0, .nsec = 0 }; + } + export fn Bun__Timer__clearViTimers(vm: *VirtualMachine) callconv(.C) void { const timer = &vm.timer; timer.lock.lock(); @@ -636,7 +667,9 @@ pub const All = struct { @export(&jsc.host_fn.wrap2(clearTimeout), .{ .name = "Bun__Timer__clearTimeout" }); @export(&jsc.host_fn.wrap2(clearInterval), .{ .name = "Bun__Timer__clearInterval" }); @export(&jsc.host_fn.wrap2(runAllTimers), .{ .name = "Bun__Timer__runAllTimers" }); + @export(&jsc.host_fn.wrap2(advanceTimersToNextTimer), .{ .name = "Bun__Timer__advanceTimersToNextTimer" }); @export(&getNextID, .{ .name = "Bun__Timer__getNextID" }); + _ = &Bun__Timer__initViTime; _ = &Bun__Timer__clearViTimers; } }; diff --git a/src/bun.js/bindings/JSMockFunction.cpp b/src/bun.js/bindings/JSMockFunction.cpp index 132bc66fe5..4621bcf159 100644 --- a/src/bun.js/bindings/JSMockFunction.cpp +++ b/src/bun.js/bindings/JSMockFunction.cpp @@ -1423,6 +1423,8 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject using namespace Bun; using namespace JSC; +extern "C" void Bun__Timer__initViTime(void*); + // Enables fake timers and sets up Date mocking BUN_DEFINE_HOST_FUNCTION(JSMock__jsUseFakeTimers, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1433,6 +1435,14 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsUseFakeTimers, (JSC::JSGlobalObject * globalO // Initialize with current time if not already set globalObject->overridenDateNow = globalObject->jsDateNow(); } + + // Initialize vi_current_time so timers are scheduled relative to time 0 + auto* bunGlobal = jsCast(globalObject); + auto* vm = bunGlobal->bunVM(); + if (vm) { + Bun__Timer__initViTime(vm); + } + return JSValue::encode(callframe->thisValue()); } diff --git a/src/bun.js/test/jest.zig b/src/bun.js/test/jest.zig index b00959925a..9e4fcf017d 100644 --- a/src/bun.js/test/jest.zig +++ b/src/bun.js/test/jest.zig @@ -260,6 +260,7 @@ pub const Jest = struct { vi.put(globalObject, ZigString.static("isFakeTimers"), jsc.host_fn.NewFunction(globalObject, ZigString.static("isFakeTimers"), 0, JSMock__jsIsFakeTimers, false)); vi.put(globalObject, ZigString.static("runAllTimers"), jsc.host_fn.NewFunction(globalObject, ZigString.static("runAllTimers"), 0, Bun__Timer__runAllTimers, false)); vi.put(globalObject, ZigString.static("runAllTicks"), jsc.host_fn.NewFunction(globalObject, ZigString.static("runAllTicks"), 0, JSMock__jsRunAllTicks, false)); + vi.put(globalObject, ZigString.static("advanceTimersToNextTimer"), jsc.host_fn.NewFunction(globalObject, ZigString.static("advanceTimersToNextTimer"), 0, Bun__Timer__advanceTimersToNextTimer, false)); module.put(globalObject, ZigString.static("vi"), vi); } @@ -278,6 +279,7 @@ pub const Jest = struct { extern fn JSMock__jsUseFakeTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue; extern fn JSMock__jsUseRealTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue; extern fn Bun__Timer__runAllTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue; + extern fn Bun__Timer__advanceTimersToNextTimer(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue; pub fn call( globalObject: *JSGlobalObject, diff --git a/test/js/bun/test/fake-timers/order.test.ts b/test/js/bun/test/fake-timers/order.test.ts index d76fba7b41..838637eac2 100644 --- a/test/js/bun/test/fake-timers/order.test.ts +++ b/test/js/bun/test/fake-timers/order.test.ts @@ -3,22 +3,35 @@ import { vi, test, beforeAll, afterAll, expect } from "vitest"; beforeAll(() => vi.useFakeTimers()); afterAll(() => vi.useRealTimers()); -test.each(Array.from({ length: 2 }).map((_, i) => i))("runAllTimers runs in order of time", i => { - const order: string[] = []; - const orderNum: number[] = []; - - let base = 0; - const time = (d: number, l: string, cb: () => void) => { - const start = base; +class TimeHelper { + base: number = 0; + order: string[] = []; + orderNum: number[] = []; + time(d: number, l: string, cb: () => void) { + const start = this.base; setTimeout(() => { - orderNum.push(start + d); - order.push(`${start + d}${l ? ` (${l})` : ""}`); - if (base != 0) throw new Error("base is not 0"); - base = start + d; + this.addOrder(start + d, l); + if (this.base != 0) throw new Error("base is not 0"); + this.base = start + d; cb(); - base = 0; + this.base = 0; }, d); - }; + } + addOrder(d: number, l: string) { + this.orderNum.push(d); + this.order.push(`${d}${l ? ` (${l})` : ""}`); + } + expectOrder() { + expect(this.orderNum).toEqual(this.orderNum.toSorted((a, b) => a - b)); + const order = this.order; + this.order = []; + return expect(order); + } +} + +test.each(["runAllTimers", "advanceTimersToNextTimer"])("%s runs in order of time", mode => { + const tester = new TimeHelper(); + const time = tester.time.bind(tester); time(1000, "", () => { time(500, "", () => {}); @@ -41,18 +54,30 @@ test.each(Array.from({ length: 2 }).map((_, i) => i))("runAllTimers runs in orde const interval = setInterval(() => { if (intervalCount > 3) clearInterval(interval); intervalCount += 1; - orderNum.push(intervalCount * 499); - order.push(`${intervalCount * 499} (interval)`); + tester.addOrder(intervalCount * 499, "interval"); setTimeout(() => { - orderNum.push(intervalCount * 499 + 25); - order.push(`${intervalCount * 499 + 25} (interval + 25)`); + tester.addOrder(intervalCount * 499 + 25, "interval + 25"); }, 25); }, 499); - vi.runAllTimers(); + if (mode === "runAllTimers") { + vi.runAllTimers(); + } else if (mode === "advanceTimersToNextTimer") { + let orderLen = 0; + while (true) { + vi.advanceTimersToNextTimer(); + if (tester.order.length > orderLen) { + expect(tester.order.length).toBe(orderLen + 1); + orderLen = tester.order.length; + } else if (tester.order.length === orderLen) { + break; + } else { + expect.fail(); + } + } + } - expect(orderNum).toEqual(orderNum.toSorted((a, b) => a - b)); - expect(order).toMatchInlineSnapshot(` + tester.expectOrder().toMatchInlineSnapshot(` [ "0 (zero 1)", "0 (zero 2)", @@ -88,8 +113,6 @@ test("runAllTimers supports interval", () => { if (ticks >= 10) clearInterval(interval); }, 25); - vi.runAllTimers(); - expect(ticks).toBe(10); }); diff --git a/test/js/bun/test/vi-advance-timers-to-next-timer.test.ts b/test/js/bun/test/vi-advance-timers-to-next-timer.test.ts new file mode 100644 index 0000000000..b1a396399e --- /dev/null +++ b/test/js/bun/test/vi-advance-timers-to-next-timer.test.ts @@ -0,0 +1,205 @@ +import { expect, test, vi } from "bun:test"; + +test("vi.advanceTimersToNextTimer() advances to next timer", () => { + vi.useFakeTimers(); + + let i = 0; + setInterval(() => i++, 50); + + expect(i).toBe(0); + + vi.advanceTimersToNextTimer(); + expect(i).toBe(1); + + vi.advanceTimersToNextTimer(); + expect(i).toBe(2); + + vi.advanceTimersToNextTimer(); + expect(i).toBe(3); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() is chainable", () => { + vi.useFakeTimers(); + + let i = 0; + setInterval(() => i++, 50); + + expect(i).toBe(0); + + vi.advanceTimersToNextTimer().advanceTimersToNextTimer().advanceTimersToNextTimer(); + + expect(i).toBe(3); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() handles multiple timers with different delays", () => { + vi.useFakeTimers(); + + const order: number[] = []; + + setTimeout(() => order.push(1), 100); + setTimeout(() => order.push(2), 50); + setTimeout(() => order.push(3), 150); + + expect(order).toEqual([]); + + // Should fire in order of scheduled time, not order they were created + vi.advanceTimersToNextTimer(); // fires timer at 50ms + expect(order).toEqual([2]); + + vi.advanceTimersToNextTimer(); // fires timer at 100ms + expect(order).toEqual([2, 1]); + + vi.advanceTimersToNextTimer(); // fires timer at 150ms + expect(order).toEqual([2, 1, 3]); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() handles nested timers", () => { + vi.useFakeTimers(); + + const order: number[] = []; + + setTimeout(() => { + order.push(1); + setTimeout(() => order.push(3), 50); + }, 100); + + setTimeout(() => order.push(2), 150); + + vi.advanceTimersToNextTimer(); // fires timer at 100ms, schedules new timer at current_time + 50ms + expect(order).toEqual([1]); + + // When two timers are at the same time (150ms), they fire in the order they were scheduled (FIFO) + // Timer 2 was scheduled first (at time 0), so it fires before timer 3 (scheduled during callback at time 100) + vi.advanceTimersToNextTimer(); // fires timer at 150ms (timer 2, scheduled first) + expect(order).toEqual([1, 2]); + + vi.advanceTimersToNextTimer(); // fires nested timer at 150ms (timer 3, scheduled during callback) + expect(order).toEqual([1, 2, 3]); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() does nothing when no timers are pending", () => { + vi.useFakeTimers(); + + let called = false; + setTimeout(() => { + called = true; + }, 100); + + vi.advanceTimersToNextTimer(); + expect(called).toBe(true); + + // No more timers, this should be safe to call + vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer(); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() works with setInterval", () => { + vi.useFakeTimers(); + + let count = 0; + const intervalId = setInterval(() => { + count++; + if (count >= 3) { + clearInterval(intervalId); + } + }, 100); + + expect(count).toBe(0); + + vi.advanceTimersToNextTimer(); + expect(count).toBe(1); + + vi.advanceTimersToNextTimer(); + expect(count).toBe(2); + + vi.advanceTimersToNextTimer(); + expect(count).toBe(3); + + // Interval was cleared, no more timers + vi.advanceTimersToNextTimer(); + expect(count).toBe(3); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() handles mix of setTimeout and setInterval", () => { + vi.useFakeTimers(); + + const events: string[] = []; + + setTimeout(() => events.push("timeout-1"), 50); + const intervalId = setInterval(() => { + events.push("interval"); + if (events.filter(e => e === "interval").length >= 2) { + clearInterval(intervalId); + } + }, 100); + setTimeout(() => events.push("timeout-2"), 200); + + vi.advanceTimersToNextTimer(); // 50ms: timeout-1 + expect(events).toEqual(["timeout-1"]); + + vi.advanceTimersToNextTimer(); // 100ms: interval (1st) + expect(events).toEqual(["timeout-1", "interval"]); + + vi.advanceTimersToNextTimer(); // 200ms: interval (2nd) and timeout-2 compete + // The interval at 200ms should fire first (it was scheduled first), then gets cleared + expect(events.length).toBe(3); + expect(events).toContain("interval"); + + vi.advanceTimersToNextTimer(); // 200ms: timeout-2 + expect(events).toContain("timeout-2"); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() executes timer callbacks with automatic microtask processing", () => { + vi.useFakeTimers(); + + const order: string[] = []; + + setTimeout(() => { + order.push("timer"); + process.nextTick(() => order.push("tick")); + }, 100); + + // When a timer callback runs, microtasks (including nextTick) are processed automatically + // This is standard JavaScript execution behavior + vi.advanceTimersToNextTimer(); + expect(order).toEqual(["timer", "tick"]); + + vi.useRealTimers(); +}); + +test("vi.advanceTimersToNextTimer() returns vi object for chaining with other vi methods", () => { + vi.useFakeTimers(); + + let timerCalled = false; + let tickCalled = false; + + setTimeout(() => { + timerCalled = true; + process.nextTick(() => { + tickCalled = true; + }); + }, 100); + + // Microtasks run automatically after timer callback, so runAllTicks() is not needed here + // but chaining still works + vi.advanceTimersToNextTimer(); + + expect(timerCalled).toBe(true); + expect(tickCalled).toBe(true); + + vi.useRealTimers(); +});