diff --git a/.changeset/fn-6173-cli-fixes.md b/.changeset/fn-6173-cli-fixes.md new file mode 100644 index 0000000000..18d67cc492 --- /dev/null +++ b/.changeset/fn-6173-cli-fixes.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix CLI task retry behavior and plugin SDK runtime shims, and harden CLI tests against stale constructor mocks. diff --git a/packages/cli/src/__tests__/task-retry.test.ts b/packages/cli/src/__tests__/task-retry.test.ts index 8e18a28d65..2e4c794fac 100644 --- a/packages/cli/src/__tests__/task-retry.test.ts +++ b/packages/cli/src/__tests__/task-retry.test.ts @@ -42,12 +42,14 @@ describe("runTaskRetry", () => { error: "merge deadlock", paused: true, pausedReason: "in-review-stall-deadlock", + steps: [{ name: "implemented", status: "done" }], mergeRetries: 4, }); await runTaskRetry(task.id); - const updated = await store.getTask(task.id); + const verificationStore = await createStore(); + const updated = await verificationStore.getTask(task.id); expect(updated.column).toBe("todo"); expect(updated.status).toBeUndefined(); expect(updated.error).toBeUndefined(); diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 1d5aeb5e2f..21f70d75a1 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -9,10 +9,12 @@ const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCt mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), - mockHybridExecutorCtor: vi.fn().mockImplementation(() => ({ - initialize: mockHybridExecutorInitialize, - shutdown: mockHybridExecutorShutdown, - })), + mockHybridExecutorCtor: vi.fn().mockImplementation(function () { + return { + initialize: mockHybridExecutorInitialize, + shutdown: mockHybridExecutorShutdown, + }; + }), })); vi.mock("../startup-model-sync.js", () => ({ syncStartupModels: mockSyncStartupModels, @@ -109,13 +111,13 @@ const mocks = vi.hoisted(() => { }); } - const taskStoreCtor = vi.fn().mockImplementation(() => { + const taskStoreCtor = vi.fn().mockImplementation(function () { const store = createTaskStoreMock(); taskStores.push(store); return store; }); - const automationStoreCtor = vi.fn().mockImplementation(() => { + const automationStoreCtor = vi.fn().mockImplementation(function () { const automationStore = { init: vi.fn().mockResolvedValue(undefined), }; @@ -123,7 +125,7 @@ const mocks = vi.hoisted(() => { return automationStore; }); - const agentStoreCtor = vi.fn().mockImplementation(() => { + const agentStoreCtor = vi.fn().mockImplementation(function () { const agentStore = { init: vi.fn().mockResolvedValue(undefined), }; @@ -131,7 +133,7 @@ const mocks = vi.hoisted(() => { return agentStore; }); - const centralCoreCtor = vi.fn().mockImplementation(() => { + const centralCoreCtor = vi.fn().mockImplementation(function () { const now = new Date().toISOString(); const projects = [ { id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: now, updatedAt: now }, @@ -203,7 +205,7 @@ const mocks = vi.hoisted(() => { }), })); - const triageCtor = vi.fn().mockImplementation(() => { + const triageCtor = vi.fn().mockImplementation(function () { const triage = { start: vi.fn(), stop: vi.fn(), @@ -213,7 +215,7 @@ const mocks = vi.hoisted(() => { return triage; }); - const executorCtor = vi.fn().mockImplementation(() => { + const executorCtor = vi.fn().mockImplementation(function () { const executor = { resumeOrphaned: vi.fn().mockResolvedValue(undefined), markStuckAborted: vi.fn(), @@ -225,7 +227,7 @@ const mocks = vi.hoisted(() => { return executor; }); - const schedulerCtor = vi.fn().mockImplementation(() => { + const schedulerCtor = vi.fn().mockImplementation(function () { const scheduler = { start: vi.fn(), stop: vi.fn(), @@ -234,7 +236,7 @@ const mocks = vi.hoisted(() => { return scheduler; }); - const stuckDetectorCtor = vi.fn().mockImplementation(() => { + const stuckDetectorCtor = vi.fn().mockImplementation(function () { const detector = { start: vi.fn(), stop: vi.fn(), @@ -244,7 +246,7 @@ const mocks = vi.hoisted(() => { return detector; }); - const selfHealingCtor = vi.fn().mockImplementation(() => { + const selfHealingCtor = vi.fn().mockImplementation(function () { const manager = { start: vi.fn(), stop: vi.fn(), @@ -254,7 +256,7 @@ const mocks = vi.hoisted(() => { return manager; }); - const cronRunnerCtor = vi.fn().mockImplementation(() => { + const cronRunnerCtor = vi.fn().mockImplementation(function () { const cron = { start: vi.fn(), stop: vi.fn(), @@ -263,7 +265,7 @@ const mocks = vi.hoisted(() => { return cron; }); - const missionAutopilotCtor = vi.fn().mockImplementation(() => { + const missionAutopilotCtor = vi.fn().mockImplementation(function () { const autopilot = { start: vi.fn(), stop: vi.fn(), @@ -273,7 +275,7 @@ const mocks = vi.hoisted(() => { return autopilot; }); - const missionExecutionLoopCtor = vi.fn().mockImplementation(() => { + const missionExecutionLoopCtor = vi.fn().mockImplementation(function () { const loop = { start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), @@ -284,7 +286,7 @@ const mocks = vi.hoisted(() => { return loop; }); - const notifierCtor = vi.fn().mockImplementation(() => { + const notifierCtor = vi.fn().mockImplementation(function () { const notifier = { start: vi.fn(), stop: vi.fn(), @@ -293,7 +295,7 @@ const mocks = vi.hoisted(() => { return notifier; }); - const pluginStoreCtor = vi.fn().mockImplementation(() => { + const pluginStoreCtor = vi.fn().mockImplementation(function () { const pluginStore = { init: vi.fn().mockResolvedValue(undefined), listPlugins: vi.fn().mockResolvedValue([]), @@ -309,7 +311,7 @@ const mocks = vi.hoisted(() => { return pluginStore; }); - const pluginLoaderCtor = vi.fn().mockImplementation(() => { + const pluginLoaderCtor = vi.fn().mockImplementation(function () { const pluginLoader = { loadPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), @@ -341,25 +343,31 @@ const mocks = vi.hoisted(() => { refresh: vi.fn(), }; - const agentSemaphoreCtor = vi.fn().mockImplementation(() => ({ - _active: 0, - run: (fn: () => Promise) => fn(), - })); + const agentSemaphoreCtor = vi.fn().mockImplementation(function () { + return { + _active: 0, + run: (fn: () => Promise) => 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 heartbeatMonitorCtor = vi.fn().mockImplementation(function () { + return { + 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 heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(function () { + return { + 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); @@ -373,7 +381,7 @@ const mocks = vi.hoisted(() => { pruning: { applied: false }, }); - const projectEngineCtor = vi.fn().mockImplementation((runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) => { + const projectEngineCtor = vi.fn().mockImplementation(function (runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) { const store = taskStoreCtor(runtimeConfig.workingDirectory); const automationStore = automationStoreCtor(runtimeConfig.workingDirectory); const agentStore = agentStoreCtor(); @@ -520,17 +528,21 @@ vi.mock("@fusion/core", async (importOriginal) => { CentralCore: mocks.centralCoreCtor, PluginStore: mocks.pluginStoreCtor, PluginLoader: mocks.pluginLoaderCtor, - GlobalSettingsStore: vi.fn().mockImplementation(() => mocks.globalSettingsStoreInstance), + GlobalSettingsStore: vi.fn().mockImplementation(function () { + return mocks.globalSettingsStoreInstance; + }), resolveGlobalDir: vi.fn().mockReturnValue("/home/user/.fusion"), getEnabledPiExtensionPaths: vi.fn(() => []), - DaemonTokenManager: vi.fn().mockImplementation(() => ({ - getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)), - generateToken: vi.fn().mockImplementation(() => { - const token = "fn_a1b2c3d4e5f6789012345678901234ab"; - mocks.globalSettingsData.daemonToken = token; - return Promise.resolve(token); - }), - })), + DaemonTokenManager: vi.fn().mockImplementation(function () { + return { + getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)), + generateToken: vi.fn().mockImplementation(function () { + const token = "fn_a1b2c3d4e5f6789012345678901234ab"; + mocks.globalSettingsData.daemonToken = token; + return Promise.resolve(token); + }), + }; + }), getTaskMergeBlocker: vi.fn().mockReturnValue(null), syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock, INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction", @@ -540,7 +552,9 @@ vi.mock("@fusion/core", async (importOriginal) => { vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServerMock, - GitHubClient: vi.fn().mockImplementation(() => ({})), + GitHubClient: vi.fn().mockImplementation(function () { + return {}; + }), createSkillsAdapter: vi.fn().mockReturnValue(undefined), getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), @@ -550,7 +564,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); return createCliEngineMock(() => importOriginal(), { ProjectEngine: mocks.projectEngineCtor, - ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => { + ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { const engines = new Map(); return { startAll: vi.fn(async () => { @@ -578,27 +592,35 @@ vi.mock("@fusion/engine", async (importOriginal) => { startReconciliation: vi.fn(), }; }), - PeerExchangeService: vi.fn().mockImplementation(() => ({ - start: vi.fn(), - stop: vi.fn().mockResolvedValue(undefined), - updateGlobalSettings: vi.fn(), - })), + PeerExchangeService: vi.fn().mockImplementation(function () { + return { + start: vi.fn(), + stop: vi.fn().mockResolvedValue(undefined), + updateGlobalSettings: vi.fn(), + }; + }), TriageProcessor: mocks.triageCtor, TaskExecutor: mocks.executorCtor, Scheduler: mocks.schedulerCtor, AgentSemaphore: mocks.agentSemaphoreCtor, - WorktreePool: vi.fn().mockImplementation(() => ({ - rehydrate: vi.fn(), - })), + WorktreePool: vi.fn().mockImplementation(function () { + return { + rehydrate: vi.fn(), + }; + }), aiMergeTask: vi.fn().mockResolvedValue({ merged: true }), - UsageLimitPauser: vi.fn().mockImplementation(() => ({})), + UsageLimitPauser: vi.fn().mockImplementation(function () { + return {}; + }), PRIORITY_MERGE: 100, scanIdleWorktrees: vi.fn().mockResolvedValue([]), cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0), NtfyNotifier: mocks.notifierCtor, - PrMonitor: vi.fn().mockImplementation(() => ({ - onNewComments: vi.fn(), - })), + PrMonitor: vi.fn().mockImplementation(function () { + return { + onNewComments: vi.fn(), + }; + }), PrCommentHandler: vi.fn().mockImplementation(() => ({ handleNewComments: vi.fn(), createFollowUpTask: vi.fn().mockResolvedValue(undefined), @@ -619,9 +641,11 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ AuthStorage: { create: vi.fn(() => mocks.authStorage), }, - DefaultPackageManager: vi.fn().mockImplementation(() => ({ - resolve: vi.fn().mockResolvedValue({ extensions: [] }), - })), + DefaultPackageManager: vi.fn().mockImplementation(function () { + return { + resolve: vi.fn().mockResolvedValue({ extensions: [] }), + }; + }), ModelRegistry: { create: vi.fn(() => mocks.modelRegistry), inMemory: vi.fn(() => mocks.modelRegistry), @@ -642,6 +666,8 @@ vi.mock("../task-lifecycle.js", () => ({ processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), createGroupPrCallback: vi.fn(() => vi.fn()), syncGroupPrCallback: vi.fn(() => vi.fn()), + createPrNodeGithubOps: vi.fn(() => ({})), + createPrReconcileGithubOps: vi.fn(() => ({})), })); vi.mock("../project-context.js", () => ({ @@ -679,9 +705,9 @@ describe("runDaemon", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { @@ -741,15 +767,17 @@ describe("runDaemon", () => { it("continues startup when plugin auto-load fails", async () => { const { PluginLoader } = await import("@fusion/core"); - (PluginLoader as unknown as ReturnType).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([]), - })); + (PluginLoader as unknown as ReturnType).mockImplementationOnce(function () { + return { + 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(runDaemon({})).resolves.toBeUndefined(); expect(errorSpy).toHaveBeenCalledWith( @@ -937,8 +965,8 @@ describe("runDaemon --token-only mode", () => { vi.clearAllMocks(); mocks.reset(); - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processExitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`process.exit:${code ?? 0}`); diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 728d3c871a..19e9327970 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -4,15 +4,32 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join } from "node:path"; +function makeConstructibleMock unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCtor, mockHybridExecutorInitialize, mockHybridExecutorShutdown } = vi.hoisted(() => ({ mockSyncStartupModels: vi.fn().mockResolvedValue(undefined), mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), - mockHybridExecutorCtor: vi.fn().mockImplementation(() => ({ - initialize: mockHybridExecutorInitialize, - shutdown: mockHybridExecutorShutdown, - })), + mockHybridExecutorCtor: makeConstructibleMock(function () { + return { + initialize: mockHybridExecutorInitialize, + shutdown: mockHybridExecutorShutdown, + }; + }), })); vi.mock("../startup-model-sync.js", () => ({ syncStartupModels: mockSyncStartupModels, @@ -137,8 +154,8 @@ function makeMockStore() { vi.mock("@fusion/core", async (importOriginal) => { const { createCliCoreMock } = await import("../../test/mockCoreEngine"); return createCliCoreMock(() => importOriginal(), { - TaskStore: vi.fn().mockImplementation(() => makeMockStore()), - CentralCore: vi.fn().mockImplementation(() => ({ + TaskStore: makeConstructibleMock(() => makeMockStore()), + CentralCore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), @@ -149,7 +166,7 @@ vi.mock("@fusion/core", async (importOriginal) => { { id: "project-1", name: "Test Project", path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, ]), })), - AutomationStore: vi.fn().mockImplementation(() => ({ + AutomationStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), listSchedules: vi.fn().mockResolvedValue([]), getSchedule: vi.fn().mockResolvedValue(null), @@ -159,7 +176,7 @@ vi.mock("@fusion/core", async (importOriginal) => { recordRun: vi.fn().mockResolvedValue({}), getDueSchedules: vi.fn().mockResolvedValue([]), })), - AgentStore: vi.fn().mockImplementation(() => ({ + AgentStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), createAgent: vi.fn(), updateAgentState: vi.fn(), @@ -172,7 +189,7 @@ vi.mock("@fusion/core", async (importOriginal) => { getBudgetStatus: vi.fn().mockResolvedValue({ isOverBudget: false, isOverThreshold: false, usagePercent: 0 }), getRecentRuns: vi.fn().mockResolvedValue([]), })), - PluginStore: vi.fn().mockImplementation(() => { + PluginStore: makeConstructibleMock(function () { const emitter = new EventEmitter(); return { init: vi.fn().mockResolvedValue(undefined), @@ -193,7 +210,7 @@ vi.mock("@fusion/core", async (importOriginal) => { emit: emitter.emit.bind(emitter), }; }), - PluginLoader: vi.fn().mockImplementation(() => { + PluginLoader: makeConstructibleMock(function () { const emitter = new EventEmitter(); return { loadPlugin: vi.fn().mockResolvedValue(undefined), @@ -214,12 +231,12 @@ vi.mock("@fusion/core", async (importOriginal) => { }), getEnabledPiExtensionPaths: vi.fn(() => []), resolveGlobalDir: mockResolveGlobalDir, - GlobalSettingsStore: vi.fn().mockImplementation(() => ({ + GlobalSettingsStore: makeConstructibleMock(() => ({ init: vi.fn().mockResolvedValue(undefined), getSettings: mockGlobalSettingsGetSettings, updateSettings: mockGlobalSettingsUpdateSettings, })), - DaemonTokenManager: vi.fn().mockImplementation(() => ({ + DaemonTokenManager: makeConstructibleMock(() => ({ getOrCreateToken: mockDaemonTokenGetOrCreate, getToken: vi.fn().mockResolvedValue(undefined), generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"), @@ -330,7 +347,7 @@ vi.mock("@fusion/dashboard", () => ({ opts.onProjectFirstAccessed?.("project-1"); return { listen: mockListen }; }), - GitHubClient: vi.fn().mockImplementation(() => ({ + GitHubClient: makeConstructibleMock(() => ({ findPrForBranch: mockFindPrForBranch, createPr: mockCreatePr, getPrMergeStatus: mockGetPrMergeStatus, @@ -359,7 +376,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); const coreModule = await import("@fusion/core"); const taskStoreMock = (coreModule.TaskStore as any); - const TriageProcessor = vi.fn().mockImplementation(() => ({ + const TriageProcessor = makeConstructibleMock(() => ({ start: vi.fn(), stop: vi.fn(), })); @@ -369,7 +386,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { resumeOrphaned: vi.fn().mockResolvedValue(undefined), }; }); - const StuckTaskDetector = vi.fn().mockImplementation(() => ({ + const StuckTaskDetector = makeConstructibleMock(() => ({ start: vi.fn(), stop: vi.fn(), checkNow: mockStuckCheckNow, @@ -377,11 +394,11 @@ vi.mock("@fusion/engine", async (importOriginal) => { untrackTask: vi.fn(), markTaskProgress: vi.fn(), })); - const Scheduler = vi.fn().mockImplementation(() => ({ + const Scheduler = makeConstructibleMock(() => ({ start: vi.fn(), stop: vi.fn(), })); - const PrMonitor = vi.fn().mockImplementation(() => ({ + const PrMonitor = makeConstructibleMock(() => ({ onNewComments: vi.fn(), startMonitoring: vi.fn(), stopMonitoring: vi.fn(), @@ -390,12 +407,12 @@ vi.mock("@fusion/engine", async (importOriginal) => { updatePrInfo: vi.fn(), drainComments: vi.fn().mockReturnValue([]), })); - const PrCommentHandler = vi.fn().mockImplementation(() => ({ + const PrCommentHandler = makeConstructibleMock(() => ({ handleNewComments: vi.fn().mockResolvedValue(undefined), createFollowUpTask: vi.fn().mockResolvedValue(undefined), })); const aiMergeTask = vi.fn().mockImplementation(() => Promise.resolve({ merged: true })); - const CronRunner = vi.fn().mockImplementation(() => ({ + const CronRunner = makeConstructibleMock(() => ({ start: vi.fn(), stop: vi.fn(), })); @@ -674,7 +691,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { AgentSemaphore: original.AgentSemaphore, // Stub heavy classes/functions ProjectEngine, - ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => { + ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => { const engines = new Map(); const starting = new Map>(); // Keep the chosen HEAD startEngine/starting async shape from the conflict resolution. @@ -726,7 +743,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { startReconciliation: vi.fn(), }; }), - ProjectManager: vi.fn().mockImplementation(() => ({ + ProjectManager: makeConstructibleMock(() => ({ getRuntime: vi.fn().mockReturnValue(undefined), addProject: vi.fn().mockResolvedValue(undefined), stopAll: vi.fn().mockResolvedValue(undefined), @@ -741,12 +758,12 @@ vi.mock("@fusion/engine", async (importOriginal) => { CronRunner, createAiPromptExecutor, SelfHealingManager, - MissionAutopilot: vi.fn().mockImplementation(() => ({ + MissionAutopilot: makeConstructibleMock(() => ({ start: vi.fn(), stop: vi.fn(), setScheduler: vi.fn(), })), - PluginLoader: vi.fn().mockImplementation(() => ({ + PluginLoader: makeConstructibleMock(() => ({ loadPlugin: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined), @@ -754,7 +771,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { getPlugin: vi.fn(), getLoadedPlugins: vi.fn().mockReturnValue([]), })), - PeerExchangeService: vi.fn().mockImplementation(() => ({ + PeerExchangeService: makeConstructibleMock(() => ({ start: vi.fn(), stop: vi.fn().mockResolvedValue(undefined), updateGlobalSettings: vi.fn(), @@ -772,7 +789,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ AuthStorage: { create: vi.fn(() => mockAuthStorage), }, - DefaultPackageManager: vi.fn().mockImplementation(() => ({ + DefaultPackageManager: makeConstructibleMock(() => ({ resolve: vi.fn().mockResolvedValue({ extensions: [] }), })), ModelRegistry: { @@ -3103,6 +3120,8 @@ describe("StreamedLogBuffer", () => { describe("runDashboard — merge stream sink routing", () => { it("routes streamed merge deltas through log sink without raw stdout writes", async () => { + vi.clearAllMocks(); + resetGitHubMocks(); process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core"); const { aiMergeTask } = await import("@fusion/engine"); diff --git a/packages/cli/src/commands/__tests__/research.test.ts b/packages/cli/src/commands/__tests__/research.test.ts index 7360192e01..791281b449 100644 --- a/packages/cli/src/commands/__tests__/research.test.ts +++ b/packages/cli/src/commands/__tests__/research.test.ts @@ -1,6 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js"; +function makeConstructibleMock unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; + if (impl) { + mock.mockImplementation(impl); + } + return mock; +} + const mockRun = { id: "RR-001", query: "test query", @@ -35,12 +50,12 @@ const orchestratorMock = { const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => ({ resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })), - providerRegistryMock: vi.fn(() => ({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) })), + providerRegistryMock: makeConstructibleMock(function () { return { getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) }; }), writeFileMock: vi.fn(async () => undefined), })); vi.mock("@fusion/core", () => ({ - TaskStore: vi.fn(() => storeMock), + TaskStore: makeConstructibleMock(() => storeMock), resolveResearchSettings: resolveResearchSettingsMock, RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"], RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"], @@ -49,7 +64,7 @@ vi.mock("@fusion/core", () => ({ vi.mock("@fusion/engine", () => ({ ResearchProviderRegistry: providerRegistryMock, ResearchStepRunner: vi.fn(), - ResearchOrchestrator: vi.fn(() => orchestratorMock), + ResearchOrchestrator: makeConstructibleMock(() => orchestratorMock), })); vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) })); @@ -66,7 +81,7 @@ describe("research commands", () => { throw new Error(`process.exit:${code ?? 0}`); }) as typeof process.exit); resolveResearchSettingsMock.mockReturnValue({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } }); - providerRegistryMock.mockReturnValue({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) }); + providerRegistryMock.mockImplementation(function () { return { getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) }; }); researchStoreMock.getRun.mockReturnValue(mockRun); researchStoreMock.listRuns.mockReturnValue([mockRun]); orchestratorMock.retryRun.mockReturnValue("RR-003"); @@ -89,7 +104,7 @@ describe("research commands", () => { searchProvider: "builtin", limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 }, }); - providerRegistryMock.mockReturnValueOnce({ getAvailableProviders: () => ["web-search"], getProvider: () => ({ type: "web-search" }) }); + providerRegistryMock.mockImplementationOnce(function () { return { getAvailableProviders: () => ["web-search"], getProvider: () => ({ type: "web-search" }) }; }); await runResearchCreate({ query: "hello builtin" }); @@ -154,7 +169,7 @@ describe("research commands", () => { }); it("errors when providers are unavailable", async () => { - providerRegistryMock.mockReturnValue({ getAvailableProviders: () => [], getProvider: () => undefined }); + providerRegistryMock.mockImplementation(function () { return { getAvailableProviders: () => [], getProvider: () => undefined }; }); await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable")); }); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index a490c51324..994c732186 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -9,10 +9,12 @@ const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCt mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), - mockHybridExecutorCtor: vi.fn().mockImplementation(() => ({ - initialize: mockHybridExecutorInitialize, - shutdown: mockHybridExecutorShutdown, - })), + mockHybridExecutorCtor: vi.fn().mockImplementation(function () { + return { + initialize: mockHybridExecutorInitialize, + shutdown: mockHybridExecutorShutdown, + }; + }), })); vi.mock("../startup-model-sync.js", () => ({ syncStartupModels: mockSyncStartupModels, @@ -132,13 +134,13 @@ const mocks = vi.hoisted(() => { }); } - const taskStoreCtor = vi.fn().mockImplementation(() => { + const taskStoreCtor = vi.fn().mockImplementation(function () { const store = createTaskStoreMock(); taskStores.push(store); return store; }); - const automationStoreCtor = vi.fn().mockImplementation(() => { + const automationStoreCtor = vi.fn().mockImplementation(function () { const automationStore = { init: vi.fn().mockResolvedValue(undefined), }; @@ -146,7 +148,7 @@ const mocks = vi.hoisted(() => { return automationStore; }); - const agentStoreCtor = vi.fn().mockImplementation(() => { + const agentStoreCtor = vi.fn().mockImplementation(function () { const agentStore = { init: vi.fn().mockResolvedValue(undefined), }; @@ -154,7 +156,7 @@ const mocks = vi.hoisted(() => { return agentStore; }); - const centralCoreCtor = vi.fn().mockImplementation(() => { + const centralCoreCtor = vi.fn().mockImplementation(function () { const now = new Date().toISOString(); const projects = [ { ...PROJECT_FIXTURES.primary, createdAt: now, updatedAt: now }, @@ -223,7 +225,7 @@ const mocks = vi.hoisted(() => { }), })); - const triageCtor = vi.fn().mockImplementation(() => { + const triageCtor = vi.fn().mockImplementation(function () { const triage = { start: vi.fn(), stop: vi.fn(), @@ -233,7 +235,7 @@ const mocks = vi.hoisted(() => { return triage; }); - const executorCtor = vi.fn().mockImplementation(() => { + const executorCtor = vi.fn().mockImplementation(function () { const executor = { resumeOrphaned: vi.fn().mockResolvedValue(undefined), markStuckAborted: vi.fn(), @@ -245,7 +247,7 @@ const mocks = vi.hoisted(() => { return executor; }); - const schedulerCtor = vi.fn().mockImplementation(() => { + const schedulerCtor = vi.fn().mockImplementation(function () { const scheduler = { start: vi.fn(), stop: vi.fn(), @@ -254,7 +256,7 @@ const mocks = vi.hoisted(() => { return scheduler; }); - const stuckDetectorCtor = vi.fn().mockImplementation(() => { + const stuckDetectorCtor = vi.fn().mockImplementation(function () { const detector = { start: vi.fn(), stop: vi.fn(), @@ -264,7 +266,7 @@ const mocks = vi.hoisted(() => { return detector; }); - const selfHealingCtor = vi.fn().mockImplementation(() => { + const selfHealingCtor = vi.fn().mockImplementation(function () { const manager = { start: vi.fn(), stop: vi.fn(), @@ -274,7 +276,7 @@ const mocks = vi.hoisted(() => { return manager; }); - const cronRunnerCtor = vi.fn().mockImplementation(() => { + const cronRunnerCtor = vi.fn().mockImplementation(function () { const cron = { start: vi.fn(), stop: vi.fn(), @@ -283,7 +285,7 @@ const mocks = vi.hoisted(() => { return cron; }); - const missionAutopilotCtor = vi.fn().mockImplementation(() => { + const missionAutopilotCtor = vi.fn().mockImplementation(function () { const autopilot = { start: vi.fn(), stop: vi.fn(), @@ -293,7 +295,7 @@ const mocks = vi.hoisted(() => { return autopilot; }); - const missionExecutionLoopCtor = vi.fn().mockImplementation(() => { + const missionExecutionLoopCtor = vi.fn().mockImplementation(function () { const loop = { start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), @@ -304,7 +306,7 @@ const mocks = vi.hoisted(() => { return loop; }); - const notifierCtor = vi.fn().mockImplementation(() => { + const notifierCtor = vi.fn().mockImplementation(function () { const notifier = { start: vi.fn(), stop: vi.fn(), @@ -313,7 +315,7 @@ const mocks = vi.hoisted(() => { return notifier; }); - const pluginStoreCtor = vi.fn().mockImplementation(() => { + const pluginStoreCtor = vi.fn().mockImplementation(function () { const pluginStore = { init: vi.fn().mockResolvedValue(undefined), listPlugins: vi.fn().mockResolvedValue([]), @@ -329,7 +331,7 @@ const mocks = vi.hoisted(() => { return pluginStore; }); - const pluginLoaderCtor = vi.fn().mockImplementation(() => { + const pluginLoaderCtor = vi.fn().mockImplementation(function () { const pluginLoader = { loadPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), @@ -361,25 +363,31 @@ const mocks = vi.hoisted(() => { refresh: vi.fn(), }; - const agentSemaphoreCtor = vi.fn().mockImplementation(() => ({ - _active: 0, - run: (fn: () => Promise) => fn(), - })); + const agentSemaphoreCtor = vi.fn().mockImplementation(function () { + return { + _active: 0, + run: (fn: () => Promise) => 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 heartbeatMonitorCtor = vi.fn().mockImplementation(function () { + return { + 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 heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(function () { + return { + 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); @@ -393,7 +401,7 @@ const mocks = vi.hoisted(() => { pruning: { applied: false }, }); - const projectEngineCtor = vi.fn().mockImplementation((runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) => { + const projectEngineCtor = vi.fn().mockImplementation(function (runtimeConfig: { workingDirectory: string }, _centralCore: unknown, options: { onInsightRunProcessed?: unknown }) { const store = taskStoreCtor(runtimeConfig.workingDirectory); const automationStore = automationStoreCtor(runtimeConfig.workingDirectory); const agentStore = agentStoreCtor(); @@ -575,19 +583,25 @@ vi.mock("@fusion/core", async (importOriginal) => { 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(() => ({})), + DaemonTokenManager: vi.fn().mockImplementation(function () { + return { + getToken: vi.fn().mockResolvedValue(null), + generateToken: vi.fn().mockResolvedValue("fn_generated1234567890"), + storeToken: vi.fn().mockResolvedValue(undefined), + }; + }), + GlobalSettingsStore: vi.fn().mockImplementation(function () { + return {}; + }), resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"), }); }); vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServerMock, - GitHubClient: vi.fn().mockImplementation(() => ({})), + GitHubClient: vi.fn().mockImplementation(function () { + return {}; + }), createSkillsAdapter: vi.fn().mockReturnValue(undefined), getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), @@ -597,7 +611,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); return createCliEngineMock(() => importOriginal(), { ProjectEngine: mocks.projectEngineCtor, - ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => { + ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { const engines = new Map(); return { startAll: vi.fn(async () => { @@ -629,26 +643,34 @@ vi.mock("@fusion/engine", async (importOriginal) => { startReconciliation: vi.fn(), }; }), - PeerExchangeService: vi.fn().mockImplementation(() => ({ - start: vi.fn(), - stop: vi.fn().mockResolvedValue(undefined), - })), + PeerExchangeService: vi.fn().mockImplementation(function () { + return { + 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(), - })), + WorktreePool: vi.fn().mockImplementation(function () { + return { + rehydrate: vi.fn(), + }; + }), aiMergeTask: vi.fn().mockResolvedValue({ merged: true }), - UsageLimitPauser: vi.fn().mockImplementation(() => ({})), + UsageLimitPauser: vi.fn().mockImplementation(function () { + return {}; + }), PRIORITY_MERGE: 100, scanIdleWorktrees: vi.fn().mockResolvedValue([]), cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0), NtfyNotifier: mocks.notifierCtor, - PrMonitor: vi.fn().mockImplementation(() => ({ - onNewComments: vi.fn(), - })), + PrMonitor: vi.fn().mockImplementation(function () { + return { + onNewComments: vi.fn(), + }; + }), PrCommentHandler: vi.fn().mockImplementation(() => ({ handleNewComments: vi.fn(), createFollowUpTask: vi.fn().mockResolvedValue(undefined), @@ -669,9 +691,11 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ AuthStorage: { create: vi.fn(() => mocks.authStorage), }, - DefaultPackageManager: vi.fn().mockImplementation(() => ({ - resolve: vi.fn().mockResolvedValue({ extensions: [] }), - })), + DefaultPackageManager: vi.fn().mockImplementation(function () { + return { + resolve: vi.fn().mockResolvedValue({ extensions: [] }), + }; + }), ModelRegistry: { create: vi.fn(() => mocks.modelRegistry), inMemory: vi.fn(() => mocks.modelRegistry), @@ -696,6 +720,8 @@ vi.mock("../task-lifecycle.js", () => ({ processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), createGroupPrCallback: vi.fn(() => vi.fn()), syncGroupPrCallback: vi.fn(() => vi.fn()), + createPrNodeGithubOps: vi.fn(() => ({})), + createPrReconcileGithubOps: vi.fn(() => ({})), })); vi.mock("../project-context.js", () => ({ @@ -735,9 +761,9 @@ describe("runServe", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { @@ -956,7 +982,7 @@ describe("runServe — Plugin wiring", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { if (event === "SIGINT" || event === "SIGTERM") { @@ -1041,16 +1067,18 @@ describe("runServe — Plugin wiring", () => { 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).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([]), - })); + const errorSpy = vi.spyOn(console, "error").mockImplementation(function () {}); + (PluginLoader as unknown as ReturnType).mockImplementationOnce(function () { + return { + 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( @@ -1101,9 +1129,9 @@ describe("runServe — Memory Insight Automation wiring", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { @@ -1139,7 +1167,9 @@ describe("runServe — Memory Insight Automation wiring", () => { stopDiscovery: vi.fn(), }; mocks.centralInstances.push(instance); - CentralCore.mockImplementation(() => instance); + CentralCore.mockImplementation(function () { + return instance; + }); }); afterEach(() => { @@ -1229,7 +1259,7 @@ describe("runServe — Memory Insight Automation wiring", () => { it("handles syncInsightExtractionAutomation errors gracefully", async () => { const { syncInsightExtractionAutomation } = await import("@fusion/core"); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(function () {}); syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed")); await runServe(4040, {}); @@ -1300,7 +1330,9 @@ describe("runServe — Semaphore boundary (task lanes only)", () => { stopDiscovery: vi.fn(), }; mocks.centralInstances.push(instance); - CentralCore.mockImplementation(() => instance); + CentralCore.mockImplementation(function () { + return instance; + }); }); afterEach(() => { @@ -1452,7 +1484,7 @@ describe("runServe — Peer exchange and discovery", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { if (event === "SIGINT" || event === "SIGTERM") { @@ -1465,7 +1497,7 @@ describe("runServe — Peer exchange and discovery", () => { // 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(() => { + CentralCore.mockImplementation(function () { const instance = { init: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -1625,7 +1657,7 @@ describe("runServe --daemon flag", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { if (event === "SIGINT" || event === "SIGTERM") { @@ -1637,7 +1669,7 @@ describe("runServe --daemon flag", () => { // Override CentralCore to use original implementation that pushes to centralInstances const { CentralCore } = await import("@fusion/core"); - CentralCore.mockImplementation(() => { + CentralCore.mockImplementation(function () { const instance = { init: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -1817,7 +1849,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => { signalHandlers = { SIGINT: [], SIGTERM: [] }; - logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + logSpy = vi.spyOn(console, "log").mockImplementation(function () {}); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { if (event === "SIGINT" || event === "SIGTERM") { @@ -1829,7 +1861,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => { // Override CentralCore to use original implementation that pushes to centralInstances const { CentralCore } = await import("@fusion/core"); - CentralCore.mockImplementation(() => { + CentralCore.mockImplementation(function () { const instance = { init: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -1999,7 +2031,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => { it("--no-auto-register falls back to existing started engines", async () => { const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-")); cwdSpy.mockReturnValue(freshCwd); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(function () {}); const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered") .mockResolvedValue(null); diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index 87f07e74b8..0363711814 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -54,6 +54,23 @@ vi.mock("@fusion/core", async (importActual) => { return impl(...args); })) as typeof TaskStoreMock.mockImplementation; + const CentralCoreMock = vi.fn(function () {}); + const centralCoreMockImplementation = CentralCoreMock.mockImplementation.bind(CentralCoreMock); + CentralCoreMock.mockImplementation = ((impl: (...args: any[]) => unknown) => + centralCoreMockImplementation(function (this: unknown, ...args: any[]) { + return impl(...args); + })) as typeof CentralCoreMock.mockImplementation; + CentralCoreMock.mockImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listProjects: vi.fn().mockResolvedValue([]), + getProject: vi.fn().mockResolvedValue(undefined), + getProjectByPath: vi.fn().mockResolvedValue(undefined), + registerProject: vi.fn().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }), + getNode: vi.fn().mockResolvedValue(undefined), + getNodeByName: vi.fn().mockResolvedValue(undefined), + })); + return { ...actual, TaskStore: TaskStoreMock, @@ -74,18 +91,7 @@ vi.mock("@fusion/core", async (importActual) => { } return ids; }), - CentralCore: vi.fn().mockImplementation(function() { - return { - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - listProjects: vi.fn().mockResolvedValue([]), - getProject: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue(undefined), - registerProject: vi.fn().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }), - getNode: vi.fn().mockResolvedValue(undefined), - getNodeByName: vi.fn().mockResolvedValue(undefined), - }; - }), + CentralCore: CentralCoreMock, }; }); @@ -94,9 +100,11 @@ vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() })); // Mock @fusion/dashboard vi.mock("@fusion/dashboard", () => ({ - GitHubClient: vi.fn().mockImplementation(() => ({ - createPr: vi.fn(), - })), + GitHubClient: vi.fn().mockImplementation(function () { + return { + createPr: vi.fn(), + }; + }), generatePrMetadata: vi.fn(), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), })); @@ -167,6 +175,8 @@ function makeTask(overrides: Record = {}) { } beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveProject).mockRejectedValue(new Error("No project context")); vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({ action: "proceed", fingerprint: null, @@ -973,12 +983,16 @@ describe("project-aware task command behavior", () => { vi.mocked(isGhAvailable).mockReturnValue(true); vi.mocked(isGhAuthenticated).mockReturnValue(true); vi.mocked(getCurrentRepo).mockReturnValue({ owner: "acme", repo: "demo" }); - vi.mocked(GitHubClient).mockImplementation(() => ({ createPr: mockCreatePr }) as never); + vi.mocked(GitHubClient).mockImplementation(function () { + return { createPr: mockCreatePr } as never; + }); (TaskStore as unknown as ReturnType).mockImplementation(() => ({ init: vi.fn(), getTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-001", column: "in-review", branchName: "fusion/fn-001" })), updatePrInfo: vi.fn().mockResolvedValue(undefined), + ensurePrEntityForSource: vi.fn(() => ({ id: "pr-entity-1" })), + updatePrEntity: vi.fn(), logEntry: vi.fn().mockResolvedValue(undefined), })); @@ -2607,10 +2621,10 @@ describe("runTaskRetry", () => { error: null, mergeRetries: 0, })); - expect(mockMoveTask).not.toHaveBeenCalled(); + expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo"); expect(mockLogEntry).toHaveBeenCalledWith( "FN-001", - "Retry requested from CLI (in-review merge retry, mergeRetries reset)", + "Retry requested from CLI (merge retry → todo, mergeRetries reset)", ); }); @@ -3014,6 +3028,7 @@ describe("runTaskPrCreate", () => { } beforeEach(() => { + vi.clearAllMocks(); logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); @@ -3025,9 +3040,11 @@ describe("runTaskPrCreate", () => { // Setup GitHubClient mock mockCreatePr = vi.fn(); - vi.mocked(GitHubClient).mockImplementation(() => ({ - createPr: mockCreatePr, - } as unknown as GitHubClient)); + vi.mocked(GitHubClient).mockImplementation(function () { + return { + createPr: mockCreatePr, + } as unknown as GitHubClient; + }); vi.mocked(generatePrMetadata).mockResolvedValue({ title: "AI Generated Title", body: "AI Generated Body", templateUsed: false }); // Setup gh-cli mocks @@ -3044,6 +3061,8 @@ describe("runTaskPrCreate", () => { init: vi.fn(), getTask: mockGetTask, updatePrInfo: mockUpdatePrInfo, + ensurePrEntityForSource: vi.fn(() => ({ id: "pr-entity-1" })), + updatePrEntity: vi.fn(), logEntry: mockLogEntry, })); }); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 641f737f2a..b70c94603b 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1058,6 +1058,7 @@ export async function runTaskRetry(id: string, projectName?: string) { // and merge failures (all steps done). if (isInReviewRetry) { if (isExecutionFailureInReview) { + await store.moveTask(id, "todo", { preserveProgress: true }); await store.updateTask(id, { status: null, error: null, @@ -1070,7 +1071,6 @@ export async function runTaskRetry(id: string, projectName?: string) { ? `Retry requested from CLI (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})` : `Retry requested from CLI (execution failure in-review → todo, preserving progress${retryLogSuffix})`, ); - await store.moveTask(id, "todo", { preserveProgress: true }); console.log(); console.log(` ✓ Retried ${id} → todo (execution failure, preserving step progress)`); @@ -1078,20 +1078,26 @@ export async function runTaskRetry(id: string, projectName?: string) { return; } + await store.moveTask(id, "todo"); await store.updateTask(id, { status: null, error: null, ...autoPauseClearPatch, ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); - await store.logEntry(id, `Retry requested from CLI (in-review merge retry, mergeRetries reset${retryLogSuffix})`); + await store.logEntry(id, `Retry requested from CLI (merge retry → todo, mergeRetries reset${retryLogSuffix})`); console.log(); - console.log(` ✓ Retried ${id} → in-review (merge retry state cleared)`); + console.log(` ✓ Retried ${id} → todo (merge retry state cleared)`); console.log(); return; } + // Move to todo column before applying retry resets. `moveTask` reads from the + // store's durable index and may overwrite task.json-only updates, so apply the + // manual retry reset patch after the move to make the cleared counters stick. + await store.moveTask(id, 'todo'); + // Clear failure state and stale branch refs so retry can choose a fresh base. await store.updateTask(id, { status: null, @@ -1104,9 +1110,6 @@ export async function runTaskRetry(id: string, projectName?: string) { ...buildManualRetryResetPatch({ resetMergeRetries: true }), }); - // Move to todo column - await store.moveTask(id, 'todo'); - // Log the retry action await store.logEntry( id, diff --git a/packages/cli/src/plugin-sdk-core-runtime-shim.ts b/packages/cli/src/plugin-sdk-core-runtime-shim.ts new file mode 100644 index 0000000000..416adb601d --- /dev/null +++ b/packages/cli/src/plugin-sdk-core-runtime-shim.ts @@ -0,0 +1,33 @@ +import type { BoardActionTaskStore, ColumnId, Task } from "@fusion/core"; + +export const WORKFLOW_EXTENSION_SCHEMA_VERSION = 1 as const; + +export function workflowExtensionRegistryId(pluginId: string, extensionId: string): string { + return `plugin:${pluginId}:${extensionId}`; +} + +export interface MoveBoardTaskInput { + taskId: string; + column: ColumnId; + preserveProgress?: boolean; + source?: "user" | "engine" | "scheduler"; +} + +export interface UpdateBoardTaskInput { + taskId: string; + updates: Record; +} + +export function createBoardActionServices(store: BoardActionTaskStore) { + return { + moveTask(input: MoveBoardTaskInput): Promise { + return store.moveTask(input.taskId, input.column, { + preserveProgress: input.preserveProgress, + moveSource: input.source ?? "user", + }); + }, + updateTask(input: UpdateBoardTaskInput): Promise { + return store.updateTask(input.taskId, input.updates); + }, + }; +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index f02188255e..3c608fbcb1 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -130,6 +130,7 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal } const pluginSdkEntry = join(__dirname, "..", "plugin-sdk", "src", "index.ts"); +const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.ts"); const cliBuildConfig = { entry: ["src/bin.ts", "src/extension.ts"], @@ -321,6 +322,12 @@ const pluginSdkBuildConfig = { }, }, noExternal: [/^@fusion\//], + esbuildOptions(options: { alias?: Record }) { + options.alias = { + ...(options.alias || {}), + "@fusion/core": pluginSdkCoreRuntimeShim, + }; + }, clean: false, outDir: "dist", };