import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import type { CronEvent, CronServiceDeps } from "./service.js"; import { CronService } from "./service.js"; import { createCronServiceState, type CronServiceState } from "./service/state.js"; import type { CronJob } from "./types.js"; export type NoopLogger = { debug: MockFn; info: MockFn; warn: MockFn; error: MockFn; }; export function createNoopLogger(): NoopLogger { return { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; } export function createCronStoreHarness(options?: { prefix?: string }) { let fixtureRoot = ""; let caseId = 0; beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-cron-")); }); afterAll(async () => { if (!fixtureRoot) { return; } await fs.rm(fixtureRoot, { recursive: true, force: true }); }); async function makeStorePath() { const dir = path.join(fixtureRoot, `case-${caseId++}`); await fs.mkdir(dir, { recursive: true }); return { storePath: path.join(dir, "cron", "jobs.json"), cleanup: async () => {}, }; } return { makeStorePath }; } export async function writeCronStoreSnapshot(params: { storePath: string; jobs: CronJob[] }) { await fs.mkdir(path.dirname(params.storePath), { recursive: true }); await fs.writeFile( params.storePath, JSON.stringify( { version: 1, jobs: params.jobs, }, null, 2, ), "utf-8", ); } export function installCronTestHooks(options: { logger: ReturnType; baseTimeIso?: string; }) { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(options.baseTimeIso ?? "2025-12-13T00:00:00.000Z")); options.logger.debug.mockClear(); options.logger.info.mockClear(); options.logger.warn.mockClear(); options.logger.error.mockClear(); }); afterEach(() => { vi.useRealTimers(); }); } export function setupCronServiceSuite(options?: { prefix?: string; baseTimeIso?: string }) { const logger = createNoopLogger(); const { makeStorePath } = createCronStoreHarness({ prefix: options?.prefix }); installCronTestHooks({ logger, baseTimeIso: options?.baseTimeIso, }); return { logger, makeStorePath }; } export function createFinishedBarrier() { const resolvers = new Map void>(); return { waitForOk: (jobId: string) => new Promise((resolve) => { resolvers.set(jobId, resolve); }), onEvent: (evt: CronEvent) => { if (evt.action !== "finished" || evt.status !== "ok") { return; } const resolve = resolvers.get(evt.jobId); if (!resolve) { return; } resolvers.delete(evt.jobId); resolve(evt); }, }; } export function createStartedCronServiceWithFinishedBarrier(params: { storePath: string; logger: ReturnType; }): { cron: CronService; enqueueSystemEvent: MockFn; requestHeartbeatNow: MockFn; finished: ReturnType; } { const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const finished = createFinishedBarrier(); const cron = new CronService({ storePath: params.storePath, cronEnabled: true, log: params.logger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), onEvent: finished.onEvent, }); return { cron, enqueueSystemEvent, requestHeartbeatNow, finished }; } export async function withCronServiceForTest( params: { makeStorePath: () => Promise<{ storePath: string; cleanup: () => Promise }>; logger: ReturnType; cronEnabled: boolean; runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"]; }, run: (context: { cron: CronService; enqueueSystemEvent: ReturnType; requestHeartbeatNow: ReturnType; }) => Promise, ): Promise { const store = await params.makeStorePath(); const enqueueSystemEvent = vi.fn(); const requestHeartbeatNow = vi.fn(); const cron = new CronService({ cronEnabled: params.cronEnabled, storePath: store.storePath, log: params.logger, enqueueSystemEvent, requestHeartbeatNow, runIsolatedAgentJob: params.runIsolatedAgentJob ?? (vi.fn(async () => ({ status: "ok" as const, summary: "done" })) as never), }); await cron.start(); try { await run({ cron, enqueueSystemEvent, requestHeartbeatNow }); } finally { cron.stop(); await store.cleanup(); } } export function createRunningCronServiceState(params: { storePath: string; log: ReturnType; nowMs: () => number; jobs: CronJob[]; }) { const state = createCronServiceState({ cronEnabled: true, storePath: params.storePath, log: params.log, nowMs: params.nowMs, enqueueSystemEvent: vi.fn(), requestHeartbeatNow: vi.fn(), runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "ok" }), }); state.running = true; state.store = { version: 1, jobs: params.jobs, }; return state; } export function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } export function createMockCronStateForJobs(params: { jobs: CronJob[]; nowMs?: number; }): CronServiceState { const nowMs = params.nowMs ?? Date.now(); return { store: { version: 1, jobs: params.jobs }, running: false, timer: null, storeLoadedAtMs: nowMs, storeFileMtimeMs: null, op: Promise.resolve(), warnedDisabled: false, deps: { storePath: "/mock/path", cronEnabled: true, nowMs: () => nowMs, enqueueSystemEvent: () => {}, requestHeartbeatNow: () => {}, runIsolatedAgentJob: async () => ({ status: "ok" }), log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, } as never, }, }; }