import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CronService } from "./service.js"; const noopLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; async function makeStorePath() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-")); return { storePath: path.join(dir, "cron", "jobs.json"), cleanup: async () => { await fs.rm(dir, { recursive: true, force: true }); }, }; } describe("CronService", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2025-12-13T00:00:00.000Z")); noopLogger.debug.mockClear(); noopLogger.info.mockClear(); noopLogger.warn.mockClear(); noopLogger.error.mockClear(); }); afterEach(() => { vi.useRealTimers(); }); it("avoids duplicate runs when two services share a store", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" })); const cronA = new CronService({ storePath: store.storePath, cronEnabled: true, log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob, }); await cronA.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); await cronA.add({ name: "shared store job", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, }); const cronB = new CronService({ storePath: store.storePath, cronEnabled: true, log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob, }); await cronB.start(); vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); await cronA.status(); await cronB.status(); expect(enqueueSystemEvent).toHaveBeenCalledTimes(1); expect(requestHeartbeatNow).toHaveBeenCalledTimes(1); cronA.stop(); cronB.stop(); await store.cleanup(); }); });