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",
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();

View File

@@ -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<unknown>) => fn(),
}));
const agentSemaphoreCtor = vi.fn().mockImplementation(function () {
return {
_active: 0,
run: (fn: () => Promise<unknown>) => fn(),
};
});
const heartbeatMonitorCtor = vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
startRun: vi.fn().mockResolvedValue({ id: "run-1" }),
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
stopRun: vi.fn().mockResolvedValue(undefined),
}));
const 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<typeof import("@fusion/engine")>(), {
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>();
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<typeof vi.fn>).mockImplementationOnce(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
}));
(PluginLoader as unknown as ReturnType<typeof vi.fn>).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}`);

View File

@@ -4,15 +4,32 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
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(() => ({
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<typeof import("@fusion/core")>(), {
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<string, any>();
const starting = new Map<string, Promise<any>>();
// 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");

View File

@@ -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<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 = {
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"));
});

View File

@@ -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<unknown>) => fn(),
}));
const agentSemaphoreCtor = vi.fn().mockImplementation(function () {
return {
_active: 0,
run: (fn: () => Promise<unknown>) => fn(),
};
});
const heartbeatMonitorCtor = vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
startRun: vi.fn().mockResolvedValue({ id: "run-1" }),
executeHeartbeat: vi.fn().mockResolvedValue({ id: "run-1" }),
stopRun: vi.fn().mockResolvedValue(undefined),
}));
const 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<typeof import("@fusion/engine")>(), {
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>();
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<typeof vi.fn>).mockImplementationOnce(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
}));
const errorSpy = vi.spyOn(console, "error").mockImplementation(function () {});
(PluginLoader as unknown as ReturnType<typeof vi.fn>).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);

View File

@@ -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<string, unknown> = {}) {
}
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<typeof vi.fn>).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,
}));
});

View File

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

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 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<string, string> }) {
options.alias = {
...(options.alias || {}),
"@fusion/core": pluginSdkCoreRuntimeShim,
};
},
clean: false,
outDir: "dist",
};