FN-8081: migrate CLI tests to PostgreSQL harness
Migrate CLI test coverage from SQLite-specific fixtures to PostgreSQL-compatible stores. - Run built extension tests with the shared PostgreSQL TaskStore harness. - Inject the harness async layer into extension diagnostics and agent setup. - Remove SQLite-only writer-lock and collision fixtures while retaining portable retry coverage. Files changed: .../src/__tests__/extension-integration.test.ts | 115 +++++++++++---------- packages/cli/src/__tests__/extension.test.ts | 24 +++-- .../src/commands/__tests__/task-lock-retry.test.ts | 96 ++--------------- 3 files changed, 84 insertions(+), 151 deletions(-) Fusion-Task-Id: FN-8081 Fusion-Task-Lineage: 89cc39d8-0094-4486-8a9d-772b6b964bb1 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { AgentStore, TaskStore } from "@fusion/core";
|
||||
import { AgentStore } from "@fusion/core";
|
||||
import { createSharedPgTaskStoreTestHarness, pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
buildCliWithRealDashboardAssets,
|
||||
cliRoot,
|
||||
@@ -72,35 +70,25 @@ function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
async function importBuiltExtension() {
|
||||
const mod = await import(`${pathToFileURL(extensionBundlePath).href}?t=${Date.now()}`);
|
||||
const extension = mod.default;
|
||||
if (typeof extension !== "function") {
|
||||
interface BuiltExtensionModule {
|
||||
default: (api: MockExtensionApi) => void;
|
||||
__setCachedStoreForTesting: (projectRoot: string, store: unknown) => void;
|
||||
}
|
||||
|
||||
async function importBuiltExtension(): Promise<BuiltExtensionModule> {
|
||||
const mod = await import(`${pathToFileURL(extensionBundlePath).href}?t=${Date.now()}`) as BuiltExtensionModule;
|
||||
if (typeof mod.default !== "function") {
|
||||
throw new Error("dist/extension.js did not export the pi extension function");
|
||||
}
|
||||
return extension as (api: MockExtensionApi) => void;
|
||||
return mod;
|
||||
}
|
||||
|
||||
async function removeDirWithRetries(path: string) {
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
try {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOTEMPTY" && code !== "EBUSY") {
|
||||
throw error;
|
||||
}
|
||||
if (attempt === 4) {
|
||||
throw error;
|
||||
}
|
||||
await delay(25 * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAgent(cwd: string, options: { name: string; ephemeral?: boolean }) {
|
||||
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
|
||||
async function seedAgent(
|
||||
cwd: string,
|
||||
asyncLayer: ReturnType<typeof h.layer>,
|
||||
options: { name: string; ephemeral?: boolean },
|
||||
) {
|
||||
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion"), asyncLayer });
|
||||
await agentStore.init();
|
||||
return agentStore.createAgent({
|
||||
name: options.name,
|
||||
@@ -109,29 +97,47 @@ async function seedAgent(cwd: string, options: { name: string; ephemeral?: boole
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integration", () => {
|
||||
const h = createSharedPgTaskStoreTestHarness({ prefix: "fusion-built-ext" });
|
||||
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-16-08:08:
|
||||
The CI-shape opt-in contract (`describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)`) is
|
||||
preserved semantically by pgDescribe, which additionally skips safely without PostgreSQL.
|
||||
*/
|
||||
pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integration", () => {
|
||||
let tmpDir: string;
|
||||
let api: MockExtensionApi;
|
||||
let extension: (api: MockExtensionApi) => void;
|
||||
let builtExtension: BuiltExtensionModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
buildCliWithRealDashboardAssets();
|
||||
extension = await importBuiltExtension();
|
||||
await h.beforeAll();
|
||||
builtExtension = await importBuiltExtension();
|
||||
extension = builtExtension.default;
|
||||
}, 300_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "fusion-built-ext-"));
|
||||
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
|
||||
await h.beforeEach();
|
||||
tmpDir = h.rootDir();
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-16-07:56:
|
||||
FN-8081 runs the opt-in built-extension checks against the same injected
|
||||
PostgreSQL TaskStore used for agent seeding and persistence assertions.
|
||||
*/
|
||||
builtExtension.__setCachedStoreForTesting(tmpDir, h.store());
|
||||
api = createMockAPI();
|
||||
extension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const shutdown = api.events.get("session_shutdown");
|
||||
if (shutdown) {
|
||||
await shutdown();
|
||||
}
|
||||
await removeDirWithRetries(tmpDir);
|
||||
if (shutdown) await shutdown();
|
||||
await h.afterEach();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await h.afterAll();
|
||||
});
|
||||
|
||||
it("registers the current public extension surface from dist/extension.js", () => {
|
||||
@@ -188,9 +194,7 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
expect(listed.content[0].text).toContain(created.details.taskId);
|
||||
expect(listed.content[0].text).toContain("Ship the packed CLI contract");
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const persisted = await store.getTask(created.details.taskId);
|
||||
const persisted = await h.store().getTask(created.details.taskId);
|
||||
expect(persisted?.description).toBe("Ship the packed CLI contract");
|
||||
|
||||
const urgent = await createTool.execute(
|
||||
@@ -201,7 +205,7 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
makeCtx(tmpDir),
|
||||
);
|
||||
expect(urgent.details.priority).toBe("high");
|
||||
const urgentPersisted = await store.getTask(urgent.details.taskId);
|
||||
const urgentPersisted = await h.store().getTask(urgent.details.taskId);
|
||||
expect(urgentPersisted?.priority).toBe("high");
|
||||
});
|
||||
|
||||
@@ -232,8 +236,12 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
});
|
||||
|
||||
it("delegates to real non-ephemeral agents and rejects runtime workers", async () => {
|
||||
const agent = await seedAgent(tmpDir, { name: "release-agent" });
|
||||
const runtimeWorker = await seedAgent(tmpDir, { name: "runtime-worker", ephemeral: true });
|
||||
const agent = await seedAgent(tmpDir, h.layer(), { name: "release-agent" });
|
||||
const runtimeWorker = await seedAgent(
|
||||
tmpDir,
|
||||
h.layer(),
|
||||
{ name: "runtime-worker", ephemeral: true },
|
||||
);
|
||||
|
||||
const listAgentsTool = api.tools.get("fn_list_agents")!;
|
||||
const listedAgents = await listAgentsTool.execute("agents-1", {}, undefined, undefined, makeCtx(tmpDir));
|
||||
@@ -263,18 +271,15 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
|
||||
expect(rejected.content[0].text).toContain("ephemeral/runtime agent");
|
||||
});
|
||||
|
||||
it("returns explicit error when fn_delegate_task hits task-id collision", async () => {
|
||||
const agent = await seedAgent(tmpDir, { name: "release-agent" });
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
store.getDatabase().exec(`
|
||||
CREATE TRIGGER force_delegate_collision
|
||||
BEFORE INSERT ON tasks
|
||||
WHEN NEW.description = 'collision task'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Task ID already exists: FN-001');
|
||||
END;
|
||||
`);
|
||||
/*
|
||||
* FNXC:CliTests 2026-07-16-07:47:
|
||||
* FN-8081 removes this SQLite-trigger collision reproduction. The opt-in
|
||||
* built-extension fixture has no backend-supported allocator seam without
|
||||
* changing FN-8097-owned AgentStore/build setup; FN-8100 restores this exact
|
||||
* assertion through a PostgreSQL fixture.
|
||||
*/
|
||||
it.skip("returns explicit error when fn_delegate_task hits task-id collision", async () => {
|
||||
const agent = await seedAgent(tmpDir, h.layer(), { name: "release-agent" });
|
||||
|
||||
const delegateTool = api.tools.get("fn_delegate_task")!;
|
||||
const result = await delegateTool.execute(
|
||||
|
||||
@@ -1178,6 +1178,12 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
||||
* outside the task worktree boundary (ctx.cwd), and must never create an
|
||||
* attachment when it does.
|
||||
*/
|
||||
/*
|
||||
* FNXC:PostgresCutover 2026-07-16-07:43:
|
||||
* FN-8081 completes the attachment boundary assertion reads on the existing
|
||||
* injected PostgreSQL harness. Bare TaskStore construction was the removed
|
||||
* SQLite runtime path and must not reappear in this migrated suite.
|
||||
*/
|
||||
describe("worktree boundary guard (FN-7619)", () => {
|
||||
let outsideDir: string;
|
||||
let outsideFile: string;
|
||||
@@ -1215,8 +1221,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
||||
),
|
||||
).rejects.toThrow(/boundary|outside/i);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
const task = await store.getTask("FN-001");
|
||||
const task = await h.store().getTask("FN-001");
|
||||
expect(task?.attachments ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1241,8 +1246,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
||||
),
|
||||
).rejects.toThrow(/boundary|outside/i);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
const task = await store.getTask("FN-001");
|
||||
const task = await h.store().getTask("FN-001");
|
||||
expect(task?.attachments ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1269,8 +1273,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
||||
),
|
||||
).rejects.toThrow(/boundary|outside/i);
|
||||
|
||||
const store = new TaskStore(tmpDir);
|
||||
const task = await store.getTask("FN-001");
|
||||
const task = await h.store().getTask("FN-001");
|
||||
expect(task?.attachments ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -4038,7 +4041,12 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => {
|
||||
});
|
||||
|
||||
it("surfaces error and pause diagnostics only for error/paused agents", async () => {
|
||||
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
|
||||
/*
|
||||
FNXC:PostgresCutover 2026-07-16-07:56:
|
||||
FN-8081 completes the diagnostics coverage migration: AgentStore must share
|
||||
the extension harness async layer because SQLite-backed construction was removed.
|
||||
*/
|
||||
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion"), asyncLayer: h.store().getAsyncLayer() });
|
||||
await agentStore.init();
|
||||
const errorAgent = await agentStore.createAgent({ name: "error-agent", role: "executor", metadata: {} });
|
||||
const pausedAgent = await agentStore.createAgent({ name: "paused-agent", role: "executor", metadata: {} });
|
||||
@@ -4296,7 +4304,7 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => {
|
||||
});
|
||||
|
||||
it("surfaces lastError, pauseReason, and recovery counters", async () => {
|
||||
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
|
||||
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion"), asyncLayer: h.store().getAsyncLayer() });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "diagnostic-agent",
|
||||
|
||||
@@ -5,22 +5,16 @@
|
||||
* surfacing a raw `database is locked` error or hanging, and must always
|
||||
* close the resolved `TaskStore` so the CLI process exits promptly.
|
||||
*
|
||||
* Two layers of coverage:
|
||||
* 1. Unit-level tests against `retryOnLock` itself (fake timers, no real
|
||||
* waits) proving the bounded-backoff/fast-fail/non-lock-passthrough
|
||||
* contract in isolation.
|
||||
* 2. An integration-level reproduction against a REAL `TaskStore`/SQLite
|
||||
* database with a genuine external writer lock (a spawned Node
|
||||
* subprocess holding `BEGIN IMMEDIATE`), driving `runTaskShow`/
|
||||
* `runTaskMove` exactly as the CLI would, proving the original
|
||||
* `database is locked` symptom is gone end-to-end.
|
||||
* Unit-level and CLI-boundary mocked-store coverage use fake timers to prove
|
||||
* the bounded-backoff/fast-fail/non-lock-passthrough contract without a
|
||||
* database-specific writer lock.
|
||||
*
|
||||
* FNXC:CliTests 2026-07-16-07:49:
|
||||
* FN-8081 removes the obsolete spawned `DatabaseSync` writer-lock helper.
|
||||
* PostgreSQL has no portable whole-database writer lock; the retained fake-timer
|
||||
* and mocked-store tests cover retry, error, and close-on-every-exit behavior.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
|
||||
import { retryOnLock, LockRetryExhaustedError, DEFAULT_CLI_LOCK_RETRY_MS } from "../../lock-retry.js";
|
||||
|
||||
@@ -105,80 +99,6 @@ describe("retryOnLock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Real-store integration reproduction ──────────────────────────────────
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fn-task-lock-retry-test-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a subprocess that opens the given SQLite file, takes a real
|
||||
* `BEGIN IMMEDIATE` writer lock, and holds it until told to release (or
|
||||
* until `holdMs` elapses in timer mode). Mirrors the pattern used by
|
||||
* `packages/core/src/__tests__/store-concurrent-writes.test.ts`.
|
||||
*/
|
||||
async function holdWriteLock(
|
||||
dbPath: string,
|
||||
options?: { holdMs?: number },
|
||||
): Promise<{ child: ChildProcessWithoutNullStreams; release: () => Promise<void> }> {
|
||||
const holdMs = options?.holdMs;
|
||||
const releaseMode = holdMs !== undefined ? "timer" : "manual";
|
||||
const script = `
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(${JSON.stringify(dbPath)});
|
||||
db.exec("PRAGMA busy_timeout = 0");
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
process.stdout.write("LOCKED\\n");
|
||||
const release = () => {
|
||||
try { db.exec("COMMIT"); } catch {}
|
||||
try { db.close(); } catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
if (${JSON.stringify(releaseMode)} === "timer") {
|
||||
const signal = new Int32Array(new SharedArrayBuffer(4));
|
||||
Atomics.wait(signal, 0, 0, ${holdMs ?? 0});
|
||||
release();
|
||||
} else {
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
if (chunk.includes("RELEASE")) release();
|
||||
});
|
||||
}
|
||||
`;
|
||||
|
||||
const child = spawn(process.execPath, ["-e", script], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.stdout.on("data", (chunk) => {
|
||||
if (chunk.toString().includes("LOCKED")) resolve();
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
if (code !== 0) reject(new Error(`Lock helper exited early (${code}): ${stderr || "no stderr"}`));
|
||||
});
|
||||
child.once("error", reject);
|
||||
});
|
||||
|
||||
await ready;
|
||||
|
||||
return {
|
||||
child,
|
||||
release: async () => {
|
||||
if (child.exitCode !== null || child.killed) return;
|
||||
if (releaseMode === "timer") {
|
||||
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
|
||||
return;
|
||||
}
|
||||
child.stdin.write("RELEASE\n");
|
||||
await new Promise<void>((resolve) => child.once("exit", () => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:PostgresCutover 2026-07-10:
|
||||
* Upstream's "real locked-store reproduction" describe held a REAL write lock
|
||||
|
||||
Reference in New Issue
Block a user