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("skips main jobs with empty systemEvent text", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const cron = new CronService({ storePath: store.storePath, cronEnabled: true, log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), }); await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); await cron.add({ name: "empty systemEvent test", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: " " }, }); vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(requestHeartbeatNow).not.toHaveBeenCalled(); const jobs = await cron.list({ includeDisabled: true }); expect(jobs[0]?.state.lastStatus).toBe("skipped"); expect(jobs[0]?.state.lastError).toMatch(/non-empty/i); cron.stop(); await store.cleanup(); }); it("does not schedule timers when cron is disabled", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const cron = new CronService({ storePath: store.storePath, cronEnabled: false, log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), }); await cron.start(); const atMs = Date.parse("2025-12-13T00:00:01.000Z"); await cron.add({ name: "disabled cron job", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "now", payload: { kind: "systemEvent", text: "hello" }, }); const status = await cron.status(); expect(status.enabled).toBe(false); expect(status.nextWakeAtMs).toBeNull(); vi.setSystemTime(new Date("2025-12-13T00:00:01.000Z")); await vi.runOnlyPendingTimersAsync(); expect(enqueueSystemEvent).not.toHaveBeenCalled(); expect(requestHeartbeatNow).not.toHaveBeenCalled(); expect(noopLogger.warn).toHaveBeenCalled(); cron.stop(); await store.cleanup(); }); it("status reports next wake when enabled", async () => { const store = await makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const cron = new CronService({ storePath: store.storePath, cronEnabled: true, log: noopLogger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" })), }); await cron.start(); const atMs = Date.parse("2025-12-13T00:00:05.000Z"); await cron.add({ name: "status next wake", enabled: true, schedule: { kind: "at", atMs }, sessionTarget: "main", wakeMode: "next-heartbeat", payload: { kind: "systemEvent", text: "hello" }, }); const status = await cron.status(); expect(status.enabled).toBe(true); expect(status.jobs).toBe(1); expect(status.nextWakeAtMs).toBe(atMs); cron.stop(); await store.cleanup(); }); });