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