The merge consolidates mock helpers across packages by creating a new shared `mockCoreEngine.ts` in the CLI package and strengthening the core/engine helpers, then migrating both CLI command tests and the dashboard's `AgentsView` tests to use the canonical helpers. It also publishes SSE architecture Fusion-Task-Id: FN-3053
1880 lines
67 KiB
TypeScript
1880 lines
67 KiB
TypeScript
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;
|
|
host?: string;
|
|
server: {
|
|
close: ReturnType<typeof vi.fn>;
|
|
address: ReturnType<typeof vi.fn>;
|
|
once: (event: string, cb: (...args: unknown[]) => void) => void;
|
|
on: (event: string, cb: (...args: unknown[]) => void) => void;
|
|
emit: (event: string, ...args: unknown[]) => boolean;
|
|
};
|
|
};
|
|
|
|
const taskStores: any[] = [];
|
|
const automationStores: any[] = [];
|
|
const agentStores: any[] = [];
|
|
const centralInstances: any[] = [];
|
|
const triageInstances: any[] = [];
|
|
const executorInstances: any[] = [];
|
|
const schedulerInstances: any[] = [];
|
|
const stuckDetectorInstances: any[] = [];
|
|
const selfHealingInstances: any[] = [];
|
|
const cronRunnerInstances: any[] = [];
|
|
const missionAutopilotInstances: any[] = [];
|
|
const missionExecutionLoopInstances: any[] = [];
|
|
const notifierInstances: any[] = [];
|
|
const pluginStoreInstances: any[] = [];
|
|
const pluginLoaderInstances: any[] = [];
|
|
const projectEngineInstances: any[] = [];
|
|
const listenCalls: ListenCall[] = [];
|
|
|
|
function createTaskStoreMock(projectId = "") {
|
|
const emitter = new EventEmitter();
|
|
const missionStore = {
|
|
listMissions: vi.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
return {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
watch: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn(),
|
|
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
|
|
getGlobalSettingsStore: vi.fn(() => ({
|
|
getSettings: vi.fn().mockResolvedValue({}),
|
|
})),
|
|
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
|
getSettings: vi.fn().mockResolvedValue({
|
|
maxConcurrent: 2,
|
|
recycleWorktrees: false,
|
|
autoMerge: false,
|
|
pollIntervalMs: 60_000,
|
|
openrouterModelSync: false,
|
|
}),
|
|
updateSettings: vi.fn().mockResolvedValue(undefined),
|
|
listTasks: vi.fn().mockResolvedValue([]),
|
|
getTask: vi.fn(),
|
|
updateTask: vi.fn().mockResolvedValue(undefined),
|
|
moveTask: vi.fn().mockResolvedValue(undefined),
|
|
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
|
logEntry: vi.fn().mockResolvedValue(undefined),
|
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
emitter.on(event, handler);
|
|
}),
|
|
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
emitter.off(event, handler);
|
|
}),
|
|
emit: emitter.emit.bind(emitter),
|
|
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
|
|
};
|
|
}
|
|
|
|
function createMockServer(port: number) {
|
|
const emitter = new EventEmitter();
|
|
return Object.assign(emitter, {
|
|
close: vi.fn((cb?: () => void) => cb?.()),
|
|
address: vi.fn(() => ({ port, family: "IPv4", address: "0.0.0.0" })),
|
|
once: emitter.once.bind(emitter),
|
|
on: emitter.on.bind(emitter),
|
|
});
|
|
}
|
|
|
|
const taskStoreCtor = vi.fn().mockImplementation(() => {
|
|
const store = createTaskStoreMock();
|
|
taskStores.push(store);
|
|
return store;
|
|
});
|
|
|
|
const automationStoreCtor = vi.fn().mockImplementation(() => {
|
|
const automationStore = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
automationStores.push(automationStore);
|
|
return automationStore;
|
|
});
|
|
|
|
const agentStoreCtor = vi.fn().mockImplementation(() => {
|
|
const agentStore = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
agentStores.push(agentStore);
|
|
return agentStore;
|
|
});
|
|
|
|
const centralCoreCtor = vi.fn().mockImplementation(() => {
|
|
const instance = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
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([
|
|
{ ...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" },
|
|
]),
|
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
|
stopDiscovery: vi.fn(),
|
|
};
|
|
centralInstances.push(instance);
|
|
return instance;
|
|
});
|
|
|
|
const createServerMock = vi.fn().mockImplementation(() => ({
|
|
listen: vi.fn((port: number, host?: string) => {
|
|
const actualPort = port === 0 ? 5050 : port;
|
|
const server = createMockServer(actualPort);
|
|
listenCalls.push({ port, host, server });
|
|
queueMicrotask(() => {
|
|
server.emit("listening");
|
|
});
|
|
return server;
|
|
}),
|
|
}));
|
|
|
|
const triageCtor = vi.fn().mockImplementation(() => {
|
|
const triage = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
markStuckAborted: vi.fn(),
|
|
};
|
|
triageInstances.push(triage);
|
|
return triage;
|
|
});
|
|
|
|
const executorCtor = vi.fn().mockImplementation(() => {
|
|
const executor = {
|
|
resumeOrphaned: vi.fn().mockResolvedValue(undefined),
|
|
markStuckAborted: vi.fn(),
|
|
handleLoopDetected: vi.fn().mockResolvedValue(false),
|
|
recoverCompletedTask: vi.fn().mockResolvedValue(false),
|
|
getExecutingTaskIds: vi.fn().mockReturnValue(new Set()),
|
|
};
|
|
executorInstances.push(executor);
|
|
return executor;
|
|
});
|
|
|
|
const schedulerCtor = vi.fn().mockImplementation(() => {
|
|
const scheduler = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
schedulerInstances.push(scheduler);
|
|
return scheduler;
|
|
});
|
|
|
|
const stuckDetectorCtor = vi.fn().mockImplementation(() => {
|
|
const detector = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
checkNow: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
stuckDetectorInstances.push(detector);
|
|
return detector;
|
|
});
|
|
|
|
const selfHealingCtor = vi.fn().mockImplementation(() => {
|
|
const manager = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
checkStuckBudget: vi.fn().mockResolvedValue(true),
|
|
};
|
|
selfHealingInstances.push(manager);
|
|
return manager;
|
|
});
|
|
|
|
const cronRunnerCtor = vi.fn().mockImplementation(() => {
|
|
const cron = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
cronRunnerInstances.push(cron);
|
|
return cron;
|
|
});
|
|
|
|
const missionAutopilotCtor = vi.fn().mockImplementation(() => {
|
|
const autopilot = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
setScheduler: vi.fn(),
|
|
};
|
|
missionAutopilotInstances.push(autopilot);
|
|
return autopilot;
|
|
});
|
|
|
|
const missionExecutionLoopCtor = vi.fn().mockImplementation(() => {
|
|
const loop = {
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
processTaskOutcome: vi.fn().mockResolvedValue(undefined),
|
|
recoverActiveMissions: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
missionExecutionLoopInstances.push(loop);
|
|
return loop;
|
|
});
|
|
|
|
const notifierCtor = vi.fn().mockImplementation(() => {
|
|
const notifier = {
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
};
|
|
notifierInstances.push(notifier);
|
|
return notifier;
|
|
});
|
|
|
|
const pluginStoreCtor = vi.fn().mockImplementation(() => {
|
|
const pluginStore = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
listPlugins: vi.fn().mockResolvedValue([]),
|
|
getPlugin: vi.fn(),
|
|
registerPlugin: vi.fn(),
|
|
enablePlugin: vi.fn(),
|
|
disablePlugin: vi.fn(),
|
|
updatePluginSettings: vi.fn(),
|
|
unregisterPlugin: vi.fn(),
|
|
updatePluginState: vi.fn(),
|
|
};
|
|
pluginStoreInstances.push(pluginStore);
|
|
return pluginStore;
|
|
});
|
|
|
|
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
|
|
const pluginLoader = {
|
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
|
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
|
getPlugin: vi.fn(),
|
|
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
|
};
|
|
pluginLoaderInstances.push(pluginLoader);
|
|
return pluginLoader;
|
|
});
|
|
|
|
const authStorage = {
|
|
getApiKey: vi.fn().mockResolvedValue(undefined),
|
|
reload: vi.fn(),
|
|
getOAuthProviders: vi.fn().mockReturnValue([]),
|
|
hasAuth: vi.fn().mockReturnValue(false),
|
|
login: vi.fn(),
|
|
logout: vi.fn(),
|
|
set: vi.fn(),
|
|
remove: vi.fn(),
|
|
get: vi.fn(),
|
|
};
|
|
|
|
const modelRegistry = {
|
|
getAll: vi.fn().mockReturnValue([]),
|
|
registerProvider: vi.fn(),
|
|
refresh: vi.fn(),
|
|
};
|
|
|
|
const agentSemaphoreCtor = vi.fn().mockImplementation(() => ({
|
|
_active: 0,
|
|
run: (fn: () => Promise<unknown>) => fn(),
|
|
}));
|
|
|
|
const heartbeatMonitorCtor = vi.fn().mockImplementation(() => ({
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
startRun: vi.fn().mockResolvedValue({ id: "run-1" }),
|
|
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
|
|
stopRun: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(() => ({
|
|
start: vi.fn(),
|
|
stop: vi.fn(),
|
|
registerAgent: vi.fn(),
|
|
getRegisteredAgents: vi.fn().mockReturnValue([]),
|
|
}));
|
|
|
|
const createAiPromptExecutorMock = vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok"));
|
|
const syncInsightExtractionAutomationMock = vi.fn().mockResolvedValue(undefined);
|
|
const processAndAuditInsightExtractionMock = vi.fn().mockResolvedValue({
|
|
generatedAt: new Date().toISOString(),
|
|
health: "healthy",
|
|
checks: [],
|
|
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
|
insightsMemory: { exists: true, size: 50, insightCount: 3, categories: {}, lastUpdated: "2026-04-09" },
|
|
extraction: { runAt: new Date().toISOString(), success: true, insightCount: 3, duplicateCount: 0, skippedCount: 0, summary: "Test" },
|
|
pruning: { applied: false },
|
|
});
|
|
|
|
const projectEngineCtor = vi.fn().mockImplementation((runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) => {
|
|
const store = taskStoreCtor(runtimeConfig.workingDirectory);
|
|
const automationStore = automationStoreCtor(runtimeConfig.workingDirectory);
|
|
const agentStore = agentStoreCtor();
|
|
const semaphore = agentSemaphoreCtor();
|
|
const heartbeatMonitor = heartbeatMonitorCtor({});
|
|
const heartbeatTriggerScheduler = heartbeatTriggerSchedulerCtor(agentStore, vi.fn(), store);
|
|
const missionAutopilot = missionAutopilotCtor();
|
|
const missionExecutionLoop = missionExecutionLoopCtor();
|
|
const triage = triageCtor(store, undefined, { semaphore });
|
|
const executor = executorCtor(store, undefined, { semaphore });
|
|
const scheduler = schedulerCtor(store, { semaphore });
|
|
const stuckDetector = stuckDetectorCtor();
|
|
const selfHealing = selfHealingCtor();
|
|
const cronRunner = cronRunnerCtor(store, automationStore, {
|
|
onScheduleRunProcessed: options.onInsightRunProcessed,
|
|
});
|
|
const notifier = notifierCtor();
|
|
|
|
const remoteStatus = {
|
|
provider: "cloudflare" as const,
|
|
state: "running" as const,
|
|
pid: 1234,
|
|
startedAt: new Date().toISOString(),
|
|
stoppedAt: null,
|
|
url: "https://remote.example.com",
|
|
lastError: null,
|
|
};
|
|
|
|
const engine = {
|
|
start: vi.fn(async () => {
|
|
await store.init();
|
|
await automationStore.init();
|
|
await agentStore.init();
|
|
const settings = await store.getSettings();
|
|
try {
|
|
await syncInsightExtractionAutomationMock(automationStore, settings);
|
|
} catch (err) {
|
|
console.error(`[memory-audit] Failed to sync insight extraction: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
store.on("settings:updated", async (event: { settings?: Record<string, unknown>; previous?: Record<string, unknown> }) => {
|
|
const watchedKeys = [
|
|
"insightExtractionEnabled",
|
|
"insightExtractionSchedule",
|
|
"insightExtractionTime",
|
|
];
|
|
const changed = watchedKeys.some((key) => event.settings?.[key] !== event.previous?.[key]);
|
|
if (changed) {
|
|
await syncInsightExtractionAutomationMock(automationStore, { ...settings, ...event.settings });
|
|
}
|
|
});
|
|
triage.start();
|
|
scheduler.start();
|
|
missionAutopilot.start();
|
|
stuckDetector.start();
|
|
selfHealing.start();
|
|
cronRunner.start();
|
|
notifier.start();
|
|
heartbeatMonitor.start();
|
|
heartbeatTriggerScheduler.start();
|
|
await executor.resumeOrphaned();
|
|
await createAiPromptExecutorMock(runtimeConfig.workingDirectory);
|
|
}),
|
|
stop: vi.fn(async () => {
|
|
selfHealing.stop();
|
|
stuckDetector.stop();
|
|
missionAutopilot.stop();
|
|
triage.stop();
|
|
scheduler.stop();
|
|
cronRunner.stop();
|
|
notifier.stop();
|
|
heartbeatMonitor.stop();
|
|
heartbeatTriggerScheduler.stop();
|
|
}),
|
|
getTaskStore: vi.fn(() => store),
|
|
getAutomationStore: vi.fn(() => automationStore),
|
|
getRuntime: vi.fn(() => ({
|
|
getHeartbeatMonitor: () => heartbeatMonitor,
|
|
getMissionAutopilot: () => missionAutopilot,
|
|
getMissionExecutionLoop: () => missionExecutionLoop,
|
|
})),
|
|
getRemoteTunnelManager: vi.fn(() => ({ getStatus: vi.fn(() => remoteStatus) })),
|
|
getRemoteTunnelRestoreDiagnostics: vi.fn(() => ({
|
|
outcome: "skipped",
|
|
reason: "not_attempted",
|
|
at: new Date().toISOString(),
|
|
provider: null,
|
|
})),
|
|
startRemoteTunnel: vi.fn(async () => remoteStatus),
|
|
stopRemoteTunnel: vi.fn(async () => ({ ...remoteStatus, state: "stopped" as const, provider: null, pid: null, url: null })),
|
|
onMerge: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
projectEngineInstances.push(engine);
|
|
return engine;
|
|
});
|
|
|
|
return {
|
|
taskStores,
|
|
automationStores,
|
|
agentStores,
|
|
centralInstances,
|
|
triageInstances,
|
|
executorInstances,
|
|
schedulerInstances,
|
|
stuckDetectorInstances,
|
|
selfHealingInstances,
|
|
cronRunnerInstances,
|
|
missionAutopilotInstances,
|
|
missionExecutionLoopInstances,
|
|
notifierInstances,
|
|
projectEngineInstances,
|
|
listenCalls,
|
|
taskStoreCtor,
|
|
automationStoreCtor,
|
|
agentStoreCtor,
|
|
centralCoreCtor,
|
|
createServerMock,
|
|
triageCtor,
|
|
executorCtor,
|
|
schedulerCtor,
|
|
stuckDetectorCtor,
|
|
selfHealingCtor,
|
|
cronRunnerCtor,
|
|
missionAutopilotCtor,
|
|
missionExecutionLoopCtor,
|
|
notifierCtor,
|
|
pluginStoreCtor,
|
|
pluginLoaderCtor,
|
|
projectEngineCtor,
|
|
agentSemaphoreCtor,
|
|
heartbeatMonitorCtor,
|
|
heartbeatTriggerSchedulerCtor,
|
|
createAiPromptExecutorMock,
|
|
syncInsightExtractionAutomationMock,
|
|
processAndAuditInsightExtractionMock,
|
|
authStorage,
|
|
modelRegistry,
|
|
reset() {
|
|
taskStores.length = 0;
|
|
automationStores.length = 0;
|
|
agentStores.length = 0;
|
|
centralInstances.length = 0;
|
|
triageInstances.length = 0;
|
|
executorInstances.length = 0;
|
|
schedulerInstances.length = 0;
|
|
stuckDetectorInstances.length = 0;
|
|
selfHealingInstances.length = 0;
|
|
cronRunnerInstances.length = 0;
|
|
missionAutopilotInstances.length = 0;
|
|
missionExecutionLoopInstances.length = 0;
|
|
notifierInstances.length = 0;
|
|
pluginStoreInstances.length = 0;
|
|
pluginLoaderInstances.length = 0;
|
|
projectEngineInstances.length = 0;
|
|
listenCalls.length = 0;
|
|
syncInsightExtractionAutomationMock.mockReset();
|
|
syncInsightExtractionAutomationMock.mockResolvedValue(undefined);
|
|
processAndAuditInsightExtractionMock.mockClear();
|
|
createAiPromptExecutorMock.mockClear();
|
|
// Reset multi-project state
|
|
engineUsageLog.length = 0;
|
|
getProjectByPathResolver = null;
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@fusion/core", async (importOriginal) => {
|
|
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
|
|
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
|
|
TaskStore: mocks.taskStoreCtor,
|
|
AutomationStore: mocks.automationStoreCtor,
|
|
AgentStore: mocks.agentStoreCtor,
|
|
CentralCore: mocks.centralCoreCtor,
|
|
PluginStore: mocks.pluginStoreCtor,
|
|
PluginLoader: mocks.pluginLoaderCtor,
|
|
getEnabledPiExtensionPaths: vi.fn(() => []),
|
|
getTaskMergeBlocker: vi.fn().mockReturnValue(null),
|
|
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
|
|
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
|
|
processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock,
|
|
DaemonTokenManager: vi.fn().mockImplementation(() => ({
|
|
getToken: vi.fn().mockResolvedValue(null),
|
|
generateToken: vi.fn().mockResolvedValue("fn_generated1234567890"),
|
|
storeToken: vi.fn().mockResolvedValue(undefined),
|
|
})),
|
|
GlobalSettingsStore: vi.fn().mockImplementation(() => ({})),
|
|
resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"),
|
|
});
|
|
});
|
|
|
|
vi.mock("@fusion/dashboard", () => ({
|
|
createServer: mocks.createServerMock,
|
|
GitHubClient: vi.fn().mockImplementation(() => ({})),
|
|
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
|
|
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
|
|
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@fusion/engine", async (importOriginal) => {
|
|
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
|
|
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
|
|
ProjectEngine: mocks.projectEngineCtor,
|
|
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
|
|
const engines = new Map<string, any>();
|
|
return {
|
|
startAll: vi.fn(async () => {
|
|
const projects = await centralCore.listProjects();
|
|
for (const project of projects) {
|
|
const engine = mocks.projectEngineCtor(
|
|
{ projectId: project.id, workingDirectory: project.path, isolationMode: "in-process", maxConcurrent: 4, maxWorktrees: 10 },
|
|
centralCore,
|
|
{ ...options, projectId: project.id },
|
|
);
|
|
await engine.start();
|
|
engines.set(project.id, engine);
|
|
}
|
|
}),
|
|
// 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)),
|
|
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
|
|
stopAll: vi.fn(async () => {
|
|
for (const engine of engines.values()) await engine.stop();
|
|
engines.clear();
|
|
}),
|
|
onProjectAccessed: vi.fn(),
|
|
startReconciliation: vi.fn(),
|
|
};
|
|
}),
|
|
PeerExchangeService: vi.fn().mockImplementation(() => ({
|
|
start: vi.fn(),
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
})),
|
|
TriageProcessor: mocks.triageCtor,
|
|
TaskExecutor: mocks.executorCtor,
|
|
Scheduler: mocks.schedulerCtor,
|
|
AgentSemaphore: mocks.agentSemaphoreCtor,
|
|
WorktreePool: vi.fn().mockImplementation(() => ({
|
|
rehydrate: vi.fn(),
|
|
})),
|
|
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
|
|
UsageLimitPauser: vi.fn().mockImplementation(() => ({})),
|
|
PRIORITY_MERGE: 100,
|
|
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
|
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
|
NtfyNotifier: mocks.notifierCtor,
|
|
PrMonitor: vi.fn().mockImplementation(() => ({
|
|
onNewComments: vi.fn(),
|
|
})),
|
|
PrCommentHandler: vi.fn().mockImplementation(() => ({
|
|
handleNewComments: vi.fn(),
|
|
createFollowUpTask: vi.fn().mockResolvedValue(undefined),
|
|
})),
|
|
CronRunner: mocks.cronRunnerCtor,
|
|
StuckTaskDetector: mocks.stuckDetectorCtor,
|
|
SelfHealingManager: mocks.selfHealingCtor,
|
|
MissionAutopilot: mocks.missionAutopilotCtor,
|
|
MissionExecutionLoop: mocks.missionExecutionLoopCtor,
|
|
createAiPromptExecutor: mocks.createAiPromptExecutorMock,
|
|
HeartbeatMonitor: mocks.heartbeatMonitorCtor,
|
|
HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor,
|
|
});
|
|
});
|
|
|
|
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
AuthStorage: {
|
|
create: vi.fn(() => mocks.authStorage),
|
|
},
|
|
DefaultPackageManager: vi.fn().mockImplementation(() => ({
|
|
resolve: vi.fn().mockResolvedValue({ extensions: [] }),
|
|
})),
|
|
ModelRegistry: {
|
|
create: vi.fn(() => mocks.modelRegistry),
|
|
inMemory: vi.fn(() => mocks.modelRegistry),
|
|
},
|
|
SettingsManager: {
|
|
create: vi.fn(() => ({})),
|
|
},
|
|
discoverAndLoadExtensions: vi.fn().mockResolvedValue({
|
|
runtime: { pendingProviderRegistrations: [] },
|
|
errors: [],
|
|
}),
|
|
getAgentDir: vi.fn(() => "/mock-agent-dir"),
|
|
createExtensionRuntime: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../port-prompt.js", () => ({
|
|
promptForPort: vi.fn(async (port: number) => port),
|
|
}));
|
|
|
|
vi.mock("../task-lifecycle.js", () => ({
|
|
getMergeStrategy: vi.fn((settings: { mergeStrategy?: "direct" | "pull-request" }) => settings.mergeStrategy ?? "direct"),
|
|
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
|
|
}));
|
|
|
|
const { runServe } = await import("../serve.js");
|
|
|
|
describe("runServe", () => {
|
|
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 warnSpy: ReturnType<typeof vi.spyOn>;
|
|
let errorSpy: 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();
|
|
|
|
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
|
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
errorSpy = vi.spyOn(console, "error").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;
|
|
});
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
cwdSpy.mockRestore();
|
|
processOnSpy.mockRestore();
|
|
process.cwd = originalCwd;
|
|
process.on = originalOn;
|
|
process.exit = originalExit;
|
|
});
|
|
|
|
it("initializes stores, starts engine services, and creates a headless server", async () => {
|
|
await runServe(4040, {});
|
|
|
|
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
|
|
expect(mocks.taskStores[0].init).toHaveBeenCalledTimes(1);
|
|
expect(mocks.taskStores[0].watch).toHaveBeenCalledTimes(1);
|
|
expect(mocks.automationStoreCtor).toHaveBeenCalledWith("/repo");
|
|
expect(mocks.automationStores[0].init).toHaveBeenCalledTimes(1);
|
|
expect(mocks.agentStores[0].init).toHaveBeenCalledTimes(1);
|
|
|
|
expect(mocks.createServerMock).toHaveBeenCalledTimes(1);
|
|
expect(mocks.createServerMock.mock.calls[0][1]).toMatchObject({
|
|
headless: true,
|
|
});
|
|
|
|
expect(mocks.triageInstances[0].start).toHaveBeenCalledTimes(1);
|
|
expect(mocks.schedulerInstances[0].start).toHaveBeenCalledTimes(1);
|
|
expect(mocks.missionAutopilotInstances[0].start).toHaveBeenCalledTimes(1);
|
|
expect(mocks.stuckDetectorInstances[0].start).toHaveBeenCalledTimes(1);
|
|
expect(mocks.selfHealingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
expect(mocks.executorInstances[0].resumeOrphaned).toHaveBeenCalledTimes(1);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("passes remote-capable engine hooks into headless createServer for fn serve parity", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(0, {});
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOptions = createServer.mock.calls[0][1];
|
|
expect(serverOptions).toMatchObject({ headless: true });
|
|
expect(serverOptions.engine).toBeDefined();
|
|
expect(typeof serverOptions.engine.startRemoteTunnel).toBe("function");
|
|
expect(typeof serverOptions.engine.stopRemoteTunnel).toBe("function");
|
|
expect(typeof serverOptions.engine.getRemoteTunnelManager).toBe("function");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("preserves remote-capable headless wiring when daemon auth is enabled", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(0, { daemon: true });
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOptions = createServer.mock.calls[0][1];
|
|
expect(serverOptions).toMatchObject({ headless: true, daemon: { token: expect.any(String) } });
|
|
expect(serverOptions.daemon.token.length).toBeGreaterThan(0);
|
|
expect(serverOptions.engine).toBeDefined();
|
|
expect(typeof serverOptions.engine.startRemoteTunnel).toBe("function");
|
|
expect(typeof serverOptions.engine.stopRemoteTunnel).toBe("function");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("sets enginePaused when started with paused=true", async () => {
|
|
await runServe(0, { paused: true });
|
|
|
|
expect(mocks.taskStores[0].updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
|
|
|
await triggerSignal("SIGTERM");
|
|
});
|
|
|
|
it("updates the local node status online on startup and offline on shutdown", async () => {
|
|
await runServe(4040, {});
|
|
|
|
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
|
expect(nodeCentral).toBeDefined();
|
|
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "online" });
|
|
|
|
await triggerSignal("SIGINT");
|
|
|
|
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
|
|
});
|
|
|
|
it("stops engine services during shutdown", async () => {
|
|
await runServe(4040, {});
|
|
|
|
const listenCall = mocks.listenCalls[0];
|
|
expect(listenCall).toBeDefined();
|
|
|
|
await triggerSignal("SIGTERM");
|
|
|
|
expect(mocks.selfHealingInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(mocks.stuckDetectorInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(mocks.missionAutopilotInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(mocks.triageInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(mocks.schedulerInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(mocks.cronRunnerInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(mocks.notifierInstances[0].stop).toHaveBeenCalledTimes(1);
|
|
expect(listenCall.server.close).toHaveBeenCalledTimes(1);
|
|
expect(mocks.taskStores[0].close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("listens on 127.0.0.1 by default and respects a custom host", async () => {
|
|
await runServe(3010, {});
|
|
expect(mocks.listenCalls[0]).toMatchObject({
|
|
port: 3010,
|
|
host: "127.0.0.1",
|
|
});
|
|
await triggerSignal("SIGINT");
|
|
|
|
await runServe(3020, { host: "0.0.0.0" });
|
|
expect(mocks.listenCalls[1]).toMatchObject({
|
|
port: 3020,
|
|
host: "0.0.0.0",
|
|
});
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
});
|
|
|
|
describe("runServe — Plugin wiring", () => {
|
|
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();
|
|
|
|
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;
|
|
});
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore();
|
|
cwdSpy.mockRestore();
|
|
processOnSpy.mockRestore();
|
|
process.cwd = originalCwd;
|
|
process.on = originalOn;
|
|
process.exit = originalExit;
|
|
});
|
|
|
|
it("creates PluginStore and PluginLoader instances", async () => {
|
|
const { PluginStore, PluginLoader } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(PluginStore).toHaveBeenCalledTimes(1);
|
|
expect(PluginLoader).toHaveBeenCalledTimes(1);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("passes pluginStore, pluginLoader, and pluginRunner to createServer", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts).toHaveProperty("pluginStore");
|
|
expect(serverOpts).toHaveProperty("pluginLoader");
|
|
expect(serverOpts).toHaveProperty("pluginRunner");
|
|
expect(serverOpts.pluginRunner).toBe(serverOpts.pluginLoader);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("initializes PluginStore with the task store's fusion directory", async () => {
|
|
const { PluginStore } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("initializes PluginLoader with pluginStore and taskStore", async () => {
|
|
const { PluginLoader } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(PluginLoader).toHaveBeenCalledTimes(1);
|
|
const loaderOptions = PluginLoader.mock.calls[0][0];
|
|
expect(loaderOptions).toHaveProperty("pluginStore");
|
|
expect(loaderOptions).toHaveProperty("taskStore");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("auto-loads installed plugins during startup", async () => {
|
|
const { PluginLoader } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
|
|
| { loadAllPlugins: ReturnType<typeof vi.fn> }
|
|
| undefined;
|
|
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("continues startup when plugin auto-load fails", async () => {
|
|
const { PluginLoader } = await import("@fusion/core");
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
|
|
loadPlugin: vi.fn().mockResolvedValue(undefined),
|
|
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
|
|
stopPlugin: vi.fn().mockResolvedValue(undefined),
|
|
reloadPlugin: vi.fn().mockResolvedValue(undefined),
|
|
getPluginRoutes: vi.fn().mockReturnValue([]),
|
|
getPlugin: vi.fn(),
|
|
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
|
}));
|
|
|
|
await expect(runServe(4040, {})).resolves.toBeUndefined();
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
|
|
);
|
|
|
|
await triggerSignal("SIGINT");
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it("includes plugin wiring in headless server", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts.headless).toBe(true);
|
|
expect(serverOpts.pluginStore).toBeDefined();
|
|
expect(serverOpts.pluginLoader).toBeDefined();
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
});
|
|
|
|
describe("runServe — Memory Insight Automation wiring", () => {
|
|
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 warnSpy: ReturnType<typeof vi.spyOn>;
|
|
let errorSpy: 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(async () => {
|
|
vi.clearAllMocks();
|
|
mocks.reset();
|
|
|
|
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
|
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
errorSpy = vi.spyOn(console, "error").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;
|
|
|
|
// Override listProjects to return only the primary project for these tests
|
|
const { CentralCore } = await import("@fusion/core");
|
|
const instance = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
|
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([
|
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
|
]),
|
|
listNodes: vi.fn().mockResolvedValue([
|
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
|
]),
|
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
|
stopDiscovery: vi.fn(),
|
|
};
|
|
mocks.centralInstances.push(instance);
|
|
CentralCore.mockImplementation(() => instance);
|
|
});
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
cwdSpy.mockRestore();
|
|
processOnSpy.mockRestore();
|
|
process.cwd = originalCwd;
|
|
process.on = originalOn;
|
|
process.exit = originalExit;
|
|
});
|
|
|
|
it("syncs insight extraction automation on startup", async () => {
|
|
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
|
|
expect(syncInsightExtractionAutomation).toHaveBeenCalledWith(
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
maxConcurrent: 2,
|
|
recycleWorktrees: false,
|
|
autoMerge: false,
|
|
pollIntervalMs: 60_000,
|
|
}),
|
|
);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("passes onScheduleRunProcessed callback to CronRunner", async () => {
|
|
await runServe(4040, {});
|
|
|
|
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
|
|
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
|
|
expect(cronOptions).toHaveProperty("onScheduleRunProcessed");
|
|
expect(typeof cronOptions.onScheduleRunProcessed).toBe("function");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("calls syncInsightExtractionAutomation when insight extraction settings change", async () => {
|
|
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
// Simulate settings update
|
|
syncInsightExtractionAutomation.mockClear();
|
|
mocks.taskStores[0].emit("settings:updated", {
|
|
settings: {
|
|
insightExtractionEnabled: true,
|
|
insightExtractionSchedule: "0 3 * * *",
|
|
},
|
|
previous: {
|
|
insightExtractionEnabled: false,
|
|
insightExtractionSchedule: "0 2 * * *",
|
|
},
|
|
});
|
|
|
|
expect(syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("does not call syncInsightExtractionAutomation for unrelated settings changes", async () => {
|
|
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
|
|
|
await runServe(4040, {});
|
|
|
|
// Simulate unrelated settings update
|
|
syncInsightExtractionAutomation.mockClear();
|
|
mocks.taskStores[0].emit("settings:updated", {
|
|
settings: {
|
|
maxConcurrent: 5,
|
|
},
|
|
previous: {
|
|
maxConcurrent: 2,
|
|
},
|
|
});
|
|
|
|
expect(syncInsightExtractionAutomation).not.toHaveBeenCalled();
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("handles syncInsightExtractionAutomation errors gracefully", async () => {
|
|
const { syncInsightExtractionAutomation } = await import("@fusion/core");
|
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed"));
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("[memory-audit] Failed to sync insight extraction"),
|
|
);
|
|
|
|
consoleSpy.mockRestore();
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
});
|
|
|
|
describe("runServe — Semaphore boundary (task lanes only)", () => {
|
|
const originalCwd = process.cwd;
|
|
const originalOn = process.on;
|
|
const originalExit = process.exit;
|
|
|
|
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
|
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(async () => {
|
|
vi.clearAllMocks();
|
|
mocks.reset();
|
|
|
|
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
|
|
|
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;
|
|
|
|
// Override listProjects to return only the primary project for semaphore tests
|
|
// These tests verify semaphore sharing across task lanes within a single engine
|
|
const { CentralCore } = await import("@fusion/core");
|
|
const instance = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
|
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([
|
|
{ ...PROJECT_FIXTURES.primary, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
|
]),
|
|
listNodes: vi.fn().mockResolvedValue([
|
|
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
|
]),
|
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
|
stopDiscovery: vi.fn(),
|
|
};
|
|
mocks.centralInstances.push(instance);
|
|
CentralCore.mockImplementation(() => instance);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cwdSpy.mockRestore();
|
|
processOnSpy.mockRestore();
|
|
process.cwd = originalCwd;
|
|
process.on = originalOn;
|
|
process.exit = originalExit;
|
|
});
|
|
|
|
it("passes semaphore to TriageProcessor (task lane)", async () => {
|
|
await runServe(4040, {});
|
|
|
|
expect(mocks.triageCtor).toHaveBeenCalledTimes(1);
|
|
const triageOptions = mocks.triageCtor.mock.calls[0][2];
|
|
expect(triageOptions).toHaveProperty("semaphore");
|
|
expect(triageOptions.semaphore).toBeDefined();
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("passes semaphore to TaskExecutor (task lane)", async () => {
|
|
await runServe(4040, {});
|
|
|
|
expect(mocks.executorCtor).toHaveBeenCalledTimes(1);
|
|
const executorOptions = mocks.executorCtor.mock.calls[0][2];
|
|
expect(executorOptions).toHaveProperty("semaphore");
|
|
expect(executorOptions.semaphore).toBeDefined();
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("passes semaphore to Scheduler (task lane)", async () => {
|
|
await runServe(4040, {});
|
|
|
|
expect(mocks.schedulerCtor).toHaveBeenCalledTimes(1);
|
|
const schedulerOptions = mocks.schedulerCtor.mock.calls[0][1];
|
|
expect(schedulerOptions).toHaveProperty("semaphore");
|
|
expect(schedulerOptions.semaphore).toBeDefined();
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("creates shared semaphore instance for task lanes", async () => {
|
|
await runServe(4040, {});
|
|
|
|
// Get the semaphore instance from each component
|
|
const triageSemaphore = mocks.triageCtor.mock.calls[0][2].semaphore;
|
|
const executorSemaphore = mocks.executorCtor.mock.calls[0][2].semaphore;
|
|
const schedulerSemaphore = mocks.schedulerCtor.mock.calls[0][1].semaphore;
|
|
|
|
// All should reference the same semaphore instance
|
|
expect(triageSemaphore).toBe(executorSemaphore);
|
|
expect(executorSemaphore).toBe(schedulerSemaphore);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("does NOT pass semaphore to HeartbeatMonitor (utility path)", async () => {
|
|
const { HeartbeatMonitor } = await import("@fusion/engine");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(HeartbeatMonitor).toHaveBeenCalledTimes(1);
|
|
const heartbeatOptions = HeartbeatMonitor.mock.calls[0][0];
|
|
expect(heartbeatOptions).not.toHaveProperty("semaphore");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("does NOT pass semaphore to HeartbeatTriggerScheduler (utility path)", async () => {
|
|
const { HeartbeatTriggerScheduler } = await import("@fusion/engine");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(HeartbeatTriggerScheduler).toHaveBeenCalledTimes(1);
|
|
// HeartbeatTriggerScheduler takes 2-3 args: (agentStore, callback, taskStore?)
|
|
const triggerArgs = HeartbeatTriggerScheduler.mock.calls[0];
|
|
// Semaphore should NOT be in any of the arguments (it would have _active property)
|
|
expect(triggerArgs).not.toContainEqual(expect.objectContaining({ _active: expect.any(Number) }));
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("does NOT pass semaphore to CronRunner (utility path)", async () => {
|
|
await runServe(4040, {});
|
|
|
|
expect(mocks.cronRunnerCtor).toHaveBeenCalledTimes(1);
|
|
// CronRunner takes (taskStore, automationStore, options)
|
|
const cronOptions = mocks.cronRunnerCtor.mock.calls[0][2];
|
|
expect(cronOptions).not.toHaveProperty("semaphore");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("calls createAiPromptExecutor with cwd only (no semaphore)", async () => {
|
|
const { createAiPromptExecutor } = await import("@fusion/engine");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(createAiPromptExecutor).toHaveBeenCalledTimes(1);
|
|
// createAiPromptExecutor takes only cwd parameter
|
|
expect(createAiPromptExecutor).toHaveBeenCalledWith(expect.any(String));
|
|
const calledWith = createAiPromptExecutor.mock.calls[0];
|
|
// Should be called with exactly one argument (cwd)
|
|
expect(calledWith.length).toBe(1);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("onMerge uses semaphore.run() to gate merge execution (task lane)", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(4040, {});
|
|
|
|
// The onMerge function is passed to createServer and should use semaphore.run()
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts).toHaveProperty("onMerge");
|
|
expect(typeof serverOpts.onMerge).toBe("function");
|
|
// The onMerge function should be a wrapper that uses semaphore.run()
|
|
// We can't directly test the internals, but we verified semaphore is passed to
|
|
// the same instance used by triage/executor/scheduler above
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
});
|
|
|
|
describe("runServe — Peer exchange and discovery", () => {
|
|
const originalCwd = process.cwd;
|
|
const originalOn = process.on;
|
|
const originalExit = process.exit;
|
|
|
|
let signalHandlers: Record<"SIGINT" | "SIGTERM", Array<() => void>>;
|
|
let warnSpy: 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(async () => {
|
|
vi.clearAllMocks();
|
|
mocks.reset();
|
|
|
|
signalHandlers = { SIGINT: [], SIGTERM: [] };
|
|
|
|
warnSpy = vi.spyOn(console, "warn").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;
|
|
|
|
// Override CentralCore to use original implementation that pushes to centralInstances
|
|
const { CentralCore } = await import("@fusion/core");
|
|
// Reset to the original constructor that creates and pushes instances
|
|
CentralCore.mockImplementation(() => {
|
|
const instance = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
|
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([
|
|
{ ...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" },
|
|
]),
|
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
|
stopDiscovery: vi.fn(),
|
|
};
|
|
mocks.centralInstances.push(instance);
|
|
return instance;
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
warnSpy.mockRestore();
|
|
cwdSpy.mockRestore();
|
|
processOnSpy.mockRestore();
|
|
process.cwd = originalCwd;
|
|
process.on = originalOn;
|
|
process.exit = originalExit;
|
|
});
|
|
|
|
it("creates PeerExchangeService with CentralCore and calls start()", async () => {
|
|
const { PeerExchangeService } = await import("@fusion/engine");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(PeerExchangeService).toHaveBeenCalledTimes(1);
|
|
const peerExchangeInstance = PeerExchangeService.mock.results[0]?.value;
|
|
expect(peerExchangeInstance.start).toHaveBeenCalledTimes(1);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("calls centralCore.startDiscovery() with correct config after server starts", async () => {
|
|
await runServe(4040, {});
|
|
|
|
// Find the central core instance that was used
|
|
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
|
expect(nodeCentral).toBeDefined();
|
|
|
|
// startDiscovery should have been called with broadcast, listen, and correct port
|
|
expect(nodeCentral.startDiscovery).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
broadcast: true,
|
|
listen: true,
|
|
serviceType: "_fusion._tcp",
|
|
port: 4040,
|
|
staleTimeoutMs: 300_000,
|
|
}),
|
|
);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("starts discovery with port 5050 when port 0 is requested", async () => {
|
|
await runServe(0, {});
|
|
|
|
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
|
expect(nodeCentral).toBeDefined();
|
|
|
|
// Port 0 maps to 5050 in the mock
|
|
expect(nodeCentral.startDiscovery).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
port: 5050,
|
|
}),
|
|
);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("calls peerExchangeService.stop() on shutdown before engineManager.stopAll()", async () => {
|
|
const { PeerExchangeService } = await import("@fusion/engine");
|
|
|
|
await runServe(4040, {});
|
|
|
|
// Get the peer exchange instance
|
|
const peerExchangeInstance = PeerExchangeService.mock.results[0]?.value;
|
|
expect(peerExchangeInstance).toBeDefined();
|
|
|
|
// Reset mocks to isolate shutdown behavior
|
|
peerExchangeInstance.stop.mockClear();
|
|
|
|
await triggerSignal("SIGTERM");
|
|
|
|
// stop() should have been called
|
|
expect(peerExchangeInstance.stop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("calls centralCore.stopDiscovery() on shutdown before closing", async () => {
|
|
await runServe(4040, {});
|
|
|
|
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
|
expect(nodeCentral).toBeDefined();
|
|
|
|
// Reset to isolate shutdown behavior
|
|
nodeCentral.stopDiscovery.mockClear();
|
|
|
|
await triggerSignal("SIGTERM");
|
|
|
|
// stopDiscovery should have been called
|
|
expect(nodeCentral.stopDiscovery).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sets local node to offline on shutdown", async () => {
|
|
await runServe(4040, {});
|
|
|
|
const nodeCentral = mocks.centralInstances.find((instance) => instance.listNodes.mock.calls.length > 0);
|
|
expect(nodeCentral).toBeDefined();
|
|
|
|
// Reset to isolate shutdown behavior
|
|
nodeCentral.updateNode.mockClear();
|
|
|
|
await triggerSignal("SIGTERM");
|
|
|
|
// Should have been called twice: once to set online, once to set offline
|
|
expect(nodeCentral.updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
|
|
});
|
|
});
|
|
|
|
describe("runServe --daemon flag", () => {
|
|
const originalCwd = process.cwd;
|
|
const originalOn = process.on;
|
|
const originalExit = process.exit;
|
|
const originalEnv = process.env.FUSION_DAEMON_TOKEN;
|
|
|
|
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(async () => {
|
|
vi.clearAllMocks();
|
|
mocks.reset();
|
|
|
|
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;
|
|
|
|
// Override CentralCore to use original implementation that pushes to centralInstances
|
|
const { CentralCore } = await import("@fusion/core");
|
|
CentralCore.mockImplementation(() => {
|
|
const instance = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
|
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([
|
|
{ ...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" },
|
|
]),
|
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
|
stopDiscovery: vi.fn(),
|
|
};
|
|
mocks.centralInstances.push(instance);
|
|
return instance;
|
|
});
|
|
|
|
// Clear env var before each test
|
|
delete process.env.FUSION_DAEMON_TOKEN;
|
|
});
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore();
|
|
cwdSpy.mockRestore();
|
|
processOnSpy.mockRestore();
|
|
process.cwd = originalCwd;
|
|
process.on = originalOn;
|
|
process.exit = originalExit;
|
|
|
|
// Restore env var
|
|
if (originalEnv !== undefined) {
|
|
process.env.FUSION_DAEMON_TOKEN = originalEnv;
|
|
} else {
|
|
delete process.env.FUSION_DAEMON_TOKEN;
|
|
}
|
|
});
|
|
|
|
it("passes daemonToken to createServer when daemon: true", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(4040, { daemon: true });
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts.daemon).toBeDefined();
|
|
expect(serverOpts.daemon?.token).toBeDefined();
|
|
expect(typeof serverOpts.daemon?.token).toBe("string");
|
|
expect(serverOpts.daemon?.token).toMatch(/^fn_/);
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("shows '(daemon mode)' in startup banner when daemon: true", async () => {
|
|
await runServe(4040, { daemon: true });
|
|
|
|
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("(daemon mode)");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("shows 'fn node connect' hint in startup banner when daemon: true", async () => {
|
|
await runServe(4040, { daemon: true });
|
|
|
|
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("fn node connect");
|
|
expect(output).toContain("--api-key");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("resolves token from FUSION_DAEMON_TOKEN env var", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
process.env.FUSION_DAEMON_TOKEN = "fn_envtest1234567890";
|
|
|
|
await runServe(4040, { daemon: true });
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts.daemon?.token).toBe("fn_envtest1234567890");
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("does not pass daemon to createServer when daemon: false", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(4040, { daemon: false });
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts.daemon).toBeUndefined();
|
|
|
|
await triggerSignal("SIGINT");
|
|
});
|
|
|
|
it("does not pass daemon to createServer when daemon option is omitted", async () => {
|
|
const { createServer } = await import("@fusion/dashboard");
|
|
|
|
await runServe(4040, {});
|
|
|
|
expect(createServer).toHaveBeenCalledTimes(1);
|
|
const serverOpts = createServer.mock.calls[0][1];
|
|
expect(serverOpts.daemon).toBeUndefined();
|
|
|
|
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(async () => {
|
|
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;
|
|
|
|
// Override CentralCore to use original implementation that pushes to centralInstances
|
|
const { CentralCore } = await import("@fusion/core");
|
|
CentralCore.mockImplementation(() => {
|
|
const instance = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
getProjectByPath: vi.fn().mockImplementation((cwd: string) => {
|
|
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([
|
|
{ ...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" },
|
|
]),
|
|
updateNode: vi.fn().mockResolvedValue(undefined),
|
|
startDiscovery: vi.fn().mockResolvedValue({}),
|
|
stopDiscovery: vi.fn(),
|
|
};
|
|
mocks.centralInstances.push(instance);
|
|
return instance;
|
|
});
|
|
|
|
// 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");
|
|
});
|
|
});
|