feat(FN-1766): merge fusion/fn-1766

This commit is contained in:
gsxdsm
2026-04-15 16:53:30 -07:00
parent b2d982a894
commit c18608c7e6
2 changed files with 562 additions and 1 deletions

View File

@@ -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);
});
});
});