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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 08:58:13 -07:00
parent 40aa924ec0
commit c484964b82
8 changed files with 114 additions and 28 deletions

View File

@@ -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<T extends (...args: any[]) => 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() {

View File

@@ -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);

View File

@@ -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";
/*

View File

@@ -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();

View File

@@ -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 () => {

View File

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

View File

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

View File

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