feat(FN-3053): consolidate mock helpers across CLI, dashboard, and engine p

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
This commit is contained in:
Fusion
2026-05-01 05:02:16 -07:00
committed by gsxdsm
parent 5eb1d09787
commit fc3864ebd4
10 changed files with 226 additions and 47 deletions

View File

@@ -452,7 +452,9 @@ const mocks = vi.hoisted(() => {
}; };
}); });
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: mocks.taskStoreCtor, TaskStore: mocks.taskStoreCtor,
AutomationStore: mocks.automationStoreCtor, AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor, AgentStore: mocks.agentStoreCtor,
@@ -474,7 +476,8 @@ vi.mock("@fusion/core", () => ({
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock, syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction", INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock, processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock,
})); });
});
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock, createServer: mocks.createServerMock,
@@ -484,7 +487,9 @@ vi.mock("@fusion/dashboard", () => ({
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
})); }));
vi.mock("@fusion/engine", () => ({ vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
ProjectEngine: mocks.projectEngineCtor, ProjectEngine: mocks.projectEngineCtor,
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>();
@@ -546,7 +551,8 @@ vi.mock("@fusion/engine", () => ({
createAiPromptExecutor: mocks.createAiPromptExecutorMock, createAiPromptExecutor: mocks.createAiPromptExecutorMock,
HeartbeatMonitor: mocks.heartbeatMonitorCtor, HeartbeatMonitor: mocks.heartbeatMonitorCtor,
HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor, HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor,
})); });
});
vi.mock("@mariozechner/pi-coding-agent", () => ({ vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {

View File

@@ -105,7 +105,9 @@ function makeMockStore() {
// ── Mock @fusion/core ────────────────────────────────────────────────── // ── Mock @fusion/core ──────────────────────────────────────────────────
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: vi.fn().mockImplementation(() => makeMockStore()), TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
CentralCore: vi.fn().mockImplementation(() => ({ CentralCore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
@@ -205,7 +207,8 @@ vi.mock("@fusion/core", () => ({
} }
return undefined; return undefined;
}), }),
})); });
});
// ── Hoisted shared mocks ─────────────────────────────────────────── // ── Hoisted shared mocks ───────────────────────────────────────────
@@ -305,6 +308,7 @@ const { WorktreePool } = await import("@fusion/engine");
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")>();
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
const TriageProcessor = vi.fn().mockImplementation(() => ({ const TriageProcessor = vi.fn().mockImplementation(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -610,8 +614,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
} }
} }
return { return createCliEngineMock(async () => original, {}, {
...original,
// Keep real WorktreePool & AgentSemaphore // Keep real WorktreePool & AgentSemaphore
WorktreePool: original.WorktreePool, WorktreePool: original.WorktreePool,
AgentSemaphore: original.AgentSemaphore, AgentSemaphore: original.AgentSemaphore,
@@ -681,7 +684,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
})), })),
scanIdleWorktrees: vi.fn().mockResolvedValue([]), scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0), cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
}; });
}); });
// ── Mock @mariozechner/pi-coding-agent ────────────────────────────── // ── Mock @mariozechner/pi-coding-agent ──────────────────────────────

View File

@@ -511,7 +511,9 @@ const mocks = vi.hoisted(() => {
}; };
}); });
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: mocks.taskStoreCtor, TaskStore: mocks.taskStoreCtor,
AutomationStore: mocks.automationStoreCtor, AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor, AgentStore: mocks.agentStoreCtor,
@@ -530,7 +532,8 @@ vi.mock("@fusion/core", () => ({
})), })),
GlobalSettingsStore: vi.fn().mockImplementation(() => ({})), GlobalSettingsStore: vi.fn().mockImplementation(() => ({})),
resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"), resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"),
})); });
});
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock, createServer: mocks.createServerMock,
@@ -540,7 +543,9 @@ vi.mock("@fusion/dashboard", () => ({
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
})); }));
vi.mock("@fusion/engine", () => ({ vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
ProjectEngine: mocks.projectEngineCtor, ProjectEngine: mocks.projectEngineCtor,
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>();
@@ -606,7 +611,8 @@ vi.mock("@fusion/engine", () => ({
createAiPromptExecutor: mocks.createAiPromptExecutorMock, createAiPromptExecutor: mocks.createAiPromptExecutorMock,
HeartbeatMonitor: mocks.heartbeatMonitorCtor, HeartbeatMonitor: mocks.heartbeatMonitorCtor,
HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor, HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor,
})); });
});
vi.mock("@mariozechner/pi-coding-agent", () => ({ vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import {
createCliCoreMock,
createCliEngineMock,
resetCliCoreEngineMockState,
} from "./mockCoreEngine";
describe("cli test mock helpers", () => {
it("creates stable fallback functions for missing callable exports", async () => {
const module = await createCliCoreMock(async () => ({ known: vi.fn() }));
const first = module.missingThing as ReturnType<typeof vi.fn>;
const second = module.missingThing as ReturnType<typeof vi.fn>;
expect(first).toBe(second);
first("x");
expect(first).toHaveBeenCalledWith("x");
resetCliCoreEngineMockState();
expect(first).not.toHaveBeenCalled();
});
it("keeps real non-function exports while allowing callable overrides", async () => {
const module = await createCliEngineMock(
async () => ({ VERSION: "1.0.0", factory: () => "real" }),
{},
{ factory: vi.fn().mockReturnValue("mocked") },
);
expect(module.VERSION).toBe("1.0.0");
expect((module.factory as () => string)()).toBe("mocked");
});
});

View File

@@ -0,0 +1,59 @@
/**
* Canonical @fusion/core and @fusion/engine mock helpers for CLI command tests.
*
* When a new commonly-mocked export is added, update defaults here instead of
* copying large inline export lists into command suites.
*/
import { vi, type Mock } from "vitest";
type AnyModule = Record<string, unknown>;
type AnyMock = Mock;
const fallbackFns = new Map<string, AnyMock>();
function getFallback(name: string): AnyMock {
if (!fallbackFns.has(name)) {
fallbackFns.set(name, vi.fn());
}
return fallbackFns.get(name)!;
}
function withFallbackFunctions(actual: AnyModule, mocked: AnyModule): AnyModule {
return new Proxy(mocked, {
get(target, prop, receiver) {
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
if (["then", "catch", "finally"].includes(prop)) return undefined;
const actualValue = actual[prop];
if (typeof actualValue === "function" || actualValue === undefined) {
const fn = getFallback(prop);
target[prop] = fn;
return fn;
}
return actualValue;
},
});
}
export async function createCliCoreMock(
importActual: () => Promise<AnyModule>,
defaults: AnyModule = {},
overrides: AnyModule = {},
): Promise<AnyModule> {
const actual = await importActual();
return withFallbackFunctions(actual, { ...actual, ...defaults, ...overrides });
}
export async function createCliEngineMock(
importActual: () => Promise<AnyModule>,
defaults: AnyModule = {},
overrides: AnyModule = {},
): Promise<AnyModule> {
const actual = await importActual();
return withFallbackFunctions(actual, { ...actual, ...defaults, ...overrides });
}
export function resetCliCoreEngineMockState(): void {
for (const fn of fallbackFns.values()) fn.mockReset();
}

View File

@@ -6,26 +6,29 @@ import type { Agent, AgentState, AgentCapability, OrgTreeNode } from "../../api"
import { scopedKey } from "../../utils/projectStorage"; import { scopedKey } from "../../utils/projectStorage";
// Mock the API module // Mock the API module
vi.mock("../../api", () => ({ vi.mock("../../api", async (importOriginal) => {
fetchAgents: vi.fn(), const { createDashboardApiMock } = await import("../../test/mockApi");
fetchAgentStats: vi.fn(), return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
createAgent: vi.fn(), fetchAgents: vi.fn(),
updateAgent: vi.fn(), fetchAgentStats: vi.fn(),
updateAgentState: vi.fn(), createAgent: vi.fn(),
deleteAgent: vi.fn(), updateAgent: vi.fn(),
startAgentRun: vi.fn(), updateAgentState: vi.fn(),
fetchOrgTree: vi.fn(), deleteAgent: vi.fn(),
fetchSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 1 }), startAgentRun: vi.fn(),
updateSettings: vi.fn().mockResolvedValue({}), fetchOrgTree: vi.fn(),
fetchModels: vi.fn().mockResolvedValue({ models: [] }), fetchSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 1 }),
fetchPluginRuntimes: vi.fn().mockResolvedValue([]), updateSettings: vi.fn().mockResolvedValue({}),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), fetchModels: vi.fn().mockResolvedValue({ models: [] }),
startAgentOnboardingStreaming: vi.fn().mockResolvedValue({ sessionId: "onb-1" }), fetchPluginRuntimes: vi.fn().mockResolvedValue([]),
respondToAgentOnboarding: vi.fn().mockResolvedValue({ type: "question", data: { id: "q1", type: "text", question: "?" } }), fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
retryAgentOnboardingSession: vi.fn().mockResolvedValue({ success: true, sessionId: "onb-1" }), startAgentOnboardingStreaming: vi.fn().mockResolvedValue({ sessionId: "onb-1" }),
stopAgentOnboardingGeneration: vi.fn().mockResolvedValue({ success: true }), respondToAgentOnboarding: vi.fn().mockResolvedValue({ type: "question", data: { id: "q1", type: "text", question: "?" } }),
cancelAgentOnboarding: vi.fn().mockResolvedValue(undefined), retryAgentOnboardingSession: vi.fn().mockResolvedValue({ success: true, sessionId: "onb-1" }),
})); stopAgentOnboardingGeneration: vi.fn().mockResolvedValue({ success: true }),
cancelAgentOnboarding: vi.fn().mockResolvedValue(undefined),
});
});
vi.mock("../AgentDetailView", () => ({ vi.mock("../AgentDetailView", () => ({
AgentDetailView: ({ agentId }: { agentId: string }) => <div data-testid="agent-detail-view">Agent detail: {agentId}</div>, AgentDetailView: ({ agentId }: { agentId: string }) => <div data-testid="agent-detail-view">Agent detail: {agentId}</div>,

View File

@@ -3,6 +3,12 @@
* *
* Add new `app/api.ts` or `app/api/legacy.ts` exports here first before * Add new `app/api.ts` or `app/api/legacy.ts` exports here first before
* introducing ad-hoc per-test `vi.mock("../../api", …)` export lists. * introducing ad-hoc per-test `vi.mock("../../api", …)` export lists.
*
* Behavior contract:
* - preserve real exports by default
* - apply canonical/common test mocks
* - allow per-suite overrides
* - auto-synthesize stable fallback fns for missing callable exports
*/ */
import { vi, type Mock } from "vitest"; import { vi, type Mock } from "vitest";

View File

@@ -27,6 +27,16 @@ describe("dashboard test mock helpers", () => {
expect(rendered.props.className).toBe("x"); expect(rendered.props.className).toBe("x");
}); });
it("preserves real non-function exports and respects overrides", async () => {
const module = await createDashboardApiMock(
async () => ({ API_VERSION: "v1", fetchTasks: async () => ["real"] }),
{ fetchTasks: vi.fn().mockResolvedValue(["mocked"]) },
);
expect(module.API_VERSION).toBe("v1");
await expect((module.fetchTasks as () => Promise<string[]>)()).resolves.toEqual(["mocked"]);
});
it("keeps canonical dashboard api mocks resettable", () => { it("keeps canonical dashboard api mocks resettable", () => {
dashboardApiMocks.fetchSettings.mockResolvedValueOnce({ hello: "world" }); dashboardApiMocks.fetchSettings.mockResolvedValueOnce({ hello: "world" });
resetDashboardApiMockState(); resetDashboardApiMockState();

View File

@@ -1,27 +1,56 @@
/** /**
* Canonical @fusion/core and @fusion/engine mock helpers for dashboard server tests. * Canonical @fusion/core and @fusion/engine mock helpers for dashboard server tests.
* *
* Add new mocked exports here first before duplicating large per-suite mock objects. * If a route test starts failing with "No \"X\" export is defined", update this
* helper first instead of adding another full inline export map in the test file.
*/ */
import { vi } from "vitest"; import { vi, type Mock } from "vitest";
type AnyModule = Record<string, unknown>; type AnyModule = Record<string, unknown>;
type AnyMock = Mock;
const fallbackFns = new Map<string, AnyMock>();
function getFallback(name: string): AnyMock {
if (!fallbackFns.has(name)) fallbackFns.set(name, vi.fn());
return fallbackFns.get(name)!;
}
function withFallbackFunctions(actual: AnyModule, moduleValue: AnyModule): AnyModule {
return new Proxy(moduleValue, {
get(target, prop, receiver) {
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
if (["then", "catch", "finally"].includes(prop)) return undefined;
const actualValue = actual[prop];
if (typeof actualValue === "function" || actualValue === undefined) {
const fn = getFallback(prop);
target[prop] = fn;
return fn;
}
return actualValue;
},
});
}
export async function createCoreMock( export async function createCoreMock(
importActual: () => Promise<AnyModule>, importActual: () => Promise<AnyModule>,
overrides: AnyModule = {}, overrides: AnyModule = {},
): Promise<AnyModule> { ): Promise<AnyModule> {
const actual = await importActual(); const actual = await importActual();
return { return withFallbackFunctions(actual, { ...actual, ...overrides });
...actual,
...overrides,
};
} }
export function createEngineMock(overrides: AnyModule = {}): AnyModule { export function createEngineMock(overrides: AnyModule = {}): AnyModule {
return { const actual: AnyModule = {};
return withFallbackFunctions(actual, {
createFnAgent: vi.fn(), createFnAgent: vi.fn(),
promptWithFallback: vi.fn(), promptWithFallback: vi.fn(),
...overrides, ...overrides,
}; });
}
export function resetDashboardServerMockState(): void {
for (const fn of fallbackFns.values()) fn.mockReset();
} }

View File

@@ -1,21 +1,45 @@
/** /**
* Canonical @fusion/core mock helper for engine tests. * Canonical @fusion/core mock helper for engine tests.
* *
* Prefer extending this helper over suite-local full export lists. * Prefer extending this helper over suite-local full export lists; this is the
* first place to update when new @fusion/core exports are consumed by engine tests.
*/ */
import type { Mock } from "vitest"; import { vi, type Mock } from "vitest";
type AnyModule = Record<string, unknown>; type AnyModule = Record<string, unknown>;
const fallbackFns = new Map<string, Mock>();
function getFallback(name: string): Mock {
if (!fallbackFns.has(name)) fallbackFns.set(name, vi.fn());
return fallbackFns.get(name)!;
}
export async function createEngineCoreMock( export async function createEngineCoreMock(
importActual: () => Promise<AnyModule>, importActual: () => Promise<AnyModule>,
overrides: AnyModule = {}, overrides: AnyModule = {},
): Promise<AnyModule> { ): Promise<AnyModule> {
const actual = await importActual(); const actual = await importActual();
return { const merged = { ...actual, ...overrides };
...actual, return new Proxy(merged, {
...overrides, get(target, prop, receiver) {
}; if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
if (["then", "catch", "finally"].includes(prop)) return undefined;
const actualValue = actual[prop];
if (typeof actualValue === "function" || actualValue === undefined) {
const fn = getFallback(prop);
target[prop] = fn;
return fn;
}
return actualValue;
},
});
}
export function resetEngineCoreMockState(): void {
for (const fn of fallbackFns.values()) fn.mockReset();
} }
export type MockFn = Mock; export type MockFn = Mock;