FN-8097: harden CLI package-lane test builds

Stabilize CLI package-lane tests with reliable asset builds and PostgreSQL-backed fixtures.

- add stale-lock recovery and deterministic coverage for CLI asset builds
- migrate affected CLI tests to shared PostgreSQL harnesses and async build setup
- restore delegate collision coverage and clean bundled extension caches

Files changed:
 .../src/__tests__/bundle-output-helpers.test.ts    | 185 ++++++++++++----
 .../cli/src/__tests__/bundle-output-helpers.ts     | 240 +++++++++++++++------
 packages/cli/src/__tests__/bundle-output.test.ts   |   4 +-
 .../extension-agent-set-instructions.test.ts       |  56 ++---
 .../src/__tests__/extension-agent-update.test.ts   |  58 ++---
 .../src/__tests__/extension-integration.test.ts    |  25 ++-
 packages/cli/src/__tests__/task-plan.test.ts       |  67 +++---
 packages/cli/src/__tests__/task-steer.test.ts      |  56 ++---
 .../src/commands/__tests__/agent-export.test.ts    |  42 +++-
 9 files changed, 481 insertions(+), 252 deletions(-)

Fusion-Task-Id: FN-8097

Fusion-Task-Lineage: 7c84efe0-eb5b-4d65-b8ca-ad71926dc84c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 09:14:15 -07:00
parent c484964b82
commit fcd400f02e
9 changed files with 490 additions and 261 deletions

View File

@@ -2,79 +2,180 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = {
existingPaths: new Set<string>(),
files: new Map<string, string>(),
mtimes: new Map<string, number>(),
indexHtml: "",
};
vi.mock("node:fs", () => ({
existsSync: (path: string) => state.existingPaths.has(path),
readFileSync: () => state.indexHtml,
execFileSync: vi.fn(),
execSync: vi.fn(),
existsSync: (path: string) => state.existingPaths.has(path) || state.files.has(path),
readFileSync: (path: string) => {
if (state.files.has(path)) return state.files.get(path)!;
if (state.existingPaths.has(path)) return state.indexHtml;
const error = new Error(`ENOENT: ${path}`) as Error & { code: string };
error.code = "ENOENT";
throw error;
},
mkdirSync: (path: string) => {
if (state.existingPaths.has(path) || state.files.has(path)) {
const error = new Error(`EEXIST: ${path}`) as Error & { code: string };
error.code = "EEXIST";
throw error;
}
state.existingPaths.add(path);
state.mtimes.set(path, 0);
},
writeFileSync: (path: string, content: string) => {
state.files.set(path, content);
},
renameSync: (from: string, to: string) => {
state.files.set(to, state.files.get(from)!);
state.files.delete(from);
},
rmSync: (path: string) => {
for (const existing of [...state.existingPaths]) {
if (existing === path || existing.startsWith(`${path}/`)) state.existingPaths.delete(existing);
}
for (const file of [...state.files.keys()]) {
if (file === path || file.startsWith(`${path}/`)) state.files.delete(file);
}
},
statSync: (path: string) => ({ mtimeMs: state.mtimes.get(path) ?? 0 }),
}));
import {
bundlePath,
BUILD_LOCK_STALE_MS,
buildCliWithRealDashboardAssets,
clientIndexPath,
dashboardClientStubMarker,
droidPluginMcpServerPath,
hasBuiltDashboardAssets,
openclawMcpSchemaServerPath,
requiredBuildAssetPaths,
} from "./bundle-output-helpers";
const cursorPluginManifestPath = bundlePath.replace(
"dist/bin.js",
"dist/plugins/fusion-plugin-cursor-runtime/manifest.json",
);
const roadmapPluginBundledPath = bundlePath.replace(
"dist/bin.js",
"dist/plugins/fusion-plugin-roadmap/bundled.js",
);
function addAllAssets() {
for (const path of requiredBuildAssetPaths) state.existingPaths.add(path);
state.files.set(clientIndexPath, state.indexHtml);
}
/**
* FNXC:TestInfrastructure 2026-07-16-09:10:
* The fake filesystem plus awaitable waitTick drives every build-lock branch
* deterministically. Loaded-lane parallelism is only a symptom check, never
* the proof that a waiter preserves live ownership or recovers stale holders.
*/
describe("hasBuiltDashboardAssets", () => {
beforeEach(() => {
state.existingPaths.clear();
state.files.clear();
state.mtimes.clear();
state.indexHtml = "<html><body><script src=\"assets/app.js\"></script></body></html>";
});
it("returns false when openclaw mcp-schema-server.cjs is missing", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
expect(hasBuiltDashboardAssets()).toBe(false);
});
it("returns false when droid mcp-schema-server.cjs is missing", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
it("returns false when a required asset is missing", () => {
addAllAssets();
state.existingPaths.delete(requiredBuildAssetPaths.at(-1)!);
expect(hasBuiltDashboardAssets()).toBe(false);
});
it("returns true when all required assets exist and dashboard stub marker is absent", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
state.existingPaths.add(droidPluginMcpServerPath);
addAllAssets();
expect(hasBuiltDashboardAssets()).toBe(true);
});
it("returns false when dashboard client index contains stub marker", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
state.existingPaths.add(droidPluginMcpServerPath);
state.indexHtml = dashboardClientStubMarker;
addAllAssets();
expect(hasBuiltDashboardAssets()).toBe(false);
});
});
describe("buildCliWithRealDashboardAssets lock protocol", () => {
beforeEach(() => {
state.existingPaths.clear();
state.files.clear();
state.mtimes.clear();
});
it("winner writes owner metadata, builds, and releases its lock", async () => {
const lockDir = "/locks/winner";
let assetsReady = false;
const build = vi.fn(async () => { assetsReady = true; });
await buildCliWithRealDashboardAssets({ lockDir, hasAssets: () => assetsReady, build, now: () => 10 });
expect(build).toHaveBeenCalledTimes(1);
expect(state.files.get(`${lockDir}/owner.json`)).toBeUndefined();
expect(state.existingPaths.has(lockDir)).toBe(false);
});
it("waits for a live owner, then reads completed assets without reclaiming or rebuilding", async () => {
const lockDir = "/locks/live-owner";
let assetsReady = false;
state.existingPaths.add(lockDir);
state.files.set(`${lockDir}/owner.json`, JSON.stringify({ pid: 99, acquiredAt: 100 }));
state.mtimes.set(lockDir, 100);
const build = vi.fn();
const waitTick = vi.fn(async () => {
assetsReady = true;
state.existingPaths.delete(lockDir);
state.files.delete(`${lockDir}/owner.json`);
});
await buildCliWithRealDashboardAssets({
lockDir,
hasAssets: () => assetsReady,
build,
now: () => 101,
isProcessAlive: (pid) => pid === 99,
statMtimeMs: () => 100,
waitTick,
});
expect(waitTick).toHaveBeenCalledTimes(1);
expect(build).not.toHaveBeenCalled();
});
it("atomically reclaims a dead owner past the stale threshold and rebuilds", async () => {
const lockDir = "/locks/dead-owner";
let assetsReady = false;
state.existingPaths.add(lockDir);
state.files.set(`${lockDir}/owner.json`, JSON.stringify({ pid: 404, acquiredAt: 0 }));
state.mtimes.set(lockDir, 0);
const build = vi.fn(async () => { assetsReady = true; });
await buildCliWithRealDashboardAssets({
lockDir,
hasAssets: () => assetsReady,
build,
now: () => BUILD_LOCK_STALE_MS + 1,
isProcessAlive: () => false,
statMtimeMs: () => 0,
});
expect(build).toHaveBeenCalledTimes(1);
expect(state.existingPaths.has(lockDir)).toBe(false);
});
it("reclaims missing owner metadata using the stale directory mtime fallback", async () => {
const lockDir = "/locks/missing-owner";
let assetsReady = false;
state.existingPaths.add(lockDir);
state.mtimes.set(lockDir, 0);
const build = vi.fn(async () => { assetsReady = true; });
await buildCliWithRealDashboardAssets({
lockDir,
hasAssets: () => assetsReady,
build,
now: () => BUILD_LOCK_STALE_MS + 1,
isProcessAlive: () => true,
statMtimeMs: () => 0,
});
expect(build).toHaveBeenCalledTimes(1);
expect(state.existingPaths.has(lockDir)).toBe(false);
});
});

View File

@@ -1,5 +1,5 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -44,6 +44,19 @@ export const droidPluginMcpServerPath = join(
export const dashboardClientStubMarker = "Dashboard assets not built";
export const requiredBuildAssetPaths = [
bundlePath,
clientIndexPath,
cursorPluginManifestPath,
roadmapPluginBundledPath,
reportsPluginBundledPath,
cliPrintingPressPluginBundledPath,
whatsappChatPluginBundledPath,
compoundEngineeringSkillPath,
openclawMcpSchemaServerPath,
droidPluginMcpServerPath,
] as const;
function runBuildCommand(command: string, cwd: string) {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath && existsSync(npmExecPath)) {
@@ -63,98 +76,193 @@ function runBuildCommand(command: string, cwd: string) {
}
export function hasBuiltDashboardAssets(): boolean {
if (
!existsSync(bundlePath) ||
!existsSync(clientIndexPath) ||
!existsSync(cursorPluginManifestPath) ||
!existsSync(roadmapPluginBundledPath) ||
!existsSync(reportsPluginBundledPath) ||
!existsSync(cliPrintingPressPluginBundledPath) ||
!existsSync(whatsappChatPluginBundledPath) ||
!existsSync(compoundEngineeringSkillPath) ||
!existsSync(openclawMcpSchemaServerPath) ||
!existsSync(droidPluginMcpServerPath)
) {
if (!requiredBuildAssetPaths.every(existsSync)) {
return false;
}
return !readFileSync(clientIndexPath, "utf-8").includes(dashboardClientStubMarker);
}
/**
* This suite verifies real copied dashboard client assets in CLI dist output.
* It must build those assets explicitly instead of skip-gating on ambient dist/.
*
* FNXC:TestInfrastructure 2026-07-13-12:20:
* bundle-output.test.ts and extension-integration.test.ts both call this helper,
* and Vitest runs test files in parallel (pool: "forks", fileParallelism: true).
* Without a cross-worker lock, two workers can simultaneously trigger vite/tsup
* builds that clean and write dist/client concurrently, causing ENOENT on
* content-hashed chunk files. The lock uses atomic mkdirSync — the winner builds,
* losers poll until the lock disappears then re-check hasBuiltDashboardAssets().
*/
const buildLockDir = join(tmpdir(), "fusion-cli-build-assets.lock");
const BUILD_LOCK_TIMEOUT_MS = 300_000;
function buildRealDashboardAssets(): void {
runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot);
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
/*
* FNXC:BundledPlugins 2026-07-15-09:08:
* bundle-output tests assert the published CLI packaging surface, including staged bundled plugins and skill assets. Local `pnpm build` may use fast package mode, so bootstrap with `build:package` to force FUSION_CLI_FULL_PACKAGE and avoid reading stale raw-src plugin output from dist/.
*/
runBuildCommand("pnpm build:package", cliRoot);
export function buildCliWithRealDashboardAssets() {
if (hasBuiltDashboardAssets()) {
return;
}
// Try to acquire the lock atomically. mkdirSync throws EEXIST if the dir exists.
let acquiredLock = false;
try {
mkdirSync(buildLockDir);
acquiredLock = true;
} catch {
// Another worker holds the lock — wait for it.
}
// Fallback for environments where build:client alone does not refresh the
// dashboard dist/client bundle consumed by the CLI copy step.
runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot);
runBuildCommand("pnpm build:package", cliRoot);
}
if (!acquiredLock) {
const deadline = Date.now() + BUILD_LOCK_TIMEOUT_MS;
while (existsSync(buildLockDir) && Date.now() < deadline) {
// Synchronous sleep without spawning a child process.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
/**
* This suite verifies real copied dashboard client assets in CLI dist output.
* It must build those assets explicitly instead of skip-gating on ambient dist/.
*
* FNXC:TestInfrastructure 2026-07-16-09:10:
* Parallel Vitest workers share this lock while bundle-output and built-extension
* suites build CLI assets. A lock records its PID and acquisition time: waiters
* never reclaim a live or fresh owner, but recover a dead/invalid owner only
* after the stale threshold, preventing a crashed holder from wedging the lane.
*/
const buildLockDir = join(tmpdir(), "fusion-cli-build-assets.lock");
const BUILD_LOCK_TIMEOUT_MS = 300_000;
export const BUILD_LOCK_STALE_MS = 60_000;
const BUILD_LOCK_OWNER_FILE = "owner.json";
const BUILD_LOCK_RECLAIM_SUFFIX = ".reclaim";
interface BuildLockOwner {
pid: number;
acquiredAt: number;
}
export interface BuildCliAssetsOptions {
/** Test-only isolated lock directory; production uses the cross-worker temp lock. */
lockDir?: string;
hasAssets?: () => boolean;
build?: () => void | Promise<void>;
now?: () => number;
isProcessAlive?: (pid: number) => boolean;
statMtimeMs?: (path: string) => number;
waitTick?: () => void | Promise<void>;
}
function readLockOwner(lockDir: string): BuildLockOwner | null {
try {
const parsed = JSON.parse(readFileSync(join(lockDir, BUILD_LOCK_OWNER_FILE), "utf8")) as Partial<BuildLockOwner>;
return typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0
&& typeof parsed.acquiredAt === "number" && Number.isFinite(parsed.acquiredAt)
? { pid: parsed.pid, acquiredAt: parsed.acquiredAt }
: null;
} catch {
return null;
}
}
function defaultIsProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return !(error && typeof error === "object" && "code" in error && error.code === "ESRCH");
}
}
async function defaultWaitTick(): Promise<void> {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
}
/**
* Build the dashboard and CLI package exactly once across Vitest workers.
* The optional options bag is a test-only seam for deterministic owner/waiter
* and stale-lock coverage; default behavior uses the shared real build lock.
*/
export async function buildCliWithRealDashboardAssets(options: BuildCliAssetsOptions = {}): Promise<void> {
const lockDir = options.lockDir ?? buildLockDir;
const hasAssets = options.hasAssets ?? hasBuiltDashboardAssets;
const build = options.build ?? buildRealDashboardAssets;
const now = options.now ?? Date.now;
const isProcessAlive = options.isProcessAlive ?? defaultIsProcessAlive;
const statMtimeMs = options.statMtimeMs ?? ((path: string) => statSync(path).mtimeMs);
const waitTick = options.waitTick ?? defaultWaitTick;
const deadline = now() + BUILD_LOCK_TIMEOUT_MS;
if (hasAssets()) return;
const reclaimDir = `${lockDir}${BUILD_LOCK_RECLAIM_SUFFIX}`;
while (true) {
/*
* FNXC:TestInfrastructure 2026-07-16-09:25:
* The reclaim guard serializes stale-owner rechecks with acquisition, so a
* newly-created owner cannot be mistaken for and deleted as the stale one.
*/
if (existsSync(reclaimDir)) {
if (now() >= deadline) {
throw new Error(
`buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for stale-lock reclamation at ${lockDir}.`,
);
}
await waitTick();
continue;
}
// The other worker should have finished building. Re-check assets.
if (hasBuiltDashboardAssets()) {
return;
let acquiredLock = false;
try {
mkdirSync(lockDir);
acquiredLock = true;
const ownerPath = join(lockDir, BUILD_LOCK_OWNER_FILE);
const ownerTempPath = join(lockDir, `.owner-${process.pid}-${now()}.tmp`);
writeFileSync(ownerTempPath, JSON.stringify({ pid: process.pid, acquiredAt: now() }), "utf8");
renameSync(ownerTempPath, ownerPath);
try {
await build();
return;
} finally {
rmSync(lockDir, { recursive: true, force: true });
}
} catch (error) {
/*
* FNXC:TestInfrastructure 2026-07-16-09:25:
* Only EEXIST from mkdir is normal contention. Build and owner-metadata
* failures must surface immediately rather than become waiter timeouts.
*/
if (acquiredLock || !(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) {
if (acquiredLock) rmSync(lockDir, { recursive: true, force: true });
throw error;
}
}
// Lock is still held after timeout — do NOT build without owning the lock
// (that reintroduces the concurrent-build race). Throw so the test fails loudly.
if (existsSync(buildLockDir)) {
if (hasAssets()) return;
const owner = readLockOwner(lockDir);
let ageMs: number;
try {
ageMs = now() - (owner?.acquiredAt ?? statMtimeMs(lockDir));
} catch {
// FNXC:TestInfrastructure 2026-07-16-09:25: A winner can remove the directory between existsSync and statSync.
continue;
}
const stale = ageMs > BUILD_LOCK_STALE_MS && (!owner || !isProcessAlive(owner.pid));
if (stale) {
try {
mkdirSync(reclaimDir);
} catch {
// FNXC:TestInfrastructure 2026-07-16-09:25: Another waiter is already rechecking the stale candidate.
continue;
}
try {
/*
* FNXC:TestInfrastructure 2026-07-16-09:25:
* Re-read under the reclaim guard because an acquirer can win just
* before it is created; that fresh owner must never be removed.
*/
const currentOwner = readLockOwner(lockDir);
const currentAge = now() - (currentOwner?.acquiredAt ?? statMtimeMs(lockDir));
if (currentAge > BUILD_LOCK_STALE_MS && (!currentOwner || !isProcessAlive(currentOwner.pid))) {
rmSync(lockDir, { recursive: true, force: true });
}
} catch {
// FNXC:TestInfrastructure 2026-07-16-09:25: The owner may have finished while we acquired the guard; retry normally.
} finally {
rmSync(reclaimDir, { recursive: true, force: true });
}
continue;
}
if (now() >= deadline) {
throw new Error(
`buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for another worker's build lock at ${buildLockDir}. ` +
`If the other worker crashed, remove the lock dir manually and rerun.`,
`buildCliWithRealDashboardAssets: timed out after ${BUILD_LOCK_TIMEOUT_MS}ms waiting for another worker's build lock at ${lockDir}.`,
);
}
// Lock disappeared but assets weren't built — try to acquire for our own build.
try { mkdirSync(buildLockDir); acquiredLock = true; } catch {
throw new Error(`buildCliWithRealDashboardAssets: could not acquire build lock at ${buildLockDir} after previous holder exited.`);
}
}
try {
runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot);
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
/*
* FNXC:BundledPlugins 2026-07-15-09:08:
* bundle-output tests assert the published CLI packaging surface, including staged bundled plugins and skill assets. Local `pnpm build` may use fast package mode, so bootstrap with `build:package` to force FUSION_CLI_FULL_PACKAGE and avoid reading stale raw-src plugin output from dist/.
*/
runBuildCommand("pnpm build:package", cliRoot);
if (hasBuiltDashboardAssets()) {
return;
}
// Fallback for environments where build:client alone does not refresh the
// dashboard dist/client bundle consumed by the CLI copy step.
runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot);
runBuildCommand("pnpm build:package", cliRoot);
} finally {
if (acquiredLock) {
rmSync(buildLockDir, { recursive: true, force: true });
}
await waitTick();
if (hasAssets()) return;
}
}

View File

@@ -79,11 +79,11 @@ function expectSelfContainedBundle(pluginId: typeof selfContainedBundlePluginIds
}
describe("CLI bundle output", () => {
beforeAll(() => {
beforeAll(async () => {
// Intentional: bundle-output tests validate compiled artifacts, so they
// perform their own explicit build bootstrap instead of relying on ambient
// workspace dist/ state.
buildCliWithRealDashboardAssets();
await buildCliWithRealDashboardAssets();
}, 300_000);
it("dist/bin.js exists", () => {

View File

@@ -1,23 +1,21 @@
import { describe, it, expect } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
import kbExtension, { closeCachedStores } from "../extension.js";
import {
createMockApi,
createPgExtensionHarness,
pgDescribe,
registerExtension,
requireTool,
} from "./pg-extension-harness";
function createMockAPI() {
const tools = new Map<string, any>();
return {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
/*
FNXC:PostgresCutover 2026-07-16-08:45:
Instruction-tool fixtures seed the PG TaskStore cache injected for the extension,
so its AgentStore avoids the removed SQLite path and tools observe the same data.
*/
const h = createPgExtensionHarness("extension-agent-set-instructions");
async function withOrg(
run: (ctx: {
@@ -27,8 +25,11 @@ async function withOrg(
ids: { manager: string; middle: string; leaf: string; peer: string };
}) => Promise<void>,
): Promise<void> {
const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-instructions-"));
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
const cwd = h.rootDir();
const agentStore = new AgentStore({
rootDir: join(cwd, ".fusion"),
asyncLayer: h.store().getAsyncLayer()!,
});
try {
await agentStore.init();
const manager = await agentStore.createAgent({ name: "manager", role: "engineer", metadata: {} });
@@ -46,10 +47,9 @@ async function withOrg(
});
const peer = await agentStore.createAgent({ name: "peer-agent", role: "executor", metadata: {} });
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_agent_set_instructions");
expect(tool).toBeTruthy();
const api = createMockApi();
registerExtension(api);
const tool = requireTool(api, "fn_agent_set_instructions");
await run({
cwd,
@@ -58,13 +58,15 @@ async function withOrg(
ids: { manager: manager.id, middle: middle.id, leaf: leaf.id, peer: peer.id },
});
} finally {
await closeCachedStores();
agentStore.close();
await rm(cwd, { recursive: true, force: true });
}
}
describe("fn_agent_set_instructions", () => {
pgDescribe("fn_agent_set_instructions", () => {
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
it("allows a manager to set inline instructions for a direct report", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const result = await tool.execute(

View File

@@ -1,23 +1,22 @@
import { describe, it, expect, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { beforeAll, beforeEach, afterEach, afterAll, expect, it, vi } from "vitest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
import kbExtension, { closeCachedStores } from "../extension.js";
import {
createMockApi,
createPgExtensionHarness,
pgDescribe,
registerExtension,
requireTool,
} from "./pg-extension-harness";
function createMockAPI() {
const tools = new Map<string, any>();
return {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
/*
FNXC:PostgresCutover 2026-07-16-08:45:
Agent-update tool fixtures must seed the exact PG TaskStore injected into the
extension cache. Bare AgentStore construction selected the removed SQLite path
and a separate backend would make seeded agents invisible to extension tools.
*/
const h = createPgExtensionHarness("extension-agent-update");
async function withOrg(
run: (ctx: {
@@ -35,8 +34,11 @@ async function withOrg(
};
}) => Promise<void>,
): Promise<void> {
const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-update-"));
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
const cwd = h.rootDir();
const agentStore = new AgentStore({
rootDir: join(cwd, ".fusion"),
asyncLayer: h.store().getAsyncLayer()!,
});
try {
await agentStore.init();
const manager = await agentStore.createAgent({ name: "manager", role: "engineer", metadata: {} });
@@ -66,12 +68,10 @@ async function withOrg(
metadata: { agentKind: "task-worker" },
});
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_agent_update");
const setInstructionsTool = api.tools.get("fn_agent_set_instructions");
expect(tool).toBeTruthy();
expect(setInstructionsTool).toBeTruthy();
const api = createMockApi();
registerExtension(api);
const tool = requireTool(api, "fn_agent_update");
const setInstructionsTool = requireTool(api, "fn_agent_set_instructions");
await run({
cwd,
@@ -88,13 +88,15 @@ async function withOrg(
},
});
} finally {
await closeCachedStores();
agentStore.close();
await rm(cwd, { recursive: true, force: true });
}
}
describe("fn_agent_update", () => {
pgDescribe("fn_agent_update", () => {
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
it("allows privileged operator calls to update config fields and preserve runtime keys", async () => {
await withOrg(async ({ cwd, tool, agentStore, ids }) => {
const updateSpy = vi.spyOn(AgentStore.prototype, "updateAgent");

View File

@@ -73,6 +73,7 @@ function makeCtx(cwd: string) {
interface BuiltExtensionModule {
default: (api: MockExtensionApi) => void;
__setCachedStoreForTesting: (projectRoot: string, store: unknown) => void;
closeCachedStores: () => Promise<void>;
}
async function importBuiltExtension(): Promise<BuiltExtensionModule> {
@@ -111,7 +112,7 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte
let builtExtension: BuiltExtensionModule;
beforeAll(async () => {
buildCliWithRealDashboardAssets();
await buildCliWithRealDashboardAssets();
await h.beforeAll();
builtExtension = await importBuiltExtension();
extension = builtExtension.default;
@@ -133,6 +134,13 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte
afterEach(async () => {
const shutdown = api.events.get("session_shutdown");
if (shutdown) await shutdown();
/*
* FNXC:PostgresCutover 2026-07-16-09:05:
* The bundle owns a separate module cache. Clear its externally-owned PG
* entry before the shared harness closes that store, even if the shutdown
* hook already cleared it, so no built-module cache leaks across tests.
*/
await builtExtension.closeCachedStores();
await h.afterEach();
});
@@ -272,14 +280,16 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte
});
/*
* 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.
* FNXC:PostgresCutover 2026-07-16-16:00:
* SQLite triggers were removed with the backend cutover. Inject the collision
* at the shared PostgreSQL TaskStore boundary so this built-extension test
* continues to prove the user-facing delegate error rather than skipping it.
*/
it.skip("returns explicit error when fn_delegate_task hits task-id collision", async () => {
it("returns explicit error when fn_delegate_task hits task-id collision", async () => {
const agent = await seedAgent(tmpDir, h.layer(), { name: "release-agent" });
const createTask = vi
.spyOn(h.store(), "createTask")
.mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
const delegateTool = api.tools.get("fn_delegate_task")!;
const result = await delegateTool.execute(
@@ -290,6 +300,7 @@ pgDescribe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension inte
makeCtx(tmpDir),
);
expect(createTask).toHaveBeenCalledTimes(1);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
expect(result.details.error).toContain("Task ID already exists: FN-001");

View File

@@ -1,39 +1,27 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
return nextImpl(...args);
};
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
if (impl) {
mock.mockImplementation(impl);
}
return mock;
}
// Mock node:readline/promises before importing
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(),
}));
// Mock @fusion/core before importing
/*
FNXC:CliTests 2026-07-16-08:55:
Planning now resolves a ProjectContext through the backend factory rather than
constructing TaskStore directly. Derive the core module from its real export
surface and inject only the command's project-context seam.
*/
const mockResolveProject = vi.fn();
const mockCloseProjectStore = vi.fn(async () => undefined);
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
TaskStore: makeConstructibleMock(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
deterministicGuardLocks: new Map(),
}));
vi.mock("../project-context.js", () => ({
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
createLocalStore: vi.fn(),
closeProjectStore: (...args: unknown[]) => mockCloseProjectStore(...args),
}));
// Mock @fusion/dashboard/planning
@@ -62,7 +50,6 @@ vi.mock("@fusion/dashboard/planning", () => ({
// Import after mocking
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError } from "@fusion/dashboard/planning";
import { runTaskPlan } from "../commands/task.js";
@@ -92,17 +79,23 @@ describe("runTaskPlan", () => {
});
function setupTaskStoreMock(overrides: Record<string, unknown> = {}) {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
const store = {
createTask: vi.fn().mockResolvedValue({
id: "FN-042",
title: "Test Task Title",
description: "Test description",
column: "triage",
dependencies: ["FN-001"],
...overrides,
}),
}));
...overrides,
};
mockResolveProject.mockResolvedValue({
projectPath: "/test/project",
projectName: "test-project",
isRegistered: true,
store,
});
return store;
}
it("prompts for initial plan when not provided", async () => {
@@ -394,10 +387,7 @@ describe("runTaskPlan", () => {
dependencies: ["FN-001"],
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
createTask: mockCreateTask,
}));
setupTaskStoreMock({ createTask: mockCreateTask });
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
@@ -554,10 +544,7 @@ describe("runTaskPlan", () => {
it("skips task creation when user declines confirmation", async () => {
const mockCreateTask = vi.fn().mockResolvedValue({ id: "FN-042" });
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
createTask: mockCreateTask,
}));
setupTaskStoreMock({ createTask: mockCreateTask });
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",

View File

@@ -1,44 +1,30 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
const mock = vi.fn(function () {});
const originalMockImplementation = mock.mockImplementation.bind(mock);
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
return nextImpl(...args);
};
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
if (impl) {
mock.mockImplementation(impl);
}
return mock;
}
// Mock node:readline/promises before importing
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(),
}));
// Mock @fusion/core before importing
/*
FNXC:CliTests 2026-07-16-08:55:
Steering now writes through a resolved ProjectContext. Keep the real core export
shape and control only that boundary so the command cannot fall into a real store.
*/
const mockResolveProject = vi.fn();
const mockCloseProjectStore = vi.fn(async () => undefined);
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
TaskStore: makeConstructibleMock(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
deterministicGuardLocks: new Map(),
}));
vi.mock("../project-context.js", () => ({
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
createLocalStore: vi.fn(),
closeProjectStore: (...args: unknown[]) => mockCloseProjectStore(...args),
}));
// Import after mocking
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { runTaskSteer } from "../commands/task.js";
describe("runTaskSteer", () => {
@@ -65,11 +51,15 @@ describe("runTaskSteer", () => {
});
function setupTaskStoreMock(overrides: Record<string, unknown> = {}) {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
addSteeringComment: mockAddComment,
...overrides,
}));
mockResolveProject.mockResolvedValue({
projectPath: "/test/project",
projectName: "test-project",
isRegistered: true,
store: {
addSteeringComment: mockAddComment,
...overrides,
},
});
}
it("adds steering comment with message argument", async () => {

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { createSharedPgTaskStoreTestHarness, pgDescribe } from "../../../../core/src/__test-utils__/pg-test-harness.js";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -6,10 +7,13 @@ import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
const mockResolveProject = vi.fn();
const mockResolveAgentStoreBase = vi.fn();
let activeProjectDir = "";
let activeAsyncLayer: () => unknown = () => undefined;
vi.mock("../../project-context.js", () => ({
// FNXC:PostgresCutover 2026-07-10: branch agent commands resolve their AgentStore base (rootDir + asyncLayer) via this helper.
resolveAgentStoreBase: vi.fn(async () => ({ rootDir: process.cwd(), asyncLayer: {}, cleanup: vi.fn(async () => undefined) })),
// FNXC:PostgresCutover 2026-07-16-08:50: Agent export must use the real PG async layer, not an empty stub that would bypass persistence behavior.
resolveAgentStoreBase: (project?: string) => mockResolveAgentStoreBase(project),
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
}));
@@ -21,7 +25,15 @@ import { runAgentExport } from "../agent-export.js";
const SHOULD_RUN_SLOW_CLI =
process.env.FUSION_TEST_SLOW_CLI === "1" || process.env.FUSION_TEST_SLOW_CLI === "true";
/*
FNXC:PostgresCutover 2026-07-16-08:50:
The opt-in export suite still exercises real AgentStore persistence, now through a
shared PG harness. `pgDescribe` registers a clean skip when the external server is unavailable.
*/
const h = createSharedPgTaskStoreTestHarness({ prefix: "agent-export" });
describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => {
pgDescribe("postgres-backed export", () => {
const tmpRoot = join(tmpdir(), `fn-agent-export-test-${process.pid}`);
let projectDir: string;
let outputDir: string;
@@ -29,10 +41,20 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
beforeAll(h.beforeAll);
beforeEach(async () => {
await h.beforeEach();
vi.clearAllMocks();
projectDir = join(tmpRoot, `project-${Date.now()}-${Math.random().toString(16).slice(2)}`);
activeProjectDir = projectDir;
activeAsyncLayer = () => h.store().getAsyncLayer()!;
mockResolveAgentStoreBase.mockImplementation(async () => ({
rootDir: activeProjectDir,
asyncLayer: activeAsyncLayer(),
cleanup: vi.fn(async () => undefined),
}));
outputDir = join(projectDir, "exports", "company");
mkdirSync(projectDir, { recursive: true });
@@ -46,17 +68,22 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => {
});
});
afterEach(() => {
afterEach(async () => {
rmSync(projectDir, { recursive: true, force: true });
await h.afterEach();
});
afterAll(() => {
afterAll(async () => {
logSpy.mockRestore();
errorSpy.mockRestore();
await h.afterAll();
});
async function seedAgents(): Promise<void> {
const store = new AgentStore({ rootDir: join(projectDir, ".fusion") });
const store = new AgentStore({
rootDir: join(projectDir, ".fusion"),
asyncLayer: h.store().getAsyncLayer()!,
});
await store.init();
const ceo = await store.createAgent({
@@ -107,7 +134,7 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => {
project: "my-project",
});
expect(mockResolveProject).toHaveBeenCalledWith("my-project");
expect(mockResolveAgentStoreBase).toHaveBeenCalledWith("my-project");
expect(existsSync(join(outputDir, "COMPANY.md"))).toBe(true);
});
@@ -127,4 +154,5 @@ describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => {
exitSpy.mockRestore();
});
});
});