FN-7000: test unified CLI merge entrypoints

Strengthen CLI merge tests around the unified runAiMerge path.\n\n- Mock runAiMerge separately from deprecated aiMergeTask in CLI task and dashboard tests.\n- Assert task merge uses runAiMerge without falling back to workspace landing or aiMergeTask.\n- Verify dashboard manual merge logging streams through runAiMerge and restores spies safely.\n\nFiles changed:\n .../cli/src/commands/__tests__/dashboard.test.ts   | 51 +++++++++++++---------\n packages/cli/src/commands/__tests__/task.test.ts   | 30 +++++++++----\n 2 files changed, 52 insertions(+), 29 deletions(-)

Fusion-Task-Id: FN-7000

Fusion-Task-Lineage: 96080841-c813-4a9e-97bd-a41aba60089f
This commit is contained in:
gsxdsm
2026-06-24 23:25:45 -07:00
parent bd5a779423
commit f9816799a0
2 changed files with 49 additions and 26 deletions

View File

@@ -412,6 +412,8 @@ vi.mock("@fusion/engine", async (importOriginal) => {
createFollowUpTask: vi.fn().mockResolvedValue(undefined),
}));
const aiMergeTask = vi.fn().mockImplementation(() => Promise.resolve({ merged: true }));
const runAiMerge = vi.fn().mockImplementation(() => Promise.resolve({ merged: true }));
const landWorkspaceTask = vi.fn().mockImplementation(() => Promise.resolve({ allLanded: true, repos: [] }));
const CronRunner = makeConstructibleMock(() => ({
start: vi.fn(),
stop: vi.fn(),
@@ -756,7 +758,8 @@ vi.mock("@fusion/engine", async (importOriginal) => {
PrMonitor,
PrCommentHandler,
aiMergeTask,
runAiMerge: aiMergeTask,
runAiMerge,
landWorkspaceTask,
CronRunner,
createAiPromptExecutor,
SelfHealingManager,
@@ -3159,7 +3162,7 @@ describe("runDashboard — merge stream sink routing", () => {
resetGitHubMocks();
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core");
const { aiMergeTask, createFusionAuthStorage } = await import("@fusion/engine");
const { runAiMerge, createFusionAuthStorage } = await import("@fusion/engine");
const { createServer } = await import("@fusion/dashboard");
const { DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@earendil-works/pi-coding-agent");
@@ -3211,7 +3214,8 @@ describe("runDashboard — merge stream sink routing", () => {
const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
(aiMergeTask as ReturnType<typeof vi.fn>).mockImplementationOnce(
// FNXC:MergerUnification 2026-06-24-23:04: UI-only dashboard manual merges stream through runAiMerge's onAgentText callback; the test must assert line-buffered merge logs instead of deprecated aiMergeTask or raw stdout writes.
(runAiMerge as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (_store: unknown, _cwd: string, _taskId: string, opts: { onAgentText?: (delta: string) => void }) => {
opts.onAgentText?.("Hel");
opts.onAgentText?.("lo");
@@ -3221,24 +3225,29 @@ describe("runDashboard — merge stream sink routing", () => {
},
);
await runDashboard(0, { open: false, noEngine: true });
consoleLogSpy.mockClear();
stdoutWriteSpy.mockClear();
try {
await runDashboard(0, { open: false, noEngine: true });
consoleLogSpy.mockClear();
stdoutWriteSpy.mockClear();
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
await serverOpts.onMerge("FN-TEST");
await serverOpts.onMerge("FN-TEST");
expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Hello");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] World!");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Tail");
expect(consoleLogSpy).not.toHaveBeenCalledWith("[merge] H");
stdoutWriteSpy.mockRestore();
consoleLogSpy.mockRestore();
delete process.env.FUSION_DASHBOARD_TOKEN;
expect(runAiMerge).toHaveBeenCalledWith(expect.anything(), expect.any(String), "FN-TEST", expect.objectContaining({
onAgentText: expect.any(Function),
}));
expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Hello");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] World!");
expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Tail");
expect(consoleLogSpy).not.toHaveBeenCalledWith("[merge] H");
} finally {
stdoutWriteSpy.mockRestore();
consoleLogSpy.mockRestore();
delete process.env.FUSION_DASHBOARD_TOKEN;
}
});
});

View File

@@ -96,8 +96,11 @@ vi.mock("@fusion/core", async (importActual) => {
});
// Mock @fusion/engine
const { runAiMergeMock } = vi.hoisted(() => ({ runAiMergeMock: vi.fn() }));
vi.mock("@fusion/engine", () => ({ runAiMerge: runAiMergeMock, aiMergeTask: runAiMergeMock }));
vi.mock("@fusion/engine", () => ({
aiMergeTask: vi.fn(),
runAiMerge: vi.fn(),
landWorkspaceTask: vi.fn(),
}));
// Mock @fusion/dashboard
vi.mock("@fusion/dashboard", () => ({
@@ -156,7 +159,7 @@ import {
import { GitHubClient, generatePrMetadata } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../../project-context.js";
import { runAiMerge } from "@fusion/engine";
import { aiMergeTask, runAiMerge, landWorkspaceTask } from "@fusion/engine";
const mockedExec = vi.mocked(exec);
@@ -1229,6 +1232,7 @@ describe("project-aware task command behavior", () => {
isRegistered: true,
store: resolvedStore,
});
// FNXC:MergerUnification 2026-06-24-22:58: CLI `fn task merge` normal-task tests must exercise the unified runAiMerge entrypoint; aiMergeTask remains deprecated and should not mask stale mocks.
vi.mocked(runAiMerge).mockResolvedValue({
merged: true,
task: makeTask({ id: "FN-123" }),
@@ -1236,16 +1240,26 @@ describe("project-aware task command behavior", () => {
worktreeRemoved: true,
branchDeleted: true,
} as never);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((((code?: number) => {
throw new Error(`process.exit:${code}`);
}) as unknown) as (code?: string | number | null | undefined) => never);
await runTaskUpdate("FN-123", "0", "done", "demo-project");
await runTaskLog("FN-123", "hello", undefined, "demo-project");
await runTaskMerge("FN-123", "demo-project");
await runTaskDuplicate("FN-123", "demo-project");
await runTaskRefine("FN-123", "more tests", "demo-project");
try {
await runTaskUpdate("FN-123", "0", "done", "demo-project");
await runTaskLog("FN-123", "hello", undefined, "demo-project");
await runTaskMerge("FN-123", "demo-project");
await runTaskDuplicate("FN-123", "demo-project");
await runTaskRefine("FN-123", "more tests", "demo-project");
} finally {
exitSpy.mockRestore();
}
expect(updateStep).toHaveBeenCalled();
expect(logEntry).toHaveBeenCalled();
expect(runAiMerge).toHaveBeenCalledWith(resolvedStore, "/test", "FN-123", expect.any(Object));
expect(landWorkspaceTask).not.toHaveBeenCalled();
expect(aiMergeTask).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
expect(duplicateTask).toHaveBeenCalledWith("FN-123");
expect(refineTask).toHaveBeenCalledWith("FN-123", "more tests");
});