FN-6173: fix CLI stale constructor mocks and plugin SDK shims

Tighten CLI retry behavior and restore constructor-safe test/runtime wiring.

- move CLI merge retries back to todo before clearing retry metadata so resets persist
- add a plugin SDK runtime shim for @fusion/core board actions and alias it in the CLI bundle
- harden CLI daemon, serve, dashboard, research, and task tests against stale non-constructible mocks
- add a patch changeset for the published CLI package

Files changed:
 .changeset/fn-6173-cli-fixes.md                    |   5 +
 packages/cli/src/__tests__/task-retry.test.ts      |   4 +-
 packages/cli/src/commands/__tests__/daemon.test.ts | 182 ++++++++++--------
 .../cli/src/commands/__tests__/dashboard.test.ts   |  69 ++++---
 .../cli/src/commands/__tests__/research.test.ts    |  27 ++-
 packages/cli/src/commands/__tests__/serve.test.ts  | 204 ++++++++++++---------
 packages/cli/src/commands/__tests__/task.test.ts   |  61 +++---
 packages/cli/src/commands/task.ts                  |  15 +-
 packages/cli/src/plugin-sdk-core-runtime-shim.ts   |  33 ++++
 packages/cli/tsup.config.ts                        |   7 +
 10 files changed, 385 insertions(+), 222 deletions(-)

Fusion-Task-Id: FN-6173

Fusion-Task-Lineage: 50bdbadd-39f7-4b43-bcf8-3891271917db
This commit is contained in:
gsxdsm
2026-06-10 01:07:48 -07:00
parent 5b5cd77021
commit 1c69ea78be
10 changed files with 385 additions and 222 deletions

View File

@@ -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.

View File

@@ -42,12 +42,14 @@ describe("runTaskRetry", () => {
error: "merge deadlock", error: "merge deadlock",
paused: true, paused: true,
pausedReason: "in-review-stall-deadlock", pausedReason: "in-review-stall-deadlock",
steps: [{ name: "implemented", status: "done" }],
mergeRetries: 4, mergeRetries: 4,
}); });
await runTaskRetry(task.id); 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.column).toBe("todo");
expect(updated.status).toBeUndefined(); expect(updated.status).toBeUndefined();
expect(updated.error).toBeUndefined(); expect(updated.error).toBeUndefined();

View File

@@ -9,10 +9,12 @@ const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCt
mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }),
mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined),
mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined),
mockHybridExecutorCtor: vi.fn().mockImplementation(() => ({ mockHybridExecutorCtor: vi.fn().mockImplementation(function () {
initialize: mockHybridExecutorInitialize, return {
shutdown: mockHybridExecutorShutdown, initialize: mockHybridExecutorInitialize,
})), shutdown: mockHybridExecutorShutdown,
};
}),
})); }));
vi.mock("../startup-model-sync.js", () => ({ vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels, syncStartupModels: mockSyncStartupModels,
@@ -109,13 +111,13 @@ const mocks = vi.hoisted(() => {
}); });
} }
const taskStoreCtor = vi.fn().mockImplementation(() => { const taskStoreCtor = vi.fn().mockImplementation(function () {
const store = createTaskStoreMock(); const store = createTaskStoreMock();
taskStores.push(store); taskStores.push(store);
return store; return store;
}); });
const automationStoreCtor = vi.fn().mockImplementation(() => { const automationStoreCtor = vi.fn().mockImplementation(function () {
const automationStore = { const automationStore = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
}; };
@@ -123,7 +125,7 @@ const mocks = vi.hoisted(() => {
return automationStore; return automationStore;
}); });
const agentStoreCtor = vi.fn().mockImplementation(() => { const agentStoreCtor = vi.fn().mockImplementation(function () {
const agentStore = { const agentStore = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
}; };
@@ -131,7 +133,7 @@ const mocks = vi.hoisted(() => {
return agentStore; return agentStore;
}); });
const centralCoreCtor = vi.fn().mockImplementation(() => { const centralCoreCtor = vi.fn().mockImplementation(function () {
const now = new Date().toISOString(); const now = new Date().toISOString();
const projects = [ const projects = [
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: now, updatedAt: now }, { 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 = { const triage = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -213,7 +215,7 @@ const mocks = vi.hoisted(() => {
return triage; return triage;
}); });
const executorCtor = vi.fn().mockImplementation(() => { const executorCtor = vi.fn().mockImplementation(function () {
const executor = { const executor = {
resumeOrphaned: vi.fn().mockResolvedValue(undefined), resumeOrphaned: vi.fn().mockResolvedValue(undefined),
markStuckAborted: vi.fn(), markStuckAborted: vi.fn(),
@@ -225,7 +227,7 @@ const mocks = vi.hoisted(() => {
return executor; return executor;
}); });
const schedulerCtor = vi.fn().mockImplementation(() => { const schedulerCtor = vi.fn().mockImplementation(function () {
const scheduler = { const scheduler = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -234,7 +236,7 @@ const mocks = vi.hoisted(() => {
return scheduler; return scheduler;
}); });
const stuckDetectorCtor = vi.fn().mockImplementation(() => { const stuckDetectorCtor = vi.fn().mockImplementation(function () {
const detector = { const detector = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -244,7 +246,7 @@ const mocks = vi.hoisted(() => {
return detector; return detector;
}); });
const selfHealingCtor = vi.fn().mockImplementation(() => { const selfHealingCtor = vi.fn().mockImplementation(function () {
const manager = { const manager = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -254,7 +256,7 @@ const mocks = vi.hoisted(() => {
return manager; return manager;
}); });
const cronRunnerCtor = vi.fn().mockImplementation(() => { const cronRunnerCtor = vi.fn().mockImplementation(function () {
const cron = { const cron = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -263,7 +265,7 @@ const mocks = vi.hoisted(() => {
return cron; return cron;
}); });
const missionAutopilotCtor = vi.fn().mockImplementation(() => { const missionAutopilotCtor = vi.fn().mockImplementation(function () {
const autopilot = { const autopilot = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -273,7 +275,7 @@ const mocks = vi.hoisted(() => {
return autopilot; return autopilot;
}); });
const missionExecutionLoopCtor = vi.fn().mockImplementation(() => { const missionExecutionLoopCtor = vi.fn().mockImplementation(function () {
const loop = { const loop = {
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
@@ -284,7 +286,7 @@ const mocks = vi.hoisted(() => {
return loop; return loop;
}); });
const notifierCtor = vi.fn().mockImplementation(() => { const notifierCtor = vi.fn().mockImplementation(function () {
const notifier = { const notifier = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -293,7 +295,7 @@ const mocks = vi.hoisted(() => {
return notifier; return notifier;
}); });
const pluginStoreCtor = vi.fn().mockImplementation(() => { const pluginStoreCtor = vi.fn().mockImplementation(function () {
const pluginStore = { const pluginStore = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]), listPlugins: vi.fn().mockResolvedValue([]),
@@ -309,7 +311,7 @@ const mocks = vi.hoisted(() => {
return pluginStore; return pluginStore;
}); });
const pluginLoaderCtor = vi.fn().mockImplementation(() => { const pluginLoaderCtor = vi.fn().mockImplementation(function () {
const pluginLoader = { const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined), loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
@@ -341,25 +343,31 @@ const mocks = vi.hoisted(() => {
refresh: vi.fn(), refresh: vi.fn(),
}; };
const agentSemaphoreCtor = vi.fn().mockImplementation(() => ({ const agentSemaphoreCtor = vi.fn().mockImplementation(function () {
_active: 0, return {
run: (fn: () => Promise<unknown>) => fn(), _active: 0,
})); run: (fn: () => Promise<unknown>) => fn(),
};
});
const heartbeatMonitorCtor = vi.fn().mockImplementation(() => ({ const heartbeatMonitorCtor = vi.fn().mockImplementation(function () {
start: vi.fn(), return {
stop: vi.fn(), start: vi.fn(),
startRun: vi.fn().mockResolvedValue({ id: "run-1" }), stop: vi.fn(),
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }), startRun: vi.fn().mockResolvedValue({ id: "run-1" }),
stopRun: vi.fn().mockResolvedValue(undefined), executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
})); stopRun: vi.fn().mockResolvedValue(undefined),
};
});
const heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(() => ({ const heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(function () {
start: vi.fn(), return {
stop: vi.fn(), start: vi.fn(),
registerAgent: vi.fn(), stop: vi.fn(),
getRegisteredAgents: vi.fn().mockReturnValue([]), registerAgent: vi.fn(),
})); getRegisteredAgents: vi.fn().mockReturnValue([]),
};
});
const createAiPromptExecutorMock = vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok")); const createAiPromptExecutorMock = vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok"));
const syncInsightExtractionAutomationMock = vi.fn().mockResolvedValue(undefined); const syncInsightExtractionAutomationMock = vi.fn().mockResolvedValue(undefined);
@@ -373,7 +381,7 @@ const mocks = vi.hoisted(() => {
pruning: { applied: false }, 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 store = taskStoreCtor(runtimeConfig.workingDirectory);
const automationStore = automationStoreCtor(runtimeConfig.workingDirectory); const automationStore = automationStoreCtor(runtimeConfig.workingDirectory);
const agentStore = agentStoreCtor(); const agentStore = agentStoreCtor();
@@ -520,17 +528,21 @@ vi.mock("@fusion/core", async (importOriginal) => {
CentralCore: mocks.centralCoreCtor, CentralCore: mocks.centralCoreCtor,
PluginStore: mocks.pluginStoreCtor, PluginStore: mocks.pluginStoreCtor,
PluginLoader: mocks.pluginLoaderCtor, PluginLoader: mocks.pluginLoaderCtor,
GlobalSettingsStore: vi.fn().mockImplementation(() => mocks.globalSettingsStoreInstance), GlobalSettingsStore: vi.fn().mockImplementation(function () {
return mocks.globalSettingsStoreInstance;
}),
resolveGlobalDir: vi.fn().mockReturnValue("/home/user/.fusion"), resolveGlobalDir: vi.fn().mockReturnValue("/home/user/.fusion"),
getEnabledPiExtensionPaths: vi.fn(() => []), getEnabledPiExtensionPaths: vi.fn(() => []),
DaemonTokenManager: vi.fn().mockImplementation(() => ({ DaemonTokenManager: vi.fn().mockImplementation(function () {
getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)), return {
generateToken: vi.fn().mockImplementation(() => { getToken: vi.fn().mockImplementation(() => Promise.resolve(mocks.globalSettingsData.daemonToken as string | undefined)),
const token = "fn_a1b2c3d4e5f6789012345678901234ab"; generateToken: vi.fn().mockImplementation(function () {
mocks.globalSettingsData.daemonToken = token; const token = "fn_a1b2c3d4e5f6789012345678901234ab";
return Promise.resolve(token); mocks.globalSettingsData.daemonToken = token;
}), return Promise.resolve(token);
})), }),
};
}),
getTaskMergeBlocker: vi.fn().mockReturnValue(null), getTaskMergeBlocker: vi.fn().mockReturnValue(null),
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock, syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction", INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
@@ -540,7 +552,9 @@ vi.mock("@fusion/core", async (importOriginal) => {
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock, createServer: mocks.createServerMock,
GitHubClient: vi.fn().mockImplementation(() => ({})), GitHubClient: vi.fn().mockImplementation(function () {
return {};
}),
createSkillsAdapter: vi.fn().mockReturnValue(undefined), createSkillsAdapter: vi.fn().mockReturnValue(undefined),
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
@@ -550,7 +564,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine"); const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), { 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(function (centralCore: any, options: any) {
const engines = new Map<string, any>(); const engines = new Map<string, any>();
return { return {
startAll: vi.fn(async () => { startAll: vi.fn(async () => {
@@ -578,27 +592,35 @@ vi.mock("@fusion/engine", async (importOriginal) => {
startReconciliation: vi.fn(), startReconciliation: vi.fn(),
}; };
}), }),
PeerExchangeService: vi.fn().mockImplementation(() => ({ PeerExchangeService: vi.fn().mockImplementation(function () {
start: vi.fn(), return {
stop: vi.fn().mockResolvedValue(undefined), start: vi.fn(),
updateGlobalSettings: vi.fn(), stop: vi.fn().mockResolvedValue(undefined),
})), updateGlobalSettings: vi.fn(),
};
}),
TriageProcessor: mocks.triageCtor, TriageProcessor: mocks.triageCtor,
TaskExecutor: mocks.executorCtor, TaskExecutor: mocks.executorCtor,
Scheduler: mocks.schedulerCtor, Scheduler: mocks.schedulerCtor,
AgentSemaphore: mocks.agentSemaphoreCtor, AgentSemaphore: mocks.agentSemaphoreCtor,
WorktreePool: vi.fn().mockImplementation(() => ({ WorktreePool: vi.fn().mockImplementation(function () {
rehydrate: vi.fn(), return {
})), rehydrate: vi.fn(),
};
}),
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }), aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
UsageLimitPauser: vi.fn().mockImplementation(() => ({})), UsageLimitPauser: vi.fn().mockImplementation(function () {
return {};
}),
PRIORITY_MERGE: 100, PRIORITY_MERGE: 100,
scanIdleWorktrees: vi.fn().mockResolvedValue([]), scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0), cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
NtfyNotifier: mocks.notifierCtor, NtfyNotifier: mocks.notifierCtor,
PrMonitor: vi.fn().mockImplementation(() => ({ PrMonitor: vi.fn().mockImplementation(function () {
onNewComments: vi.fn(), return {
})), onNewComments: vi.fn(),
};
}),
PrCommentHandler: vi.fn().mockImplementation(() => ({ PrCommentHandler: vi.fn().mockImplementation(() => ({
handleNewComments: vi.fn(), handleNewComments: vi.fn(),
createFollowUpTask: vi.fn().mockResolvedValue(undefined), createFollowUpTask: vi.fn().mockResolvedValue(undefined),
@@ -619,9 +641,11 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {
create: vi.fn(() => mocks.authStorage), create: vi.fn(() => mocks.authStorage),
}, },
DefaultPackageManager: vi.fn().mockImplementation(() => ({ DefaultPackageManager: vi.fn().mockImplementation(function () {
resolve: vi.fn().mockResolvedValue({ extensions: [] }), return {
})), resolve: vi.fn().mockResolvedValue({ extensions: [] }),
};
}),
ModelRegistry: { ModelRegistry: {
create: vi.fn(() => mocks.modelRegistry), create: vi.fn(() => mocks.modelRegistry),
inMemory: vi.fn(() => mocks.modelRegistry), inMemory: vi.fn(() => mocks.modelRegistry),
@@ -642,6 +666,8 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
createGroupPrCallback: vi.fn(() => vi.fn()), createGroupPrCallback: vi.fn(() => vi.fn()),
syncGroupPrCallback: vi.fn(() => vi.fn()), syncGroupPrCallback: vi.fn(() => vi.fn()),
createPrNodeGithubOps: vi.fn(() => ({})),
createPrReconcileGithubOps: vi.fn(() => ({})),
})); }));
vi.mock("../project-context.js", () => ({ vi.mock("../project-context.js", () => ({
@@ -679,9 +705,9 @@ describe("runDaemon", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { 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 () => { it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core"); const { PluginLoader } = await import("@fusion/core");
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({ (PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(function () {
loadPlugin: vi.fn().mockResolvedValue(undefined), return {
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")), loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
reloadPlugin: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]), reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPlugin: vi.fn(), getPluginRoutes: vi.fn().mockReturnValue([]),
getLoadedPlugins: vi.fn().mockReturnValue([]), getPlugin: vi.fn(),
})); getLoadedPlugins: vi.fn().mockReturnValue([]),
};
});
await expect(runDaemon({})).resolves.toBeUndefined(); await expect(runDaemon({})).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith( expect(errorSpy).toHaveBeenCalledWith(
@@ -937,8 +965,8 @@ describe("runDaemon --token-only mode", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.reset(); mocks.reset();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processExitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { processExitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`); throw new Error(`process.exit:${code ?? 0}`);

View File

@@ -4,15 +4,32 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
function makeConstructibleMock<T extends (...args: any[]) => 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<T>) {
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(() => ({ const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCtor, mockHybridExecutorInitialize, mockHybridExecutorShutdown } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined), mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }),
mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined),
mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined),
mockHybridExecutorCtor: vi.fn().mockImplementation(() => ({ mockHybridExecutorCtor: makeConstructibleMock(function () {
initialize: mockHybridExecutorInitialize, return {
shutdown: mockHybridExecutorShutdown, initialize: mockHybridExecutorInitialize,
})), shutdown: mockHybridExecutorShutdown,
};
}),
})); }));
vi.mock("../startup-model-sync.js", () => ({ vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels, syncStartupModels: mockSyncStartupModels,
@@ -137,8 +154,8 @@ function makeMockStore() {
vi.mock("@fusion/core", async (importOriginal) => { vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine"); const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), { return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: vi.fn().mockImplementation(() => makeMockStore()), TaskStore: makeConstructibleMock(() => makeMockStore()),
CentralCore: vi.fn().mockImplementation(() => ({ CentralCore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), getProjectByPath: vi.fn().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() }, { 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), init: vi.fn().mockResolvedValue(undefined),
listSchedules: vi.fn().mockResolvedValue([]), listSchedules: vi.fn().mockResolvedValue([]),
getSchedule: vi.fn().mockResolvedValue(null), getSchedule: vi.fn().mockResolvedValue(null),
@@ -159,7 +176,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
recordRun: vi.fn().mockResolvedValue({}), recordRun: vi.fn().mockResolvedValue({}),
getDueSchedules: vi.fn().mockResolvedValue([]), getDueSchedules: vi.fn().mockResolvedValue([]),
})), })),
AgentStore: vi.fn().mockImplementation(() => ({ AgentStore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
createAgent: vi.fn(), createAgent: vi.fn(),
updateAgentState: 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 }), getBudgetStatus: vi.fn().mockResolvedValue({ isOverBudget: false, isOverThreshold: false, usagePercent: 0 }),
getRecentRuns: vi.fn().mockResolvedValue([]), getRecentRuns: vi.fn().mockResolvedValue([]),
})), })),
PluginStore: vi.fn().mockImplementation(() => { PluginStore: makeConstructibleMock(function () {
const emitter = new EventEmitter(); const emitter = new EventEmitter();
return { return {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
@@ -193,7 +210,7 @@ vi.mock("@fusion/core", async (importOriginal) => {
emit: emitter.emit.bind(emitter), emit: emitter.emit.bind(emitter),
}; };
}), }),
PluginLoader: vi.fn().mockImplementation(() => { PluginLoader: makeConstructibleMock(function () {
const emitter = new EventEmitter(); const emitter = new EventEmitter();
return { return {
loadPlugin: vi.fn().mockResolvedValue(undefined), loadPlugin: vi.fn().mockResolvedValue(undefined),
@@ -214,12 +231,12 @@ vi.mock("@fusion/core", async (importOriginal) => {
}), }),
getEnabledPiExtensionPaths: vi.fn(() => []), getEnabledPiExtensionPaths: vi.fn(() => []),
resolveGlobalDir: mockResolveGlobalDir, resolveGlobalDir: mockResolveGlobalDir,
GlobalSettingsStore: vi.fn().mockImplementation(() => ({ GlobalSettingsStore: makeConstructibleMock(() => ({
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGlobalSettingsGetSettings, getSettings: mockGlobalSettingsGetSettings,
updateSettings: mockGlobalSettingsUpdateSettings, updateSettings: mockGlobalSettingsUpdateSettings,
})), })),
DaemonTokenManager: vi.fn().mockImplementation(() => ({ DaemonTokenManager: makeConstructibleMock(() => ({
getOrCreateToken: mockDaemonTokenGetOrCreate, getOrCreateToken: mockDaemonTokenGetOrCreate,
getToken: vi.fn().mockResolvedValue(undefined), getToken: vi.fn().mockResolvedValue(undefined),
generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"), generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
@@ -330,7 +347,7 @@ vi.mock("@fusion/dashboard", () => ({
opts.onProjectFirstAccessed?.("project-1"); opts.onProjectFirstAccessed?.("project-1");
return { listen: mockListen }; return { listen: mockListen };
}), }),
GitHubClient: vi.fn().mockImplementation(() => ({ GitHubClient: makeConstructibleMock(() => ({
findPrForBranch: mockFindPrForBranch, findPrForBranch: mockFindPrForBranch,
createPr: mockCreatePr, createPr: mockCreatePr,
getPrMergeStatus: mockGetPrMergeStatus, getPrMergeStatus: mockGetPrMergeStatus,
@@ -359,7 +376,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine"); const { createCliEngineMock } = await import("../../test/mockCoreEngine");
const coreModule = await import("@fusion/core"); const coreModule = await import("@fusion/core");
const taskStoreMock = (coreModule.TaskStore as any); const taskStoreMock = (coreModule.TaskStore as any);
const TriageProcessor = vi.fn().mockImplementation(() => ({ const TriageProcessor = makeConstructibleMock(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
})); }));
@@ -369,7 +386,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
resumeOrphaned: vi.fn().mockResolvedValue(undefined), resumeOrphaned: vi.fn().mockResolvedValue(undefined),
}; };
}); });
const StuckTaskDetector = vi.fn().mockImplementation(() => ({ const StuckTaskDetector = makeConstructibleMock(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
checkNow: mockStuckCheckNow, checkNow: mockStuckCheckNow,
@@ -377,11 +394,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
untrackTask: vi.fn(), untrackTask: vi.fn(),
markTaskProgress: vi.fn(), markTaskProgress: vi.fn(),
})); }));
const Scheduler = vi.fn().mockImplementation(() => ({ const Scheduler = makeConstructibleMock(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
})); }));
const PrMonitor = vi.fn().mockImplementation(() => ({ const PrMonitor = makeConstructibleMock(() => ({
onNewComments: vi.fn(), onNewComments: vi.fn(),
startMonitoring: vi.fn(), startMonitoring: vi.fn(),
stopMonitoring: vi.fn(), stopMonitoring: vi.fn(),
@@ -390,12 +407,12 @@ vi.mock("@fusion/engine", async (importOriginal) => {
updatePrInfo: vi.fn(), updatePrInfo: vi.fn(),
drainComments: vi.fn().mockReturnValue([]), drainComments: vi.fn().mockReturnValue([]),
})); }));
const PrCommentHandler = vi.fn().mockImplementation(() => ({ const PrCommentHandler = makeConstructibleMock(() => ({
handleNewComments: vi.fn().mockResolvedValue(undefined), handleNewComments: vi.fn().mockResolvedValue(undefined),
createFollowUpTask: vi.fn().mockResolvedValue(undefined), createFollowUpTask: vi.fn().mockResolvedValue(undefined),
})); }));
const aiMergeTask = vi.fn().mockImplementation(() => Promise.resolve({ merged: true })); const aiMergeTask = vi.fn().mockImplementation(() => Promise.resolve({ merged: true }));
const CronRunner = vi.fn().mockImplementation(() => ({ const CronRunner = makeConstructibleMock(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
})); }));
@@ -674,7 +691,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
AgentSemaphore: original.AgentSemaphore, AgentSemaphore: original.AgentSemaphore,
// Stub heavy classes/functions // Stub heavy classes/functions
ProjectEngine, ProjectEngine,
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => { ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => {
const engines = new Map<string, any>(); const engines = new Map<string, any>();
const starting = new Map<string, Promise<any>>(); const starting = new Map<string, Promise<any>>();
// Keep the chosen HEAD startEngine/starting async shape from the conflict resolution. // 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(), startReconciliation: vi.fn(),
}; };
}), }),
ProjectManager: vi.fn().mockImplementation(() => ({ ProjectManager: makeConstructibleMock(() => ({
getRuntime: vi.fn().mockReturnValue(undefined), getRuntime: vi.fn().mockReturnValue(undefined),
addProject: vi.fn().mockResolvedValue(undefined), addProject: vi.fn().mockResolvedValue(undefined),
stopAll: vi.fn().mockResolvedValue(undefined), stopAll: vi.fn().mockResolvedValue(undefined),
@@ -741,12 +758,12 @@ vi.mock("@fusion/engine", async (importOriginal) => {
CronRunner, CronRunner,
createAiPromptExecutor, createAiPromptExecutor,
SelfHealingManager, SelfHealingManager,
MissionAutopilot: vi.fn().mockImplementation(() => ({ MissionAutopilot: makeConstructibleMock(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
setScheduler: vi.fn(), setScheduler: vi.fn(),
})), })),
PluginLoader: vi.fn().mockImplementation(() => ({ PluginLoader: makeConstructibleMock(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined), loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined), reloadPlugin: vi.fn().mockResolvedValue(undefined),
@@ -754,7 +771,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
getPlugin: vi.fn(), getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]), getLoadedPlugins: vi.fn().mockReturnValue([]),
})), })),
PeerExchangeService: vi.fn().mockImplementation(() => ({ PeerExchangeService: makeConstructibleMock(() => ({
start: vi.fn(), start: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
updateGlobalSettings: vi.fn(), updateGlobalSettings: vi.fn(),
@@ -772,7 +789,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {
create: vi.fn(() => mockAuthStorage), create: vi.fn(() => mockAuthStorage),
}, },
DefaultPackageManager: vi.fn().mockImplementation(() => ({ DefaultPackageManager: makeConstructibleMock(() => ({
resolve: vi.fn().mockResolvedValue({ extensions: [] }), resolve: vi.fn().mockResolvedValue({ extensions: [] }),
})), })),
ModelRegistry: { ModelRegistry: {
@@ -3103,6 +3120,8 @@ describe("StreamedLogBuffer", () => {
describe("runDashboard — merge stream sink routing", () => { describe("runDashboard — merge stream sink routing", () => {
it("routes streamed merge deltas through log sink without raw stdout writes", async () => { 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"; process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core"); const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
const { aiMergeTask } = await import("@fusion/engine"); const { aiMergeTask } = await import("@fusion/engine");

View File

@@ -1,6 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js"; import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js";
function makeConstructibleMock<T extends (...args: any[]) => 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<T>) {
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 = { const mockRun = {
id: "RR-001", id: "RR-001",
query: "test query", query: "test query",
@@ -35,12 +50,12 @@ const orchestratorMock = {
const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => ({ const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => ({
resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })), 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), writeFileMock: vi.fn(async () => undefined),
})); }));
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", () => ({
TaskStore: vi.fn(() => storeMock), TaskStore: makeConstructibleMock(() => storeMock),
resolveResearchSettings: resolveResearchSettingsMock, resolveResearchSettings: resolveResearchSettingsMock,
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"], RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"], RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
@@ -49,7 +64,7 @@ vi.mock("@fusion/core", () => ({
vi.mock("@fusion/engine", () => ({ vi.mock("@fusion/engine", () => ({
ResearchProviderRegistry: providerRegistryMock, ResearchProviderRegistry: providerRegistryMock,
ResearchStepRunner: vi.fn(), ResearchStepRunner: vi.fn(),
ResearchOrchestrator: vi.fn(() => orchestratorMock), ResearchOrchestrator: makeConstructibleMock(() => orchestratorMock),
})); }));
vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) })); vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) }));
@@ -66,7 +81,7 @@ describe("research commands", () => {
throw new Error(`process.exit:${code ?? 0}`); throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit); }) as typeof process.exit);
resolveResearchSettingsMock.mockReturnValue({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } }); 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.getRun.mockReturnValue(mockRun);
researchStoreMock.listRuns.mockReturnValue([mockRun]); researchStoreMock.listRuns.mockReturnValue([mockRun]);
orchestratorMock.retryRun.mockReturnValue("RR-003"); orchestratorMock.retryRun.mockReturnValue("RR-003");
@@ -89,7 +104,7 @@ describe("research commands", () => {
searchProvider: "builtin", searchProvider: "builtin",
limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 }, 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" }); await runResearchCreate({ query: "hello builtin" });
@@ -154,7 +169,7 @@ describe("research commands", () => {
}); });
it("errors when providers are unavailable", async () => { 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"); await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable")); expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable"));
}); });

View File

@@ -9,10 +9,12 @@ const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCt
mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }),
mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined),
mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined),
mockHybridExecutorCtor: vi.fn().mockImplementation(() => ({ mockHybridExecutorCtor: vi.fn().mockImplementation(function () {
initialize: mockHybridExecutorInitialize, return {
shutdown: mockHybridExecutorShutdown, initialize: mockHybridExecutorInitialize,
})), shutdown: mockHybridExecutorShutdown,
};
}),
})); }));
vi.mock("../startup-model-sync.js", () => ({ vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels, syncStartupModels: mockSyncStartupModels,
@@ -132,13 +134,13 @@ const mocks = vi.hoisted(() => {
}); });
} }
const taskStoreCtor = vi.fn().mockImplementation(() => { const taskStoreCtor = vi.fn().mockImplementation(function () {
const store = createTaskStoreMock(); const store = createTaskStoreMock();
taskStores.push(store); taskStores.push(store);
return store; return store;
}); });
const automationStoreCtor = vi.fn().mockImplementation(() => { const automationStoreCtor = vi.fn().mockImplementation(function () {
const automationStore = { const automationStore = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
}; };
@@ -146,7 +148,7 @@ const mocks = vi.hoisted(() => {
return automationStore; return automationStore;
}); });
const agentStoreCtor = vi.fn().mockImplementation(() => { const agentStoreCtor = vi.fn().mockImplementation(function () {
const agentStore = { const agentStore = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
}; };
@@ -154,7 +156,7 @@ const mocks = vi.hoisted(() => {
return agentStore; return agentStore;
}); });
const centralCoreCtor = vi.fn().mockImplementation(() => { const centralCoreCtor = vi.fn().mockImplementation(function () {
const now = new Date().toISOString(); const now = new Date().toISOString();
const projects = [ const projects = [
{ ...PROJECT_FIXTURES.primary, createdAt: now, updatedAt: now }, { ...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 = { const triage = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -233,7 +235,7 @@ const mocks = vi.hoisted(() => {
return triage; return triage;
}); });
const executorCtor = vi.fn().mockImplementation(() => { const executorCtor = vi.fn().mockImplementation(function () {
const executor = { const executor = {
resumeOrphaned: vi.fn().mockResolvedValue(undefined), resumeOrphaned: vi.fn().mockResolvedValue(undefined),
markStuckAborted: vi.fn(), markStuckAborted: vi.fn(),
@@ -245,7 +247,7 @@ const mocks = vi.hoisted(() => {
return executor; return executor;
}); });
const schedulerCtor = vi.fn().mockImplementation(() => { const schedulerCtor = vi.fn().mockImplementation(function () {
const scheduler = { const scheduler = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -254,7 +256,7 @@ const mocks = vi.hoisted(() => {
return scheduler; return scheduler;
}); });
const stuckDetectorCtor = vi.fn().mockImplementation(() => { const stuckDetectorCtor = vi.fn().mockImplementation(function () {
const detector = { const detector = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -264,7 +266,7 @@ const mocks = vi.hoisted(() => {
return detector; return detector;
}); });
const selfHealingCtor = vi.fn().mockImplementation(() => { const selfHealingCtor = vi.fn().mockImplementation(function () {
const manager = { const manager = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -274,7 +276,7 @@ const mocks = vi.hoisted(() => {
return manager; return manager;
}); });
const cronRunnerCtor = vi.fn().mockImplementation(() => { const cronRunnerCtor = vi.fn().mockImplementation(function () {
const cron = { const cron = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -283,7 +285,7 @@ const mocks = vi.hoisted(() => {
return cron; return cron;
}); });
const missionAutopilotCtor = vi.fn().mockImplementation(() => { const missionAutopilotCtor = vi.fn().mockImplementation(function () {
const autopilot = { const autopilot = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -293,7 +295,7 @@ const mocks = vi.hoisted(() => {
return autopilot; return autopilot;
}); });
const missionExecutionLoopCtor = vi.fn().mockImplementation(() => { const missionExecutionLoopCtor = vi.fn().mockImplementation(function () {
const loop = { const loop = {
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
@@ -304,7 +306,7 @@ const mocks = vi.hoisted(() => {
return loop; return loop;
}); });
const notifierCtor = vi.fn().mockImplementation(() => { const notifierCtor = vi.fn().mockImplementation(function () {
const notifier = { const notifier = {
start: vi.fn(), start: vi.fn(),
stop: vi.fn(), stop: vi.fn(),
@@ -313,7 +315,7 @@ const mocks = vi.hoisted(() => {
return notifier; return notifier;
}); });
const pluginStoreCtor = vi.fn().mockImplementation(() => { const pluginStoreCtor = vi.fn().mockImplementation(function () {
const pluginStore = { const pluginStore = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]), listPlugins: vi.fn().mockResolvedValue([]),
@@ -329,7 +331,7 @@ const mocks = vi.hoisted(() => {
return pluginStore; return pluginStore;
}); });
const pluginLoaderCtor = vi.fn().mockImplementation(() => { const pluginLoaderCtor = vi.fn().mockImplementation(function () {
const pluginLoader = { const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined), loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
@@ -361,25 +363,31 @@ const mocks = vi.hoisted(() => {
refresh: vi.fn(), refresh: vi.fn(),
}; };
const agentSemaphoreCtor = vi.fn().mockImplementation(() => ({ const agentSemaphoreCtor = vi.fn().mockImplementation(function () {
_active: 0, return {
run: (fn: () => Promise<unknown>) => fn(), _active: 0,
})); run: (fn: () => Promise<unknown>) => fn(),
};
});
const heartbeatMonitorCtor = vi.fn().mockImplementation(() => ({ const heartbeatMonitorCtor = vi.fn().mockImplementation(function () {
start: vi.fn(), return {
stop: vi.fn(), start: vi.fn(),
startRun: vi.fn().mockResolvedValue({ id: "run-1" }), stop: vi.fn(),
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }), startRun: vi.fn().mockResolvedValue({ id: "run-1" }),
stopRun: vi.fn().mockResolvedValue(undefined), executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
})); stopRun: vi.fn().mockResolvedValue(undefined),
};
});
const heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(() => ({ const heartbeatTriggerSchedulerCtor = vi.fn().mockImplementation(function () {
start: vi.fn(), return {
stop: vi.fn(), start: vi.fn(),
registerAgent: vi.fn(), stop: vi.fn(),
getRegisteredAgents: vi.fn().mockReturnValue([]), registerAgent: vi.fn(),
})); getRegisteredAgents: vi.fn().mockReturnValue([]),
};
});
const createAiPromptExecutorMock = vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok")); const createAiPromptExecutorMock = vi.fn().mockResolvedValue(vi.fn().mockResolvedValue("ok"));
const syncInsightExtractionAutomationMock = vi.fn().mockResolvedValue(undefined); const syncInsightExtractionAutomationMock = vi.fn().mockResolvedValue(undefined);
@@ -393,7 +401,7 @@ const mocks = vi.hoisted(() => {
pruning: { applied: false }, 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 store = taskStoreCtor(runtimeConfig.workingDirectory);
const automationStore = automationStoreCtor(runtimeConfig.workingDirectory); const automationStore = automationStoreCtor(runtimeConfig.workingDirectory);
const agentStore = agentStoreCtor(); const agentStore = agentStoreCtor();
@@ -575,19 +583,25 @@ vi.mock("@fusion/core", async (importOriginal) => {
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,
DaemonTokenManager: vi.fn().mockImplementation(() => ({ DaemonTokenManager: vi.fn().mockImplementation(function () {
getToken: vi.fn().mockResolvedValue(null), return {
generateToken: vi.fn().mockResolvedValue("fn_generated1234567890"), getToken: vi.fn().mockResolvedValue(null),
storeToken: vi.fn().mockResolvedValue(undefined), generateToken: vi.fn().mockResolvedValue("fn_generated1234567890"),
})), storeToken: vi.fn().mockResolvedValue(undefined),
GlobalSettingsStore: vi.fn().mockImplementation(() => ({})), };
}),
GlobalSettingsStore: vi.fn().mockImplementation(function () {
return {};
}),
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,
GitHubClient: vi.fn().mockImplementation(() => ({})), GitHubClient: vi.fn().mockImplementation(function () {
return {};
}),
createSkillsAdapter: vi.fn().mockReturnValue(undefined), createSkillsAdapter: vi.fn().mockReturnValue(undefined),
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
@@ -597,7 +611,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine"); const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), { 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(function (centralCore: any, options: any) {
const engines = new Map<string, any>(); const engines = new Map<string, any>();
return { return {
startAll: vi.fn(async () => { startAll: vi.fn(async () => {
@@ -629,26 +643,34 @@ vi.mock("@fusion/engine", async (importOriginal) => {
startReconciliation: vi.fn(), startReconciliation: vi.fn(),
}; };
}), }),
PeerExchangeService: vi.fn().mockImplementation(() => ({ PeerExchangeService: vi.fn().mockImplementation(function () {
start: vi.fn(), return {
stop: vi.fn().mockResolvedValue(undefined), start: vi.fn(),
})), stop: vi.fn().mockResolvedValue(undefined),
};
}),
TriageProcessor: mocks.triageCtor, TriageProcessor: mocks.triageCtor,
TaskExecutor: mocks.executorCtor, TaskExecutor: mocks.executorCtor,
Scheduler: mocks.schedulerCtor, Scheduler: mocks.schedulerCtor,
AgentSemaphore: mocks.agentSemaphoreCtor, AgentSemaphore: mocks.agentSemaphoreCtor,
WorktreePool: vi.fn().mockImplementation(() => ({ WorktreePool: vi.fn().mockImplementation(function () {
rehydrate: vi.fn(), return {
})), rehydrate: vi.fn(),
};
}),
aiMergeTask: vi.fn().mockResolvedValue({ merged: true }), aiMergeTask: vi.fn().mockResolvedValue({ merged: true }),
UsageLimitPauser: vi.fn().mockImplementation(() => ({})), UsageLimitPauser: vi.fn().mockImplementation(function () {
return {};
}),
PRIORITY_MERGE: 100, PRIORITY_MERGE: 100,
scanIdleWorktrees: vi.fn().mockResolvedValue([]), scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0), cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
NtfyNotifier: mocks.notifierCtor, NtfyNotifier: mocks.notifierCtor,
PrMonitor: vi.fn().mockImplementation(() => ({ PrMonitor: vi.fn().mockImplementation(function () {
onNewComments: vi.fn(), return {
})), onNewComments: vi.fn(),
};
}),
PrCommentHandler: vi.fn().mockImplementation(() => ({ PrCommentHandler: vi.fn().mockImplementation(() => ({
handleNewComments: vi.fn(), handleNewComments: vi.fn(),
createFollowUpTask: vi.fn().mockResolvedValue(undefined), createFollowUpTask: vi.fn().mockResolvedValue(undefined),
@@ -669,9 +691,11 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {
create: vi.fn(() => mocks.authStorage), create: vi.fn(() => mocks.authStorage),
}, },
DefaultPackageManager: vi.fn().mockImplementation(() => ({ DefaultPackageManager: vi.fn().mockImplementation(function () {
resolve: vi.fn().mockResolvedValue({ extensions: [] }), return {
})), resolve: vi.fn().mockResolvedValue({ extensions: [] }),
};
}),
ModelRegistry: { ModelRegistry: {
create: vi.fn(() => mocks.modelRegistry), create: vi.fn(() => mocks.modelRegistry),
inMemory: vi.fn(() => mocks.modelRegistry), inMemory: vi.fn(() => mocks.modelRegistry),
@@ -696,6 +720,8 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"), processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
createGroupPrCallback: vi.fn(() => vi.fn()), createGroupPrCallback: vi.fn(() => vi.fn()),
syncGroupPrCallback: vi.fn(() => vi.fn()), syncGroupPrCallback: vi.fn(() => vi.fn()),
createPrNodeGithubOps: vi.fn(() => ({})),
createPrReconcileGithubOps: vi.fn(() => ({})),
})); }));
vi.mock("../project-context.js", () => ({ vi.mock("../project-context.js", () => ({
@@ -735,9 +761,9 @@ describe("runServe", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
@@ -956,7 +982,7 @@ describe("runServe — Plugin wiring", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") { if (event === "SIGINT" || event === "SIGTERM") {
@@ -1041,16 +1067,18 @@ describe("runServe — Plugin wiring", () => {
it("continues startup when plugin auto-load fails", async () => { it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core"); const { PluginLoader } = await import("@fusion/core");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({ (PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(function () {
loadPlugin: vi.fn().mockResolvedValue(undefined), return {
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")), loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined), loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
reloadPlugin: vi.fn().mockResolvedValue(undefined), stopPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]), reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPlugin: vi.fn(), getPluginRoutes: vi.fn().mockReturnValue([]),
getLoadedPlugins: vi.fn().mockReturnValue([]), getPlugin: vi.fn(),
})); getLoadedPlugins: vi.fn().mockReturnValue([]),
};
});
await expect(runServe(4040, {})).resolves.toBeUndefined(); await expect(runServe(4040, {})).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith( expect(errorSpy).toHaveBeenCalledWith(
@@ -1101,9 +1129,9 @@ describe("runServe — Memory Insight Automation wiring", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
@@ -1139,7 +1167,9 @@ describe("runServe — Memory Insight Automation wiring", () => {
stopDiscovery: vi.fn(), stopDiscovery: vi.fn(),
}; };
mocks.centralInstances.push(instance); mocks.centralInstances.push(instance);
CentralCore.mockImplementation(() => instance); CentralCore.mockImplementation(function () {
return instance;
});
}); });
afterEach(() => { afterEach(() => {
@@ -1229,7 +1259,7 @@ describe("runServe — Memory Insight Automation wiring", () => {
it("handles syncInsightExtractionAutomation errors gracefully", async () => { it("handles syncInsightExtractionAutomation errors gracefully", async () => {
const { syncInsightExtractionAutomation } = await import("@fusion/core"); 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")); syncInsightExtractionAutomation.mockRejectedValueOnce(new Error("Sync failed"));
await runServe(4040, {}); await runServe(4040, {});
@@ -1300,7 +1330,9 @@ describe("runServe — Semaphore boundary (task lanes only)", () => {
stopDiscovery: vi.fn(), stopDiscovery: vi.fn(),
}; };
mocks.centralInstances.push(instance); mocks.centralInstances.push(instance);
CentralCore.mockImplementation(() => instance); CentralCore.mockImplementation(function () {
return instance;
});
}); });
afterEach(() => { afterEach(() => {
@@ -1452,7 +1484,7 @@ describe("runServe — Peer exchange and discovery", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); warnSpy = vi.spyOn(console, "warn").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") { 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 // Override CentralCore to use original implementation that pushes to centralInstances
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
// Reset to the original constructor that creates and pushes instances // Reset to the original constructor that creates and pushes instances
CentralCore.mockImplementation(() => { CentralCore.mockImplementation(function () {
const instance = { const instance = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
@@ -1625,7 +1657,7 @@ describe("runServe --daemon flag", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") { if (event === "SIGINT" || event === "SIGTERM") {
@@ -1637,7 +1669,7 @@ describe("runServe --daemon flag", () => {
// Override CentralCore to use original implementation that pushes to centralInstances // Override CentralCore to use original implementation that pushes to centralInstances
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
CentralCore.mockImplementation(() => { CentralCore.mockImplementation(function () {
const instance = { const instance = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
@@ -1817,7 +1849,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
signalHandlers = { SIGINT: [], SIGTERM: [] }; signalHandlers = { SIGINT: [], SIGTERM: [] };
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(function () {});
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo"); cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/repo");
processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => { processOnSpy = vi.spyOn(process, "on").mockImplementation(((event: string, listener: () => void) => {
if (event === "SIGINT" || event === "SIGTERM") { 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 // Override CentralCore to use original implementation that pushes to centralInstances
const { CentralCore } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core");
CentralCore.mockImplementation(() => { CentralCore.mockImplementation(function () {
const instance = { const instance = {
init: vi.fn().mockResolvedValue(undefined), init: vi.fn().mockResolvedValue(undefined),
close: 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 () => { it("--no-auto-register falls back to existing started engines", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-")); const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd); cwdSpy.mockReturnValue(freshCwd);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered") const ensureSpy = vi.spyOn(ensureProjectRegisteredModule, "ensureCwdProjectRegistered")
.mockResolvedValue(null); .mockResolvedValue(null);

View File

@@ -54,6 +54,23 @@ vi.mock("@fusion/core", async (importActual) => {
return impl(...args); return impl(...args);
})) as typeof TaskStoreMock.mockImplementation; })) 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 { return {
...actual, ...actual,
TaskStore: TaskStoreMock, TaskStore: TaskStoreMock,
@@ -74,18 +91,7 @@ vi.mock("@fusion/core", async (importActual) => {
} }
return ids; return ids;
}), }),
CentralCore: vi.fn().mockImplementation(function() { CentralCore: CentralCoreMock,
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),
};
}),
}; };
}); });
@@ -94,9 +100,11 @@ vi.mock("@fusion/engine", () => ({ aiMergeTask: vi.fn() }));
// Mock @fusion/dashboard // Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/dashboard", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({ GitHubClient: vi.fn().mockImplementation(function () {
createPr: vi.fn(), return {
})), createPr: vi.fn(),
};
}),
generatePrMetadata: vi.fn(), generatePrMetadata: vi.fn(),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
})); }));
@@ -167,6 +175,8 @@ function makeTask(overrides: Record<string, unknown> = {}) {
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveProject).mockRejectedValue(new Error("No project context"));
vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({ vi.mocked(runDeterministicDuplicateGuard).mockResolvedValue({
action: "proceed", action: "proceed",
fingerprint: null, fingerprint: null,
@@ -973,12 +983,16 @@ describe("project-aware task command behavior", () => {
vi.mocked(isGhAvailable).mockReturnValue(true); vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true); vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(getCurrentRepo).mockReturnValue({ owner: "acme", repo: "demo" }); 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<typeof vi.fn>).mockImplementation(() => ({ (TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(), init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-001", column: "in-review", branchName: "fusion/fn-001" })), getTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-001", column: "in-review", branchName: "fusion/fn-001" })),
updatePrInfo: vi.fn().mockResolvedValue(undefined), updatePrInfo: vi.fn().mockResolvedValue(undefined),
ensurePrEntityForSource: vi.fn(() => ({ id: "pr-entity-1" })),
updatePrEntity: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
})); }));
@@ -2607,10 +2621,10 @@ describe("runTaskRetry", () => {
error: null, error: null,
mergeRetries: 0, mergeRetries: 0,
})); }));
expect(mockMoveTask).not.toHaveBeenCalled(); expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith( expect(mockLogEntry).toHaveBeenCalledWith(
"FN-001", "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(() => { beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
@@ -3025,9 +3040,11 @@ describe("runTaskPrCreate", () => {
// Setup GitHubClient mock // Setup GitHubClient mock
mockCreatePr = vi.fn(); mockCreatePr = vi.fn();
vi.mocked(GitHubClient).mockImplementation(() => ({ vi.mocked(GitHubClient).mockImplementation(function () {
createPr: mockCreatePr, return {
} as unknown as GitHubClient)); createPr: mockCreatePr,
} as unknown as GitHubClient;
});
vi.mocked(generatePrMetadata).mockResolvedValue({ title: "AI Generated Title", body: "AI Generated Body", templateUsed: false }); vi.mocked(generatePrMetadata).mockResolvedValue({ title: "AI Generated Title", body: "AI Generated Body", templateUsed: false });
// Setup gh-cli mocks // Setup gh-cli mocks
@@ -3044,6 +3061,8 @@ describe("runTaskPrCreate", () => {
init: vi.fn(), init: vi.fn(),
getTask: mockGetTask, getTask: mockGetTask,
updatePrInfo: mockUpdatePrInfo, updatePrInfo: mockUpdatePrInfo,
ensurePrEntityForSource: vi.fn(() => ({ id: "pr-entity-1" })),
updatePrEntity: vi.fn(),
logEntry: mockLogEntry, logEntry: mockLogEntry,
})); }));
}); });

View File

@@ -1058,6 +1058,7 @@ export async function runTaskRetry(id: string, projectName?: string) {
// and merge failures (all steps done). // and merge failures (all steps done).
if (isInReviewRetry) { if (isInReviewRetry) {
if (isExecutionFailureInReview) { if (isExecutionFailureInReview) {
await store.moveTask(id, "todo", { preserveProgress: true });
await store.updateTask(id, { await store.updateTask(id, {
status: null, status: null,
error: 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 (stranded in-review execution retry → todo, preserving progress${retryLogSuffix})`
: `Retry requested from CLI (execution failure in-review → 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();
console.log(` ✓ Retried ${id} → todo (execution failure, preserving step progress)`); console.log(` ✓ Retried ${id} → todo (execution failure, preserving step progress)`);
@@ -1078,20 +1078,26 @@ export async function runTaskRetry(id: string, projectName?: string) {
return; return;
} }
await store.moveTask(id, "todo");
await store.updateTask(id, { await store.updateTask(id, {
status: null, status: null,
error: null, error: null,
...autoPauseClearPatch, ...autoPauseClearPatch,
...buildManualRetryResetPatch({ resetMergeRetries: true }), ...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();
console.log(` ✓ Retried ${id} → in-review (merge retry state cleared)`); console.log(` ✓ Retried ${id} → todo (merge retry state cleared)`);
console.log(); console.log();
return; 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. // Clear failure state and stale branch refs so retry can choose a fresh base.
await store.updateTask(id, { await store.updateTask(id, {
status: null, status: null,
@@ -1104,9 +1110,6 @@ export async function runTaskRetry(id: string, projectName?: string) {
...buildManualRetryResetPatch({ resetMergeRetries: true }), ...buildManualRetryResetPatch({ resetMergeRetries: true }),
}); });
// Move to todo column
await store.moveTask(id, 'todo');
// Log the retry action // Log the retry action
await store.logEntry( await store.logEntry(
id, id,

View File

@@ -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<string, unknown>;
}
export function createBoardActionServices(store: BoardActionTaskStore) {
return {
moveTask(input: MoveBoardTaskInput): Promise<Task> {
return store.moveTask(input.taskId, input.column, {
preserveProgress: input.preserveProgress,
moveSource: input.source ?? "user",
});
},
updateTask(input: UpdateBoardTaskInput): Promise<Task> {
return store.updateTask(input.taskId, input.updates);
},
};
}

View File

@@ -130,6 +130,7 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal
} }
const pluginSdkEntry = join(__dirname, "..", "plugin-sdk", "src", "index.ts"); const pluginSdkEntry = join(__dirname, "..", "plugin-sdk", "src", "index.ts");
const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.ts");
const cliBuildConfig = { const cliBuildConfig = {
entry: ["src/bin.ts", "src/extension.ts"], entry: ["src/bin.ts", "src/extension.ts"],
@@ -321,6 +322,12 @@ const pluginSdkBuildConfig = {
}, },
}, },
noExternal: [/^@fusion\//], noExternal: [/^@fusion\//],
esbuildOptions(options: { alias?: Record<string, string> }) {
options.alias = {
...(options.alias || {}),
"@fusion/core": pluginSdkCoreRuntimeShim,
};
},
clean: false, clean: false,
outDir: "dist", outDir: "dist",
}; };