Initial commit: Discord-Claude Gateway with event-driven agent runtime
This commit is contained in:
129
tests/unit/channel-queue.test.ts
Normal file
129
tests/unit/channel-queue.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { ChannelQueue } from "../../src/channel-queue.js";
|
||||
|
||||
describe("ChannelQueue", () => {
|
||||
let queue: ChannelQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new ChannelQueue();
|
||||
});
|
||||
|
||||
it("executes a single task immediately", async () => {
|
||||
let executed = false;
|
||||
await queue.enqueue("ch-1", async () => { executed = true; });
|
||||
expect(executed).toBe(true);
|
||||
});
|
||||
|
||||
it("enqueue resolves when the task completes", async () => {
|
||||
const order: number[] = [];
|
||||
const p = queue.enqueue("ch-1", async () => {
|
||||
await delay(20);
|
||||
order.push(1);
|
||||
});
|
||||
await p;
|
||||
expect(order).toEqual([1]);
|
||||
});
|
||||
|
||||
it("executes tasks for the same channel sequentially in FIFO order", async () => {
|
||||
const order: number[] = [];
|
||||
|
||||
const p1 = queue.enqueue("ch-1", async () => {
|
||||
await delay(30);
|
||||
order.push(1);
|
||||
});
|
||||
const p2 = queue.enqueue("ch-1", async () => {
|
||||
await delay(10);
|
||||
order.push(2);
|
||||
});
|
||||
const p3 = queue.enqueue("ch-1", async () => {
|
||||
order.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([p1, p2, p3]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("executes tasks for different channels concurrently", async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
const p1 = queue.enqueue("ch-1", async () => {
|
||||
await delay(40);
|
||||
order.push("ch-1");
|
||||
});
|
||||
const p2 = queue.enqueue("ch-2", async () => {
|
||||
await delay(10);
|
||||
order.push("ch-2");
|
||||
});
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
// ch-2 should finish first since it has a shorter delay
|
||||
expect(order).toEqual(["ch-2", "ch-1"]);
|
||||
});
|
||||
|
||||
it("no concurrent execution within the same channel", async () => {
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
|
||||
const makeTask = () => async () => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
await delay(10);
|
||||
concurrent--;
|
||||
};
|
||||
|
||||
const promises = [
|
||||
queue.enqueue("ch-1", makeTask()),
|
||||
queue.enqueue("ch-1", makeTask()),
|
||||
queue.enqueue("ch-1", makeTask()),
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
expect(maxConcurrent).toBe(1);
|
||||
});
|
||||
|
||||
it("propagates task errors to the enqueue caller", async () => {
|
||||
await expect(
|
||||
queue.enqueue("ch-1", async () => { throw new Error("boom"); })
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("continues processing after a task error", async () => {
|
||||
let secondRan = false;
|
||||
|
||||
const p1 = queue.enqueue("ch-1", async () => { throw new Error("fail"); }).catch(() => {});
|
||||
const p2 = queue.enqueue("ch-1", async () => { secondRan = true; });
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
expect(secondRan).toBe(true);
|
||||
});
|
||||
|
||||
it("drainAll resolves immediately when no tasks are queued", async () => {
|
||||
await queue.drainAll();
|
||||
});
|
||||
|
||||
it("drainAll waits for all in-flight and queued tasks", async () => {
|
||||
const order: string[] = [];
|
||||
|
||||
queue.enqueue("ch-1", async () => {
|
||||
await delay(20);
|
||||
order.push("ch-1-a");
|
||||
});
|
||||
queue.enqueue("ch-1", async () => {
|
||||
order.push("ch-1-b");
|
||||
});
|
||||
queue.enqueue("ch-2", async () => {
|
||||
await delay(10);
|
||||
order.push("ch-2-a");
|
||||
});
|
||||
|
||||
await queue.drainAll();
|
||||
expect(order).toContain("ch-1-a");
|
||||
expect(order).toContain("ch-1-b");
|
||||
expect(order).toContain("ch-2-a");
|
||||
expect(order.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
81
tests/unit/config-loader.test.ts
Normal file
81
tests/unit/config-loader.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { loadConfig } from "../../src/config.js";
|
||||
|
||||
describe("loadConfig", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
// Set required vars by default
|
||||
process.env.DISCORD_BOT_TOKEN = "test-discord-token";
|
||||
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should load required environment variables", () => {
|
||||
const config = loadConfig();
|
||||
expect(config.discordBotToken).toBe("test-discord-token");
|
||||
expect(config.anthropicApiKey).toBe("test-anthropic-key");
|
||||
});
|
||||
|
||||
it("should apply default values for optional config", () => {
|
||||
const config = loadConfig();
|
||||
expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]);
|
||||
expect(config.permissionMode).toBe("bypassPermissions");
|
||||
expect(config.queryTimeoutMs).toBe(120_000);
|
||||
expect(config.maxConcurrentQueries).toBe(5);
|
||||
expect(config.configDir).toBe("./config");
|
||||
expect(config.maxQueueDepth).toBe(100);
|
||||
expect(config.outputChannelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should parse ALLOWED_TOOLS from comma-separated string", () => {
|
||||
process.env.ALLOWED_TOOLS = "Read,Write,Bash";
|
||||
const config = loadConfig();
|
||||
expect(config.allowedTools).toEqual(["Read", "Write", "Bash"]);
|
||||
});
|
||||
|
||||
it("should trim whitespace from ALLOWED_TOOLS entries", () => {
|
||||
process.env.ALLOWED_TOOLS = " Read , Write , Bash ";
|
||||
const config = loadConfig();
|
||||
expect(config.allowedTools).toEqual(["Read", "Write", "Bash"]);
|
||||
});
|
||||
|
||||
it("should read all optional environment variables", () => {
|
||||
process.env.PERMISSION_MODE = "default";
|
||||
process.env.QUERY_TIMEOUT_MS = "60000";
|
||||
process.env.MAX_CONCURRENT_QUERIES = "10";
|
||||
process.env.CONFIG_DIR = "/custom/config";
|
||||
process.env.MAX_QUEUE_DEPTH = "200";
|
||||
process.env.OUTPUT_CHANNEL_ID = "123456789";
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.permissionMode).toBe("default");
|
||||
expect(config.queryTimeoutMs).toBe(60_000);
|
||||
expect(config.maxConcurrentQueries).toBe(10);
|
||||
expect(config.configDir).toBe("/custom/config");
|
||||
expect(config.maxQueueDepth).toBe(200);
|
||||
expect(config.outputChannelId).toBe("123456789");
|
||||
});
|
||||
|
||||
it("should throw when DISCORD_BOT_TOKEN is missing", () => {
|
||||
delete process.env.DISCORD_BOT_TOKEN;
|
||||
expect(() => loadConfig()).toThrow("DISCORD_BOT_TOKEN");
|
||||
});
|
||||
|
||||
it("should throw when ANTHROPIC_API_KEY is missing", () => {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
expect(() => loadConfig()).toThrow("ANTHROPIC_API_KEY");
|
||||
});
|
||||
|
||||
it("should list all missing required variables in error message", () => {
|
||||
delete process.env.DISCORD_BOT_TOKEN;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
expect(() => loadConfig()).toThrow(
|
||||
"Missing required environment variables: DISCORD_BOT_TOKEN, ANTHROPIC_API_KEY"
|
||||
);
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
65
tests/unit/error-formatter.test.ts
Normal file
65
tests/unit/error-formatter.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatErrorForUser } from "../../src/error-formatter.js";
|
||||
|
||||
describe("formatErrorForUser", () => {
|
||||
it("includes the error name for standard Error instances", () => {
|
||||
const err = new TypeError("something went wrong");
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).toContain("TypeError");
|
||||
expect(result).toContain("something went wrong");
|
||||
});
|
||||
|
||||
it("includes custom error names", () => {
|
||||
const err = new Error("bad auth");
|
||||
err.name = "AuthenticationError";
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).toContain("AuthenticationError");
|
||||
expect(result).toContain("bad auth");
|
||||
});
|
||||
|
||||
it("excludes stack traces from error messages", () => {
|
||||
const err = new Error("fail\n at Object.<anonymous> (/home/user/app.js:10:5)\n at Module._compile (node:internal/modules/cjs/loader:1234:14)");
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).not.toMatch(/\bat\s+/);
|
||||
expect(result).not.toContain("app.js");
|
||||
});
|
||||
|
||||
it("excludes API keys (sk-... pattern)", () => {
|
||||
const err = new Error("Auth failed with key sk-ant-api03-abcdefghijklmnop");
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).not.toContain("sk-ant-api03");
|
||||
expect(result).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("excludes Unix file paths", () => {
|
||||
const err = new Error("File not found: /home/user/projects/app/config.ts");
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).not.toContain("/home/user");
|
||||
expect(result).toContain("[REDACTED_PATH]");
|
||||
});
|
||||
|
||||
it("excludes Windows file paths", () => {
|
||||
const err = new Error("Cannot read C:\\Users\\admin\\secrets.txt");
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).not.toContain("C:\\Users");
|
||||
expect(result).toContain("[REDACTED_PATH]");
|
||||
});
|
||||
|
||||
it("handles string errors", () => {
|
||||
const result = formatErrorForUser("something broke");
|
||||
expect(result).toBe("Error: something broke");
|
||||
});
|
||||
|
||||
it("handles non-Error, non-string values", () => {
|
||||
expect(formatErrorForUser(42)).toBe("An unknown error occurred");
|
||||
expect(formatErrorForUser(null)).toBe("An unknown error occurred");
|
||||
expect(formatErrorForUser(undefined)).toBe("An unknown error occurred");
|
||||
});
|
||||
|
||||
it("handles Error with empty message", () => {
|
||||
const err = new Error("");
|
||||
err.name = "RangeError";
|
||||
const result = formatErrorForUser(err);
|
||||
expect(result).toContain("RangeError");
|
||||
});
|
||||
});
|
||||
150
tests/unit/event-queue.test.ts
Normal file
150
tests/unit/event-queue.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { EventQueue, type Event, type EventType } from "../../src/event-queue.js";
|
||||
|
||||
function makeEvent(type: EventType = "message", source = "test"): Omit<Event, "id" | "timestamp"> {
|
||||
if (type === "message") {
|
||||
return { type, payload: { prompt: { text: "hello", channelId: "ch1", userId: "u1", guildId: null } }, source };
|
||||
}
|
||||
if (type === "heartbeat") {
|
||||
return { type, payload: { instruction: "check email", checkName: "email-check" }, source };
|
||||
}
|
||||
if (type === "cron") {
|
||||
return { type, payload: { instruction: "run report", jobName: "daily-report" }, source };
|
||||
}
|
||||
return { type, payload: { hookType: "startup" as const }, source };
|
||||
}
|
||||
|
||||
describe("EventQueue", () => {
|
||||
it("enqueue assigns monotonically increasing IDs", () => {
|
||||
const q = new EventQueue(10);
|
||||
const e1 = q.enqueue(makeEvent());
|
||||
const e2 = q.enqueue(makeEvent());
|
||||
const e3 = q.enqueue(makeEvent());
|
||||
|
||||
expect(e1).not.toBeNull();
|
||||
expect(e2).not.toBeNull();
|
||||
expect(e3).not.toBeNull();
|
||||
expect(e1!.id).toBe(1);
|
||||
expect(e2!.id).toBe(2);
|
||||
expect(e3!.id).toBe(3);
|
||||
});
|
||||
|
||||
it("enqueue assigns timestamps", () => {
|
||||
const q = new EventQueue(10);
|
||||
const e = q.enqueue(makeEvent());
|
||||
expect(e).not.toBeNull();
|
||||
expect(e!.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns null when queue is at max depth", () => {
|
||||
const q = new EventQueue(2);
|
||||
expect(q.enqueue(makeEvent())).not.toBeNull();
|
||||
expect(q.enqueue(makeEvent())).not.toBeNull();
|
||||
expect(q.enqueue(makeEvent())).toBeNull();
|
||||
expect(q.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("dequeue returns events in FIFO order", () => {
|
||||
const q = new EventQueue(10);
|
||||
q.enqueue(makeEvent("message"));
|
||||
q.enqueue(makeEvent("heartbeat"));
|
||||
q.enqueue(makeEvent("cron"));
|
||||
|
||||
expect(q.dequeue()!.type).toBe("message");
|
||||
expect(q.dequeue()!.type).toBe("heartbeat");
|
||||
expect(q.dequeue()!.type).toBe("cron");
|
||||
});
|
||||
|
||||
it("dequeue returns undefined when empty", () => {
|
||||
const q = new EventQueue(10);
|
||||
expect(q.dequeue()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("size returns current queue depth", () => {
|
||||
const q = new EventQueue(10);
|
||||
expect(q.size()).toBe(0);
|
||||
q.enqueue(makeEvent());
|
||||
expect(q.size()).toBe(1);
|
||||
q.enqueue(makeEvent());
|
||||
expect(q.size()).toBe(2);
|
||||
q.dequeue();
|
||||
expect(q.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("onEvent handler processes events sequentially", async () => {
|
||||
const q = new EventQueue(10);
|
||||
const processed: number[] = [];
|
||||
|
||||
q.enqueue(makeEvent());
|
||||
q.enqueue(makeEvent());
|
||||
q.enqueue(makeEvent());
|
||||
|
||||
q.onEvent(async (event) => {
|
||||
processed.push(event.id);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
|
||||
await q.drain();
|
||||
expect(processed).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("onEvent auto-processes newly enqueued events", async () => {
|
||||
const q = new EventQueue(10);
|
||||
const processed: number[] = [];
|
||||
|
||||
q.onEvent(async (event) => {
|
||||
processed.push(event.id);
|
||||
});
|
||||
|
||||
q.enqueue(makeEvent());
|
||||
q.enqueue(makeEvent());
|
||||
|
||||
await q.drain();
|
||||
expect(processed).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("drain resolves immediately when queue is empty and not processing", async () => {
|
||||
const q = new EventQueue(10);
|
||||
await q.drain(); // should not hang
|
||||
});
|
||||
|
||||
it("drain waits for in-flight processing to complete", async () => {
|
||||
const q = new EventQueue(10);
|
||||
let handlerDone = false;
|
||||
|
||||
q.onEvent(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
handlerDone = true;
|
||||
});
|
||||
|
||||
q.enqueue(makeEvent());
|
||||
await q.drain();
|
||||
expect(handlerDone).toBe(true);
|
||||
});
|
||||
|
||||
it("handler errors do not block subsequent processing", async () => {
|
||||
const q = new EventQueue(10);
|
||||
const processed: number[] = [];
|
||||
|
||||
q.onEvent(async (event) => {
|
||||
if (event.id === 1) throw new Error("fail");
|
||||
processed.push(event.id);
|
||||
});
|
||||
|
||||
q.enqueue(makeEvent());
|
||||
q.enqueue(makeEvent());
|
||||
|
||||
await q.drain();
|
||||
expect(processed).toEqual([2]);
|
||||
});
|
||||
|
||||
it("accepts all event types", () => {
|
||||
const q = new EventQueue(10);
|
||||
const types: EventType[] = ["message", "heartbeat", "cron", "hook", "webhook"];
|
||||
for (const type of types) {
|
||||
const e = q.enqueue(makeEvent(type));
|
||||
expect(e).not.toBeNull();
|
||||
expect(e!.type).toBe(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
176
tests/unit/heartbeat-scheduler.test.ts
Normal file
176
tests/unit/heartbeat-scheduler.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { HeartbeatScheduler, type HeartbeatCheck } from "../../src/heartbeat-scheduler.js";
|
||||
import type { Event } from "../../src/event-queue.js";
|
||||
|
||||
type EnqueueFn = (event: Omit<Event, "id" | "timestamp">) => Event | null;
|
||||
|
||||
describe("HeartbeatScheduler", () => {
|
||||
let scheduler: HeartbeatScheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
scheduler = new HeartbeatScheduler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("parseConfig", () => {
|
||||
it("parses a single check definition", () => {
|
||||
const content = `## check-email
|
||||
Interval: 300
|
||||
Instruction: Check my inbox for urgent items`;
|
||||
|
||||
const checks = scheduler.parseConfig(content);
|
||||
expect(checks).toEqual([
|
||||
{ name: "check-email", instruction: "Check my inbox for urgent items", intervalSeconds: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple check definitions", () => {
|
||||
const content = `## check-email
|
||||
Interval: 300
|
||||
Instruction: Check my inbox for urgent items
|
||||
|
||||
## check-calendar
|
||||
Interval: 600
|
||||
Instruction: Review upcoming calendar events`;
|
||||
|
||||
const checks = scheduler.parseConfig(content);
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(checks[0]).toEqual({ name: "check-email", instruction: "Check my inbox for urgent items", intervalSeconds: 300 });
|
||||
expect(checks[1]).toEqual({ name: "check-calendar", instruction: "Review upcoming calendar events", intervalSeconds: 600 });
|
||||
});
|
||||
|
||||
it("returns empty array for empty content", () => {
|
||||
expect(scheduler.parseConfig("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips incomplete check definitions (missing instruction)", () => {
|
||||
const content = `## incomplete-check
|
||||
Interval: 300`;
|
||||
|
||||
expect(scheduler.parseConfig(content)).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips incomplete check definitions (missing interval)", () => {
|
||||
const content = `## incomplete-check
|
||||
Instruction: Do something`;
|
||||
|
||||
expect(scheduler.parseConfig(content)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("start", () => {
|
||||
it("starts timers for valid checks and enqueues heartbeat events", () => {
|
||||
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
|
||||
const enqueue: EnqueueFn = (event) => {
|
||||
enqueued.push(event);
|
||||
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
|
||||
};
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "check-email", instruction: "Check inbox", intervalSeconds: 120 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
// No events yet — timer hasn't fired
|
||||
expect(enqueued).toHaveLength(0);
|
||||
|
||||
// Advance time by 120 seconds
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
expect(enqueued[0]).toEqual({
|
||||
type: "heartbeat",
|
||||
payload: { instruction: "Check inbox", checkName: "check-email" },
|
||||
source: "heartbeat-scheduler",
|
||||
});
|
||||
|
||||
// Advance another 120 seconds
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(enqueued).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects checks with interval < 60 seconds with a warning", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const enqueue: EnqueueFn = () => null;
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "too-fast", instruction: "Do something", intervalSeconds: 30 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("too-fast")
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("below the minimum")
|
||||
);
|
||||
|
||||
// Advance time — no events should be enqueued
|
||||
vi.advanceTimersByTime(60_000);
|
||||
// No way to check enqueue wasn't called since it returns null, but the warn confirms rejection
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("starts valid checks 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 checks: HeartbeatCheck[] = [
|
||||
{ name: "too-fast", instruction: "Bad check", intervalSeconds: 10 },
|
||||
{ name: "valid-check", instruction: "Good check", intervalSeconds: 60 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
expect(enqueued[0].payload).toEqual({ instruction: "Good check", checkName: "valid-check" });
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop", () => {
|
||||
it("clears all timers so no more events are enqueued", () => {
|
||||
const enqueued: Omit<Event, "id" | "timestamp">[] = [];
|
||||
const enqueue: EnqueueFn = (event) => {
|
||||
enqueued.push(event);
|
||||
return { ...event, id: enqueued.length, timestamp: new Date() } as Event;
|
||||
};
|
||||
|
||||
const checks: HeartbeatCheck[] = [
|
||||
{ name: "check-a", instruction: "Do A", intervalSeconds: 60 },
|
||||
{ name: "check-b", instruction: "Do B", intervalSeconds: 120 },
|
||||
];
|
||||
|
||||
scheduler.start(checks, enqueue);
|
||||
|
||||
// Fire one tick for check-a
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
|
||||
scheduler.stop();
|
||||
|
||||
// Advance more time — no new events
|
||||
vi.advanceTimersByTime(300_000);
|
||||
expect(enqueued).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is safe to call stop when no timers are running", () => {
|
||||
expect(() => scheduler.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
144
tests/unit/response-formatter.test.ts
Normal file
144
tests/unit/response-formatter.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { splitMessage } from "../../src/response-formatter.js";
|
||||
|
||||
describe("splitMessage", () => {
|
||||
it("should return empty array for empty text", () => {
|
||||
expect(splitMessage("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return single chunk for text shorter than maxLength", () => {
|
||||
const text = "Hello, world!";
|
||||
expect(splitMessage(text)).toEqual([text]);
|
||||
});
|
||||
|
||||
it("should return single chunk for text exactly at maxLength", () => {
|
||||
const text = "a".repeat(2000);
|
||||
expect(splitMessage(text)).toEqual([text]);
|
||||
});
|
||||
|
||||
it("should split text exceeding maxLength into multiple chunks", () => {
|
||||
const text = "a".repeat(3000);
|
||||
const chunks = splitMessage(text, 2000);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
});
|
||||
|
||||
it("should prefer splitting at line boundaries", () => {
|
||||
const line = "x".repeat(80) + "\n";
|
||||
// 25 lines of 81 chars each = 2025 chars total
|
||||
const text = line.repeat(25);
|
||||
const chunks = splitMessage(text, 2000);
|
||||
// Each chunk should end at a newline boundary
|
||||
for (const chunk of chunks.slice(0, -1)) {
|
||||
expect(chunk.endsWith("\n") || chunk.endsWith("```")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle custom maxLength", () => {
|
||||
const text = "Hello\nWorld\nFoo\nBar";
|
||||
const chunks = splitMessage(text, 10);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(10);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve content when splitting plain text", () => {
|
||||
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`);
|
||||
const text = lines.join("\n");
|
||||
const chunks = splitMessage(text, 100);
|
||||
const reassembled = chunks.join("");
|
||||
expect(reassembled).toBe(text);
|
||||
});
|
||||
|
||||
it("should close and reopen code blocks across splits", () => {
|
||||
const codeContent = "x\n".repeat(1500);
|
||||
const text = "Before\n```typescript\n" + codeContent + "```\nAfter";
|
||||
const chunks = splitMessage(text, 2000);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// First chunk should contain the opening fence
|
||||
expect(chunks[0]).toContain("```typescript");
|
||||
// First chunk should end with a closing fence since it splits mid-block
|
||||
expect(chunks[0].trimEnd().endsWith("```")).toBe(true);
|
||||
|
||||
// Second chunk should reopen the code block
|
||||
expect(chunks[1].startsWith("```typescript")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple code blocks in the same text", () => {
|
||||
const block1 = "```js\nconsole.log('hello');\n```";
|
||||
const block2 = "```python\nprint('world')\n```";
|
||||
const text = block1 + "\n\nSome text\n\n" + block2;
|
||||
const chunks = splitMessage(text, 2000);
|
||||
// Should fit in one chunk
|
||||
expect(chunks).toEqual([text]);
|
||||
});
|
||||
|
||||
it("should handle code block that fits entirely in one chunk", () => {
|
||||
const text = "Hello\n```js\nconst x = 1;\n```\nGoodbye";
|
||||
const chunks = splitMessage(text, 2000);
|
||||
expect(chunks).toEqual([text]);
|
||||
});
|
||||
|
||||
it("should handle text with no newlines", () => {
|
||||
const text = "a".repeat(5000);
|
||||
const chunks = splitMessage(text, 2000);
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(3);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
expect(chunks.join("")).toBe(text);
|
||||
});
|
||||
|
||||
it("should ensure every chunk is within maxLength", () => {
|
||||
const codeContent = "x\n".repeat(2000);
|
||||
const text = "```\n" + codeContent + "```";
|
||||
const chunks = splitMessage(text, 2000);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle code block with language tag across splits", () => {
|
||||
const longCode = ("const x = 1;\n").repeat(200);
|
||||
const text = "```typescript\n" + longCode + "```";
|
||||
const chunks = splitMessage(text, 500);
|
||||
|
||||
// All continuation chunks should reopen with the language tag
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
if (chunks[i].startsWith("```")) {
|
||||
expect(chunks[i].startsWith("```typescript")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should produce chunks that reconstruct original text modulo fence delimiters", () => {
|
||||
const longCode = ("line\n").repeat(300);
|
||||
const text = "Start\n```\n" + longCode + "```\nEnd";
|
||||
const chunks = splitMessage(text, 500);
|
||||
|
||||
// Remove inserted fence closers/openers and rejoin
|
||||
const cleaned = chunks.map((chunk, i) => {
|
||||
let c = chunk;
|
||||
// Remove reopened fence at start (for continuation chunks)
|
||||
if (i > 0 && c.startsWith("```")) {
|
||||
const newlineIdx = c.indexOf("\n");
|
||||
if (newlineIdx !== -1) {
|
||||
c = c.slice(newlineIdx + 1);
|
||||
}
|
||||
}
|
||||
// Remove inserted closing fence at end (for non-last chunks that close a block)
|
||||
if (i < chunks.length - 1 && c.trimEnd().endsWith("```")) {
|
||||
const lastFence = c.lastIndexOf("\n```");
|
||||
if (lastFence !== -1) {
|
||||
c = c.slice(0, lastFence);
|
||||
}
|
||||
}
|
||||
return c;
|
||||
});
|
||||
expect(cleaned.join("")).toBe(text);
|
||||
});
|
||||
});
|
||||
52
tests/unit/session-manager.test.ts
Normal file
52
tests/unit/session-manager.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { SessionManager } from "../../src/session-manager.js";
|
||||
|
||||
describe("SessionManager", () => {
|
||||
let manager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SessionManager();
|
||||
});
|
||||
|
||||
it("returns undefined for unknown channel", () => {
|
||||
expect(manager.getSessionId("unknown")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores and retrieves a session", () => {
|
||||
manager.setSessionId("ch-1", "sess-abc");
|
||||
expect(manager.getSessionId("ch-1")).toBe("sess-abc");
|
||||
});
|
||||
|
||||
it("overwrites an existing session", () => {
|
||||
manager.setSessionId("ch-1", "sess-old");
|
||||
manager.setSessionId("ch-1", "sess-new");
|
||||
expect(manager.getSessionId("ch-1")).toBe("sess-new");
|
||||
});
|
||||
|
||||
it("removes a session", () => {
|
||||
manager.setSessionId("ch-1", "sess-abc");
|
||||
manager.removeSession("ch-1");
|
||||
expect(manager.getSessionId("ch-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removeSession on unknown channel is a no-op", () => {
|
||||
expect(() => manager.removeSession("nope")).not.toThrow();
|
||||
});
|
||||
|
||||
it("clear removes all sessions", () => {
|
||||
manager.setSessionId("ch-1", "s1");
|
||||
manager.setSessionId("ch-2", "s2");
|
||||
manager.clear();
|
||||
expect(manager.getSessionId("ch-1")).toBeUndefined();
|
||||
expect(manager.getSessionId("ch-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isolates sessions across channels", () => {
|
||||
manager.setSessionId("ch-1", "s1");
|
||||
manager.setSessionId("ch-2", "s2");
|
||||
expect(manager.getSessionId("ch-1")).toBe("s1");
|
||||
expect(manager.getSessionId("ch-2")).toBe("s2");
|
||||
manager.removeSession("ch-1");
|
||||
expect(manager.getSessionId("ch-2")).toBe("s2");
|
||||
});
|
||||
});
|
||||
57
tests/unit/shutdown-handler.test.ts
Normal file
57
tests/unit/shutdown-handler.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { registerShutdownHandler } from "../../src/shutdown-handler.js";
|
||||
|
||||
describe("registerShutdownHandler", () => {
|
||||
let mockGateway: { shutdown: ReturnType<typeof vi.fn> };
|
||||
let sigintListeners: Array<() => void>;
|
||||
let sigtermListeners: Array<() => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGateway = { shutdown: vi.fn() };
|
||||
sigintListeners = [];
|
||||
sigtermListeners = [];
|
||||
|
||||
vi.spyOn(process, "on").mockImplementation((event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (event === "SIGINT") sigintListeners.push(listener as () => void);
|
||||
if (event === "SIGTERM") sigtermListeners.push(listener as () => void);
|
||||
return process;
|
||||
});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers listeners for SIGTERM and SIGINT", () => {
|
||||
registerShutdownHandler(mockGateway as never);
|
||||
expect(sigtermListeners).toHaveLength(1);
|
||||
expect(sigintListeners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls gateway.shutdown() on SIGTERM", () => {
|
||||
registerShutdownHandler(mockGateway as never);
|
||||
sigtermListeners[0]();
|
||||
expect(mockGateway.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls gateway.shutdown() on SIGINT", () => {
|
||||
registerShutdownHandler(mockGateway as never);
|
||||
sigintListeners[0]();
|
||||
expect(mockGateway.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("prevents double-shutdown on repeated signals", () => {
|
||||
registerShutdownHandler(mockGateway as never);
|
||||
sigtermListeners[0]();
|
||||
sigintListeners[0]();
|
||||
sigtermListeners[0]();
|
||||
expect(mockGateway.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("logs the signal name", () => {
|
||||
registerShutdownHandler(mockGateway as never);
|
||||
sigtermListeners[0]();
|
||||
expect(console.log).toHaveBeenCalledWith("Received SIGTERM, shutting down...");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user