diff --git a/packages/engine/src/__tests__/research-dispatcher.test.ts b/packages/engine/src/__tests__/research-dispatcher.test.ts index 8e4a4dc6c7..bda2fe7975 100644 --- a/packages/engine/src/__tests__/research-dispatcher.test.ts +++ b/packages/engine/src/__tests__/research-dispatcher.test.ts @@ -1,11 +1,27 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { ResearchRun, ResearchStore } from "@fusion/core"; import type { ResearchOrchestrator } from "../research/research-orchestrator.js"; import { ResearchRunDispatcher } from "../research/research-dispatcher.js"; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - +/* +FNXC:ResearchDispatcher 2026-08-04-00:03: +This is a pure timer-driven polling unit test (no subprocess, no network), so it uses +FAKE TIMERS instead of real `sleep()` waits per AGENTS.md "Do Not Add Slow Tests" / +"Prefer fake timers over real polling/time waits". The previous real-sleep version was +both slow (~200ms of wall-clock waits) AND weaker: the dispatcher clamps tickIntervalMs +to a 100ms floor, so 30-40ms real sleeps never let the interval re-fire — only the +immediate `void this.tick()` in start() ran. The double-dispatch and stop-cancels-timer +cases therefore never exercised a SECOND tick. Advancing fake timers past the 100ms +interval now deterministically fires follow-up ticks, so those invariants are genuinely +asserted. stop()'s drain loop only spins when inFlight is non-empty, so every test drains +inFlight (immediate/resolved runs) before stop, keeping the fake-timer clock from stalling +in that real-`setTimeout` loop. +*/ describe("ResearchRunDispatcher", () => { + afterEach(() => { + vi.useRealTimers(); + }); + function createStore(runs: ResearchRun[]): ResearchStore { return { listRuns: vi.fn(() => runs), @@ -13,6 +29,7 @@ describe("ResearchRunDispatcher", () => { } it("dispatches queued runs", async () => { + vi.useFakeTimers(); const runs = [{ id: "RR-1", query: "hello", status: "queued" } as ResearchRun]; const store = createStore(runs); const startRun = vi.fn(async () => ({ id: "RR-1" } as ResearchRun)); @@ -20,13 +37,15 @@ describe("ResearchRunDispatcher", () => { const dispatcher = new ResearchRunDispatcher({ store, orchestrator, tickIntervalMs: 10 }); dispatcher.start(); - await sleep(30); + // Flush the immediate `void this.tick()` chain (await listRuns -> startRun). + await vi.advanceTimersByTimeAsync(0); expect(startRun).toHaveBeenCalledWith("RR-1", "hello", expect.objectContaining({ abortSignal: expect.any(AbortSignal) })); await dispatcher.stop(); }); it("does not double-dispatch in-flight runs", async () => { + vi.useFakeTimers(); const runs = [{ id: "RR-1", query: "hello", status: "queued" } as ResearchRun]; const store = createStore(runs); let resolveRun: (() => void) | undefined; @@ -37,15 +56,21 @@ describe("ResearchRunDispatcher", () => { const dispatcher = new ResearchRunDispatcher({ store, orchestrator, tickIntervalMs: 10 }); dispatcher.start(); - await sleep(40); + // Immediate tick dispatches RR-1 and marks it in-flight (startRun stays pending). + await vi.advanceTimersByTimeAsync(0); + expect(startRun).toHaveBeenCalledTimes(1); + + // Fire a follow-up interval tick (100ms floor): the in-flight guard must skip RR-1. + await vi.advanceTimersByTimeAsync(100); expect(startRun).toHaveBeenCalledTimes(1); resolveRun?.(); - await sleep(10); + await vi.advanceTimersByTimeAsync(0); await dispatcher.stop(); }); it("survives startRun rejection", async () => { + vi.useFakeTimers(); const runs = [{ id: "RR-1", query: "hello", status: "queued" } as ResearchRun]; const store = createStore(runs); const startRun = vi.fn(async () => { @@ -55,13 +80,14 @@ describe("ResearchRunDispatcher", () => { const dispatcher = new ResearchRunDispatcher({ store, orchestrator, tickIntervalMs: 10 }); dispatcher.start(); - await sleep(40); + await vi.advanceTimersByTimeAsync(0); expect(startRun).toHaveBeenCalled(); await dispatcher.stop(); }); it("stop cancels timer", async () => { + vi.useFakeTimers(); const runs = [{ id: "RR-1", query: "hello", status: "queued" } as ResearchRun]; const store = createStore(runs); const startRun = vi.fn(async () => ({ id: "RR-1" } as ResearchRun)); @@ -69,11 +95,12 @@ describe("ResearchRunDispatcher", () => { const dispatcher = new ResearchRunDispatcher({ store, orchestrator, tickIntervalMs: 10 }); dispatcher.start(); - await sleep(30); + await vi.advanceTimersByTimeAsync(0); await dispatcher.stop(); const callsAfterStop = startRun.mock.calls.length; - await sleep(40); + // Advancing well past several intervals must produce no further ticks once stopped. + await vi.advanceTimersByTimeAsync(300); expect(startRun).toHaveBeenCalledTimes(callsAfterStop); }); });