import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { CronScheduler, type CronJob } from "../../src/cron-scheduler.js"; import type { Event } from "../../src/event-queue.js"; // Mock node-cron vi.mock("node-cron", () => { const tasks: Array<{ expression: string; callback: () => void; stopped: boolean }> = []; return { default: { validate: (expr: string) => { // Simple validation: reject obviously invalid expressions if (expr === "invalid" || expr === "bad-cron" || expr === "not a cron") return false; // Accept standard 5-field cron expressions const parts = expr.trim().split(/\s+/); return parts.length === 5 || parts.length === 6; }, schedule: (expression: string, callback: () => void) => { const task = { expression, callback, stopped: false }; tasks.push(task); return { stop: () => { task.stopped = true; }, start: () => { task.stopped = false; }, }; }, // Helper to get tasks for testing _getTasks: () => tasks, _clearTasks: () => { tasks.length = 0; }, _fireLast: () => { const last = tasks[tasks.length - 1]; if (last && !last.stopped) last.callback(); }, _fireAll: () => { for (const t of tasks) { if (!t.stopped) t.callback(); } }, }, }; }); // Import the mock to access helpers import cron from "node-cron"; const mockCron = cron as unknown as { validate: (expr: string) => boolean; schedule: (expression: string, callback: () => void) => { stop: () => void }; _getTasks: () => Array<{ expression: string; callback: () => void; stopped: boolean }>; _clearTasks: () => void; _fireLast: () => void; _fireAll: () => void; }; type EnqueueFn = (event: Omit) => Event | null; describe("CronScheduler", () => { let scheduler: CronScheduler; beforeEach(() => { mockCron._clearTasks(); scheduler = new CronScheduler(); }); afterEach(() => { scheduler.stop(); }); describe("parseConfig", () => { it("parses a single cron job from agents.md content", () => { const content = `## Cron Jobs ### daily-email-check Cron: 0 9 * * * Instruction: Check email and flag anything urgent`; const jobs = scheduler.parseConfig(content); expect(jobs).toEqual([ { name: "daily-email-check", expression: "0 9 * * *", instruction: "Check email and flag anything urgent" }, ]); }); it("parses multiple cron jobs", () => { const content = `## Cron Jobs ### daily-email-check Cron: 0 9 * * * Instruction: Check email and flag anything urgent ### weekly-review Cron: 0 15 * * 1 Instruction: Review calendar for the week`; const jobs = scheduler.parseConfig(content); expect(jobs).toHaveLength(2); expect(jobs[0]).toEqual({ name: "daily-email-check", expression: "0 9 * * *", instruction: "Check email and flag anything urgent", }); expect(jobs[1]).toEqual({ name: "weekly-review", expression: "0 15 * * 1", instruction: "Review calendar for the week", }); }); it("returns empty array when no Cron Jobs section exists", () => { const content = `## Hooks ### startup Instruction: Say hello`; expect(scheduler.parseConfig(content)).toEqual([]); }); it("returns empty array for empty content", () => { expect(scheduler.parseConfig("")).toEqual([]); }); it("skips incomplete job definitions (missing instruction)", () => { const content = `## Cron Jobs ### incomplete-job Cron: 0 9 * * *`; expect(scheduler.parseConfig(content)).toEqual([]); }); it("skips incomplete job definitions (missing cron expression)", () => { const content = `## Cron Jobs ### incomplete-job Instruction: Do something`; expect(scheduler.parseConfig(content)).toEqual([]); }); it("only parses jobs within the Cron Jobs section", () => { const content = `## Hooks ### startup Cron: 0 0 * * * Instruction: This should not be parsed ## Cron Jobs ### real-job Cron: 0 9 * * * Instruction: This should be parsed ## Other Section ### not-a-job Cron: 0 0 * * * Instruction: This should not be parsed either`; const jobs = scheduler.parseConfig(content); expect(jobs).toHaveLength(1); expect(jobs[0].name).toBe("real-job"); }); }); describe("start", () => { it("schedules valid cron jobs and enqueues events on trigger", () => { const enqueued: Omit[] = []; const enqueue: EnqueueFn = (event) => { enqueued.push(event); return { ...event, id: enqueued.length, timestamp: new Date() } as Event; }; const jobs: CronJob[] = [ { name: "daily-check", expression: "0 9 * * *", instruction: "Check email" }, ]; scheduler.start(jobs, enqueue); // Simulate cron trigger mockCron._fireAll(); expect(enqueued).toHaveLength(1); expect(enqueued[0]).toEqual({ type: "cron", payload: { instruction: "Check email", jobName: "daily-check" }, source: "cron-scheduler", }); }); it("skips jobs with invalid cron expressions and logs a warning", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const enqueue: EnqueueFn = (event) => ({ ...event, id: 1, timestamp: new Date() }) as Event; const jobs: CronJob[] = [ { name: "bad-job", expression: "invalid", instruction: "Do something" }, ]; scheduler.start(jobs, enqueue); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("bad-job") ); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining("invalid cron expression") ); warnSpy.mockRestore(); }); it("schedules valid jobs and skips invalid ones in the same batch", () => { const enqueued: Omit[] = []; const enqueue: EnqueueFn = (event) => { enqueued.push(event); return { ...event, id: enqueued.length, timestamp: new Date() } as Event; }; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const jobs: CronJob[] = [ { name: "bad-job", expression: "invalid", instruction: "Bad" }, { name: "good-job", expression: "0 9 * * *", instruction: "Good" }, ]; scheduler.start(jobs, enqueue); expect(warnSpy).toHaveBeenCalledTimes(1); // Only the valid job should have been scheduled — fire all mockCron._fireAll(); expect(enqueued).toHaveLength(1); expect(enqueued[0].payload).toEqual({ instruction: "Good", jobName: "good-job" }); warnSpy.mockRestore(); }); }); describe("stop", () => { it("stops all scheduled cron tasks", () => { const enqueued: Omit[] = []; const enqueue: EnqueueFn = (event) => { enqueued.push(event); return { ...event, id: enqueued.length, timestamp: new Date() } as Event; }; const jobs: CronJob[] = [ { name: "job-a", expression: "0 9 * * *", instruction: "Do A" }, { name: "job-b", expression: "0 15 * * 1", instruction: "Do B" }, ]; scheduler.start(jobs, enqueue); // Fire once before stopping mockCron._fireAll(); expect(enqueued).toHaveLength(2); scheduler.stop(); // After stop, firing should not enqueue (tasks are stopped) mockCron._fireAll(); expect(enqueued).toHaveLength(2); }); it("is safe to call stop when no tasks are scheduled", () => { expect(() => scheduler.stop()).not.toThrow(); }); }); });