From ed14682d6331ca04562e53cfcb0130e4f46612cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Mar 2026 00:04:34 +0000 Subject: [PATCH] test: share heartbeat scheduler helpers --- src/infra/heartbeat-runner.scheduler.test.ts | 179 +++++++++---------- 1 file changed, 89 insertions(+), 90 deletions(-) diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index 4a184650128..b75fa195d03 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -4,15 +4,60 @@ import { startHeartbeatRunner } from "./heartbeat-runner.js"; import { requestHeartbeatNow, resetHeartbeatWakeStateForTests } from "./heartbeat-wake.js"; describe("startHeartbeatRunner", () => { + function useFakeHeartbeatTime() { + vi.useFakeTimers(); + vi.setSystemTime(new Date(0)); + } + function startDefaultRunner(runOnce: Parameters[0]["runOnce"]) { return startHeartbeatRunner({ - cfg: { - agents: { defaults: { heartbeat: { every: "30m" } } }, - } as OpenClawConfig, + cfg: heartbeatConfig(), runOnce, }); } + function heartbeatConfig( + list?: NonNullable["list"]>, + ): OpenClawConfig { + return { + agents: { + defaults: { heartbeat: { every: "30m" } }, + ...(list ? { list } : {}), + }, + } as OpenClawConfig; + } + + function createRequestsInFlightRunSpy(skipCount: number) { + let callCount = 0; + return vi.fn().mockImplementation(async () => { + callCount++; + if (callCount <= skipCount) { + return { status: "skipped", reason: "requests-in-flight" } as const; + } + return { status: "ran", durationMs: 1 } as const; + }); + } + + async function expectWakeDispatch(params: { + cfg: OpenClawConfig; + runSpy: ReturnType; + wake: { reason: string; agentId?: string; sessionKey?: string; coalesceMs: number }; + expectedCall: Record; + }) { + const runner = startHeartbeatRunner({ + cfg: params.cfg, + runOnce: params.runSpy, + }); + + requestHeartbeatNow(params.wake); + await vi.advanceTimersByTimeAsync(1); + + expect(params.runSpy).toHaveBeenCalledTimes(1); + expect(params.runSpy).toHaveBeenCalledWith(expect.objectContaining(params.expectedCall)); + + return runner; + } + afterEach(() => { resetHeartbeatWakeStateForTests(); vi.useRealTimers(); @@ -20,8 +65,7 @@ describe("startHeartbeatRunner", () => { }); it("updates scheduling when config changes without restart", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); + useFakeHeartbeatTime(); const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); @@ -62,8 +106,7 @@ describe("startHeartbeatRunner", () => { }); it("continues scheduling after runOnce throws an unhandled error", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); + useFakeHeartbeatTime(); let callCount = 0; const runSpy = vi.fn().mockImplementation(async () => { @@ -89,8 +132,7 @@ describe("startHeartbeatRunner", () => { }); it("cleanup is idempotent and does not clear a newer runner's handler", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); + useFakeHeartbeatTime(); const runSpy1 = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); const runSpy2 = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); @@ -120,8 +162,7 @@ describe("startHeartbeatRunner", () => { }); it("run() returns skipped when runner is stopped", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); + useFakeHeartbeatTime(); const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); @@ -135,22 +176,12 @@ describe("startHeartbeatRunner", () => { }); it("reschedules timer when runOnce returns requests-in-flight", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); + useFakeHeartbeatTime(); - let callCount = 0; - const runSpy = vi.fn().mockImplementation(async () => { - callCount++; - if (callCount === 1) { - return { status: "skipped", reason: "requests-in-flight" }; - } - return { status: "ran", durationMs: 1 }; - }); + const runSpy = createRequestsInFlightRunSpy(1); const runner = startHeartbeatRunner({ - cfg: { - agents: { defaults: { heartbeat: { every: "30m" } } }, - } as OpenClawConfig, + cfg: heartbeatConfig(), runOnce: runSpy, }); @@ -167,24 +198,14 @@ describe("startHeartbeatRunner", () => { }); it("does not push nextDueMs forward on repeated requests-in-flight skips", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); + useFakeHeartbeatTime(); // Simulate a long-running heartbeat: the first 5 calls return // requests-in-flight (retries from the wake layer), then the 6th succeeds. - let callCount = 0; - const runSpy = vi.fn().mockImplementation(async () => { - callCount++; - if (callCount <= 5) { - return { status: "skipped", reason: "requests-in-flight" }; - } - return { status: "ran", durationMs: 1 }; - }); + const runSpy = createRequestsInFlightRunSpy(5); const runner = startHeartbeatRunner({ - cfg: { - agents: { defaults: { heartbeat: { every: "30m" } } }, - } as OpenClawConfig, + cfg: heartbeatConfig(), runOnce: runSpy, }); @@ -208,76 +229,54 @@ describe("startHeartbeatRunner", () => { }); it("routes targeted wake requests to the requested agent/session", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); - + useFakeHeartbeatTime(); const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); - const runner = startHeartbeatRunner({ + const runner = await expectWakeDispatch({ cfg: { - agents: { - defaults: { heartbeat: { every: "30m" } }, - list: [ - { id: "main", heartbeat: { every: "30m" } }, - { id: "ops", heartbeat: { every: "15m" } }, - ], - }, + ...heartbeatConfig([ + { id: "main", heartbeat: { every: "30m" } }, + { id: "ops", heartbeat: { every: "15m" } }, + ]), } as OpenClawConfig, - runOnce: runSpy, - }); - - requestHeartbeatNow({ - reason: "cron:job-123", - agentId: "ops", - sessionKey: "agent:ops:discord:channel:alerts", - coalesceMs: 0, - }); - await vi.advanceTimersByTimeAsync(1); - - expect(runSpy).toHaveBeenCalledTimes(1); - expect(runSpy).toHaveBeenCalledWith( - expect.objectContaining({ + runSpy, + wake: { + reason: "cron:job-123", + agentId: "ops", + sessionKey: "agent:ops:discord:channel:alerts", + coalesceMs: 0, + }, + expectedCall: { agentId: "ops", reason: "cron:job-123", sessionKey: "agent:ops:discord:channel:alerts", - }), - ); + }, + }); runner.stop(); }); it("does not fan out to unrelated agents for session-scoped exec wakes", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(0)); - + useFakeHeartbeatTime(); const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }); - const runner = startHeartbeatRunner({ + const runner = await expectWakeDispatch({ cfg: { - agents: { - defaults: { heartbeat: { every: "30m" } }, - list: [ - { id: "main", heartbeat: { every: "30m" } }, - { id: "finance", heartbeat: { every: "30m" } }, - ], - }, + ...heartbeatConfig([ + { id: "main", heartbeat: { every: "30m" } }, + { id: "finance", heartbeat: { every: "30m" } }, + ]), } as OpenClawConfig, - runOnce: runSpy, - }); - - requestHeartbeatNow({ - reason: "exec-event", - sessionKey: "agent:main:main", - coalesceMs: 0, - }); - await vi.advanceTimersByTimeAsync(1); - - expect(runSpy).toHaveBeenCalledTimes(1); - expect(runSpy).toHaveBeenCalledWith( - expect.objectContaining({ + runSpy, + wake: { + reason: "exec-event", + sessionKey: "agent:main:main", + coalesceMs: 0, + }, + expectedCall: { agentId: "main", reason: "exec-event", sessionKey: "agent:main:main", - }), - ); + }, + }); expect(runSpy.mock.calls.some((call) => call[0]?.agentId === "finance")).toBe(false); runner.stop();