Documents the memory backup feature in the settings reference and adds a changeset to prepare for publishing. Fusion-Task-Id: FN-3891
1961 lines
76 KiB
TypeScript
1961 lines
76 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { CronRunner, createAiPromptExecutor, isInProcessBackupCommand, isInProcessMemoryBackupCommand, isInProcessScheduledEvalCommand } from "../cron-runner.js";
|
|
import type { AiPromptExecutor } from "../cron-runner.js";
|
|
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
const cronLoggerSpies = vi.hoisted(() => ({
|
|
log: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}));
|
|
|
|
const piModuleMocks = vi.hoisted(() => ({
|
|
createFnAgent: vi.fn(),
|
|
promptWithFallback: vi.fn(),
|
|
}));
|
|
|
|
const coreModuleMocks = vi.hoisted(() => ({
|
|
runMemoryBackupCommand: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../logger.js", () => ({
|
|
createLogger: () => ({
|
|
log: cronLoggerSpies.log,
|
|
warn: cronLoggerSpies.warn,
|
|
error: cronLoggerSpies.error,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../pi.js", () => ({
|
|
createFnAgent: piModuleMocks.createFnAgent,
|
|
promptWithFallback: piModuleMocks.promptWithFallback,
|
|
}));
|
|
|
|
vi.mock("@fusion/core", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@fusion/core")>();
|
|
return {
|
|
...actual,
|
|
runMemoryBackupCommand: coreModuleMocks.runMemoryBackupCommand,
|
|
};
|
|
});
|
|
|
|
// Default settings inline to avoid @fusion/core build dependency during tests
|
|
const DEFAULT_SETTINGS: Settings = {
|
|
maxConcurrent: 2,
|
|
maxWorktrees: 4,
|
|
pollIntervalMs: 30000,
|
|
autoResolveConflicts: true,
|
|
requirePlanApproval: false,
|
|
recycleWorktrees: false,
|
|
worktreeNaming: "random",
|
|
globalPause: false,
|
|
enginePaused: false,
|
|
ntfyEnabled: false,
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
planningProvider: "anthropic",
|
|
planningModelId: "claude-sonnet-4-5",
|
|
validatorProvider: "openai",
|
|
validatorModelId: "gpt-4o",
|
|
taskStuckTimeoutMs: undefined,
|
|
groupOverlappingFiles: false,
|
|
autoMerge: true,
|
|
};
|
|
|
|
function createMockSchedule(overrides: Partial<ScheduledTask> = {}): ScheduledTask {
|
|
return {
|
|
id: "test-schedule-id",
|
|
name: "Test Schedule",
|
|
description: "A test schedule",
|
|
scheduleType: "hourly",
|
|
cronExpression: "0 * * * *",
|
|
command: "echo hello",
|
|
enabled: true,
|
|
runCount: 0,
|
|
runHistory: [],
|
|
nextRunAt: new Date(Date.now() - 60000).toISOString(), // past = due
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createMockStore(settingsOverrides: Partial<Settings> = {}): TaskStore {
|
|
return {
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
...DEFAULT_SETTINGS,
|
|
...settingsOverrides,
|
|
}),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
} as unknown as TaskStore;
|
|
}
|
|
|
|
function createMockAutomationStore(schedules: ScheduledTask[] = []): AutomationStore {
|
|
return {
|
|
getDueSchedules: vi.fn().mockResolvedValue(schedules),
|
|
getDueSchedulesAllScopes: vi.fn().mockResolvedValue(schedules),
|
|
recordRun: vi.fn().mockResolvedValue(undefined),
|
|
getSchedule: vi.fn().mockImplementation(async (id: string) => {
|
|
const s = schedules.find((s) => s.id === id);
|
|
if (!s) throw new Error(`Schedule ${id} not found`);
|
|
return s;
|
|
}),
|
|
} as unknown as AutomationStore;
|
|
}
|
|
|
|
describe("CronRunner", () => {
|
|
let runner: CronRunner;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
piModuleMocks.promptWithFallback.mockResolvedValue(undefined);
|
|
piModuleMocks.createFnAgent.mockResolvedValue({
|
|
session: {
|
|
dispose: vi.fn(),
|
|
},
|
|
});
|
|
coreModuleMocks.runMemoryBackupCommand.mockResolvedValue({
|
|
success: true,
|
|
output: "memory backup ok",
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (runner) runner.stop();
|
|
});
|
|
|
|
describe("start/stop", () => {
|
|
it("starts and stops without error", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
runner.start();
|
|
expect(runner["running"]).toBe(true);
|
|
|
|
runner.stop();
|
|
expect(runner["running"]).toBe(false);
|
|
});
|
|
|
|
it("is idempotent on start", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
runner.start();
|
|
runner.start(); // should not double-start
|
|
runner.stop();
|
|
});
|
|
|
|
it("is safe to stop when not started", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Should not throw
|
|
expect(() => runner.stop()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("createAiPromptExecutor", () => {
|
|
it("returns response text even when session disposal throws", async () => {
|
|
piModuleMocks.createFnAgent.mockImplementation(async (options: { onText?: (delta: string) => void }) => {
|
|
options.onText?.("hello ");
|
|
options.onText?.("world");
|
|
return {
|
|
session: {
|
|
dispose: () => {
|
|
throw new Error("dispose failed");
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
const executor = await createAiPromptExecutor("/test/project");
|
|
const result = await executor("Summarize this");
|
|
|
|
expect(result).toBe("hello world");
|
|
expect(piModuleMocks.promptWithFallback).toHaveBeenCalledTimes(1);
|
|
expect(cronLoggerSpies.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("Session disposal failed: dispose failed"),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("tick", () => {
|
|
it("skips when globalPause is true", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const automationStore = createMockAutomationStore([createMockSchedule()]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips when enginePaused is true", async () => {
|
|
const store = createMockStore({ enginePaused: true });
|
|
const automationStore = createMockAutomationStore([createMockSchedule()]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does nothing when no schedules are due", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).toHaveBeenCalledTimes(1);
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles errors in tick gracefully", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([createMockSchedule()]);
|
|
|
|
// Make getDueSchedules throw an error
|
|
(automationStore.getDueSchedules as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
new Error("Database error")
|
|
);
|
|
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Should not throw
|
|
await expect(runner.tick()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("executes due schedules", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo test-output" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id, result] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe(schedule.id);
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("test-output");
|
|
});
|
|
|
|
it("re-entrance guard prevents overlapping ticks", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "sleep 0.1 && echo done" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Start first tick
|
|
const tick1 = runner.tick();
|
|
// Attempt second tick immediately
|
|
const tick2 = runner.tick();
|
|
|
|
await Promise.all([tick1, tick2]);
|
|
|
|
// Should only have recorded one run (second tick was a no-op)
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("executeSchedule", () => {
|
|
it("records successful execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("success");
|
|
expect(result.startedAt).toBeTruthy();
|
|
expect(result.completedAt).toBeTruthy();
|
|
});
|
|
|
|
it("records failed execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "exit 1" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
it("records timeout execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "sleep 60",
|
|
timeoutMs: 100, // very short timeout
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("timed out");
|
|
}, 10000);
|
|
|
|
it("prevents concurrent runs of the same schedule", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "sleep 0.2 && echo done" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Start execution
|
|
const exec1 = runner.executeSchedule(schedule);
|
|
|
|
// in-flight set should contain the schedule
|
|
expect(runner["inFlight"].has(schedule.id)).toBe(true);
|
|
|
|
await exec1;
|
|
|
|
// After completion, in-flight should be cleared
|
|
expect(runner["inFlight"].has(schedule.id)).toBe(false);
|
|
});
|
|
|
|
it("calls recordRun on automation store", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo recorded" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith(
|
|
schedule.id,
|
|
expect.objectContaining({
|
|
success: true,
|
|
output: expect.stringContaining("recorded"),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("clears inFlight even when recordRun throws", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo in-flight-cleanup" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
(automationStore.recordRun as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("record failed"));
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(runner["inFlight"].has(schedule.id)).toBe(false);
|
|
});
|
|
|
|
it("captures stderr output", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo err >&2" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("err");
|
|
});
|
|
|
|
it("calls recordRun before callback on success", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const callOrder: string[] = [];
|
|
const onProcessed = vi.fn().mockImplementation(() => {
|
|
callOrder.push("callback");
|
|
});
|
|
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(callOrder).toContain("callback");
|
|
// recordRun should have been called first
|
|
expect(automationStore.recordRun).toHaveBeenCalledBefore(onProcessed);
|
|
});
|
|
|
|
it("invokes callback on successful execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn();
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(onProcessed).toHaveBeenCalledTimes(1);
|
|
expect(onProcessed).toHaveBeenCalledWith(
|
|
schedule,
|
|
expect.objectContaining({ success: true }),
|
|
);
|
|
});
|
|
|
|
it("invokes callback on failed execution", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "exit 1" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn();
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(onProcessed).toHaveBeenCalledTimes(1);
|
|
expect(onProcessed).toHaveBeenCalledWith(
|
|
schedule,
|
|
expect.objectContaining({ success: false }),
|
|
);
|
|
});
|
|
|
|
it("does not invoke callback when not provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo test" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Should not throw
|
|
await runner.executeSchedule(schedule);
|
|
});
|
|
|
|
it("callback errors do not throw or alter returned result", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo success" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
// Should not throw
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("success");
|
|
});
|
|
|
|
it("callback errors do not prevent recordRun", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo test" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
const onProcessed = vi.fn().mockRejectedValue(new Error("callback failed"));
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes schedule and result to callback", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
id: "custom-id",
|
|
name: "Custom Schedule",
|
|
command: "echo custom",
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
|
|
let receivedSchedule: ScheduledTask | undefined;
|
|
let receivedResult: AutomationRunResult | undefined;
|
|
const onProcessed = vi.fn().mockImplementation((s: ScheduledTask, r: AutomationRunResult) => {
|
|
receivedSchedule = s;
|
|
receivedResult = r;
|
|
});
|
|
|
|
runner = new CronRunner(store, automationStore, { onScheduleRunProcessed: onProcessed });
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(receivedSchedule).toEqual(schedule);
|
|
expect(receivedResult).toBeDefined();
|
|
expect(receivedResult!.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("concurrent schedule prevention in tick", () => {
|
|
it("skips schedule already in-flight during tick", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "sleep 0.3 && echo done" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Start first execution (will block for 300ms)
|
|
const exec1 = runner.executeSchedule(schedule);
|
|
|
|
// Tick while execution in progress — should skip the in-flight schedule
|
|
await runner.tick();
|
|
|
|
await exec1;
|
|
|
|
// Should only have recorded one run (tick skipped it)
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("mid-tick pause detection", () => {
|
|
it("stops executing schedules when pause is detected mid-tick", async () => {
|
|
const store = createMockStore();
|
|
const schedules = [
|
|
createMockSchedule({ id: "s1", name: "First", command: "echo first" }),
|
|
createMockSchedule({ id: "s2", name: "Second", command: "echo second" }),
|
|
];
|
|
const automationStore = createMockAutomationStore(schedules);
|
|
|
|
// Mock getSettings to return paused AFTER first two calls
|
|
// (1st call = initial tick check, 2nd call = before first schedule)
|
|
let callCount = 0;
|
|
(store.getSettings as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
callCount++;
|
|
return {
|
|
...DEFAULT_SETTINGS,
|
|
globalPause: callCount > 2, // pause before second schedule
|
|
};
|
|
});
|
|
|
|
runner = new CronRunner(store, automationStore);
|
|
await runner.tick();
|
|
|
|
// Should only execute first schedule, not second
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("s1");
|
|
});
|
|
});
|
|
|
|
describe("multiple schedules", () => {
|
|
it("executes all due schedules in a single tick", async () => {
|
|
const store = createMockStore();
|
|
const schedules = [
|
|
createMockSchedule({ id: "s1", name: "First", command: "echo first" }),
|
|
createMockSchedule({ id: "s2", name: "Second", command: "echo second" }),
|
|
];
|
|
const automationStore = createMockAutomationStore(schedules);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("output truncation", () => {
|
|
it("truncates large output to prevent memory exhaustion", async () => {
|
|
const store = createMockStore();
|
|
// Generate output larger than 10KB using printf
|
|
const schedule = createMockSchedule({
|
|
command: "python3 -c \"print('x' * 15000)\"",
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
// MAX_OUTPUT_LENGTH = 10 * 1024 = 10240
|
|
expect(result.output.length).toBeLessThanOrEqual(10240 + 20);
|
|
expect(result.output).toContain("[output truncated]");
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("continues to next schedule when recordRun fails", async () => {
|
|
const store = createMockStore();
|
|
const schedules = [
|
|
createMockSchedule({ id: "s1", name: "First", command: "echo first" }),
|
|
createMockSchedule({ id: "s2", name: "Second", command: "echo second" }),
|
|
];
|
|
const automationStore = createMockAutomationStore(schedules);
|
|
|
|
// Make recordRun fail for first schedule
|
|
let recordCalls = 0;
|
|
(automationStore.recordRun as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
recordCalls++;
|
|
if (recordCalls === 1) throw new Error("Storage error");
|
|
return undefined;
|
|
});
|
|
|
|
runner = new CronRunner(store, automationStore);
|
|
await runner.tick();
|
|
|
|
// Should still have attempted both schedules
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("poll interval", () => {
|
|
it("enforces minimum poll interval of 10 seconds", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore, { pollIntervalMs: 1000 });
|
|
|
|
expect(runner["pollIntervalMs"]).toBe(10000);
|
|
});
|
|
|
|
it("defaults to 60 seconds", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
expect(runner["pollIntervalMs"]).toBe(60000);
|
|
});
|
|
});
|
|
|
|
// ── Multi-step execution ──────────────────────────────────────────
|
|
|
|
describe("multi-step execution", () => {
|
|
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
|
|
return {
|
|
id: randomUUID(),
|
|
type: "command",
|
|
name: "Test step",
|
|
command: "echo step-output",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("legacy single-command mode still works when no steps are defined", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({ command: "echo legacy-mode" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("legacy-mode");
|
|
expect(result.stepResults).toBeUndefined();
|
|
});
|
|
|
|
it("falls back to legacy mode when steps array is empty", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "echo empty-steps-fallback",
|
|
steps: [],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.output).toContain("empty-steps-fallback");
|
|
expect(result.stepResults).toBeUndefined();
|
|
});
|
|
|
|
it("executes multiple command steps sequentially", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Step 1", command: "echo first" }),
|
|
makeStep({ name: "Step 2", command: "echo second" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(2);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("first");
|
|
expect(result.stepResults![1].success).toBe(true);
|
|
expect(result.stepResults![1].output).toContain("second");
|
|
});
|
|
|
|
it("stops on step failure when continueOnFailure is false", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Failing step", command: "exit 1" }),
|
|
makeStep({ name: "Should not run", command: "echo should-not-run" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1); // Only the first step ran
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.error).toContain("1 step(s) failed");
|
|
expect(result.error).toContain("execution stopped");
|
|
});
|
|
|
|
it("continues after failure when continueOnFailure is true", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Failing step", command: "exit 1", continueOnFailure: true }),
|
|
makeStep({ name: "Should still run", command: "echo continued" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false); // Overall failure
|
|
expect(result.stepResults).toHaveLength(2); // Both steps ran
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![1].success).toBe(true);
|
|
expect(result.stepResults![1].output).toContain("continued");
|
|
});
|
|
|
|
it("continueOnFailure also advances after a create-task failure", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "create-task",
|
|
name: "Invalid create task",
|
|
taskDescription: "",
|
|
continueOnFailure: true,
|
|
command: undefined,
|
|
}),
|
|
makeStep({ name: "Still runs", command: "echo still-ran" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(2);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![1].success).toBe(true);
|
|
expect(result.stepResults![1].output).toContain("still-ran");
|
|
});
|
|
|
|
it("uses per-step timeout override", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 30000, // schedule-level timeout (large)
|
|
steps: [
|
|
makeStep({ name: "Slow step", command: "sleep 60", timeoutMs: 100 }), // step-level timeout (tiny)
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
}, 10000);
|
|
|
|
it("falls back to schedule-level timeout when step has no timeout", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 100, // tiny schedule-level timeout
|
|
steps: [
|
|
makeStep({ name: "Slow step", command: "sleep 60" }), // no step-level timeout
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
}, 10000);
|
|
|
|
it("handles AI prompt step execution with executor", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockResolvedValue("AI analysis complete: no issues found");
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "AI Analysis",
|
|
prompt: "Analyze the codebase",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("AI analysis complete");
|
|
// Verify executor was called with correct model params from step
|
|
expect(mockExecutor).toHaveBeenCalledWith(
|
|
"Analyze the codebase",
|
|
"anthropic",
|
|
"claude-sonnet-4-5",
|
|
);
|
|
});
|
|
|
|
it("fails AI prompt step when no prompt is provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Empty prompt",
|
|
prompt: "",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no prompt specified");
|
|
});
|
|
|
|
it("fails command step when no command is provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Empty command", command: "" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no command specified");
|
|
});
|
|
|
|
it("handles unknown step type gracefully", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Unknown step", type: "unknown-type" as any }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("Unknown step type");
|
|
});
|
|
|
|
it("aggregates output from all steps with headers", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Alpha", command: "echo alpha-output" }),
|
|
makeStep({ name: "Beta", command: "echo beta-output" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.output).toContain("Step 1: Alpha");
|
|
expect(result.output).toContain("alpha-output");
|
|
expect(result.output).toContain("Step 2: Beta");
|
|
expect(result.output).toContain("beta-output");
|
|
});
|
|
|
|
it("records run result with stepResults to automation store", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeStep({ name: "S1", command: "echo ok" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith(
|
|
schedule.id,
|
|
expect.objectContaining({
|
|
success: true,
|
|
stepResults: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
stepName: "S1",
|
|
success: true,
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("mixed step types execute correctly", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockResolvedValue("Summary: all good");
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({ name: "Build", command: "echo build-done" }),
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Summarize",
|
|
prompt: "Summarize results",
|
|
command: undefined,
|
|
}),
|
|
makeStep({ name: "Deploy", command: "echo deployed" }),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(3);
|
|
expect(result.stepResults![0].stepName).toBe("Build");
|
|
expect(result.stepResults![1].stepName).toBe("Summarize");
|
|
expect(result.stepResults![2].stepName).toBe("Deploy");
|
|
expect(result.stepResults![1].output).toContain("Summary: all good");
|
|
});
|
|
|
|
// ── AI prompt step execution ────────────────────────────────────────
|
|
|
|
function createAiMockExecutor(
|
|
response: string = "mock AI response",
|
|
): AiPromptExecutor {
|
|
const fn = vi.fn().mockResolvedValue(response);
|
|
return fn as unknown as AiPromptExecutor;
|
|
}
|
|
|
|
it("returns success with AI response when executor is configured", async () => {
|
|
const store = createMockStore();
|
|
const mockExecutor = createAiMockExecutor("Analysis complete: 3 findings");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Code Review",
|
|
prompt: "Review the code for issues",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("Analysis complete: 3 findings");
|
|
});
|
|
|
|
it("passes step model provider and model ID to executor", async () => {
|
|
const store = createMockStore();
|
|
const mockExecutor = createAiMockExecutor("response");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Step with model",
|
|
prompt: "Do something",
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(mockExecutor).toHaveBeenCalledWith("Do something", "openai", "gpt-4o");
|
|
});
|
|
|
|
it("falls back to settings defaults when step has no model", async () => {
|
|
const store = createMockStore({
|
|
defaultProvider: "anthropic",
|
|
defaultModelId: "claude-sonnet-4-5",
|
|
});
|
|
const mockExecutor = createAiMockExecutor("response");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Step without model",
|
|
prompt: "Use defaults",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(mockExecutor).toHaveBeenCalledWith("Use defaults", "anthropic", "claude-sonnet-4-5");
|
|
});
|
|
|
|
it("returns configuration error when no executor is provided", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "No executor",
|
|
prompt: "This should fail",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("AI execution is not configured");
|
|
expect(result.stepResults![0].output).toBe("");
|
|
});
|
|
|
|
it("fails early when prompt is empty even with executor configured", async () => {
|
|
const store = createMockStore();
|
|
const mockExecutor = createAiMockExecutor("should not be called");
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Empty prompt",
|
|
prompt: " ",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no prompt specified");
|
|
expect(mockExecutor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("handles timeout by returning failure", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockImplementation(() => new Promise((resolve) => {
|
|
setTimeout(() => resolve("too late"), 5000);
|
|
}));
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 100,
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Slow AI",
|
|
prompt: "Take your time",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
});
|
|
|
|
it("handles executor throwing an error", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockRejectedValue(new Error("API rate limit exceeded"));
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Failing AI",
|
|
prompt: "Cause an error",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("API rate limit exceeded");
|
|
});
|
|
|
|
it("truncates output when AI response exceeds MAX_OUTPUT_LENGTH", async () => {
|
|
const store = createMockStore();
|
|
const longResponse = "x".repeat(15_000);
|
|
const mockExecutor = createAiMockExecutor(longResponse);
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Verbose AI",
|
|
prompt: "Give me lots of text",
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("[output truncated]");
|
|
expect(result.stepResults![0].output.length).toBeLessThanOrEqual(10 * 1024 + 50);
|
|
});
|
|
|
|
it("uses per-step timeout override over schedule timeout", async () => {
|
|
const store = createMockStore();
|
|
const mockFn = vi.fn()
|
|
.mockImplementation(() => new Promise((resolve) => {
|
|
setTimeout(() => resolve("too late"), 5000);
|
|
}));
|
|
const mockExecutor = mockFn as unknown as AiPromptExecutor;
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
timeoutMs: 30000,
|
|
steps: [
|
|
makeStep({
|
|
type: "ai-prompt",
|
|
name: "Slow AI",
|
|
prompt: "Take your time",
|
|
timeoutMs: 100,
|
|
command: undefined,
|
|
}),
|
|
],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { aiPromptExecutor: mockExecutor });
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("timed out");
|
|
});
|
|
});
|
|
|
|
// ── Create-task step execution ─────────────────────────────────────────
|
|
|
|
describe("create-task step execution", () => {
|
|
function makeCreateTaskStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
|
|
return {
|
|
id: randomUUID(),
|
|
type: "create-task",
|
|
name: "Create task step",
|
|
taskDescription: "Review dependencies for security vulnerabilities",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("successfully creates a task when taskDescription is provided", async () => {
|
|
const mockTask = { id: "FN-1234", title: "Review", description: "Review dependencies" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "Check npm packages" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(true);
|
|
expect(result.stepResults![0].output).toContain("Created task FN-1234");
|
|
expect(createTaskMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns error when taskDescription is missing", async () => {
|
|
const store = createMockStore();
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("no task description specified");
|
|
});
|
|
|
|
it("passes taskTitle, taskColumn, modelProvider, modelId to store.createTask()", async () => {
|
|
const mockTask = { id: "FN-5678", title: "Weekly Review", description: "Check packages" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({
|
|
taskTitle: "Weekly Review",
|
|
taskDescription: "Check packages",
|
|
taskColumn: "todo",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
})],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(createTaskMock).toHaveBeenCalledWith({
|
|
title: "Weekly Review",
|
|
description: "Check packages",
|
|
column: "todo",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
source: {
|
|
sourceType: "cron",
|
|
sourceMetadata: { scheduleId: "test-schedule-id", stepId: expect.any(String) },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("defaults column to triage when taskColumn is not set", async () => {
|
|
const mockTask = { id: "FN-9999", title: "", description: "Some task" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "Some task" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(createTaskMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ column: "triage" }),
|
|
);
|
|
});
|
|
|
|
it("handles store.createTask() errors gracefully", async () => {
|
|
const createTaskMock = vi.fn().mockRejectedValue(new Error("Database constraint violation"));
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskDescription: "This will fail" })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
const result = await runner.executeSchedule(schedule);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.stepResults).toHaveLength(1);
|
|
expect(result.stepResults![0].success).toBe(false);
|
|
expect(result.stepResults![0].error).toContain("Database constraint violation");
|
|
});
|
|
|
|
it("trims whitespace from task fields", async () => {
|
|
const mockTask = { id: "FN-0001", title: "Cleaned", description: "Trimmed" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({
|
|
taskTitle: " Cleaned ",
|
|
taskDescription: " Trimmed ",
|
|
modelProvider: " anthropic ",
|
|
modelId: " claude-sonnet-4-5 ",
|
|
})],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
expect(createTaskMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: "Cleaned",
|
|
description: "Trimmed",
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-4-5",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sets title to undefined when taskTitle is not provided", async () => {
|
|
const mockTask = { id: "FN-0002", title: "", description: "Minimal task" };
|
|
const createTaskMock = vi.fn().mockResolvedValue(mockTask);
|
|
const store = createMockStore({} as any);
|
|
(store as any).createTask = createTaskMock;
|
|
|
|
const schedule = createMockSchedule({
|
|
command: "",
|
|
steps: [makeCreateTaskStep({ taskTitle: undefined })],
|
|
});
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
await runner.executeSchedule(schedule);
|
|
|
|
// When taskTitle is undefined, title should be undefined in the input
|
|
const callArg = createTaskMock.mock.calls[0][0];
|
|
expect(callArg.title).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── Scoped lane regression tests (FN-1766) ─────────────────────────────────
|
|
|
|
describe("scoped lane polling", () => {
|
|
it("scope='project' calls getDueSchedules with 'project'", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).toHaveBeenCalledWith("project");
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='global' calls getDueSchedules with 'global'", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).toHaveBeenCalledWith("global");
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' calls getDueSchedulesAllScopes", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedulesAllScopes).toHaveBeenCalled();
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='project' skips global-scoped schedules", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-schedule", scope: "global" });
|
|
const automationStore = createMockAutomationStore([globalSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should NOT execute the global schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='global' skips project-scoped schedules", async () => {
|
|
const store = createMockStore();
|
|
const projectSchedule = createMockSchedule({ id: "project-schedule", scope: "project" });
|
|
const automationStore = createMockAutomationStore([projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should NOT execute the project schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' executes both global and project schedules", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-sched", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-sched", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should execute both schedules
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("scope='all' deduplicates by schedule ID — no double execution", async () => {
|
|
const store = createMockStore();
|
|
// Same schedule ID in both scopes
|
|
const scheduleInBothScopes = createMockSchedule({ id: "shared-id", name: "Shared", scope: "project", command: "echo shared" });
|
|
const automationStore = createMockAutomationStore([scheduleInBothScopes]);
|
|
// getDueSchedulesAllScopes returns it once
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([scheduleInBothScopes]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should only execute once
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("scope='all' skips schedule from wrong scope after scope mismatch", async () => {
|
|
const store = createMockStore();
|
|
// Schedule says global but runner is polling project scope
|
|
const mismatchedSchedule = createMockSchedule({ id: "mismatch", name: "Mismatched", scope: "global", command: "echo wrong" });
|
|
const automationStore = createMockAutomationStore([mismatchedSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should NOT execute - schedule is global but runner is in project scope
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' includes lane tag in execution log context", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-log", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-log", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Both schedules executed
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith("global-log", expect.any(Object));
|
|
expect(automationStore.recordRun).toHaveBeenCalledWith("project-log", expect.any(Object));
|
|
});
|
|
|
|
it("scope='project' default when not specified", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
expect(runner["scope"]).toBe("project");
|
|
});
|
|
});
|
|
|
|
describe("scoped lane pause regressions", () => {
|
|
it("scope='project' skips when initially paused", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const schedule = createMockSchedule({ scope: "project" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='global' skips when initially paused", async () => {
|
|
const store = createMockStore({ enginePaused: true });
|
|
const schedule = createMockSchedule({ scope: "global" });
|
|
const automationStore = createMockAutomationStore([schedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedules).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' skips when initially paused", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const globalSchedule = createMockSchedule({ scope: "global" });
|
|
const projectSchedule = createMockSchedule({ scope: "project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='project' halts mid-tick when pause flips", async () => {
|
|
const schedules = [
|
|
createMockSchedule({ id: "s1", name: "First", scope: "project", command: "echo first" }),
|
|
createMockSchedule({ id: "s2", name: "Second", scope: "project", command: "echo second" }),
|
|
];
|
|
const automationStore = createMockAutomationStore(schedules);
|
|
|
|
// Mock getSettings to pause after first schedule executes
|
|
// Initial check (1), check before s1 (2), check after s1 starts (3) = pause
|
|
let getSettingsCalls = 0;
|
|
const mockStore: TaskStore = {
|
|
getSettings: vi.fn().mockImplementation(async () => {
|
|
getSettingsCalls++;
|
|
// Pause is detected on 3rd call: initial check, before s1, then before s2
|
|
return {
|
|
...DEFAULT_SETTINGS,
|
|
globalPause: getSettingsCalls >= 3, // Pause on 3rd call (before s2)
|
|
};
|
|
}),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
} as unknown as TaskStore;
|
|
|
|
runner = new CronRunner(mockStore, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Should only execute first schedule (s1 executes, s2 is skipped due to pause)
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("s1");
|
|
});
|
|
});
|
|
|
|
describe("scoped lane ID-overlap regressions", () => {
|
|
it("identical IDs across global and project scopes must not cross lane boundaries", async () => {
|
|
const store = createMockStore();
|
|
// Same ID in both scopes - store returns both
|
|
const globalSchedule = createMockSchedule({ id: "overlap-id", name: "Global Overlap", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "overlap-id", name: "Project Overlap", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
// getDueSchedulesAllScopes returns both (simulating both lanes having schedules with same ID)
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// With scope="all", both schedules share the same ID
|
|
// The runner should execute once (first occurrence wins due to deduplication)
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
// The ID should be "overlap-id"
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("overlap-id");
|
|
});
|
|
|
|
it("scope='project' with overlapping ID only processes project-scoped version", async () => {
|
|
const store = createMockStore();
|
|
// Only the project-scoped version should be processed
|
|
const projectSchedule = createMockSchedule({ id: "overlap-id", name: "Project Only", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("overlap-id");
|
|
});
|
|
|
|
it("scope='global' with overlapping ID only processes global-scoped version", async () => {
|
|
const store = createMockStore();
|
|
// Only the global-scoped version should be processed
|
|
const globalSchedule = createMockSchedule({ id: "overlap-id", name: "Global Only", scope: "global", command: "echo global" });
|
|
const automationStore = createMockAutomationStore([globalSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
const [id] = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls[0] as [string, AutomationRunResult];
|
|
expect(id).toBe("overlap-id");
|
|
});
|
|
});
|
|
|
|
describe("utility-lane semaphore boundary", () => {
|
|
it("CronRunner does not use task-lane semaphore operations", async () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore([]);
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Cast to any to access internal state for semaphore trap
|
|
const runnerAny = runner as any;
|
|
|
|
// Verify no semaphore-related methods exist on the runner
|
|
expect(runnerAny.acquire).toBeUndefined();
|
|
expect(runnerAny.release).toBeUndefined();
|
|
expect(runnerAny.run).toBeUndefined();
|
|
expect(runnerAny.semaphore).toBeUndefined();
|
|
expect(runnerAny["_semaphore"]).toBeUndefined();
|
|
});
|
|
|
|
it("CronRunner constructor does not accept semaphore parameter", () => {
|
|
const store = createMockStore();
|
|
const automationStore = createMockAutomationStore();
|
|
runner = new CronRunner(store, automationStore);
|
|
|
|
// Verify internal state doesn't have semaphore references
|
|
const keys = Object.keys(runner);
|
|
const hasSemaphore = keys.some(k => k.toLowerCase().includes("semaphore") || k.toLowerCase().includes("acquire"));
|
|
expect(hasSemaphore).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Cross-package integration tests (FN-1743) ─────────────────────────────────
|
|
//
|
|
// These tests verify end-to-end scoped scheduling wiring across packages.
|
|
// They ensure that:
|
|
// 1. CronRunner bound to a project store executes scoped schedules only for that project lane
|
|
// 2. CronRunner with both global and project stores executes both lanes in the same tick
|
|
// 3. Execution logs contain lane identity (scope tag + projectId)
|
|
// 4. Pause behavior applies uniformly across both lanes
|
|
// 5. Lane diagnostics are verifiable in test assertions
|
|
|
|
describe("cross-package dual-lane execution integration", () => {
|
|
it("scope='all' executes both global and project lanes in the same tick with correct order", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({
|
|
id: "global-1",
|
|
name: "Global Schedule",
|
|
scope: "global",
|
|
command: "echo global",
|
|
});
|
|
const projectSchedule = createMockSchedule({
|
|
id: "project-1",
|
|
name: "Project Schedule",
|
|
scope: "project",
|
|
command: "echo project",
|
|
});
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
// getDueSchedulesAllScopes returns both lanes
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Both schedules should be executed in the same tick
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
// Verify order: global first, then project (based on getDueSchedulesAllScopes result order)
|
|
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
|
expect(calls[0][0]).toBe("global-1");
|
|
expect(calls[1][0]).toBe("project-1");
|
|
});
|
|
|
|
it("scope='all' lane identity is preserved in execution context", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({
|
|
id: "global-lane-test",
|
|
name: "Global Lane",
|
|
scope: "global",
|
|
command: "echo global-lane",
|
|
});
|
|
const projectSchedule = createMockSchedule({
|
|
id: "project-lane-test",
|
|
name: "Project Lane",
|
|
scope: "project",
|
|
command: "echo project-lane",
|
|
});
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Verify both lanes were executed
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
|
// Each call receives the schedule ID and result
|
|
expect(calls[0][0]).toBe("global-lane-test");
|
|
expect(calls[1][0]).toBe("project-lane-test");
|
|
});
|
|
|
|
it("pause applies uniformly across both lanes when scope='all'", async () => {
|
|
const store = createMockStore({ globalPause: true });
|
|
const globalSchedule = createMockSchedule({ id: "global-pause", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-pause", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Neither lane should execute when paused
|
|
expect(automationStore.getDueSchedulesAllScopes).not.toHaveBeenCalled();
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("identical schedule IDs across global and project lanes execute once (deduplication)", async () => {
|
|
const store = createMockStore();
|
|
// Same ID in both scopes
|
|
const globalSchedule = createMockSchedule({
|
|
id: "shared-schedule",
|
|
name: "Shared Global",
|
|
scope: "global",
|
|
command: "echo shared-global",
|
|
});
|
|
const projectSchedule = createMockSchedule({
|
|
id: "shared-schedule",
|
|
name: "Shared Project",
|
|
scope: "project",
|
|
command: "echo shared-project",
|
|
});
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Only one execution should occur due to deduplication
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("cross-package scoped isolation integration", () => {
|
|
it("CronRunner bound to project store never executes global-lane schedules", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-iso", name: "Global", scope: "global", command: "echo global" });
|
|
const automationStore = createMockAutomationStore([globalSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "project" });
|
|
|
|
await runner.tick();
|
|
|
|
// Project-scoped runner should not execute global schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("CronRunner bound to global store never executes project-lane schedules", async () => {
|
|
const store = createMockStore();
|
|
const projectSchedule = createMockSchedule({ id: "project-iso", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "global" });
|
|
|
|
await runner.tick();
|
|
|
|
// Global-scoped runner should not execute project schedule
|
|
expect(automationStore.recordRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scope='all' executes schedules from both lanes but never crosses lane boundaries", async () => {
|
|
const store = createMockStore();
|
|
const globalSchedule = createMockSchedule({ id: "global-boundary", name: "Global", scope: "global", command: "echo global" });
|
|
const projectSchedule = createMockSchedule({ id: "project-boundary", name: "Project", scope: "project", command: "echo project" });
|
|
const automationStore = createMockAutomationStore([globalSchedule, projectSchedule]);
|
|
(automationStore.getDueSchedulesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalSchedule, projectSchedule]);
|
|
runner = new CronRunner(store, automationStore, { scope: "all" });
|
|
|
|
await runner.tick();
|
|
|
|
// Both lanes should execute
|
|
expect(automationStore.recordRun).toHaveBeenCalledTimes(2);
|
|
// But each schedule is executed exactly once (no double execution)
|
|
const calls = (automationStore.recordRun as ReturnType<typeof vi.fn>).mock.calls;
|
|
expect(calls[0][0]).toBe("global-boundary");
|
|
expect(calls[1][0]).toBe("project-boundary");
|
|
});
|
|
});
|
|
|
|
describe("isInProcessBackupCommand", () => {
|
|
const positives = [
|
|
// Bare binary forms.
|
|
"fn backup --create",
|
|
"fusion backup --create",
|
|
"runfusion.ai backup --create",
|
|
"runfusion backup --create",
|
|
"@runfusion/fusion backup --create",
|
|
// npx prefix without flags.
|
|
"npx runfusion.ai backup --create",
|
|
"npx @runfusion/fusion backup --create",
|
|
// npx with flags — including the canonical `npx -y runfusion.ai`
|
|
// form FN_NPX_INVOCATION emits.
|
|
"npx -y runfusion.ai backup --create",
|
|
"npx --yes runfusion.ai backup --create",
|
|
"npx -y -p runfusion.ai runfusion.ai backup --create",
|
|
"npx --package=runfusion.ai runfusion.ai backup --create",
|
|
// Whitespace / case tolerance.
|
|
"FN BACKUP --CREATE",
|
|
"fn backup --create --some-other-flag",
|
|
" fn backup --create ",
|
|
];
|
|
|
|
const negatives: Array<string | undefined> = [
|
|
// Wrong subcommand — must not be intercepted, the in-process path
|
|
// only does create+cleanup and would silently swallow these.
|
|
"fn backup --list",
|
|
"fn backup --restore /tmp/old.db",
|
|
"fn backup --cleanup",
|
|
"fn backup",
|
|
"fn task list",
|
|
"echo hello",
|
|
"fn-other backup --create",
|
|
"fnbackup --create",
|
|
"fnext backup --create",
|
|
"",
|
|
undefined,
|
|
// Shell continuations after --create encode user-authored side
|
|
// effects we cannot mirror in-process; let them shell out.
|
|
"fn backup --create && notify-send done",
|
|
"fn backup --create || echo failed",
|
|
"fn backup --create | tee /tmp/log",
|
|
"fn backup --create ; rm -rf /tmp/garbage",
|
|
"fn backup --create > /tmp/out.txt",
|
|
"fn backup --create 2> /tmp/err.txt",
|
|
"fn backup --create `whoami`",
|
|
"fn backup --create $(date)",
|
|
// Trailing positional arguments aren't `--flag`s — refuse.
|
|
"fn backup --create extra-positional",
|
|
];
|
|
|
|
for (const cmd of positives) {
|
|
it(`intercepts: ${cmd}`, () => {
|
|
expect(isInProcessBackupCommand(cmd)).toBe(true);
|
|
});
|
|
}
|
|
|
|
for (const cmd of negatives) {
|
|
it(`does NOT intercept: ${JSON.stringify(cmd)}`, () => {
|
|
expect(isInProcessBackupCommand(cmd)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("isInProcessMemoryBackupCommand", () => {
|
|
const positives = [
|
|
"fn memory-backup --create",
|
|
"npx runfusion.ai memory-backup --create",
|
|
" fn memory-backup --create ",
|
|
"FN MEMORY-BACKUP --CREATE",
|
|
];
|
|
|
|
const negatives: Array<string | undefined> = [
|
|
"fn memory-backup --list",
|
|
"fn memory-backup",
|
|
"fn backup --create",
|
|
"",
|
|
undefined,
|
|
];
|
|
|
|
for (const cmd of positives) {
|
|
it(`intercepts: ${cmd}`, () => {
|
|
expect(isInProcessMemoryBackupCommand(cmd)).toBe(true);
|
|
});
|
|
}
|
|
|
|
for (const cmd of negatives) {
|
|
it(`does NOT intercept: ${JSON.stringify(cmd)}`, () => {
|
|
expect(isInProcessMemoryBackupCommand(cmd)).toBe(false);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("memory backup in-process dispatch", () => {
|
|
it("routes fn memory-backup --create through runMemoryBackupCommand", async () => {
|
|
const store = createMockStore() as unknown as TaskStore & { getFusionDir: () => string };
|
|
store.getFusionDir = vi.fn().mockReturnValue("/tmp/.fusion");
|
|
const schedule = createMockSchedule({ id: "mem-bk", command: "fn memory-backup --create" });
|
|
runner = new CronRunner(store, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
|
|
expect(coreModuleMocks.runMemoryBackupCommand).toHaveBeenCalledWith("/tmp/.fusion", expect.any(Object));
|
|
expect(runResult.success).toBe(true);
|
|
expect(runResult.output).toContain("memory backup ok");
|
|
});
|
|
});
|
|
|
|
describe("isInProcessScheduledEvalCommand", () => {
|
|
it("matches canonical scheduled eval command", () => {
|
|
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch")).toBe(true);
|
|
expect(isInProcessScheduledEvalCommand("fusion eval --scheduled-batch --flag")).toBe(true);
|
|
});
|
|
|
|
it("rejects non-canonical and shell-metachar commands", () => {
|
|
expect(isInProcessScheduledEvalCommand("fn eval")).toBe(false);
|
|
expect(isInProcessScheduledEvalCommand("fn eval --scheduled-batch && echo x")).toBe(false);
|
|
expect(isInProcessScheduledEvalCommand("echo fn eval --scheduled-batch")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("scheduled eval command feature gating", () => {
|
|
it("returns a disabled result when evalsView experimental feature is off", async () => {
|
|
const store = createMockStore({ experimentalFeatures: { evalsView: false } as Settings["experimentalFeatures"] });
|
|
const schedule = createMockSchedule({ id: "eval-off", command: "fn eval --scheduled-batch" });
|
|
runner = new CronRunner(store, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
expect(runResult.success).toBe(false);
|
|
expect(runResult.output).toBe("evals-experimental-disabled");
|
|
expect(runResult.error).toBe("Evals experimental feature is disabled");
|
|
});
|
|
|
|
it("does not return the disabled sentinel when evalsView experimental feature is on", async () => {
|
|
const store = createMockStore({ experimentalFeatures: { evalsView: true } as Settings["experimentalFeatures"] }) as unknown as {
|
|
getEvalStore: () => {
|
|
listRuns: () => Array<{ id: string; status: string; metadata?: Record<string, unknown>; window: { until?: string } }>;
|
|
createRun: (input: { projectId: string; trigger: string; scope: string; window: { since?: string; until: string }; metadata: Record<string, unknown> }) => { id: string; startedAt?: string; window: { until: string } };
|
|
appendRunEvent: (runId: string, event: Record<string, unknown>) => void;
|
|
updateRun: (runId: string, patch: Record<string, unknown>) => void;
|
|
};
|
|
listTasks: (opts: { column: string }) => Promise<Array<{ id: string; column: string; createdAt: string; updatedAt: string; executionCompletedAt?: string; title: string; summary?: string }>>;
|
|
};
|
|
|
|
store.getEvalStore = () => ({
|
|
listRuns: () => [],
|
|
createRun: () => ({ id: "run-1", window: { until: new Date().toISOString() } }),
|
|
appendRunEvent: () => {},
|
|
updateRun: () => {},
|
|
});
|
|
store.listTasks = async () => [];
|
|
|
|
const schedule = createMockSchedule({ id: "eval-on", command: "fn eval --scheduled-batch" });
|
|
runner = new CronRunner(store as unknown as TaskStore, createMockAutomationStore());
|
|
|
|
const runResult = await (runner as unknown as { executeLegacyCommand: (s: ScheduledTask, startedAt: string) => Promise<AutomationRunResult> })
|
|
.executeLegacyCommand(schedule, new Date().toISOString());
|
|
expect(runResult.output).not.toBe("evals-experimental-disabled");
|
|
});
|
|
});
|
|
});
|