feat(FN-1766): merge fusion/fn-1766
This commit is contained in:
@@ -59,6 +59,7 @@ function createMockStore(settingsOverrides: Partial<Settings> = {}): 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);
|
||||
@@ -1229,4 +1230,280 @@ describe("CronRunner", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ function createMockRoutineStore(routines: Routine[] = []): RoutineStore {
|
||||
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 });
|
||||
}),
|
||||
@@ -107,13 +108,14 @@ function createRoutineScheduler(
|
||||
taskStore?: TaskStore,
|
||||
routineStore?: RoutineStore,
|
||||
routineRunner?: RoutineRunner,
|
||||
options?: Partial<Pick<RoutineSchedulerOptions, "pollIntervalMs">>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,4 +457,286 @@ describe("RoutineScheduler", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user