Initial commit: Discord-Claude Gateway with event-driven agent runtime
This commit is contained in:
264
tests/unit/cron-scheduler.test.ts
Normal file
264
tests/unit/cron-scheduler.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user