chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts
- Move all co-located *.test.* files into sibling __tests__/ directories so the
layout is consistent across packages (159 renames + content-rewrite moves).
Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
accidentally committed in a merge and the stale pi.js was masking a real
test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
createFnAgent / promptWithFallback / describeModel from @fusion/engine
instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
the two plugin package.jsons that were missing it; exports describeModel
from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
.test.ts; switch three boundary-result toEqual assertions to toMatchObject
so the new content/isError fields don't trip exact-match comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
882
packages/engine/src/__tests__/routine-scheduler.test.ts
Normal file
882
packages/engine/src/__tests__/routine-scheduler.test.ts
Normal file
@@ -0,0 +1,882 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { RoutineScheduler, type RoutineSchedulerOptions } from "../routine-scheduler.js";
|
||||
import type { RoutineStore, Routine, TaskStore, RoutineExecutionResult, Settings } from "@fusion/core";
|
||||
import type { RoutineRunner } from "../routine-runner.js";
|
||||
|
||||
// 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 createMockRoutine(overrides: Partial<Routine> = {}): Routine {
|
||||
return {
|
||||
id: "test-routine-id",
|
||||
agentId: "test-agent",
|
||||
name: "Test Routine",
|
||||
description: "A test routine",
|
||||
trigger: { type: "cron", cronExpression: "0 * * * *" },
|
||||
catchUpPolicy: "run_one",
|
||||
executionPolicy: "parallel",
|
||||
enabled: true,
|
||||
runCount: 0,
|
||||
runHistory: [],
|
||||
cronExpression: "0 * * * *",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTaskStore(settingsOverrides: Partial<Settings> = {}): TaskStore {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
...settingsOverrides,
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createMockRoutineStore(routines: Routine[] = []): RoutineStore {
|
||||
const routineMap = new Map(routines.map((r) => [r.id, r]));
|
||||
|
||||
return {
|
||||
getRoutine: vi.fn().mockImplementation((id: string) => {
|
||||
const routine = routineMap.get(id);
|
||||
if (!routine) {
|
||||
throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" });
|
||||
}
|
||||
return routine;
|
||||
}),
|
||||
listRoutines: vi.fn().mockResolvedValue(routines),
|
||||
updateRoutine: vi.fn().mockImplementation((id: string, _updates: any) => {
|
||||
const routine = routineMap.get(id);
|
||||
if (!routine) {
|
||||
throw Object.assign(new Error(`Routine '${id}' not found`), { code: "ENOENT" });
|
||||
}
|
||||
return routine;
|
||||
}),
|
||||
getDueRoutines: vi.fn().mockResolvedValue(routines),
|
||||
getDueRoutinesAllScopes: vi.fn().mockResolvedValue(routines),
|
||||
recordRun: vi.fn().mockImplementation((id: string, result: RoutineExecutionResult) => {
|
||||
return createMockRoutine({ id, lastRunResult: result });
|
||||
}),
|
||||
startRoutineExecution: vi.fn().mockResolvedValue(undefined),
|
||||
completeRoutineExecution: vi.fn().mockResolvedValue(undefined),
|
||||
cancelRoutineExecution: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as RoutineStore;
|
||||
}
|
||||
|
||||
function createMockRoutineRunner(): RoutineRunner {
|
||||
return {
|
||||
executeRoutine: vi.fn().mockResolvedValue({
|
||||
routineId: "test-routine",
|
||||
success: true,
|
||||
output: "Success",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
} as RoutineExecutionResult),
|
||||
handleCatchUp: vi.fn().mockResolvedValue(undefined),
|
||||
getInFlightCount: vi.fn().mockReturnValue(0),
|
||||
isRoutineRunning: vi.fn().mockReturnValue(false),
|
||||
} as unknown as RoutineRunner;
|
||||
}
|
||||
|
||||
function createRoutineScheduler(
|
||||
taskStore?: TaskStore,
|
||||
routineStore?: RoutineStore,
|
||||
routineRunner?: RoutineRunner,
|
||||
options?: Partial<Pick<RoutineSchedulerOptions, "pollIntervalMs" | "scope">>,
|
||||
): RoutineScheduler {
|
||||
return new RoutineScheduler({
|
||||
taskStore: taskStore ?? createMockTaskStore(),
|
||||
routineStore: routineStore ?? createMockRoutineStore(),
|
||||
routineRunner: routineRunner ?? createMockRoutineRunner(),
|
||||
pollIntervalMs: options?.pollIntervalMs,
|
||||
scope: options?.scope,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RoutineScheduler", () => {
|
||||
let scheduler: RoutineScheduler;
|
||||
|
||||
afterEach(() => {
|
||||
if (scheduler) {
|
||||
scheduler.stop();
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("clamps poll interval to minimum 10000ms", () => {
|
||||
scheduler = createRoutineScheduler(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ pollIntervalMs: 100 }, // Below minimum
|
||||
);
|
||||
|
||||
// The scheduler should have clamped to 10000ms
|
||||
expect(scheduler["pollIntervalMs"]).toBe(10000);
|
||||
});
|
||||
|
||||
it("uses default poll interval of 60000ms when not specified", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
expect(scheduler["pollIntervalMs"]).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("start/stop", () => {
|
||||
it("sets running = true after start", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
scheduler.start();
|
||||
expect(scheduler.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears interval and sets running = false after stop", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
scheduler.start();
|
||||
scheduler.stop();
|
||||
expect(scheduler.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("runs first tick immediately on start", async () => {
|
||||
const routineStore = createMockRoutineStore([createMockRoutine({ id: "due-routine" })]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
scheduler.start();
|
||||
// Wait for the tick to complete
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
scheduler.stop();
|
||||
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent on start", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
scheduler.start();
|
||||
scheduler.start(); // Should not throw
|
||||
expect(scheduler.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it("is safe to stop when not started", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
expect(() => scheduler.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tick", () => {
|
||||
it("skips when globalPause is true", async () => {
|
||||
const routineStore = createMockRoutineStore([createMockRoutine()]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ globalPause: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when enginePaused is true", async () => {
|
||||
const routineStore = createMockRoutineStore([createMockRoutine()]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ enginePaused: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when no routines are due", async () => {
|
||||
const routineStore = createMockRoutineStore([]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes due routines in order", async () => {
|
||||
const routine1 = createMockRoutine({ id: "routine-1" });
|
||||
const routine2 = createMockRoutine({ id: "routine-2" });
|
||||
const routineStore = createMockRoutineStore([routine1, routine2]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both routines should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("calls handleCatchUp then executeRoutine for each routine", async () => {
|
||||
const routine1 = createMockRoutine({ id: "routine-order-1" });
|
||||
const routine2 = createMockRoutine({ id: "routine-order-2" });
|
||||
const routineStore = createMockRoutineStore([routine1, routine2]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both routines should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("handles errors in individual routine execution gracefully", async () => {
|
||||
const routine = createMockRoutine({ id: "routine-error" });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
vi.mocked(routineRunner.executeRoutine).mockRejectedValueOnce(
|
||||
new Error("Execution failed"),
|
||||
);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
// Should not throw
|
||||
await expect(scheduler.tick()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-entrance guard prevents overlapping ticks", async () => {
|
||||
const routine = createMockRoutine({ id: "routine-reentrant" });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
// Make execution slow
|
||||
vi.mocked(routineRunner.executeRoutine).mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return {
|
||||
routineId: "routine-reentrant",
|
||||
success: true,
|
||||
output: "",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
} as RoutineExecutionResult;
|
||||
});
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
// Start two ticks concurrently
|
||||
const [tick1, tick2] = [scheduler.tick(), scheduler.tick()];
|
||||
await Promise.all([tick1, tick2]);
|
||||
|
||||
// Should only have executed once (second tick was blocked by re-entrance guard)
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerManual", () => {
|
||||
it("delegates to routineRunner.executeRoutine with 'api' trigger", async () => {
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), createMockRoutineStore(), routineRunner);
|
||||
|
||||
await scheduler.triggerManual("test-routine");
|
||||
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("test-routine", "api");
|
||||
});
|
||||
|
||||
it("passes through the result from routineRunner", async () => {
|
||||
const mockResult: RoutineExecutionResult = {
|
||||
routineId: "test-routine",
|
||||
success: true,
|
||||
output: "Success",
|
||||
startedAt: new Date().toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
vi.mocked(routineRunner.executeRoutine).mockResolvedValue(mockResult);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), createMockRoutineStore(), routineRunner);
|
||||
|
||||
const result = await scheduler.triggerManual("test-routine");
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerWebhook", () => {
|
||||
it("delegates to routineRunner.executeRoutine with 'webhook' trigger", async () => {
|
||||
const routine = createMockRoutine({ id: "webhook-routine", trigger: { type: "webhook", webhookPath: "/test" } });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
await scheduler.triggerWebhook("webhook-routine", { data: "test" });
|
||||
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith(
|
||||
"webhook-routine",
|
||||
"webhook",
|
||||
expect.objectContaining({ webhookPayload: { data: "test" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for routine with non-webhook trigger type", async () => {
|
||||
const routine = createMockRoutine({ id: "cron-routine", trigger: { type: "cron", cronExpression: "0 * * * *" } });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
|
||||
|
||||
await expect(scheduler.triggerWebhook("cron-routine", {})).rejects.toThrow(
|
||||
"does not have webhook trigger type",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for nonexistent routine", async () => {
|
||||
const routineStore = createMockRoutineStore([]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
|
||||
|
||||
await expect(scheduler.triggerWebhook("nonexistent", {})).rejects.toThrow(
|
||||
"not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("proceeds without signature check when no secret is configured", async () => {
|
||||
// Clear the env var if it exists
|
||||
const originalSecret = process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
|
||||
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
|
||||
|
||||
try {
|
||||
const routine = createMockRoutine({ id: "webhook-no-secret", trigger: { type: "webhook", webhookPath: "/test" } });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
// Should not throw even without signature
|
||||
await scheduler.triggerWebhook("webhook-no-secret", { data: "test" });
|
||||
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalled();
|
||||
} finally {
|
||||
// Restore env var
|
||||
if (originalSecret !== undefined) {
|
||||
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = originalSecret;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws for invalid HMAC signature when secret is configured", async () => {
|
||||
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = "test-secret";
|
||||
|
||||
try {
|
||||
const routine = createMockRoutine({ id: "webhook-with-secret", trigger: { type: "webhook", webhookPath: "/test" } });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
|
||||
|
||||
await expect(
|
||||
scheduler.triggerWebhook("webhook-with-secret", { data: "test" }, "sha256=invalidsignature"),
|
||||
).rejects.toThrow("Invalid webhook signature");
|
||||
} finally {
|
||||
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid HMAC signature", async () => {
|
||||
const secret = "test-secret-123";
|
||||
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = secret;
|
||||
|
||||
try {
|
||||
const routine = createMockRoutine({ id: "webhook-valid-sig", trigger: { type: "webhook", webhookPath: "/test" } });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner);
|
||||
|
||||
const payload = { data: "test" };
|
||||
const crypto = await import("node:crypto");
|
||||
const signature = "sha256=" + crypto.createHmac("sha256", secret).update(JSON.stringify(payload)).digest("hex");
|
||||
|
||||
await scheduler.triggerWebhook("webhook-valid-sig", payload, signature);
|
||||
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalled();
|
||||
} finally {
|
||||
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when signature is missing but secret is configured", async () => {
|
||||
process.env.FUSION_ROUTINE_WEBHOOK_SECRET = "test-secret";
|
||||
|
||||
try {
|
||||
const routine = createMockRoutine({ id: "webhook-missing-sig", trigger: { type: "webhook", webhookPath: "/test" } });
|
||||
const routineStore = createMockRoutineStore([routine]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore);
|
||||
|
||||
await expect(
|
||||
scheduler.triggerWebhook("webhook-missing-sig", { data: "test" }),
|
||||
).rejects.toThrow("Missing webhook signature");
|
||||
} finally {
|
||||
delete process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActive", () => {
|
||||
it("returns false initially", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
expect(scheduler.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after start", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
scheduler.start();
|
||||
expect(scheduler.isActive()).toBe(true);
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
it("returns false after stop", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
scheduler.start();
|
||||
scheduler.stop();
|
||||
expect(scheduler.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scoped lane regression tests (FN-1766) ─────────────────────────────────
|
||||
|
||||
describe("scoped lane polling", () => {
|
||||
it("scope='project' calls getDueRoutines with 'project'", async () => {
|
||||
const routineStore = createMockRoutineStore([]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner(), { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutines).toHaveBeenCalledWith("project");
|
||||
expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='global' calls getDueRoutines with 'global'", async () => {
|
||||
const routineStore = createMockRoutineStore([]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner(), { scope: "global" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutines).toHaveBeenCalledWith("global");
|
||||
expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='all' calls getDueRoutinesAllScopes", async () => {
|
||||
const routineStore = createMockRoutineStore([]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner(), { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutinesAllScopes).toHaveBeenCalled();
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='project' skips global-scoped routines", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-routine", scope: "global" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Should NOT execute the global routine
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='global' skips project-scoped routines", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-routine", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Should NOT execute the project routine
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='all' executes both global and project routines", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-rout", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-rout", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both routines should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scope='all' deduplicates by routine ID — no double execution", async () => {
|
||||
// Same routine ID in both scopes
|
||||
const sharedRoutine = createMockRoutine({ id: "shared-id", name: "Shared", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([sharedRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([sharedRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Should only execute once
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("scope='all' skips routine from wrong scope after scope mismatch", async () => {
|
||||
// Routine says global but scheduler is polling project scope
|
||||
const mismatchedRoutine = createMockRoutine({ id: "mismatch", name: "Mismatched", scope: "global" });
|
||||
const routineStore = createMockRoutineStore([mismatchedRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Should NOT execute - routine is global but scheduler is in project scope
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='project' default when not specified", () => {
|
||||
const routineStore = createMockRoutineStore([]);
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, createMockRoutineRunner());
|
||||
|
||||
expect(scheduler["scope"]).toBe("project");
|
||||
});
|
||||
|
||||
it("scope is preserved when invoking handleCatchUp", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-catchup", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// handleCatchUp should be called with the routine (preserving scope context)
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine);
|
||||
});
|
||||
|
||||
it("scope is preserved when invoking executeRoutine", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-exec", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// executeRoutine should be called with the routine ID and trigger type
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-exec", "cron");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoped lane pause regressions", () => {
|
||||
it("scope='project' skips when initially paused", async () => {
|
||||
const routineStore = createMockRoutineStore([createMockRoutine({ scope: "project" })]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ globalPause: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
{ scope: "project" },
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='global' skips when initially paused", async () => {
|
||||
const routineStore = createMockRoutineStore([createMockRoutine({ scope: "global" })]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ enginePaused: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
{ scope: "global" },
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='all' skips when initially paused", async () => {
|
||||
const globalRoutine = createMockRoutine({ scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ globalPause: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
{ scope: "all" },
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='project' halts mid-loop when pause flips", async () => {
|
||||
const projectRoutine1 = createMockRoutine({ id: "r1", name: "Routine 1", scope: "project" });
|
||||
const projectRoutine2 = createMockRoutine({ id: "r2", name: "Routine 2", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine1, projectRoutine2]);
|
||||
|
||||
// Mock getSettings to pause after first routine
|
||||
let getSettingsCalls = 0;
|
||||
const mockTaskStore: TaskStore = {
|
||||
getSettings: vi.fn().mockImplementation(async () => {
|
||||
getSettingsCalls++;
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
globalPause: getSettingsCalls >= 3, // Pause on 3rd call (before r2)
|
||||
};
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(mockTaskStore, routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Should only process first routine
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(1);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoped lane ID-overlap regressions", () => {
|
||||
it("identical IDs across global and project scopes must not cross lane boundaries", async () => {
|
||||
// Same ID in both scopes - store returns both
|
||||
const globalRoutine = createMockRoutine({ id: "overlap-id", name: "Global Overlap", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "overlap-id", name: "Project Overlap", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// With scope="all", both routines share the same ID
|
||||
// The scheduler should execute once (first occurrence wins due to deduplication)
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("overlap-id", "cron");
|
||||
});
|
||||
|
||||
it("scope='project' with overlapping ID only processes project-scoped version", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "overlap-id", name: "Project Only", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("overlap-id", "cron");
|
||||
});
|
||||
|
||||
it("scope='global' with overlapping ID only processes global-scoped version", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "overlap-id", name: "Global Only", scope: "global" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("overlap-id", "cron");
|
||||
});
|
||||
});
|
||||
|
||||
describe("utility-lane semaphore boundary", () => {
|
||||
it("RoutineScheduler does not use task-lane semaphore operations", async () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
|
||||
// Cast to any to access internal state for semaphore trap
|
||||
const schedulerAny = scheduler as any;
|
||||
|
||||
// Verify no semaphore-related methods exist on the scheduler
|
||||
expect(schedulerAny.acquire).toBeUndefined();
|
||||
expect(schedulerAny.release).toBeUndefined();
|
||||
expect(schedulerAny.run).toBeUndefined();
|
||||
expect(schedulerAny.semaphore).toBeUndefined();
|
||||
expect(schedulerAny["_semaphore"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("RoutineScheduler constructor does not accept semaphore parameter", () => {
|
||||
scheduler = createRoutineScheduler();
|
||||
|
||||
// Verify internal state doesn't have semaphore references
|
||||
const keys = Object.keys(scheduler);
|
||||
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 routine scheduling wiring across packages.
|
||||
// They ensure that:
|
||||
// 1. RoutineScheduler bound to a project store executes scoped routines only for that project lane
|
||||
// 2. RoutineScheduler polls both global and project lanes when configured with both stores
|
||||
// 3. Lane identity is preserved through handleCatchUp → executeRoutine
|
||||
// 4. Identical routine IDs in different lanes do not cause cross-lane execution
|
||||
// 5. Lane diagnostics are verifiable in test assertions
|
||||
|
||||
describe("cross-package dual-lane execution integration", () => {
|
||||
it("scope='all' polls both global and project lanes in the same tick", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-rout-all", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-rout-all", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both lanes should be polled
|
||||
expect(routineStore.getDueRoutinesAllScopes).toHaveBeenCalledTimes(1);
|
||||
expect(routineStore.getDueRoutines).not.toHaveBeenCalled();
|
||||
// Both routines should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scope='all' lane identity is preserved through handleCatchUp → executeRoutine", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-lane-id", name: "Global Lane", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-lane-id", name: "Project Lane", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Verify both lanes were processed with correct IDs
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("global-lane-id", "cron");
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-lane-id", "cron");
|
||||
});
|
||||
|
||||
it("pause applies uniformly across both lanes when scope='all'", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-rout-pause", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-rout-pause", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(
|
||||
createMockTaskStore({ globalPause: true }),
|
||||
routineStore,
|
||||
routineRunner,
|
||||
{ scope: "all" },
|
||||
);
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Neither lane should be polled when paused
|
||||
expect(routineStore.getDueRoutinesAllScopes).not.toHaveBeenCalled();
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("identical routine IDs across global and project lanes execute once (deduplication)", async () => {
|
||||
// Same ID in both scopes
|
||||
const globalRoutine = createMockRoutine({ id: "shared-rout", name: "Shared Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "shared-rout", name: "Shared Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Only one execution should occur due to deduplication
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-package scoped isolation integration", () => {
|
||||
it("RoutineScheduler bound to project store never executes global-lane routines", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-iso-rout", name: "Global", scope: "global" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Project-scoped scheduler should not execute global routine
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("RoutineScheduler bound to global store never executes project-lane routines", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-iso-rout", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "global" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Global-scoped scheduler should not execute project routine
|
||||
expect(routineRunner.handleCatchUp).not.toHaveBeenCalled();
|
||||
expect(routineRunner.executeRoutine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scope='all' processes routines from both lanes but never crosses lane boundaries", async () => {
|
||||
const globalRoutine = createMockRoutine({ id: "global-boundary-rout", name: "Global", scope: "global" });
|
||||
const projectRoutine = createMockRoutine({ id: "project-boundary-rout", name: "Project", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([globalRoutine, projectRoutine]);
|
||||
(routineStore.getDueRoutinesAllScopes as ReturnType<typeof vi.fn>).mockResolvedValue([globalRoutine, projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "all" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Both lanes should be processed
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledTimes(2);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledTimes(2);
|
||||
// Each routine is executed exactly once (no double execution)
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("global-boundary-rout", "cron");
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-boundary-rout", "cron");
|
||||
});
|
||||
|
||||
it("scope='project' lane identity is preserved through handleCatchUp → executeRoutine", async () => {
|
||||
const projectRoutine = createMockRoutine({ id: "project-preserved-id", name: "Project Preserved", scope: "project" });
|
||||
const routineStore = createMockRoutineStore([projectRoutine]);
|
||||
const routineRunner = createMockRoutineRunner();
|
||||
scheduler = createRoutineScheduler(createMockTaskStore(), routineStore, routineRunner, { scope: "project" });
|
||||
|
||||
await scheduler.tick();
|
||||
|
||||
// Lane identity preserved through the execution pipeline
|
||||
expect(routineRunner.handleCatchUp).toHaveBeenCalledWith(projectRoutine);
|
||||
expect(routineRunner.executeRoutine).toHaveBeenCalledWith("project-preserved-id", "cron");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user