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:
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
/*
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user