269 lines
7.6 KiB
TypeScript
269 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";
|
|
|
|
vi.mock("../../src/logger.js", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { logger } from "../../src/logger.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();
|
|
vi.mocked(logger.warn).mockClear();
|
|
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 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(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: "bad-job" }),
|
|
expect.stringContaining("invalid cron expression")
|
|
);
|
|
});
|
|
|
|
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 jobs: CronJob[] = [
|
|
{ name: "bad-job", expression: "invalid", instruction: "Bad" },
|
|
{ name: "good-job", expression: "0 9 * * *", instruction: "Good" },
|
|
];
|
|
|
|
scheduler.start(jobs, enqueue);
|
|
|
|
expect(logger.warn).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" });
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|