feat(FN-1770): merge fusion/fn-1770
This commit is contained in:
@@ -7,10 +7,34 @@ const { centralInstances } = vi.hoisted(() => {
|
|||||||
return { centralInstances };
|
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 ───────────────────────────────────────────────
|
// ── Capture arguments ───────────────────────────────────────────────
|
||||||
|
|
||||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||||
function makeMockStore() {
|
function makeMockStore(projectId = "default") {
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
// runDashboard registers several independent settings listeners by design;
|
// runDashboard registers several independent settings listeners by design;
|
||||||
// keep the test mock above Node's low default threshold while still checking
|
// keep the test mock above Node's low default threshold while still checking
|
||||||
@@ -36,8 +60,9 @@ function makeMockStore() {
|
|||||||
openrouterModelSync: true,
|
openrouterModelSync: true,
|
||||||
}),
|
}),
|
||||||
listTasks: vi.fn().mockResolvedValue([]),
|
listTasks: vi.fn().mockResolvedValue([]),
|
||||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
getFusionDir: vi.fn().mockReturnValue(`/tmp/${projectId}/.fusion`),
|
||||||
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
|
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
|
||||||
|
healthCheck: vi.fn().mockReturnValue(true),
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
emitter.on(event, handler);
|
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" },
|
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", () => ({
|
vi.mock("@fusion/core", () => ({
|
||||||
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||||
CentralCore: vi.fn().mockImplementation(() => {
|
CentralCore: vi.fn().mockImplementation(() => {
|
||||||
const instance = {
|
const instance = {
|
||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
close: 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) =>
|
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([
|
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([
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
@@ -159,6 +194,9 @@ vi.mock("@fusion/dashboard", () => ({
|
|||||||
|
|
||||||
// ── Mock @fusion/engine ────────────────────────────────────────────────
|
// ── 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) => {
|
vi.mock("@fusion/engine", async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import("@fusion/engine")>();
|
const original = await importOriginal<typeof import("@fusion/engine")>();
|
||||||
return {
|
return {
|
||||||
@@ -183,10 +221,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
|||||||
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, _options: any) => {
|
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, _options: any) => {
|
||||||
const engines = new Map<string, any>();
|
const engines = new Map<string, any>();
|
||||||
// Create mock engines that match the ProjectEngine mock shape above.
|
// 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),
|
start: vi.fn().mockResolvedValue(undefined),
|
||||||
stop: vi.fn().mockResolvedValue(undefined),
|
stop: vi.fn().mockResolvedValue(undefined),
|
||||||
getTaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
getTaskStore: vi.fn().mockImplementation(() => makeMockStore(projectId)),
|
||||||
getRuntime: vi.fn().mockReturnValue({
|
getRuntime: vi.fn().mockReturnValue({
|
||||||
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
|
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
|
||||||
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
|
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
|
||||||
@@ -202,12 +241,15 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
|||||||
startAll: vi.fn(async () => {
|
startAll: vi.fn(async () => {
|
||||||
const projects = await centralCore.listProjects();
|
const projects = await centralCore.listProjects();
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const engine = createMockEngine();
|
const engine = createMockEngine(project.id);
|
||||||
await engine.start();
|
await engine.start();
|
||||||
engines.set(project.id, engine);
|
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),
|
getAllEngines: vi.fn(() => engines),
|
||||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||||
has: vi.fn((id: string) => engines.has(id)),
|
has: vi.fn((id: string) => engines.has(id)),
|
||||||
@@ -328,10 +370,45 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
|
|||||||
return result;
|
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 ───────────────────────────────────────────────────────────
|
// ── Tests ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
disposeTrackedDashboards();
|
disposeTrackedDashboards();
|
||||||
|
resetMultiProjectState();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { EventEmitter } from "node:events";
|
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(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
type ListenCall = {
|
type ListenCall = {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -32,7 +62,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
const projectEngineInstances: any[] = [];
|
const projectEngineInstances: any[] = [];
|
||||||
const listenCalls: ListenCall[] = [];
|
const listenCalls: ListenCall[] = [];
|
||||||
|
|
||||||
function createTaskStoreMock() {
|
function createTaskStoreMock(projectId = "default") {
|
||||||
const emitter = new EventEmitter();
|
const emitter = new EventEmitter();
|
||||||
const missionStore = {
|
const missionStore = {
|
||||||
listMissions: vi.fn().mockResolvedValue([]),
|
listMissions: vi.fn().mockResolvedValue([]),
|
||||||
@@ -42,7 +72,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
watch: vi.fn().mockResolvedValue(undefined),
|
watch: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
getFusionDir: vi.fn().mockReturnValue("/repo/.fusion"),
|
getFusionDir: vi.fn().mockReturnValue(`/repo/${projectId}/.fusion`),
|
||||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
@@ -105,12 +135,19 @@ const mocks = vi.hoisted(() => {
|
|||||||
const instance = {
|
const instance = {
|
||||||
init: vi.fn().mockResolvedValue(undefined),
|
init: vi.fn().mockResolvedValue(undefined),
|
||||||
close: 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) =>
|
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() }),
|
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([
|
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([
|
listNodes: vi.fn().mockResolvedValue([
|
||||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||||
@@ -444,6 +481,9 @@ const mocks = vi.hoisted(() => {
|
|||||||
syncInsightExtractionAutomationMock.mockResolvedValue(undefined);
|
syncInsightExtractionAutomationMock.mockResolvedValue(undefined);
|
||||||
processAndAuditInsightExtractionMock.mockClear();
|
processAndAuditInsightExtractionMock.mockClear();
|
||||||
createAiPromptExecutorMock.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);
|
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),
|
getAllEngines: vi.fn(() => engines),
|
||||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||||
has: vi.fn((id: string) => engines.has(id)),
|
has: vi.fn((id: string) => engines.has(id)),
|
||||||
@@ -1371,3 +1415,218 @@ describe("runServe --daemon flag", () => {
|
|||||||
await triggerSignal("SIGINT");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user