Files
aetheel-2/tests/unit/cron-scheduler.test.ts

265 lines
7.6 KiB
TypeScript

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, "id" | "timestamp">) => 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<Event, "id" | "timestamp">[] = [];
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<Event, "id" | "timestamp">[] = [];
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<Event, "id" | "timestamp">[] = [];
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();
});
});
});