From c484964b8271fbe227a56b4fa62fdf681c7b1afd Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 08:58:13 -0700 Subject: [PATCH] FN-8102: fix CLI package-lane test scaffolding Restore CLI tests to the current PostgreSQL harness and structured tool-error contracts. - Inject cached stores and project-context mocks for isolated extension and command tests - Assert structured MCP error responses for delete and lineage rejection cases - Update workflow, retry, and project mocks for current harness and core APIs Files changed: .../extension-experiment-finalize.test.ts | 21 ++++++++++-- .../src/__tests__/extension-workflow-tools.test.ts | 15 ++++++-- .../__tests__/task-command-gitlab-import.test.ts | 20 +++++++++++ .../task-delete-allow-resurrection.test.ts | 20 ++++++----- .../cli/src/__tests__/task-lineage-unlink.test.ts | 40 +++++++++++++++------- packages/cli/src/__tests__/task-retry.test.ts | 16 +++++++-- .../cli/src/commands/__tests__/project.test.ts | 4 +++ .../cli/src/commands/__tests__/task.test.ts | 6 ++++ 8 files changed, 114 insertions(+), 28 deletions(-) Fusion-Task-Id: FN-8102 Fusion-Task-Lineage: 20723ce0-77bb-4d37-9395-4191769ee752 Co-authored-by: Fusion (runfusion.ai) --- .../extension-experiment-finalize.test.ts | 21 +++++++++- .../extension-workflow-tools.test.ts | 15 +++++-- .../task-command-gitlab-import.test.ts | 20 ++++++++++ .../task-delete-allow-resurrection.test.ts | 20 ++++++---- .../src/__tests__/task-lineage-unlink.test.ts | 40 +++++++++++++------ packages/cli/src/__tests__/task-retry.test.ts | 16 ++++++-- .../src/commands/__tests__/project.test.ts | 4 ++ .../cli/src/commands/__tests__/task.test.ts | 6 +++ 8 files changed, 114 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/__tests__/extension-experiment-finalize.test.ts b/packages/cli/src/__tests__/extension-experiment-finalize.test.ts index 7a060ebc5a..cb8f2d6ddd 100644 --- a/packages/cli/src/__tests__/extension-experiment-finalize.test.ts +++ b/packages/cli/src/__tests__/extension-experiment-finalize.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { workflowAuthoringEngineMock } from "./helpers/engine-workflow-authoring-mock.js"; function makeConstructibleMock unknown>(impl?: T) { @@ -88,11 +88,28 @@ vi.mock("@fusion/engine", () => ({ ExperimentFinalizeCherryPickConflictError: mockErrors.CherryPickError, })); -import kbExtension from "../extension.js"; +import kbExtension, { + __setCachedStoreForTesting, + closeCachedStores, +} from "../extension.js"; describe("extension fn_experiment_finalize", () => { beforeEach(() => { vi.clearAllMocks(); + /* + FNXC:CliTests 2026-07-16-08:58: + FN-8102 injects the experiment-session store through the extension cache so + the test reaches ExperimentFinalizeService instead of booting a real store. + The mocked engine error classes therefore remain the exact identities used + by the extension's instanceof error-to-code mapping. + */ + __setCachedStoreForTesting(process.cwd(), { + getExperimentSessionStore: vi.fn(() => ({})), + } as any); + }); + + afterEach(async () => { + await closeCachedStores(); }); function getTool() { diff --git a/packages/cli/src/__tests__/extension-workflow-tools.test.ts b/packages/cli/src/__tests__/extension-workflow-tools.test.ts index cf8fb240f1..4757a310e2 100644 --- a/packages/cli/src/__tests__/extension-workflow-tools.test.ts +++ b/packages/cli/src/__tests__/extension-workflow-tools.test.ts @@ -4,6 +4,11 @@ * PostgreSQL extension harness. Workflow state is seeded and read back through * `h.store()` (PG-backed), and the authoring tools resolve that same store via * the harness-injected `getStore(cwd)` cache. + * + * FNXC:CliTests 2026-07-16-08:45: + * FN-8102 restores the per-test extension registration and harness root to the + * intake-column cases. They must not reference pre-migration `api` or `tmpDir` + * locals that no longer exist in this PostgreSQL-backed suite. */ import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; @@ -249,6 +254,8 @@ pgTest("pi extension workflow authoring tools", () => { ACTUAL landing column instead of a fixed "Column: triage" string. */ it("lands a task in a custom workflow's intake column and echoes it in the response text", async () => { + const api = createMockApi(); + registerExtension(api); const inboxIr: WorkflowIr = { version: "v2", name: "Inbox-intake workflow", @@ -277,7 +284,7 @@ pgTest("pi extension workflow authoring tools", () => { { name: "Inbox-intake workflow", ir: inboxIr }, undefined, undefined, - makeCtx(tmpDir), + makeCtx(h.rootDir()), ); expect(createWorkflow.isError).not.toBe(true); const workflowId = createWorkflow.details.workflowId; @@ -288,7 +295,7 @@ pgTest("pi extension workflow authoring tools", () => { { description: "Needs manual release", workflow_id: workflowId }, undefined, undefined, - makeCtx(tmpDir), + makeCtx(h.rootDir()), ); expect(result.isError).not.toBe(true); @@ -298,13 +305,15 @@ pgTest("pi extension workflow authoring tools", () => { }); it("still reports Column: triage for the default builtin:coding workflow (byte-identical regression guard)", async () => { + const api = createMockApi(); + registerExtension(api); const createTask = api.tools.get("fn_task_create")!; const result = await createTask.execute( "create-default-task", { description: "Default workflow task" }, undefined, undefined, - makeCtx(tmpDir), + makeCtx(h.rootDir()), ); expect(result.isError).not.toBe(true); diff --git a/packages/cli/src/__tests__/task-command-gitlab-import.test.ts b/packages/cli/src/__tests__/task-command-gitlab-import.test.ts index 8126055f71..32cd4439f4 100644 --- a/packages/cli/src/__tests__/task-command-gitlab-import.test.ts +++ b/packages/cli/src/__tests__/task-command-gitlab-import.test.ts @@ -52,6 +52,26 @@ vi.mock("@fusion/dashboard", () => { }; }); +vi.mock("../project-context.js", () => ({ + // FNXC:CliTests 2026-07-16-08:56: FN-8102 keeps GitLab import on the + // controllable command-context seam, preventing fallback startup from + // masking provenance assertions behind an embedded PostgreSQL boot. + resolveProject: vi.fn(async () => ({ + store: { + getSettings: mocks.getSettings, + getGlobalSettingsStore: () => ({ getSettings: mocks.getGlobalSettings }), + listTasks: mocks.listTasks, + createTask: mocks.createTask, + logEntry: mocks.logEntry, + }, + projectId: "test-project", + projectPath: process.cwd(), + projectName: "test-project", + isRegistered: false, + })), + closeProjectStore: vi.fn().mockResolvedValue(undefined), +})); + import { runTaskImportFromGitLab } from "../commands/task.js"; /* diff --git a/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts b/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts index a819ea409e..0af1bd2efa 100644 --- a/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts +++ b/packages/cli/src/__tests__/task-delete-allow-resurrection.test.ts @@ -5,6 +5,10 @@ * via `getStore(cwd)` (injected by the harness), and task state is read back * through `store.getTask(id, { includeDeleted: true })` instead of the removed * sync `readTaskFromDb` path. + * + * FNXC:CliTests 2026-07-16-08:50: + * FN-8102 preserves the self-delete rejection contract after extension tools + * began returning structured MCP errors rather than rejecting their promises. */ import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; @@ -62,14 +66,14 @@ pgTest("task delete allowResurrection plumbing", () => { registerExtension(api); const tool = requireTool(api, "fn_task_delete"); - await expect( - tool.execute("call-self", { id: task.id }, undefined, undefined, { - cwd: h.rootDir(), - taskId: task.id, - agentId: "agent-test", - runId: "run-test", - }), - ).rejects.toThrow(`Task ${task.id} cannot delete itself`); + const result = await tool.execute("call-self", { id: task.id }, undefined, undefined, { + cwd: h.rootDir(), + taskId: task.id, + agentId: "agent-test", + runId: "run-test", + }); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toMatch(new RegExp(`Task ${task.id} cannot delete itself`)); const row = await store.getTask(task.id, { includeDeleted: true }); expect(row.deletedAt).toBeUndefined(); diff --git a/packages/cli/src/__tests__/task-lineage-unlink.test.ts b/packages/cli/src/__tests__/task-lineage-unlink.test.ts index 25581401f8..829b2438b0 100644 --- a/packages/cli/src/__tests__/task-lineage-unlink.test.ts +++ b/packages/cli/src/__tests__/task-lineage-unlink.test.ts @@ -12,6 +12,10 @@ FNXC:PostgresCutover 2026-07-08-00:00: Ported from upstream's sqlite version: runs on the shared PG extension harness (the sqlite TaskStore path is removed on this branch), seeds lineage via createTask's `source` provenance input instead of raw sqlite UPDATEs, and reads forensic state via getTask({includeDeleted}). + +FNXC:CliTests 2026-07-16-08:50: +FN-8102 keeps all archive/delete lineage-parent rejection cases strict after tools switched from +thrown errors to structured MCP results: each case must assert both `isError` and the message. */ import type { TaskStore } from "@fusion/core"; import { @@ -53,9 +57,9 @@ pgDescribe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", registerExtension(api); const tool = requireTool(api, "fn_task_archive"); - await expect(tool.execute("call-1", { id: parent.id }, undefined, undefined, ctx())).rejects.toThrow( - /still referenced as a lineage parent/, - ); + const result = await tool.execute("call-1", { id: parent.id }, undefined, undefined, ctx()); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toMatch(/still referenced as a lineage parent/); const row = await store.getTask(parent.id, { includeDeleted: true }); expect(row.column).not.toBe("archived"); @@ -69,9 +73,15 @@ pgDescribe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", registerExtension(api); const tool = requireTool(api, "fn_task_archive"); - await expect( - tool.execute("call-2", { id: parent.id, removeLineageReferences: false }, undefined, undefined, ctx()), - ).rejects.toThrow(/still referenced as a lineage parent/); + const result = await tool.execute( + "call-2", + { id: parent.id, removeLineageReferences: false }, + undefined, + undefined, + ctx(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toMatch(/still referenced as a lineage parent/); }); it("fn_task_archive with removeLineageReferences:true archives the parent and clears the child reference", async () => { @@ -115,9 +125,9 @@ pgDescribe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", registerExtension(api); const tool = requireTool(api, "fn_task_delete"); - await expect(tool.execute("call-5", { id: parent.id }, undefined, undefined, ctx())).rejects.toThrow( - /still referenced as a lineage parent/, - ); + const result = await tool.execute("call-5", { id: parent.id }, undefined, undefined, ctx()); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toMatch(/still referenced as a lineage parent/); const row = await store.getTask(parent.id, { includeDeleted: true }); expect(row.deletedAt).toBeUndefined(); @@ -131,9 +141,15 @@ pgDescribe("fn_task_archive / fn_task_delete removeLineageReferences plumbing", registerExtension(api); const tool = requireTool(api, "fn_task_delete"); - await expect( - tool.execute("call-6", { id: parent.id, removeLineageReferences: false }, undefined, undefined, ctx()), - ).rejects.toThrow(/still referenced as a lineage parent/); + const result = await tool.execute( + "call-6", + { id: parent.id, removeLineageReferences: false }, + undefined, + undefined, + ctx(), + ); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toMatch(/still referenced as a lineage parent/); }); it("fn_task_delete with removeLineageReferences:true soft-deletes the parent and clears the child reference", async () => { diff --git a/packages/cli/src/__tests__/task-retry.test.ts b/packages/cli/src/__tests__/task-retry.test.ts index 0fad6b2d1e..ec1e011249 100644 --- a/packages/cli/src/__tests__/task-retry.test.ts +++ b/packages/cli/src/__tests__/task-retry.test.ts @@ -7,6 +7,11 @@ * redirected to the harness's PG-backed store, and the full retry lifecycle * (moveTask / updateTask / getTask / logEntry) runs against real PostgreSQL * state instead of the removed SQLite runtime. + * + * FNXC:CliTests 2026-07-16-08:45: + * FN-8102 repairs stale retry scaffolding left after the PG migration: every + * lifecycle seed and verification read must use the initialized harness store, + * rather than the removed `createStore()` helper. */ import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; @@ -18,8 +23,12 @@ import { createPgExtensionHarness } from "./pg-extension-harness.js"; // injects. Redirect resolveProject to the harness PG store so the command path // and the seeded task share one isolated PostgreSQL database. const resolveProjectMock = vi.hoisted(() => vi.fn()); +const closeProjectStoreMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); vi.mock("../project-context.js", () => ({ resolveProject: resolveProjectMock, + // FNXC:CliTests 2026-07-16-08:47: FN-8102 keeps command-finally cleanup + // awaitable while the PG harness retains ownership of the test store lifecycle. + closeProjectStore: closeProjectStoreMock, })); import { runTaskRetry } from "../commands/task.js"; @@ -44,12 +53,13 @@ pgTest("runTaskRetry", () => { afterEach(async () => { vi.restoreAllMocks(); resolveProjectMock.mockReset(); + closeProjectStoreMock.mockClear(); await h.afterEach(); }); afterAll(h.afterAll); it("retries merge-active missing-worktree session failures by clearing phantom metadata", async () => { - const store = await createStore(); + const store = h.store(); const task = await store.createTask({ title: "missing worktree merge-active task", description: "test", @@ -70,7 +80,7 @@ pgTest("runTaskRetry", () => { await runTaskRetry(task.id); - const verificationStore = await createStore(); + const verificationStore = h.store(); const updated = await verificationStore.getTask(task.id); expect(updated.column).toBe("todo"); expect(updated.status).toBeUndefined(); @@ -84,7 +94,7 @@ pgTest("runTaskRetry", () => { }); it("rejects unrelated merge-active tasks without the missing-worktree signature", async () => { - const store = await createStore(); + const store = h.store(); const task = await store.createTask({ title: "ordinary merge", description: "test", column: "todo" }); await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "in-review"); diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index b392a7d757..dfb8922c96 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -81,6 +81,10 @@ vi.mock("@fusion/core", () => ({ // (which imports `isSqliteLockError` from @fusion/core) — stub it per // project memory's mocked-module pitfall. isSqliteLockError: vi.fn(() => false), + // FNXC:CliTests 2026-07-16-08:47: FN-8102 mirrors the current core project + // identity surface so runProjectAdd reaches its forced registration behavior. + hasProjectIdentity: vi.fn(() => false), + isValidSqliteDatabaseFile: vi.fn(() => false), countRunningAgentTasks: (tasks: Array<{ column: string; status?: string; paused?: boolean }>) => tasks.filter((task) => ( task.column === "in-progress" || (task.column === "triage" && task.status === "planning" && !task.paused) || diff --git a/packages/cli/src/commands/__tests__/task.test.ts b/packages/cli/src/commands/__tests__/task.test.ts index 4b220efa2a..f6f3f0a80b 100644 --- a/packages/cli/src/commands/__tests__/task.test.ts +++ b/packages/cli/src/commands/__tests__/task.test.ts @@ -2633,6 +2633,9 @@ describe("runTaskRetry", () => { baseBranch: null, baseCommitSha: null, nextRecoveryAt: null, + // FNXC:CliTests 2026-07-16-08:55: FN-8102 tracks the current manual + // retry reset patch, including the plan-review replan recovery budget. + planReviewReplanCount: 0, stuckKillCount: 0, recoveryRetryCount: 0, taskDoneRetryCount: 0, @@ -2710,6 +2713,9 @@ describe("runTaskRetry", () => { baseBranch: null, baseCommitSha: null, nextRecoveryAt: null, + // FNXC:CliTests 2026-07-16-08:55: FN-8102 tracks the current manual + // retry reset patch, including the plan-review replan recovery budget. + planReviewReplanCount: 0, stuckKillCount: 0, recoveryRetryCount: 0, taskDoneRetryCount: 0,