test(agent): migrated yield tests to use YieldGate for stability

- Replaced global timer mocks with local `YieldGate` instances to avoid test flakiness from concurrent environment interference.
- Introduced an injected clock and counting sleep pattern to test gating logic without relying on `process` globals.
- Added a test case to ensure the gate correctly handles negative clock jumps without stalling.
This commit is contained in:
can1357
2026-06-24 15:30:29 +02:00
parent 1052af5fa6
commit c053afa087
+41 -26
View File
@@ -1,51 +1,66 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { scheduler } from "node:timers/promises";
import { ExponentialYield, yieldIfDue } from "@oh-my-pi/pi-agent-core/utils/yield";
import { ExponentialYield, YieldGate } from "@oh-my-pi/pi-agent-core/utils/yield";
const YIELD_INTERVAL_MS = 50;
const YIELD_CLOCK_STEP_MS = 60_000;
let fakeClockNow = Date.now();
afterEach(() => {
vi.restoreAllMocks();
});
function installYieldClock(): { advanceBy: (ms: number) => void } {
fakeClockNow += YIELD_CLOCK_STEP_MS;
let now = fakeClockNow;
vi.spyOn(Date, "now").mockImplementation(() => now);
/**
* Build a gate over an injected clock and a counting sleep so the test drives
* the gate logic without spying on process-global `Date.now`/`scheduler.wait`.
* Those globals are shared across files, so under concurrent `bun test` a
* sibling file's `vi.restoreAllMocks()` could wipe the spies mid-run — the
* exact race that made the previous singleton-based test flake.
*/
function makeGate(): { gate: YieldGate; advanceBy: (ms: number) => void; sleeps: () => number } {
let now = 1_000_000;
const sleep = vi.fn(async () => {});
const gate = new YieldGate({ now: () => now, sleep });
return {
advanceBy(ms: number) {
gate,
advanceBy: (ms: number) => {
now += ms;
fakeClockNow = now;
},
sleeps: () => sleep.mock.calls.length,
};
}
describe("yieldIfDue", () => {
describe("YieldGate.yieldIfDue", () => {
it("sleeps on the first call and gates immediate callers", async () => {
const clock = installYieldClock();
const waitSpy = vi.spyOn(scheduler, "wait");
const { gate, advanceBy, sleeps } = makeGate();
await yieldIfDue();
expect(waitSpy.mock.calls.length).toBeGreaterThan(0);
const callsAfterFirstYield = waitSpy.mock.calls.length;
await gate.yieldIfDue();
expect(sleeps()).toBe(1);
clock.advanceBy(YIELD_INTERVAL_MS - 1);
await yieldIfDue();
expect(waitSpy.mock.calls.length).toBe(callsAfterFirstYield);
advanceBy(YIELD_INTERVAL_MS - 1);
await gate.yieldIfDue();
expect(sleeps()).toBe(1);
});
it("sleeps again once the gate window elapses", async () => {
const clock = installYieldClock();
const waitSpy = vi.spyOn(scheduler, "wait");
const { gate, advanceBy, sleeps } = makeGate();
await yieldIfDue();
const callsAfterFirstYield = waitSpy.mock.calls.length;
await gate.yieldIfDue();
expect(sleeps()).toBe(1);
clock.advanceBy(YIELD_INTERVAL_MS);
await yieldIfDue();
expect(waitSpy.mock.calls.length).toBeGreaterThan(callsAfterFirstYield);
advanceBy(YIELD_INTERVAL_MS);
await gate.yieldIfDue();
expect(sleeps()).toBe(2);
});
it("treats a backward clock jump as due instead of gating forever", async () => {
const { gate, advanceBy, sleeps } = makeGate();
await gate.yieldIfDue();
expect(sleeps()).toBe(1);
// NTP correction / fake timers can move the wall clock backward; the next
// call must still yield rather than wait for an interval that never comes.
advanceBy(-YIELD_INTERVAL_MS * 4);
await gate.yieldIfDue();
expect(sleeps()).toBe(2);
});
});