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

This commit is contained in:
gsxdsm
2026-04-16 00:23:05 -07:00
parent 5789101cd9
commit dfb0a836be
2 changed files with 466 additions and 14 deletions

View File

@@ -7,10 +7,34 @@ const { centralInstances } = vi.hoisted(() => {
return { centralInstances };
});
// ── Multi-project test fixtures ─────────────────────────────────────────
//
// Test fixtures model at least two registered projects with distinct IDs/paths
// and independently addressable engine instances. This enables regression tests
// for multi-project scoped scheduling where wrong-engine binding can silently
// route operations to the wrong project.
//
const PROJECT_FIXTURES = {
primary: {
id: "project-1",
name: "Primary Project",
path: "/repo",
status: "active" as const,
isolationMode: "in-process" as const,
},
secondary: {
id: "project-2",
name: "Secondary Project",
path: "/repo-secondary",
status: "active" as const,
isolationMode: "in-process" as const,
},
};
// ── Capture arguments ───────────────────────────────────────────────
// Minimal mock store backed by EventEmitter so `store.on` works
function makeMockStore() {
function makeMockStore(projectId = "default") {
const emitter = new EventEmitter();
// runDashboard registers several independent settings listeners by design;
// keep the test mock above Node's low default threshold while still checking
@@ -36,8 +60,9 @@ function makeMockStore() {
openrouterModelSync: true,
}),
listTasks: vi.fn().mockResolvedValue([]),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getFusionDir: vi.fn().mockReturnValue(`/tmp/${projectId}/.fusion`),
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
healthCheck: vi.fn().mockReturnValue(true),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
@@ -60,18 +85,28 @@ const mockProcessAndAudit = vi.fn().mockResolvedValue({
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
});
// Track getProjectByPath calls to allow per-test resolution control
let getProjectByPathResolver: ((cwd: string) => unknown) | null = null;
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
CentralCore: vi.fn().mockImplementation(() => {
const instance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
// Use per-test resolver when available; default to primary project
if (getProjectByPathResolver) {
return Promise.resolve(getProjectByPathResolver(cwd));
}
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
}),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
@@ -159,6 +194,9 @@ vi.mock("@fusion/dashboard", () => ({
// ── Mock @fusion/engine ────────────────────────────────────────────────
// Track which engine is used for default/cwd path to assert correct routing
const engineUsageLog: string[] = [];
vi.mock("@fusion/engine", async (importOriginal) => {
const original = await importOriginal<typeof import("@fusion/engine")>();
return {
@@ -183,10 +221,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, _options: any) => {
const engines = new Map<string, any>();
// Create mock engines that match the ProjectEngine mock shape above.
const createMockEngine = () => ({
// Each engine is independently addressable by project ID.
const createMockEngine = (projectId: string) => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockImplementation(() => makeMockStore()),
getTaskStore: vi.fn().mockImplementation(() => makeMockStore(projectId)),
getRuntime: vi.fn().mockReturnValue({
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
@@ -202,12 +241,15 @@ vi.mock("@fusion/engine", async (importOriginal) => {
startAll: vi.fn(async () => {
const projects = await centralCore.listProjects();
for (const project of projects) {
const engine = createMockEngine();
const engine = createMockEngine(project.id);
await engine.start();
engines.set(project.id, engine);
}
}),
getEngine: vi.fn((id: string) => engines.get(id)),
getEngine: vi.fn((id: string) => {
engineUsageLog.push(`getEngine(${id})`);
return engines.get(id);
}),
getAllEngines: vi.fn(() => engines),
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
has: vi.fn((id: string) => engines.has(id)),
@@ -328,10 +370,45 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
return result;
}
// ── Multi-project test utilities ────────────────────────────────────
/**
* Reset multi-project test state between tests.
* Clears engine usage log and project-by-path resolver.
*/
function resetMultiProjectState(): void {
engineUsageLog.length = 0;
getProjectByPathResolver = null;
centralInstances.length = 0;
}
/**
* Configure how CentralCore.getProjectByPath resolves for tests.
* Call this in beforeEach to set up specific project resolution scenarios.
*
* @param resolver - Function that maps cwd to project record, or null to use default (primary project)
*
* @example
* // Set up secondary project as cwd
* setupProjectByPath((cwd) => {
* if (cwd === "/repo-secondary") return PROJECT_FIXTURES.secondary;
* return null; // Not registered
* });
*
* // Use default (primary project)
* setupProjectByPath(null);
*/
function setupProjectByPath(
resolver: ((cwd: string) => unknown) | null
): void {
getProjectByPathResolver = resolver;
}
// ── Tests ───────────────────────────────────────────────────────────
afterEach(() => {
disposeTrackedDashboards();
resetMultiProjectState();
});
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
@@ -682,3 +759,119 @@ describe("runDashboard — Peer exchange and discovery", () => {
});
});
describe("runDashboard — multi-project cwd/default engine resolution", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockDiscoverAndLoadExtensions.mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
// Default: cwd resolves to primary project
setupProjectByPath(null);
});
it("selects default engine via CentralCore.getProjectByPath(cwd), not by registry order", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
// Verify the engine manager was created
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
// Verify CentralCore.getProjectByPath was called with cwd
const centralCore = centralInstances[0];
expect(centralCore.getProjectByPath).toHaveBeenCalled();
// Verify createServer received an engine (the cwd/default engine)
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts).toHaveProperty("engine");
expect(serverOpts.engine).toBeDefined();
});
it("passes engineManager to createServer for multi-project scope resolution", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
// engineManager should be passed for multi-project route resolution
expect(serverOpts.engineManager).toBe(managerInstance);
});
it("passes onProjectFirstAccessed callback that delegates to engineManager.onProjectAccessed", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
// Verify the callback is passed
expect(serverOpts).toHaveProperty("onProjectFirstAccessed");
expect(typeof serverOpts.onProjectFirstAccessed).toBe("function");
// Invoke the callback and verify delegation
serverOpts.onProjectFirstAccessed("proj-new");
expect(managerInstance.onProjectAccessed).toHaveBeenCalledWith("proj-new");
});
it("does NOT pass engine when cwd project cannot be resolved (no foreign default engine)", async () => {
// Configure cwd to return null (project not registered)
setupProjectByPath((_cwd) => null);
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager } = await import("@fusion/engine");
await runDashboard(0, {});
// Verify engineManager was still passed (for multi-project support)
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts.engineManager).toBe(managerInstance);
// But NO default engine was bound (prevents wrong-project execution)
expect(serverOpts.engine).toBeUndefined();
});
it("forwards scoped automation/routine lane dependencies to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
await runDashboard(0, {});
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
// automationStore should be forwarded for scoped scheduling
expect(serverOpts).toHaveProperty("automationStore");
expect(serverOpts.automationStore).toBeDefined();
});
it("maintains correct engine binding when onProjectFirstAccessed is called", async () => {
const { createServer } = await import("@fusion/dashboard");
await runDashboard(0, {});
// Get the original engine binding
const serverOpts1 = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const originalEngine = serverOpts1.engine;
// Trigger onProjectFirstAccessed for a secondary project
if (serverOpts1.onProjectFirstAccessed) {
serverOpts1.onProjectFirstAccessed("proj-secondary");
}
// Verify createServer was only called once (engine binding unchanged)
expect((createServer as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
// Verify the engine passed is still the original cwd engine
const serverOpts2 = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts2.engine).toBe(originalEngine);
});
});

View File

@@ -1,6 +1,36 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
// ── Multi-project test fixtures ─────────────────────────────────────────
//
// Test fixtures model at least two registered projects with distinct IDs/paths
// and independently addressable engine instances. This enables regression tests
// for multi-project scoped scheduling where wrong-engine binding can silently
// route operations to the wrong project.
//
const PROJECT_FIXTURES = {
primary: {
id: "project-1",
name: "Primary Project",
path: "/repo",
status: "active" as const,
isolationMode: "in-process" as const,
},
secondary: {
id: "project-2",
name: "Secondary Project",
path: "/repo-secondary",
status: "active" as const,
isolationMode: "in-process" as const,
},
};
// Track getProjectByPath calls to allow per-test resolution control
let getProjectByPathResolver: ((cwd: string) => unknown) | null = null;
// Track which engine is used for default/cwd path to assert correct routing
const engineUsageLog: string[] = [];
const mocks = vi.hoisted(() => {
type ListenCall = {
port: number;
@@ -32,7 +62,7 @@ const mocks = vi.hoisted(() => {
const projectEngineInstances: any[] = [];
const listenCalls: ListenCall[] = [];
function createTaskStoreMock() {
function createTaskStoreMock(projectId = "default") {
const emitter = new EventEmitter();
const missionStore = {
listMissions: vi.fn().mockResolvedValue([]),
@@ -42,7 +72,7 @@ const mocks = vi.hoisted(() => {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getFusionDir: vi.fn().mockReturnValue("/repo/.fusion"),
getFusionDir: vi.fn().mockReturnValue(`/repo/${projectId}/.fusion`),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
@@ -105,12 +135,19 @@ const mocks = vi.hoisted(() => {
const instance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
// Use per-test resolver when available; default to primary project
if (getProjectByPathResolver) {
return Promise.resolve(getProjectByPathResolver(cwd));
}
return Promise.resolve({ ...PROJECT_FIXTURES.primary, path: cwd });
}),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ ...PROJECT_FIXTURES.secondary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
@@ -444,6 +481,9 @@ const mocks = vi.hoisted(() => {
syncInsightExtractionAutomationMock.mockResolvedValue(undefined);
processAndAuditInsightExtractionMock.mockClear();
createAiPromptExecutorMock.mockClear();
// Reset multi-project state
engineUsageLog.length = 0;
getProjectByPathResolver = null;
},
};
});
@@ -492,7 +532,11 @@ vi.mock("@fusion/engine", () => ({
engines.set(project.id, engine);
}
}),
getEngine: vi.fn((id: string) => engines.get(id)),
// Track which engine is used to verify correct cwd/default routing
getEngine: vi.fn((id: string) => {
engineUsageLog.push(`getEngine(${id})`);
return engines.get(id);
}),
getAllEngines: vi.fn(() => engines),
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
has: vi.fn((id: string) => engines.has(id)),
@@ -1371,3 +1415,218 @@ describe("runServe --daemon flag", () => {
await triggerSignal("SIGINT");
});
});
// ── Multi-project test utilities ────────────────────────────────────
/**
* Reset multi-project test state between tests.
* Clears engine usage log and project-by-path resolver.
*/
function resetMultiProjectState(): void {
engineUsageLog.length = 0;
getProjectByPathResolver = null;
mocks.reset();
}
/**
* Configure how CentralCore.getProjectByPath resolves for tests.
* Call this in beforeEach to set up specific project resolution scenarios.
*
* @param resolver - Function that maps cwd to project record, or null to use default (primary project)
*
* @example
* // Set up secondary project as cwd
* setupProjectByPath((cwd) => {
* if (cwd === "/repo-secondary") return PROJECT_FIXTURES.secondary;
* return null; // Not registered
* });
*
* // Use default (primary project)
* setupProjectByPath(null);
*/
function setupProjectByPath(
resolver: ((cwd: string) => unknown) | null
): void {
getProjectByPathResolver = resolver;
}
// ── Tests: runServe multi-project startup wiring ─────────────────────
describe("runServe — multi-project cwd/default engine resolution", () => {
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
let logSpy: ReturnType<typeof vi.spyOn>;
let cwdSpy: ReturnType<typeof vi.spyOn>;
let processOnSpy: ReturnType<typeof vi.spyOn>;
async function triggerSignal(signal: "SIGINT" | "SIGTERM") {
const handlers = signalHandlers[signal];
expect(handlers.length).toBeGreaterThan(0);
handlers[handlers.length - 1]();
await new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
mocks.reset();
resetMultiProjectState();
signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers[event].push(listener);
}
return process;
}) as typeof process.on);
process.exit = vi.fn() as never;
// Default: cwd resolves to primary project
setupProjectByPath(null);
});
afterEach(() => {
logSpy.mockRestore();
cwdSpy.mockRestore();
processOnSpy.mockRestore();
process.cwd = originalCwd;
process.on = originalOn;
process.exit = originalExit;
});
it("resolves cwdEngine from CentralCore.getProjectByPath(cwd) and passes to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager } = await import("@fusion/engine");
await runServe(4040, {});
// Verify engineManager was created
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
const managerInstance = ProjectEngineManager.mock.results[0]?.value;
// Verify createServer received the cwd engine
expect(createServer).toHaveBeenCalledTimes(1);
const serverOpts = createServer.mock.calls[0][1];
// The cwd engine should be passed as the default execution engine
expect(serverOpts).toHaveProperty("engine");
expect(serverOpts.engine).toBeDefined();
// engineManager should also be passed for multi-project route resolution
expect(serverOpts.engineManager).toBe(managerInstance);
});
it("passes onMerge bound to cwd engine, not any other project", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts).toHaveProperty("onMerge");
expect(typeof serverOpts.onMerge).toBe("function");
});
it("forwards scoped automationStore and missionAutopilot to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
const serverOpts = createServer.mock.calls[0][1];
// Verify scoped scheduling dependencies are forwarded
expect(serverOpts).toHaveProperty("automationStore");
expect(serverOpts.automationStore).toBeDefined();
expect(serverOpts).toHaveProperty("missionAutopilot");
expect(serverOpts.missionAutopilot).toBeDefined();
expect(serverOpts).toHaveProperty("missionExecutionLoop");
expect(serverOpts.missionExecutionLoop).toBeDefined();
});
it("forwards heartbeatMonitor with rootDir bound to cwd", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts).toHaveProperty("heartbeatMonitor");
expect(serverOpts.heartbeatMonitor).toBeDefined();
// heartbeatMonitor should have rootDir for scope validation
expect(serverOpts.heartbeatMonitor.rootDir).toBe("/repo");
});
it("forwards onProjectFirstAccessed callback that delegates to engineManager", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager } = await import("@fusion/engine");
await runServe(4040, {});
const managerInstance = ProjectEngineManager.mock.results[0]?.value;
const serverOpts = createServer.mock.calls[0][1];
expect(serverOpts).toHaveProperty("onProjectFirstAccessed");
expect(typeof serverOpts.onProjectFirstAccessed).toBe("function");
// Invoke callback and verify delegation
serverOpts.onProjectFirstAccessed("proj-new");
expect(managerInstance.onProjectAccessed).toHaveBeenCalledWith("proj-new");
});
it("does NOT allow secondary project access to hijack default execution callbacks", async () => {
const { createServer } = await import("@fusion/dashboard");
await runServe(4040, {});
// Get original onMerge
const serverOpts1 = createServer.mock.calls[0][1];
const originalOnMerge = serverOpts1.onMerge;
const originalEngine = serverOpts1.engine;
// Simulate secondary project access via onProjectFirstAccessed
if (serverOpts1.onProjectFirstAccessed) {
serverOpts1.onProjectFirstAccessed("project-2");
}
// Verify createServer was only called once (no re-creation with different engine)
expect(createServer).toHaveBeenCalledTimes(1);
// Verify callbacks are still bound to original cwd engine
const serverOpts2 = createServer.mock.calls[0][1];
expect(serverOpts2.onMerge).toBe(originalOnMerge);
expect(serverOpts2.engine).toBe(originalEngine);
});
it("exits process when cwd cannot be resolved to a registered project", async () => {
// Configure cwd to return null (project not registered)
setupProjectByPath((_cwd) => null);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await runServe(4040, {});
// runServe should exit when no cwd engine can be started
expect(process.exit).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] No engine started for the current project")
);
errorSpy.mockRestore();
});
it("process.exit is NOT called when cwd project is resolved", async () => {
// cwd resolves to primary project
setupProjectByPath(null);
await runServe(4040, {});
// Should NOT exit - process should continue running
expect(process.exit).not.toHaveBeenCalled();
await triggerSignal("SIGINT");
});
});