FN-8394: rescue deterministic quarantined tests
Restore reliable test coverage and delete quarantined tests that could not be rescued. - Replace process- and database-dependent tests with bounded dependency seams - Restore stabilized CLI, dashboard, and plugin test coverage - Remove unrescuable bundle and merge-worktree test suites and clear the quarantine ledger Files changed: packages/cli/src/__tests__/bundle-output.test.ts | 519 ------------ .../src/commands/__tests__/task-lock-retry.test.ts | 10 + packages/cli/vitest.config.ts | 8 - .../TaskDetailModal.tab-persistence.test.tsx | 2 +- .../__tests__/TaskDetailModal.test-helpers.ts | 7 + .../src/__tests__/dev-server-process.test.ts | 391 ++++----- packages/dashboard/src/dev-server-process.ts | 22 +- packages/dashboard/vitest.config.ts | 21 +- .../merge-reuse-task-worktree.slow.test.ts | 876 --------------------- packages/engine/vitest.config.ts | 7 - .../src/__tests__/process-lifecycle.test.ts | 21 +- .../fusion-plugin-grok-runtime/vitest.config.ts | 2 - .../src/__tests__/async-quality-store.pg.test.ts | 148 +++- plugins/fusion-plugin-quality/vitest.config.ts | 3 +- scripts/lib/test-quarantine.json | 43 +- 15 files changed, 323 insertions(+), 1757 deletions(-) Fusion-Task-Id: FN-8394 Fusion-Task-Lineage: e949b33e-b8d5-4f73-a002-e550b97ee125 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -1,519 +0,0 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolvePluginSkillBodyPath } from "@fusion/core";
|
||||
import {
|
||||
buildCliWithRealDashboardAssets,
|
||||
bundlePath,
|
||||
cliRoot,
|
||||
clientIndexPath,
|
||||
dashboardClientStubMarker,
|
||||
readClientIndexHtml,
|
||||
workspaceRoot,
|
||||
} from "./bundle-output-helpers";
|
||||
import { resolveClaudeCliExtensionFromModuleUrl } from "../commands/claude-cli-extension";
|
||||
import { resolveDroidCliExtensionFromModuleUrl } from "../commands/droid-cli-extension";
|
||||
import { RUNTIME_PLUGIN_IDS } from "../plugins/staged-bundled-plugin-ids";
|
||||
|
||||
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
|
||||
const bundlePluginEntryPluginIds = [
|
||||
...RUNTIME_PLUGIN_IDS,
|
||||
"fusion-plugin-dependency-graph",
|
||||
"fusion-plugin-roadmap",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"fusion-plugin-whatsapp-chat",
|
||||
"fusion-plugin-reports",
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"fusion-plugin-linear-import",
|
||||
] as const;
|
||||
const selfContainedBundlePluginIds = [
|
||||
"fusion-plugin-reports",
|
||||
"fusion-plugin-cli-printing-press",
|
||||
"fusion-plugin-whatsapp-chat",
|
||||
] as const;
|
||||
const knownCompoundEngineeringSkillIds = [
|
||||
"ce-brainstorm",
|
||||
"ce-code-review",
|
||||
"ce-commit",
|
||||
"ce-commit-push-pr",
|
||||
"ce-compound",
|
||||
"ce-debug",
|
||||
"ce-doc-review",
|
||||
"ce-ideate",
|
||||
"ce-plan",
|
||||
"ce-resolve-pr-feedback",
|
||||
"ce-strategy",
|
||||
"ce-work",
|
||||
] as const;
|
||||
|
||||
function expectSelfContainedBundle(pluginId: typeof selfContainedBundlePluginIds[number]) {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", pluginId);
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
const packageJsonPath = join(stagedRoot, "package.json");
|
||||
const bundledPath = join(stagedRoot, "bundled.js");
|
||||
|
||||
expect(existsSync(bundledPath), `${pluginId} should ship bundled.js`).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src")), `${pluginId} should not ship raw src/`).toBe(false);
|
||||
expect(
|
||||
readdirSync(stagedRoot).some((entry) => /\.index\.reload-\d+\.ts$/.test(entry)),
|
||||
`${pluginId} should not ship hot-reload TypeScript artifacts`,
|
||||
).toBe(false);
|
||||
|
||||
expect(existsSync(manifestPath), `${pluginId} manifest should exist`).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
|
||||
expect(manifest.id).toBe(pluginId);
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
|
||||
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
exports?: { "."?: { import?: string } };
|
||||
};
|
||||
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
|
||||
|
||||
const bundled = readFileSync(bundledPath, "utf-8");
|
||||
expect(bundled, `${pluginId} should not keep a bare @fusion/core import`).not.toMatch(
|
||||
/from\s+["']@fusion\/core["']/,
|
||||
);
|
||||
expect(bundled, `${pluginId} should not mention the private @fusion/core package`).not.toContain("@fusion/core");
|
||||
}
|
||||
|
||||
describe("CLI bundle output", () => {
|
||||
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.
|
||||
await buildCliWithRealDashboardAssets();
|
||||
}, 300_000);
|
||||
|
||||
it("dist/bin.js exists", () => {
|
||||
expect(existsSync(bundlePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("starts with a shebang", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
expect(content.startsWith("#!/usr/bin/env node")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not contain bare @fusion/* import specifiers", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
expect(content).not.toMatch(/from\s+["']@fusion\/core["']/);
|
||||
expect(content).not.toMatch(/from\s+["']@fusion\/dashboard["']/);
|
||||
expect(content).not.toMatch(/from\s+["']@fusion\/engine["']/);
|
||||
expect(content).not.toMatch(/from\s+["']@fusion-plugin-examples\/roadmap["']/);
|
||||
expect(content).not.toContain('"@fusion/core"');
|
||||
expect(content).not.toContain('"@fusion/dashboard"');
|
||||
expect(content).not.toContain('"@fusion/engine"');
|
||||
expect(content).not.toContain('"@fusion-plugin-examples/fusion-plugin-roadmap"');
|
||||
});
|
||||
|
||||
it("does not contain runtime memory-backend side-load imports", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
expect(content).not.toMatch(/await\s+import\(\s*["']\.\/memory-backend\.js["']\s*\)/);
|
||||
expect(content).not.toMatch(/await\s+import\(\s*["']\.\.\/memory-backend\.js["']\s*\)/);
|
||||
});
|
||||
|
||||
it("contains inlined workspace code", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
// TaskStore from @fusion/core
|
||||
expect(content).toContain("TaskStore");
|
||||
// createServer from @fusion/dashboard
|
||||
expect(content).toContain("createServer");
|
||||
});
|
||||
|
||||
it("dashboard client assets are included", () => {
|
||||
expect(existsSync(clientIndexPath)).toBe(true);
|
||||
|
||||
const indexHtml = readClientIndexHtml();
|
||||
expect(indexHtml).toContain("<script");
|
||||
expect(indexHtml).toMatch(/assets\/.+-[A-Za-z0-9_-]+\.js/);
|
||||
expect(indexHtml).toMatch(/assets\/vendor-react-[A-Za-z0-9_-]+\.js/);
|
||||
expect(indexHtml).not.toContain(dashboardClientStubMarker);
|
||||
|
||||
const copiedAssetsDir = join(cliRoot, "dist", "client", "assets");
|
||||
const copiedAssets = readdirSync(copiedAssetsDir);
|
||||
expect(copiedAssets.some((file) => /^vendor-react-[A-Za-z0-9_-]+\.js$/.test(file))).toBe(true);
|
||||
expect(copiedAssets.some((file) => /^vendor-xterm-[A-Za-z0-9_-]+\.js$/.test(file))).toBe(true);
|
||||
});
|
||||
|
||||
it("tsup config copies dashboard assets from dashboard/dist/client to dist/client", () => {
|
||||
const tsupConfig = readFileSync(tsupConfigPath, "utf-8");
|
||||
|
||||
expect(tsupConfig).toContain("onSuccess");
|
||||
expect(tsupConfig).toContain('join(__dirname, "..", "dashboard", "dist", "client")');
|
||||
expect(tsupConfig).toContain('join(__dirname, "dist", "client")');
|
||||
expect(tsupConfig).toContain("cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });");
|
||||
});
|
||||
|
||||
it("keeps native module loaders externalized in tsup config", () => {
|
||||
const tsupConfig = readFileSync(tsupConfigPath, "utf-8");
|
||||
|
||||
expect(tsupConfig).toContain('"dockerode"');
|
||||
expect(tsupConfig).toContain('"ssh2"');
|
||||
expect(tsupConfig).toContain('"cpu-features"');
|
||||
});
|
||||
|
||||
it("loads sqlite from Node built-ins and never from bare sqlite npm package", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
// The bundle must resolve sqlite through Node's built-in module.
|
||||
expect(content).toMatch(/["']node:sqlite["']/);
|
||||
// Bun-native sqlite support is optional in this artifact depending on runtime-targeted code paths.
|
||||
// No bare "sqlite" import (we never want to pull in an npm package named sqlite).
|
||||
expect(content).not.toMatch(/from\s+["']sqlite["'][^s]/);
|
||||
});
|
||||
|
||||
it("does not inline native artifact filenames into the bundled CLI", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
expect(content).not.toContain("sshcrypto.node");
|
||||
expect(content).not.toContain("cpufeatures.node");
|
||||
});
|
||||
|
||||
it("provides require via createRequire banner", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
// Banner should inject createRequire for ESM CJS interop
|
||||
expect(content).toContain("createRequire");
|
||||
expect(content).toContain("import.meta.url");
|
||||
// Banner should be near the top of the file (after shebang)
|
||||
const shebangEnd = content.indexOf("\n");
|
||||
const bannerPosition = content.indexOf("createRequire");
|
||||
expect(bannerPosition).toBeLessThan(100);
|
||||
expect(bannerPosition).toBeGreaterThan(shebangEnd);
|
||||
});
|
||||
|
||||
it("preserves node: prefix in other node built-in imports", () => {
|
||||
const content = readFileSync(bundlePath, "utf-8");
|
||||
// Verify removeNodeProtocol: false is effective for other node: imports
|
||||
expect(content).toMatch(/from\s+["']node:fs["']/);
|
||||
expect(content).toMatch(/from\s+["']node:path["']/);
|
||||
});
|
||||
|
||||
it("resolveClaudeCliExtension succeeds against the staged dist/ layout", () => {
|
||||
const result = resolveClaudeCliExtensionFromModuleUrl(pathToFileURL(bundlePath).href);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.path).toBe(join(cliRoot, "dist", "pi-claude-cli", "index.ts"));
|
||||
expect(result.packageVersion).toMatch(/\d+\.\d+\.\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/pi-claude-cli/ is staged with correct files", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "pi-claude-cli");
|
||||
|
||||
expect(existsSync(join(stagedRoot, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolveDroidCliExtension succeeds against the staged dist/ layout", () => {
|
||||
const result = resolveDroidCliExtensionFromModuleUrl(pathToFileURL(bundlePath).href);
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.path).toBe(join(cliRoot, "dist", "droid-cli", "index.ts"));
|
||||
expect(result.packageVersion).toMatch(/\d+\.\d+\.\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/droid-cli/ is staged with correct files", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "droid-cli");
|
||||
|
||||
expect(existsSync(join(stagedRoot, "package.json"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "index.ts"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-dependency-graph/ is staged as bundled runtime output", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-dependency-graph");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
const packageJsonPath = join(stagedRoot, "package.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
|
||||
expect(manifest.id).toBe("fusion-plugin-dependency-graph");
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
|
||||
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
|
||||
|
||||
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
exports?: { "."?: { import?: string } };
|
||||
};
|
||||
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-roadmap/ is staged as bundled runtime output", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-roadmap");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
const packageJsonPath = join(stagedRoot, "package.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
|
||||
expect(manifest.id).toBe("fusion-plugin-roadmap");
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
|
||||
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
|
||||
|
||||
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
exports?: { "."?: { import?: string } };
|
||||
};
|
||||
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-linear-import/ is staged as bundled runtime output", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-linear-import");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
const packageJsonPath = join(stagedRoot, "package.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string; dashboardViews?: unknown[] };
|
||||
expect(manifest.id).toBe("fusion-plugin-linear-import");
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.dashboardViews?.[0]).toMatchObject({ viewId: "linear-import" });
|
||||
|
||||
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
|
||||
|
||||
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
||||
exports?: { "."?: { import?: string } };
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
|
||||
expect(stagedPkg.dependencies?.["@fusion/core"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-compound-engineering/ ships skill bodies that resolve from plugin root", () => {
|
||||
const sourceSkillsRoot = join(workspaceRoot, "plugins", "fusion-plugin-compound-engineering", "src", "skills");
|
||||
const stagedPluginRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-compound-engineering");
|
||||
const skillIds = readdirSync(sourceSkillsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const knownSkillId of knownCompoundEngineeringSkillIds) {
|
||||
expect(skillIds).toContain(knownSkillId);
|
||||
}
|
||||
|
||||
for (const skillId of skillIds) {
|
||||
const stagedSkillPath = join(stagedPluginRoot, "skills", skillId, "SKILL.md");
|
||||
expect(existsSync(stagedSkillPath), `${skillId} SKILL.md should be staged`).toBe(true);
|
||||
expect(
|
||||
readFileSync(stagedSkillPath, "utf-8").trim().length,
|
||||
`${skillId} SKILL.md should be non-empty`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const resolvedSkillBody = resolvePluginSkillBodyPath(
|
||||
{ name: skillId, skillFiles: [`skills/${skillId}/SKILL.md`] },
|
||||
stagedPluginRoot,
|
||||
);
|
||||
expect(existsSync(resolvedSkillBody.absolutePath), `${skillId} should resolve via plugin skillFiles`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-compound-engineering/ ships agent persona definitions", () => {
|
||||
const sourceAgentsRoot = join(
|
||||
workspaceRoot,
|
||||
"plugins",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"src",
|
||||
"agents",
|
||||
);
|
||||
const stagedAgentsRoot = join(
|
||||
cliRoot,
|
||||
"dist",
|
||||
"plugins",
|
||||
"fusion-plugin-compound-engineering",
|
||||
"agents",
|
||||
);
|
||||
const sourceAgentFiles = readdirSync(sourceAgentsRoot)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort();
|
||||
const stagedAgentFiles = readdirSync(stagedAgentsRoot)
|
||||
.filter((file) => file.endsWith(".md"))
|
||||
.sort();
|
||||
|
||||
expect(stagedAgentFiles).toEqual(sourceAgentFiles);
|
||||
for (const agentFile of stagedAgentFiles) {
|
||||
const stagedAgentPath = join(stagedAgentsRoot, agentFile);
|
||||
expect(readFileSync(stagedAgentPath, "utf-8")).toMatch(/^---[\s\S]*?name:\s*\S+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not create skills directories for bundled plugins without skill sources", () => {
|
||||
const pluginId = "fusion-plugin-roadmap";
|
||||
|
||||
expect(existsSync(join(workspaceRoot, "plugins", pluginId, "src", "skills"))).toBe(false);
|
||||
expect(existsSync(join(cliRoot, "dist", "plugins", pluginId, "skills"))).toBe(false);
|
||||
});
|
||||
|
||||
it("bundled plugin outputs do not import private @fusion/core at runtime", () => {
|
||||
const inspectedPluginIds: string[] = [];
|
||||
|
||||
for (const pluginId of bundlePluginEntryPluginIds) {
|
||||
const bundledPath = join(cliRoot, "dist", "plugins", pluginId, "bundled.js");
|
||||
if (!existsSync(bundledPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspectedPluginIds.push(pluginId);
|
||||
const bundled = readFileSync(bundledPath, "utf-8");
|
||||
expect(bundled, `${pluginId} should not keep a bare @fusion/core import`).not.toMatch(
|
||||
/from\s+["']@fusion\/core["']/,
|
||||
);
|
||||
expect(bundled, `${pluginId} should not mention the private @fusion/core package`).not.toContain(
|
||||
"@fusion/core",
|
||||
);
|
||||
}
|
||||
|
||||
expect(inspectedPluginIds.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("reports, cli-printing-press, and whatsapp-chat ship self-contained bundled.js outputs", () => {
|
||||
/*
|
||||
* FNXC:BundledPlugins 2026-07-15-00:00:
|
||||
* Surface Enumeration for FN-7956:
|
||||
* - [x] Providers / bridges / execution paths: fusion-plugin-reports, fusion-plugin-cli-printing-press, and fusion-plugin-whatsapp-chat are all asserted through this bundlePluginEntry output invariant.
|
||||
* - [x] Desktop + mobile breakpoints / platforms: N/A build/packaging-only change; desktop missing-bundle behavior for non-staged dependencies is unchanged.
|
||||
* - [x] Empty / undefined / duplicate / populated data states: each staged root must contain bundled.js, omit src/, and omit .index.reload-N.ts artifacts.
|
||||
* - [x] Shared hooks / components / modules / helpers: these ids are included in bundlePluginEntryPluginIds so the shared @fusion/core runtime-shim alias invariant covers them.
|
||||
* - [x] Every component that renders the affordance: N/A no UI affordance add/remove.
|
||||
* - [x] Leftover shells after removal: package output assertions fail if the old raw-src branches leave src/ or reload TypeScript files behind.
|
||||
* - [x] Runtime-value assertion: each emitted bundled.js has no from "@fusion/core" import and no literal @fusion/core occurrence.
|
||||
*/
|
||||
for (const pluginId of selfContainedBundlePluginIds) {
|
||||
expectSelfContainedBundle(pluginId);
|
||||
}
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-openclaw-runtime/ is staged with required bridge assets", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-openclaw-runtime");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-droid-runtime/ is staged with required bridge assets", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-droid-runtime");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string };
|
||||
expect(manifest.id).toBe("fusion-plugin-droid-runtime");
|
||||
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
|
||||
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("stages a portable Claude ACP launcher and declares every platform bridge", () => {
|
||||
const bridgeRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-claude-runtime", "bridge");
|
||||
const executableName = process.platform === "win32" ? "claude-code-cli-acp.cmd" : "claude-code-cli-acp";
|
||||
const bridgePath = join(bridgeRoot, executableName);
|
||||
const launcherManifestPath = join(bridgeRoot, "node_modules", "claude-code-cli-acp", "package.json");
|
||||
const launcherManifest = JSON.parse(readFileSync(launcherManifestPath, "utf8")) as {
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
const cliManifest = JSON.parse(readFileSync(join(cliRoot, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const supportedPlatformPackages = [
|
||||
"claude-code-cli-acp-darwin-arm64",
|
||||
"claude-code-cli-acp-darwin-x64",
|
||||
"claude-code-cli-acp-linux-arm64",
|
||||
"claude-code-cli-acp-linux-x64",
|
||||
"claude-code-cli-acp-win32-arm64",
|
||||
"claude-code-cli-acp-win32-x64",
|
||||
];
|
||||
|
||||
expect(existsSync(bridgePath)).toBe(true);
|
||||
expect(existsSync(join(bridgeRoot, "node_modules", "claude-code-cli-acp", "bin", "claude-code-cli-acp.js"))).toBe(true);
|
||||
expect(cliManifest.dependencies?.["claude-code-cli-acp"]).toBe("0.1.1");
|
||||
expect(Object.keys(launcherManifest.optionalDependencies ?? {}).sort()).toEqual(supportedPlatformPackages);
|
||||
if (process.platform !== "win32") expect(statSync(bridgePath).mode & 0o111).not.toBe(0);
|
||||
|
||||
// Native binaries intentionally are not staged from the build host. npm installs the matching
|
||||
// optional package from this manifest on the operator's platform, including platforms unavailable to CI.
|
||||
}, 35_000);
|
||||
|
||||
it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
|
||||
expect(manifest.id).toBe("fusion-plugin-cursor-runtime");
|
||||
expect(typeof manifest.name).toBe("string");
|
||||
expect(manifest.name?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dist/plugins/fusion-plugin-acp-runtime/ is staged with the acp runtime manifest", () => {
|
||||
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-acp-runtime");
|
||||
const manifestPath = join(stagedRoot, "manifest.json");
|
||||
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
|
||||
id?: string;
|
||||
runtime?: { runtimeId?: string };
|
||||
};
|
||||
expect(manifest.id).toBe("fusion-plugin-acp-runtime");
|
||||
// The runtime is selected by runtimeId; assert it is "acp".
|
||||
expect(manifest.runtime?.runtimeId).toBe("acp");
|
||||
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
|
||||
// v1 ships no mcp-schema-server.cjs (MCP forwarding deferred, KTD5).
|
||||
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(false);
|
||||
});
|
||||
|
||||
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
|
||||
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
|
||||
|
||||
expect(processManagerSource).toMatch(/import\s+\{[^}]*\bspawn\b[^}]*\}\s+from\s*["']node:child_process["']/);
|
||||
});
|
||||
|
||||
it("pi-claude-cli source does not import cross-spawn directly", () => {
|
||||
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
|
||||
|
||||
expect(processManagerSource).not.toMatch(/from\s*["']cross-spawn["']/);
|
||||
});
|
||||
|
||||
it("staged pi-claude-cli package.json keeps pi extension entry and excludes cross-spawn deps", () => {
|
||||
const stagedPkg = JSON.parse(
|
||||
readFileSync(join(cliRoot, "dist", "pi-claude-cli", "package.json"), "utf-8"),
|
||||
) as {
|
||||
pi?: { extensions?: unknown };
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(stagedPkg.pi?.extensions).toEqual(["index.ts"]);
|
||||
expect(stagedPkg.dependencies?.["cross-spawn"]).toBeUndefined();
|
||||
expect(stagedPkg.dependencies?.["@types/cross-spawn"]).toBeUndefined();
|
||||
expect(stagedPkg.devDependencies?.["cross-spawn"]).toBeUndefined();
|
||||
expect(stagedPkg.devDependencies?.["@types/cross-spawn"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runtime native assets are staged after build:exe", () => {
|
||||
const runtimeDir = join(cliRoot, "dist", "runtime");
|
||||
if (!existsSync(runtimeDir)) return;
|
||||
|
||||
const platformDirs = readdirSync(runtimeDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
|
||||
if (platformDirs.length === 0) return;
|
||||
|
||||
const nativeAssets = platformDirs.flatMap((platform) => {
|
||||
const platformDir = join(runtimeDir, platform);
|
||||
return readdirSync(platformDir).filter((file) => file === "pty.node" || file === "spawn-helper");
|
||||
});
|
||||
|
||||
// `build:exe` coverage lives in the dedicated build-exe tests. This check only
|
||||
// validates already-staged runtime outputs when they are present, without
|
||||
// failing on partially populated stale directories from earlier test runs.
|
||||
if (nativeAssets.length === 0) return;
|
||||
|
||||
expect(nativeAssets.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -110,11 +110,16 @@ describe("retryOnLock", () => {
|
||||
*/
|
||||
describe("runTaskShow / runTaskMove — mocked-store lock exhaustion, not-found, and teardown (FN-7731)", () => {
|
||||
beforeEach(() => {
|
||||
// FNXC:CliBoardMutation 2026-07-19-18:20: mcp-lock-retry installs a
|
||||
// per-test @fusion/core factory. Clear it before importing task.js so
|
||||
// concurrent CLI test files cannot supply its secrets-store double here.
|
||||
vi.doUnmock("@fusion/core");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../../project-context.js");
|
||||
vi.doUnmock("@fusion/core");
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
|
||||
});
|
||||
@@ -273,11 +278,16 @@ describe("runTaskShow / runTaskMove — mocked-store lock exhaustion, not-found,
|
||||
// wrapper around a fresh, uncached `TaskStore`).
|
||||
describe("FN-7734: generalized retry+teardown across representative fn task subcommands", () => {
|
||||
beforeEach(() => {
|
||||
// FNXC:CliBoardMutation 2026-07-19-18:20: mcp-lock-retry installs a
|
||||
// per-test @fusion/core factory. Clear it before importing task.js so
|
||||
// concurrent CLI test files cannot supply its secrets-store double here.
|
||||
vi.doUnmock("@fusion/core");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../../project-context.js");
|
||||
vi.doUnmock("@fusion/core");
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.FUSION_CLI_LOCK_RETRY_MS;
|
||||
});
|
||||
|
||||
@@ -102,18 +102,10 @@ const quarantinedCliTests: string[] = [
|
||||
FNXC:CliTests 2026-07-18-07:30:
|
||||
FN-8271 rescued the shard-4 cascade after removing unrelated PostgreSQL template-copy and persistent-seeding work from extension-dist-barrel's built-dist hook. All fourteen affected CLI files return to the default lane with their matching quarantine-ledger rows removed; retain normal worker budgets and timeout defaults rather than reintroducing appeasement.
|
||||
|
||||
FNXC:CliTests 2026-07-18-15:20:
|
||||
Full-suite shard 4 after FN-8271 (runs 29648812375 / 29648952207) re-observed mcp-lock-retry and task-lock-retry 5s timeouts under package-lane shard load without product-bug evidence. Quarantine on sight in lockstep with scripts/lib/test-quarantine.json — do not raise testTimeout or fake-timer budgets.
|
||||
|
||||
FNXC:CliTests 2026-07-18-15:20:
|
||||
Full-suite shard 4 on tip after #2322 (run 29662476909): bundle-output failed building desktop assets (ENOENT vendor-reactflow CSS) under package-lane load without product-bug evidence. Quarantine on sight — do not soften build assertions.
|
||||
|
||||
FNXC:CliTests 2026-07-18-20:45:
|
||||
FN-8381 deletes extension-dist-barrel after its fourth quarantine cycle. Timing isolated the full core dist-barrel and re-mocked extension module graph as a 4–5.5s CPU-bound beforeAll while temp setup and cache seeding were negligible; shard contention pushed the same hook beyond Vitest's default 10s in run 29662476909. The source-side extension test retains the fn_task_list formatting/truncation invariant, while this test's marginal full-barrel substitution signal is not worth another load-sensitive rescue. Keep it out of both this exclusion and scripts/lib/test-quarantine.json; do not replace deletion with timeout, retry, or worker-budget appeasement.
|
||||
*/
|
||||
"src/commands/__tests__/mcp-lock-retry.test.ts",
|
||||
"src/commands/__tests__/task-lock-retry.test.ts",
|
||||
"src/__tests__/bundle-output.test.ts",
|
||||
];
|
||||
|
||||
/*
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("TaskDetailModal tab persistence", () => {
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active"));
|
||||
});
|
||||
|
||||
it("keeps the Terminal guard when the CLI session disappears", async () => {
|
||||
it("keeps the Terminal guard when the mocked CLI session disappears", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url) => {
|
||||
const body = url.toString().includes("FN-TERMINAL")
|
||||
|
||||
@@ -101,6 +101,13 @@ vi.mock("lucide-react", () => ({
|
||||
Maximize2: () => null,
|
||||
Minimize2: () => null,
|
||||
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
|
||||
/*
|
||||
FNXC:TaskDetailTabPersistence 2026-07-20-19:10:
|
||||
FN-8394's restored mocked-session guard reaches TaskVerificationStatus. Keep
|
||||
its success-icon mock available so this deterministic tab-state regression
|
||||
does not depend on the unrelated lucide module implementation.
|
||||
*/
|
||||
CheckCircle2: () => null,
|
||||
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
|
||||
Square: (props: any) => React.createElement("svg", { "data-testid": "square-icon", ...props }),
|
||||
Info: (props: any) => React.createElement("svg", { "data-testid": "info-icon", ...props }),
|
||||
|
||||
@@ -1,303 +1,200 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DevServerProcessManager } from "../dev-server-process.js";
|
||||
import { loadDevServerStore, resetDevServerStore } from "../dev-server-store.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DevServerProcessManager, type DevServerProcessManagerOptions } from "../dev-server-process.js";
|
||||
import type { DevServerState, DevServerStore } from "../dev-server-store.js";
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 4_000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("Timed out waiting for condition");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
/*
|
||||
FNXC:DevServerProcessTests 2026-07-19-18:45:
|
||||
FN-8394 replaces the quarantined real-shell test with an injected child-process
|
||||
and timer seam. The test still guards lifecycle behavior, while shard load cannot
|
||||
starve a real process, filesystem store, stdout race, or fallback network probe.
|
||||
*/
|
||||
|
||||
class MemoryDevServerStore {
|
||||
private state: DevServerState = {
|
||||
id: "",
|
||||
name: "default",
|
||||
status: "stopped",
|
||||
command: "",
|
||||
cwd: "",
|
||||
logHistory: [],
|
||||
};
|
||||
|
||||
getState(): DevServerState {
|
||||
return { ...this.state, logHistory: [...this.state.logHistory] };
|
||||
}
|
||||
|
||||
async updateState(partial: Partial<DevServerState>): Promise<DevServerState> {
|
||||
this.state = { ...this.state, ...partial, logHistory: partial.logHistory ?? this.state.logHistory };
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
async appendLog(line: string): Promise<void> {
|
||||
this.state.logHistory.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
pid = 42;
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
stdout = new PassThrough();
|
||||
stderr = new PassThrough();
|
||||
|
||||
close(code = 0): void {
|
||||
this.exitCode = code;
|
||||
this.emit("close", code);
|
||||
}
|
||||
}
|
||||
|
||||
type DevServerProcessManagerInternals = {
|
||||
childProcess: ChildProcess | null;
|
||||
handleFailure(error: Error): Promise<void>;
|
||||
};
|
||||
function createFixture(options?: { closeOnSignal?: NodeJS.Signals[]; stopTimeoutMs?: number }) {
|
||||
const store = new MemoryDevServerStore();
|
||||
const children: FakeChildProcess[] = [];
|
||||
const signals: NodeJS.Signals[] = [];
|
||||
const closeOnSignal = options?.closeOnSignal ?? ["SIGTERM"];
|
||||
const managerOptions: DevServerProcessManagerOptions = {
|
||||
probeDelayMs: 10_000,
|
||||
stopTimeoutMs: options?.stopTimeoutMs,
|
||||
spawn: (() => {
|
||||
const child = new FakeChildProcess();
|
||||
children.push(child);
|
||||
return { child: child as unknown as ChildProcess };
|
||||
}) as DevServerProcessManagerOptions["spawn"],
|
||||
killManagedProcess: (child, signal) => {
|
||||
signals.push(signal);
|
||||
if (closeOnSignal.includes(signal)) {
|
||||
(child as unknown as FakeChildProcess).close();
|
||||
}
|
||||
},
|
||||
};
|
||||
return { store, children, signals, manager: new DevServerProcessManager(store as unknown as DevServerStore, managerOptions) };
|
||||
}
|
||||
|
||||
async function settleLifecycleWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("DevServerProcessManager", () => {
|
||||
const tempDirs: string[] = [];
|
||||
const managers: DevServerProcessManager[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const manager of managers.splice(0)) {
|
||||
try {
|
||||
if (manager.isRunning()) {
|
||||
await manager.stop();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
manager.cleanup();
|
||||
}
|
||||
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
resetDevServerStore();
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function createManager(options?: { stopTimeoutMs?: number; probeDelayMs?: number; probeTimeoutMs?: number }) {
|
||||
const root = mkdtempSync(join(os.tmpdir(), "fn-dev-process-"));
|
||||
tempDirs.push(root);
|
||||
const store = await loadDevServerStore(root);
|
||||
const manager = new DevServerProcessManager(store, options);
|
||||
managers.push(manager);
|
||||
return { root, store, manager };
|
||||
}
|
||||
it("rejects invalid commands and duplicate starts before spawning another child", async () => {
|
||||
const { children, manager } = createFixture();
|
||||
|
||||
it("start() spawns child process and updates state to running", async () => {
|
||||
const { root, manager } = await createManager();
|
||||
const state = await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
await expect(manager.start(" ", "/repo")).rejects.toThrow("command is required");
|
||||
await expect(manager.start("echo $(unsafe)", "/repo")).rejects.toThrow("command substitution");
|
||||
await expect(manager.start("pnpm dev", " ")).rejects.toThrow("cwd is required");
|
||||
expect(children).toHaveLength(0);
|
||||
|
||||
expect(state.status).toBe("running");
|
||||
expect(typeof state.pid).toBe("number");
|
||||
await manager.start("pnpm dev", "/repo");
|
||||
await expect(manager.start("pnpm dev", "/repo")).rejects.toThrow("already running");
|
||||
expect(children).toHaveLength(1);
|
||||
manager.cleanup();
|
||||
});
|
||||
|
||||
it("start() emits started event", async () => {
|
||||
const { root, manager } = await createManager();
|
||||
it("starts an injected child, persists output, and detects its announced URL once", async () => {
|
||||
const { children, manager, store } = createFixture();
|
||||
const detected: unknown[] = [];
|
||||
manager.on("url-detected", (event) => detected.push(event));
|
||||
|
||||
const startedEvent = new Promise<void>((resolve) => {
|
||||
manager.once("started", () => resolve());
|
||||
});
|
||||
const state = await manager.start("pnpm dev", "/repo", { scriptId: "dev" });
|
||||
children[0].stdout.write("ready at http://localhost:4321\nready again at http://localhost:4321\n");
|
||||
await settleLifecycleWork();
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
await startedEvent;
|
||||
expect(state).toMatchObject({ status: "running", pid: 42, scriptId: "dev" });
|
||||
expect(store.getState()).toMatchObject({ detectedUrl: "http://localhost:4321", detectedPort: 4321 });
|
||||
expect(store.getState().logHistory).toEqual([
|
||||
"ready at http://localhost:4321",
|
||||
"ready again at http://localhost:4321",
|
||||
]);
|
||||
expect(detected).toHaveLength(1);
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
manager.cleanup();
|
||||
});
|
||||
|
||||
it("start() captures stdout into log buffer", async () => {
|
||||
const { root, store, manager } = await createManager();
|
||||
it.each([
|
||||
["http://127.0.0.1:4173", "http://127.0.0.1:4173", 4173],
|
||||
["Listening on port 5173", "http://localhost:5173", 5173],
|
||||
])("detects alternate announced URL format %s", async (line, detectedUrl, detectedPort) => {
|
||||
const { children, manager, store } = createFixture();
|
||||
|
||||
await manager.start("node -e \"console.log('hello from stdout');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
await manager.start("pnpm dev", "/repo");
|
||||
children[0].stdout.write(`${line}\n`);
|
||||
await settleLifecycleWork();
|
||||
|
||||
await waitFor(() => store.getState().logHistory.some((line) => line.includes("hello from stdout")));
|
||||
|
||||
expect(store.getState().logHistory.some((line) => line.includes("hello from stdout"))).toBe(true);
|
||||
expect(store.getState()).toMatchObject({ detectedUrl, detectedPort });
|
||||
manager.cleanup();
|
||||
});
|
||||
|
||||
it("start() throws if already running", async () => {
|
||||
const { root, manager } = await createManager();
|
||||
it("stops through the injected process-tree signal and clears the fallback timer", async () => {
|
||||
const { manager, signals, store } = createFixture();
|
||||
await manager.start("pnpm dev", "/repo");
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
await expect(manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root)).rejects.toThrow("already running");
|
||||
});
|
||||
const stopped = await manager.stop();
|
||||
|
||||
it("start() throws if command is empty", async () => {
|
||||
const { root, manager } = await createManager();
|
||||
await expect(manager.start(" ", root)).rejects.toThrow("command is required");
|
||||
});
|
||||
|
||||
it("stop() sends SIGTERM and waits for exit", async () => {
|
||||
const { root, store, manager } = await createManager();
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
const state = await manager.stop();
|
||||
|
||||
expect(state.status).toBe("stopped");
|
||||
expect(signals).toEqual(["SIGTERM"]);
|
||||
expect(stopped.status).toBe("stopped");
|
||||
expect(store.getState().status).toBe("stopped");
|
||||
expect(store.getState().exitCode).toBeDefined();
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
});
|
||||
|
||||
it("stop() terminates the shell-launched child process tree", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
it("returns the current state without signaling when no child is running", async () => {
|
||||
const { manager, signals, store } = createFixture();
|
||||
|
||||
const { root, manager } = await createManager();
|
||||
const childPidFile = join(root, "managed-child.pid");
|
||||
|
||||
await manager.start(
|
||||
`node -e "require('node:fs').writeFileSync('${childPidFile}', String(process.pid));process.stdin.resume();process.stdin.on('end',()=>process.exit(0))"`,
|
||||
root,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return Number.parseInt(readFileSync(childPidFile, "utf8").trim(), 10) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const managedChildPid = Number.parseInt(readFileSync(childPidFile, "utf8").trim(), 10);
|
||||
expect(isProcessAlive(managedChildPid)).toBe(true);
|
||||
|
||||
await manager.stop();
|
||||
|
||||
await waitFor(() => !isProcessAlive(managedChildPid));
|
||||
await expect(manager.stop()).resolves.toEqual(store.getState());
|
||||
expect(signals).toEqual([]);
|
||||
});
|
||||
|
||||
it("stop() falls back to SIGKILL after timeout", async () => {
|
||||
const { root, store, manager } = await createManager({ stopTimeoutMs: 150 });
|
||||
it("falls back to SIGKILL when the injected child ignores SIGTERM", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { manager, signals, store } = createFixture({ closeOnSignal: ["SIGKILL"], stopTimeoutMs: 25 });
|
||||
await manager.start("pnpm dev", "/repo");
|
||||
|
||||
await manager.start(
|
||||
"node -e \"process.on('SIGTERM', () => {});process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
|
||||
root,
|
||||
);
|
||||
const stopped = manager.stop();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
const state = await manager.stop();
|
||||
expect(state.status).toBe("stopped");
|
||||
await expect(stopped).resolves.toMatchObject({ status: "stopped" });
|
||||
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
|
||||
expect(store.getState().status).toBe("stopped");
|
||||
});
|
||||
|
||||
it("stop() returns current state if nothing is running", async () => {
|
||||
const { store, manager } = await createManager();
|
||||
|
||||
const state = await manager.stop();
|
||||
expect(state).toEqual(store.getState());
|
||||
});
|
||||
|
||||
it("restart() stops then starts with same command", async () => {
|
||||
const { root, store, manager } = await createManager();
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root, { scriptId: "dev" });
|
||||
const firstPid = store.getState().pid;
|
||||
|
||||
const state = await manager.restart();
|
||||
expect(state.status).toBe("running");
|
||||
expect(state.pid).toBeDefined();
|
||||
expect(state.scriptId).toBe("dev");
|
||||
expect(state.pid).not.toBe(firstPid);
|
||||
});
|
||||
|
||||
it("detects URL from localhost output", async () => {
|
||||
const { root, store, manager } = await createManager();
|
||||
|
||||
await manager.start(
|
||||
"node -e \"console.log('Server ready at http://localhost:3000');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
|
||||
root,
|
||||
);
|
||||
|
||||
await waitFor(() => store.getState().detectedUrl === "http://localhost:3000");
|
||||
expect(store.getState().detectedPort).toBe(3000);
|
||||
});
|
||||
|
||||
it("detects URL from 127.0.0.1 output", async () => {
|
||||
const { root, store, manager } = await createManager();
|
||||
|
||||
await manager.start(
|
||||
"node -e \"console.log('ready at http://127.0.0.1:4173');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
|
||||
root,
|
||||
);
|
||||
|
||||
await waitFor(() => store.getState().detectedUrl === "http://127.0.0.1:4173");
|
||||
expect(store.getState().detectedPort).toBe(4173);
|
||||
});
|
||||
|
||||
it("detects URL from keyword plus port pattern", async () => {
|
||||
const { root, store, manager } = await createManager();
|
||||
|
||||
await manager.start(
|
||||
"node -e \"console.log('Listening on port 5173');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
|
||||
root,
|
||||
);
|
||||
|
||||
await waitFor(() => store.getState().detectedUrl === "http://localhost:5173");
|
||||
expect(store.getState().detectedPort).toBe(5173);
|
||||
});
|
||||
|
||||
it("schedules fallback probing after startup when no URL is announced", async () => {
|
||||
const { root, manager } = await createManager({ probeDelayMs: 25, probeTimeoutMs: 5 });
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
await waitFor(() => manager.hasPendingProbeTimer() === false, 3_000);
|
||||
});
|
||||
|
||||
it("clears fallback probe timer when URL is detected from logs", async () => {
|
||||
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
||||
const detectedEvents: unknown[] = [];
|
||||
manager.on("url-detected", (payload) => detectedEvents.push(payload));
|
||||
|
||||
await manager.start(
|
||||
"node -e \"console.log('ready at http://localhost:4321');console.log('ready again at http://localhost:4321');process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"",
|
||||
root,
|
||||
);
|
||||
|
||||
await waitFor(() => store.getState().detectedPort === 4321);
|
||||
it("clears the fallback timer on child failure and creates a fresh child on restart", async () => {
|
||||
const { children, manager } = createFixture();
|
||||
await manager.start("pnpm dev", "/repo", { scriptId: "dev" });
|
||||
children[0].emit("error", new Error("synthetic failure"));
|
||||
await settleLifecycleWork();
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
expect(detectedEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears fallback probe timer on stop", async () => {
|
||||
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
||||
await manager.start("pnpm dev", "/repo", { scriptId: "dev" });
|
||||
const restarted = await manager.restart();
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
expect(children).toHaveLength(3);
|
||||
expect(restarted).toMatchObject({ status: "running", scriptId: "dev" });
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
|
||||
await manager.stop();
|
||||
|
||||
manager.cleanup();
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears fallback probe timer when process exits naturally", async () => {
|
||||
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
||||
|
||||
await manager.start("node -e \"setTimeout(() => process.exit(0), 20)\"", root);
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
|
||||
await waitFor(() => store.getState().status === "stopped");
|
||||
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears fallback probe timer when the child process reports failure", async () => {
|
||||
const { root, store, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
||||
|
||||
await manager.start("node -e \"setTimeout(() => process.exit(0), 50)\"", root);
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
|
||||
const internals = manager as unknown as DevServerProcessManagerInternals;
|
||||
internals.childProcess?.emit("error", new Error("synthetic process failure"));
|
||||
|
||||
await waitFor(() => store.getState().status === "failed");
|
||||
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
});
|
||||
|
||||
it("restarts with a fresh fallback probe timer", async () => {
|
||||
const { root, manager } = await createManager({ probeDelayMs: 2_000, probeTimeoutMs: 5 });
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root, { scriptId: "dev" });
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
|
||||
await manager.restart();
|
||||
|
||||
expect(manager.hasPendingProbeTimer()).toBe(true);
|
||||
await manager.stop();
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
});
|
||||
|
||||
it("cleanup() kills process and clears listeners", async () => {
|
||||
const { root, manager } = await createManager();
|
||||
|
||||
await manager.start("node -e \"process.stdin.resume();process.stdin.on('end',()=>process.exit(0))\"", root);
|
||||
it("cleanup removes manager and child stream listeners without leaving a timer", async () => {
|
||||
const { children, manager } = createFixture();
|
||||
await manager.start("pnpm dev", "/repo");
|
||||
manager.on("output", () => undefined);
|
||||
expect(manager.listenerCount("output")).toBeGreaterThan(0);
|
||||
expect(children[0].stdout.listenerCount("data")).toBeGreaterThan(0);
|
||||
|
||||
manager.cleanup();
|
||||
await waitFor(() => manager.isRunning() === false);
|
||||
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
expect(manager.listenerCount("output")).toBe(0);
|
||||
expect(children[0].stdout.listenerCount("data")).toBe(0);
|
||||
expect(manager.hasPendingProbeTimer()).toBe(false);
|
||||
expect(manager.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface DevServerProcessManagerOptions {
|
||||
stopTimeoutMs?: number;
|
||||
probeDelayMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
/** Test seam for lifecycle assertions without a shell child process. */
|
||||
spawn?: typeof superviseSpawn;
|
||||
/** Test seam for proving stop dispatches the process-tree signal without killing an OS process. */
|
||||
killManagedProcess?: (child: ChildProcess, signal: NodeJS.Signals) => void;
|
||||
}
|
||||
|
||||
interface UrlDetectedEventPayload {
|
||||
@@ -90,6 +94,8 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
private readonly stopTimeoutMs: number;
|
||||
private readonly probeDelayMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
private readonly spawn: typeof superviseSpawn;
|
||||
private readonly kill: (child: ChildProcess, signal: NodeJS.Signals) => void;
|
||||
|
||||
constructor(
|
||||
private readonly store: DevServerStore,
|
||||
@@ -99,6 +105,14 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
this.stopTimeoutMs = options?.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
|
||||
this.probeDelayMs = options?.probeDelayMs ?? DEFAULT_PROBE_DELAY_MS;
|
||||
this.probeTimeoutMs = options?.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
/*
|
||||
FNXC:DevServerProcessTests 2026-07-19-18:45:
|
||||
FN-8394 rescues lifecycle coverage from a second load-sensitive quarantine.
|
||||
Inject only spawn and process-tree signaling so unit tests retain start/stop,
|
||||
output, URL, fallback-timer, and restart invariants without real shell children.
|
||||
*/
|
||||
this.spawn = options?.spawn ?? superviseSpawn;
|
||||
this.kill = options?.killManagedProcess ?? killManagedProcess;
|
||||
}
|
||||
|
||||
async start(
|
||||
@@ -139,7 +153,7 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
detectedPort: undefined,
|
||||
});
|
||||
|
||||
const supervised = superviseSpawn(safeCommand, [], {
|
||||
const supervised = this.spawn(safeCommand, [], {
|
||||
cwd: safeCwd,
|
||||
shell: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
@@ -212,12 +226,12 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
const pid = child.pid;
|
||||
|
||||
if (typeof pid === "number") {
|
||||
killManagedProcess(child, "SIGTERM");
|
||||
this.kill(child, "SIGTERM");
|
||||
}
|
||||
|
||||
const killTimer = setTimeout(() => {
|
||||
if (this.childProcess === child && this.isRunning()) {
|
||||
killManagedProcess(child, "SIGKILL");
|
||||
this.kill(child, "SIGKILL");
|
||||
}
|
||||
}, this.stopTimeoutMs);
|
||||
|
||||
@@ -259,7 +273,7 @@ export class DevServerProcessManager extends EventEmitter {
|
||||
this.clearTimers();
|
||||
|
||||
if (this.childProcess && typeof this.childProcess.pid === "number") {
|
||||
killManagedProcess(this.childProcess, "SIGTERM");
|
||||
this.kill(this.childProcess, "SIGTERM");
|
||||
this.childProcess.removeAllListeners();
|
||||
this.childProcess.stdout?.removeAllListeners();
|
||||
this.childProcess.stderr?.removeAllListeners();
|
||||
|
||||
@@ -315,6 +315,12 @@ FN-6722 workspace verification observed dev-server-process time out only in the
|
||||
FNXC:DashboardTestQuarantine 2026-06-21-12:42:
|
||||
FN-6860 rescued dev-server-process by settling stdout detection and fallback-probe lifecycle work before stop/close/failure teardown, then removed its ledger/config quarantine entry. The same loaded API shard also confirmed FN-6742's session-cross-tab rescue still holds, so its stale ledger-only entry was removed to restore lockstep.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-07-19-18:45:
|
||||
FN-8394 rescues dev-server-process after its second load-sensitive quarantine.
|
||||
Its injected child-process and process-tree-signal seams preserve lifecycle,
|
||||
stdout/URL, fallback-timer, failure, and restart assertions without a real shell
|
||||
child or filesystem store; keep it out of this exclude list and ledger.
|
||||
|
||||
FNXC:DashboardTestQuarantine 2026-06-22-18:05:
|
||||
FN-6937 verified that FN-6860's claimed session-cross-tab ledger removal had not landed: the file was active because this exclude list was empty, but `test-quarantine.json` still carried the stale 2026-06-19 row. The repeated loaded `dashboard-api-quality-backfill` runs and lock-holder mutation proof confirmed FN-6742's rescue still holds, so remove the orphaned ledger row and keep this list empty to restore ledger↔config lockstep.
|
||||
|
||||
@@ -346,21 +352,6 @@ const quarantinedDashboardTests: string[] = [
|
||||
async-store or applicable mock/non-store contracts. Remove their ledger/exclude
|
||||
pairs so dashboard-api-quality-backfill collects the restored coverage.
|
||||
*/
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-18-14:05:
|
||||
Full-suite shard 2 (run 29660321240): Terminal-guard tab settle race under
|
||||
dashboard-app-quality-backfill load; passes focused thrice with no product bug.
|
||||
Quarantine on sight — mirrored in scripts/lib/test-quarantine.json.
|
||||
*/
|
||||
"app/components/__tests__/TaskDetailModal.tab-persistence.test.tsx",
|
||||
/*
|
||||
FNXC:DashboardTestQuarantine 2026-07-18-14:40:
|
||||
Full-suite shard 4 (run 29661202279): re-flaked stdout/fallback-probe race in
|
||||
clears fallback probe timer when URL is detected from logs under the loaded
|
||||
API backfill lane (prior FN-6722 quarantine / FN-6860 rescue). Quarantine on
|
||||
sight — mirrored in scripts/lib/test-quarantine.json.
|
||||
*/
|
||||
"src/__tests__/dev-server-process.test.ts",
|
||||
];
|
||||
|
||||
const qualityApiTests = [
|
||||
|
||||
@@ -1,876 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../pi.js", () => ({
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
prompt: vi.fn(async () => undefined),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
describeModel: vi.fn(() => "mock-provider/mock-model"),
|
||||
promptWithFallback: vi.fn(async (session: { prompt: (prompt: string) => Promise<unknown> }, prompt: string) => {
|
||||
await session.prompt(prompt);
|
||||
}),
|
||||
compactSessionContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { Settings, TaskStore, RunAuditEvent } from "@fusion/core";
|
||||
import { queryRunAuditEvents, drizzleEq, postgresSchema } from "@fusion/core";
|
||||
import { activeSessionRegistry, executingTaskLock } from "../../active-session-registry.js";
|
||||
import { aiMergeTask } from "../../merger.js";
|
||||
import { createFnAgent } from "../../pi.js";
|
||||
// FNXC:SqliteRemoval 2026-07-14: hasPg guard added — makeReliabilityFixture requires PG after SQLite removal (VAL-REMOVAL-005).
|
||||
import { git, hasGit, hasPg, makeReliabilityFixture, type ReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
/*
|
||||
FNXC:SqliteRemoval 2026-07-14:
|
||||
Async PG helpers replacing sync SQLite APIs (store.getRunAuditEvents, store.getDatabase().prepare)
|
||||
that don't work in backend mode after VAL-REMOVAL-005. Tests now run in backend mode (PG).
|
||||
*/
|
||||
const mq = postgresSchema.project.mergeQueue;
|
||||
|
||||
async function auditEvents(store: TaskStore, filter: { taskId?: string; mutationType?: string; limit?: number } = {}): Promise<RunAuditEvent[]> {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("PG required for auditEvents");
|
||||
return queryRunAuditEvents(layer.db, filter);
|
||||
}
|
||||
|
||||
async function updateMergeQueueLease(store: TaskStore, taskId: string, leasedBy: string, leasedAt: string, leaseExpiresAt: string): Promise<void> {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("PG required");
|
||||
await layer.db.update(mq).set({ leasedBy, leasedAt, leaseExpiresAt }).where(drizzleEq(mq.taskId, taskId));
|
||||
}
|
||||
|
||||
async function deleteMergeQueueRow(store: TaskStore, taskId: string): Promise<void> {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("PG required");
|
||||
await layer.db.delete(mq).where(drizzleEq(mq.taskId, taskId));
|
||||
}
|
||||
|
||||
async function insertMergeQueueRow(store: TaskStore, taskId: string, enqueuedAt: string, priority: string): Promise<void> {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("PG required");
|
||||
await layer.db.insert(mq).values({ taskId, enqueuedAt, priority, attemptCount: 0 });
|
||||
}
|
||||
|
||||
async function getMergeQueueRow(store: TaskStore, taskId: string): Promise<{ taskId: string; leasedBy: string | null } | undefined> {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("PG required");
|
||||
const rows = await layer.db.select().from(mq).where(drizzleEq(mq.taskId, taskId));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function getMergeQueueTaskIds(store: TaskStore, taskIds: string[]): Promise<string[]> {
|
||||
const layer = store.getAsyncLayer();
|
||||
if (!layer) throw new Error("PG required");
|
||||
const results: string[] = [];
|
||||
for (const id of taskIds) {
|
||||
const rows = await layer.db.select({ taskId: mq.taskId }).from(mq).where(drizzleEq(mq.taskId, id));
|
||||
if (rows.length > 0) results.push(rows[0].taskId);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||
|
||||
/**
|
||||
* Shared setup for the reuse-task-worktree handoff scenarios. Every test in
|
||||
* this suite was ~50 lines of identical fixture boilerplate; this consolidates
|
||||
* the common path. Pass `extraSettings` for variants and use the returned
|
||||
* handles to layer test-specific state on top.
|
||||
*/
|
||||
async function setupReuseHandoff(opts: {
|
||||
taskId: string;
|
||||
fileName?: string;
|
||||
fileContent?: string;
|
||||
commitMessage?: string;
|
||||
/** Skip `git worktree add`. Used by tests that exercise the missing-worktree path. */
|
||||
skipWorktreeAdd?: boolean;
|
||||
/**
|
||||
* Override `task.worktree`. `undefined` writes the standard worktreePath.
|
||||
* `null` skips the update entirely. A string sets that exact value.
|
||||
*/
|
||||
worktreeOverride?: string | null;
|
||||
/** Skip `store.enqueueMergeQueue` (used by tests that craft custom queue rows). */
|
||||
skipEnqueue?: boolean;
|
||||
/** Pass `--allow-empty` so the branch has 1 own commit but zero net diff. */
|
||||
emptyOwnDiff?: boolean;
|
||||
extraSettings?: Partial<Settings>;
|
||||
}): Promise<{
|
||||
fixture: ReliabilityFixture;
|
||||
rootDir: string;
|
||||
store: ReliabilityFixture["store"];
|
||||
task: ReliabilityFixture["task"];
|
||||
branch: string;
|
||||
worktreeRoot: string;
|
||||
worktreePath: string;
|
||||
}> {
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: opts.taskId,
|
||||
settings: {
|
||||
baseBranch: "master",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
...opts.extraSettings,
|
||||
} as Partial<Settings>,
|
||||
});
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
|
||||
|
||||
git(rootDir, "git branch -m main master");
|
||||
const completedSteps = (actualTask?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.updateTask(task.id, {
|
||||
baseBranch: "master",
|
||||
branch,
|
||||
steps: completedSteps,
|
||||
currentStep: completedSteps.length,
|
||||
} as any);
|
||||
await fixture.createBranch(branch);
|
||||
|
||||
if (opts.emptyOwnDiff) {
|
||||
git(rootDir, `git commit --allow-empty -m 'test(${actualTask!.id}): verification-only handoff'`);
|
||||
} else {
|
||||
const fileName = opts.fileName ?? `packages/engine/src/${opts.taskId.toLowerCase()}.ts`;
|
||||
const fileContent = opts.fileContent ?? "export const value = 1;\n";
|
||||
const commitMessage = opts.commitMessage ?? `feat: add ${opts.taskId} merge content`;
|
||||
await fixture.writeAndCommit(fileName, fileContent, commitMessage);
|
||||
}
|
||||
await fixture.checkout("master");
|
||||
|
||||
if (!opts.skipWorktreeAdd) {
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
}
|
||||
if (opts.worktreeOverride !== null) {
|
||||
const path = opts.worktreeOverride ?? worktreePath;
|
||||
await store.updateTask(task.id, { worktree: path, branch } as any);
|
||||
}
|
||||
if (!opts.skipEnqueue) {
|
||||
await store.enqueueMergeQueue(task.id);
|
||||
}
|
||||
|
||||
return { fixture, rootDir, store, task, branch, worktreeRoot, worktreePath };
|
||||
}
|
||||
|
||||
describe("FN-5279 reliability interactions: merge reuse task worktree", () => {
|
||||
beforeEach(() => {
|
||||
mockedCreateFnAgent.mockClear();
|
||||
activeSessionRegistry.clear();
|
||||
executingTaskLock._clearForTest();
|
||||
});
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("happy path merges from a reused task worktree and applies the squash to the project root's integration branch", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-HAPPY",
|
||||
fileName: "packages/engine/src/fn-5279-ri-happy.ts",
|
||||
fileContent: "export const value = 1;\n",
|
||||
commitMessage: "feat: add reuse merge content",
|
||||
extraSettings: { worktreeRebaseRemote: "origin" } as Partial<Settings>,
|
||||
});
|
||||
|
||||
try {
|
||||
const rootHeadBefore = git(rootDir, "git rev-parse HEAD");
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const audits = (await auditEvents(store, { taskId: task.id }));
|
||||
const auditTypes = audits.map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-released");
|
||||
const acquired = audits.find((event) => event.mutationType === "merge:reuse-handoff-acquired");
|
||||
expect(acquired?.metadata).toMatchObject({ integrationRemote: "origin", integrationBranch: "master" });
|
||||
|
||||
// Step 5c (FN-5279 reuse mode) advances the project root's integration
|
||||
// branch to the new squash commit so changes actually land on master.
|
||||
expect(auditTypes).toContain("merge:integration-ref-advance");
|
||||
const advanced = audits.find((event) => event.mutationType === "merge:integration-ref-advance");
|
||||
expect(advanced?.metadata).toMatchObject({ advanceMode: "update-ref", succeeded: true });
|
||||
expect(git(rootDir, "git rev-parse HEAD")).not.toBe(rootHeadBefore);
|
||||
// 4c31e885b (engine auto-sync) keeps the project root's working tree
|
||||
// in step with the advanced ref, so the new file is a tracked, clean
|
||||
// path at HEAD rather than appearing as a dirty/untracked entry. Verify
|
||||
// landing via `git ls-files` (commit-reachable) instead of `git status`.
|
||||
const rootLsFilesAfter = git(rootDir, "git ls-files");
|
||||
expect(rootLsFilesAfter).toContain("packages/engine/src/fn-5279-ri-happy.ts");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("dirty reused worktree is autostashed so the merge can proceed", async () => {
|
||||
const { fixture, rootDir, store, task, worktreePath } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-DIRTY",
|
||||
fileName: "packages/engine/src/fn-5279-ri-dirty.ts",
|
||||
fileContent: "export const dirty = true;\n",
|
||||
commitMessage: "feat: add dirty merge content",
|
||||
});
|
||||
git(worktreePath, "sh -c 'printf dirty > DIRTY.txt'");
|
||||
|
||||
try {
|
||||
await aiMergeTask(store, rootDir, task.id).catch(() => undefined);
|
||||
const autostash = (await auditEvents(store, { taskId: task.id }))
|
||||
.find((event) => event.mutationType === "merge:reuse-handoff-autostash");
|
||||
expect(autostash?.metadata).toMatchObject({ worktreePath });
|
||||
expect(typeof autostash?.metadata?.stashSha).toBe("string");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("active session binding refuses handoff until the worktree is released", async () => {
|
||||
const { fixture, rootDir, store, task, worktreePath } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-ACTIVE",
|
||||
fileName: "packages/engine/src/fn-5279-ri-active.ts",
|
||||
fileContent: "export const active = true;\n",
|
||||
commitMessage: "feat: add active merge content",
|
||||
});
|
||||
activeSessionRegistry.registerPath(worktreePath, { taskId: task.id, kind: "executor", ownerKey: task.id });
|
||||
executingTaskLock.tryClaim(task.id);
|
||||
|
||||
try {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "active-session-binding",
|
||||
});
|
||||
const refused = (await auditEvents(store, { taskId: task.id })).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ gate: "active-session-binding" });
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("branch/worktree mapping mismatches refuse handoff", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-MISMATCH",
|
||||
fileName: "packages/engine/src/fn-5279-ri-mismatch.ts",
|
||||
fileContent: "export const mismatch = true;\n",
|
||||
commitMessage: "feat: add mismatch merge content",
|
||||
});
|
||||
// Drift the task.branch away from the actual branch on disk to trigger the mapping refusal.
|
||||
await store.updateTask(task.id, { branch: "fusion/fn-other" } as any);
|
||||
|
||||
try {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "branch-worktree-mapping",
|
||||
});
|
||||
const refused = (await auditEvents(store, { taskId: task.id })).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ gate: "branch-worktree-mapping" });
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("missing merge queue lease refuses handoff with target-not-queued diagnostics", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-NO-LEASE",
|
||||
fileName: "packages/engine/src/fn-5279-ri-no-lease.ts",
|
||||
fileContent: "export const noLease = true;\n",
|
||||
commitMessage: "feat: add no-lease merge content",
|
||||
skipEnqueue: true,
|
||||
});
|
||||
await store.enqueueMergeQueue(task.id, { now: "2026-05-19T00:00:00.000Z" });
|
||||
await updateMergeQueueLease(store, task.id, "worker-other", "2026-05-19T00:01:00.000Z", "2099-05-19T00:10:00.000Z");
|
||||
|
||||
try {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "lease-handoff-failed",
|
||||
reason: "target-not-queued",
|
||||
});
|
||||
const refused = (await auditEvents(store, { taskId: task.id })).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({
|
||||
gate: "lease-handoff-failed",
|
||||
reason: "target-not-queued",
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5353: aiMergeTask succeeds without pre-enqueue by self-enqueueing before handoff", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5353-RI-SELF-ENQUEUE",
|
||||
fileName: "packages/engine/src/fn-5353-ri-self-enqueue.ts",
|
||||
fileContent: "export const selfEnqueue = true;\n",
|
||||
commitMessage: "feat: add self enqueue merge content",
|
||||
});
|
||||
await deleteMergeQueueRow(store, task.id);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
const auditTypes = (await auditEvents(store, { taskId: task.id })).map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5353: cross-task queue entries remain untouched when aiMergeTask self-enqueues target", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5353-RI-TARGET-A",
|
||||
fileName: "packages/engine/src/fn-5353-ri-target-not-queued.ts",
|
||||
fileContent: "export const targetNotQueued = true;\n",
|
||||
commitMessage: "feat: add target not queued reproduction",
|
||||
skipEnqueue: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const other = await store.createTask({ description: "queue head other", priority: "normal" });
|
||||
await store.moveTask(other.id, "todo");
|
||||
await store.moveTask(other.id, "in-progress");
|
||||
await store.handoffToReview(other.id, {
|
||||
ownerAgentId: "agent-1",
|
||||
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" },
|
||||
});
|
||||
await store.enqueueMergeQueue(other.id, { now: "2026-05-19T00:00:00.000Z" });
|
||||
await deleteMergeQueueRow(store, task.id);
|
||||
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const otherRow = await getMergeQueueRow(store, other.id);
|
||||
expect(otherRow.taskId).toBe(other.id);
|
||||
expect(otherRow.leasedBy).toBeNull();
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5353: reuse handoff rejects project-root worktree misconfiguration", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5353-RI-PROJECT-ROOT-WORKTREE",
|
||||
fileName: "packages/engine/src/fn-5353-ri-project-root.ts",
|
||||
fileContent: "export const projectRootReuse = true;\n",
|
||||
commitMessage: "feat: add project root misconfiguration content",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: undefined, // placeholder, real value set below
|
||||
});
|
||||
// Point task.worktree at the project root to trigger the misconfig refusal.
|
||||
await store.updateTask(task.id, { worktree: rootDir } as any);
|
||||
|
||||
try {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "reuse-misconfigured",
|
||||
reason: "worktree-equals-project-root",
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5353: missing task.worktree reacquires a reusable worktree before handoff gates", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5353-RI-MISSING-WORKTREE-HANDOFF",
|
||||
fileName: "packages/engine/src/fn-5353-ri-missing-worktree-handoff.ts",
|
||||
fileContent: "export const missingHandoff = true;\n",
|
||||
commitMessage: "feat: add missing worktree handoff content",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
});
|
||||
await store.updateTask(task.id, { worktree: null } as any);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
const audits = (await auditEvents(store, { taskId: task.id }));
|
||||
const auditTypes = audits.map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-fallback-new-worktree");
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
|
||||
const refused = audits.find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect((refused?.metadata as { reason?: string } | undefined)?.reason).not.toBe("worktree-equals-project-root");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5363: queue-head pollution by non-in-review tasks does not block target reuse handoff", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5363-RI-POLLUTED",
|
||||
fileName: "packages/engine/src/fn-5363-ri-polluted.ts",
|
||||
fileContent: "export const polluted = true;\n",
|
||||
commitMessage: "feat: add polluted queue merge content",
|
||||
skipEnqueue: true,
|
||||
extraSettings: { worktreeRebaseRemote: "origin" } as Partial<Settings>,
|
||||
});
|
||||
await store.enqueueMergeQueue(task.id, { now: "2026-05-19T00:00:02.000Z" });
|
||||
|
||||
const todoTask = await store.createTask({ description: "polluter todo", priority: "normal" });
|
||||
await store.moveTask(todoTask.id, "todo");
|
||||
const inProgressTask = await store.createTask({ description: "polluter progress", priority: "normal" });
|
||||
await store.moveTask(inProgressTask.id, "todo");
|
||||
await store.moveTask(inProgressTask.id, "in-progress");
|
||||
|
||||
await insertMergeQueueRow(store, todoTask.id, "2026-05-19T00:00:00.000Z", "normal");
|
||||
await insertMergeQueueRow(store, inProgressTask.id, "2026-05-19T00:00:01.000Z", "normal");
|
||||
await updateMergeQueueLease(store, todoTask.id, "merger-reuse-handoff", "2026-05-19T00:10:00.000Z", "2099-05-19T00:20:00.000Z");
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
expect(await getMergeQueueRow(store, task.id)).toBeUndefined();
|
||||
expect(await getMergeQueueTaskIds(store, [todoTask.id, inProgressTask.id])).toEqual([]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5363: target row leased by another worker refuses with target-not-queued diagnostics", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5363-RI-NO-LEASE-TARGET",
|
||||
fileName: "packages/engine/src/fn-5363-ri-no-lease-target.ts",
|
||||
fileContent: "export const noLeaseTarget = true;\n",
|
||||
commitMessage: "feat: add leased target merge content",
|
||||
});
|
||||
// Replay the in-review handoff so the task.column/state matches the pre-merge geometry.
|
||||
const completedSteps = ((await store.getTask(task.id))?.steps ?? []).map((step) => ({ ...step, status: "done" as const }));
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.handoffToReview(task.id, {
|
||||
ownerAgentId: "agent-1",
|
||||
evidence: { reason: "fn_task_done", runId: "run-1", agentId: "agent-1" },
|
||||
});
|
||||
// handoffToReview resets task.steps; restore completion so the merge gate
|
||||
// doesn't refuse with "task has incomplete steps".
|
||||
await store.updateTask(task.id, { steps: completedSteps, currentStep: completedSteps.length } as any);
|
||||
await updateMergeQueueLease(store, task.id, "worker-other", "2026-05-19T00:01:00.000Z", "2099-05-19T00:10:00.000Z");
|
||||
|
||||
try {
|
||||
await expect(aiMergeTask(store, rootDir, task.id)).rejects.toMatchObject({
|
||||
name: "MergeHandoffRefusedError",
|
||||
gate: "lease-handoff-failed",
|
||||
reason: "target-not-queued",
|
||||
});
|
||||
const refused = (await auditEvents(store, { taskId: task.id })).find((event) => event.mutationType === "merge:reuse-handoff-refused");
|
||||
expect(refused?.metadata).toMatchObject({ reason: "target-not-queued" });
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("FN-5444: moving task out of in-review during live lease preserves row until release cleanup", async () => {
|
||||
const { fixture, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5444-RI-COLUMN-EXIT-LIVE-LEASE",
|
||||
fileName: "packages/engine/src/fn-5444-ri-column-exit.ts",
|
||||
fileContent: "export const exitLease = true;\n",
|
||||
commitMessage: "feat: add FN-5444 column exit lease coverage",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const lease = await store.acquireMergeQueueLease("merger-reuse-handoff", {
|
||||
targetTaskId: task.id,
|
||||
leaseDurationMs: 60_000,
|
||||
now: "2099-05-19T00:00:10.000Z",
|
||||
});
|
||||
expect(lease?.taskId).toBe(task.id);
|
||||
|
||||
await store.moveTask(task.id, "todo");
|
||||
expect((await store.peekMergeQueue()).some((entry) => entry.taskId === task.id)).toBe(true);
|
||||
|
||||
const staleLeaseAudit = (await auditEvents(store, { taskId: task.id, mutationType: "mergeQueue:stale-lease-on-column-exit" }));
|
||||
expect(staleLeaseAudit).toHaveLength(1);
|
||||
expect(staleLeaseAudit[0].metadata).toMatchObject({
|
||||
taskId: task.id,
|
||||
previousColumn: "in-review",
|
||||
nextColumn: "todo",
|
||||
leasedBy: "merger-reuse-handoff",
|
||||
});
|
||||
expect(typeof staleLeaseAudit[0].metadata?.leaseExpiresAt).toBe("string");
|
||||
|
||||
await store.releaseMergeQueueLease(task.id, "merger-reuse-handoff", { kind: "success" });
|
||||
expect((await store.peekMergeQueue()).some((entry) => entry.taskId === task.id)).toBe(false);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("already-landed branch auto-finalizes from the reused worktree path", async () => {
|
||||
const { fixture, rootDir, store, task, branch } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-ALREADY-LANDED",
|
||||
fileName: "packages/engine/src/fn-5279-ri-already-landed.ts",
|
||||
fileContent: "export const landed = true;\n",
|
||||
commitMessage: "feat: add already-landed merge content",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
skipEnqueue: true,
|
||||
});
|
||||
// Fast-forward master to the branch tip (the "already landed" scenario)
|
||||
// before recreating the worktree mapping and queue lease.
|
||||
git(rootDir, `git merge --ff-only ${JSON.stringify(branch)}`);
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, task.id.toLowerCase());
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
|
||||
await store.enqueueMergeQueue(task.id);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.mergeConfirmed).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
const auditTypes = (await auditEvents(store, { taskId: task.id })).map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-released");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("Layer 3 conflict resolution sessions run from the reused worktree", async () => {
|
||||
const { fixture, rootDir, store, task, branch, worktreePath } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-LAYER3",
|
||||
fileName: "packages/engine/src/fn-5279-ri-layer3.ts",
|
||||
fileContent: "export const value = 'branch';\n",
|
||||
commitMessage: "feat: branch conflict content",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
skipEnqueue: true,
|
||||
extraSettings: { mergeConflictStrategy: "smart-prefer-main" } as Partial<Settings>,
|
||||
});
|
||||
// Inject a conflicting commit on master at the same path, then create the
|
||||
// worktree and queue entry now that the conflict geometry is in place.
|
||||
await store.updateTask(task.id, {
|
||||
prompt: "## File Scope\n- packages/engine/src/**\n",
|
||||
} as any);
|
||||
git(rootDir, "mkdir -p packages/engine/src");
|
||||
git(rootDir, "sh -c \"printf \\\"export const value = 'main';\\n\\\" > packages/engine/src/fn-5279-ri-layer3.ts\"");
|
||||
git(rootDir, "git add packages/engine/src/fn-5279-ri-layer3.ts");
|
||||
git(rootDir, "git commit -m 'feat: main conflict content'");
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
await store.updateTask(task.id, { worktree: worktreePath, branch } as any);
|
||||
await store.enqueueMergeQueue(task.id);
|
||||
|
||||
try {
|
||||
await aiMergeTask(store, rootDir, task.id);
|
||||
// FNXC:SqliteRemoval 2026-07-14: In backend mode, createResolvedAgentSession may route
|
||||
// to mockRuntimeSingleton (bypassing createFnAgent). Assert via audit events instead:
|
||||
// merge:reuse-handoff-released records worktreePath, proving the merge (including the
|
||||
// Layer 3 conflict resolution attempt) ran from the reused worktree. The specific AI
|
||||
// session cwd is an implementation detail of the resolved runtime, not assertable here.
|
||||
const events = await auditEvents(store, { taskId: task.id });
|
||||
const handoffReleased = events.find((e) => e.mutationType === "merge:reuse-handoff-released");
|
||||
expect(handoffReleased, `audit types: ${JSON.stringify(events.map((e) => e.mutationType))}`).toBeDefined();
|
||||
expect(handoffReleased?.metadata).toMatchObject({ worktreePath });
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("reacquires a fresh task worktree when reuse is requested without a task worktree", async () => {
|
||||
const { fixture, rootDir, store, task, branch } = await setupReuseHandoff({
|
||||
taskId: "FN-5353-RI-MISSING-WORKTREE",
|
||||
fileName: "packages/engine/src/fn-5353-ri-missing-worktree.ts",
|
||||
fileContent: "export const fallback = true;\n",
|
||||
commitMessage: "feat: add fallback merge content",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
});
|
||||
await store.updateTask(task.id, { worktree: null } as any);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
const audits = (await auditEvents(store, { taskId: task.id }));
|
||||
const auditTypes = audits.map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-fallback-new-worktree");
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
expect(auditTypes).not.toContain("merge:reuse-fallback-cwd-main");
|
||||
expect(auditTypes).not.toContain("merge:reuse-fallback-cwd-integration-branch");
|
||||
expect(auditTypes).not.toContain("merge:cwd-integration-fallback-removed");
|
||||
const fallback = audits.find((event) => event.mutationType === "merge:reuse-fallback-new-worktree");
|
||||
expect(fallback?.metadata).toMatchObject({
|
||||
reason: "missing-task-worktree",
|
||||
source: "fresh",
|
||||
});
|
||||
|
||||
const freshAcquire = audits.find((event) => event.mutationType === "merge:reuse-worktree-fresh-acquire");
|
||||
expect(freshAcquire?.metadata).toMatchObject({
|
||||
taskId: task.id,
|
||||
reason: "missing-task-worktree",
|
||||
expectedBranch: branch,
|
||||
});
|
||||
|
||||
const freshAcquired = audits.find((event) => event.mutationType === "merge:reuse-worktree-fresh-acquired");
|
||||
expect(freshAcquired?.metadata).toMatchObject({
|
||||
taskId: task.id,
|
||||
reason: "missing-task-worktree",
|
||||
branch,
|
||||
priorWorktreePath: null,
|
||||
});
|
||||
const freshAcquiredWorktreePath = (freshAcquired?.metadata as Record<string, unknown> | undefined)?.worktreePath;
|
||||
expect(typeof freshAcquiredWorktreePath).toBe("string");
|
||||
expect(freshAcquiredWorktreePath).toBe((fallback?.metadata as Record<string, unknown> | undefined)?.worktreePath);
|
||||
|
||||
const orderedFreshAcquireIndex = audits.findIndex(
|
||||
(event) =>
|
||||
event.mutationType === "merge:reuse-worktree-fresh-acquire" &&
|
||||
(event.metadata as Record<string, unknown> | undefined)?.reason === "missing-task-worktree",
|
||||
);
|
||||
const orderedFreshAcquiredIndex = audits.findIndex(
|
||||
(event) =>
|
||||
event.mutationType === "merge:reuse-worktree-fresh-acquired" &&
|
||||
(event.metadata as Record<string, unknown> | undefined)?.reason === "missing-task-worktree",
|
||||
);
|
||||
const orderedFallbackIndex = audits.findIndex(
|
||||
(event) =>
|
||||
event.mutationType === "merge:reuse-fallback-new-worktree" &&
|
||||
(event.metadata as Record<string, unknown> | undefined)?.reason === "missing-task-worktree",
|
||||
);
|
||||
expect(orderedFreshAcquireIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(orderedFreshAcquiredIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(orderedFallbackIndex).toBeGreaterThanOrEqual(0);
|
||||
// getRunAuditEvents() returns newest-first (timestamp DESC, rowid DESC).
|
||||
expect(orderedFallbackIndex).toBeLessThan(orderedFreshAcquiredIndex);
|
||||
expect(orderedFreshAcquiredIndex).toBeLessThan(orderedFreshAcquireIndex);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("cwd-main legacy alias is normalized to cwd-integration-branch and stays on the opt-in path with no reuse handoff events", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-CWD-MAIN",
|
||||
fileName: "packages/engine/src/fn-5279-ri-cwd-main.ts",
|
||||
fileContent: "export const legacy = true;\n",
|
||||
commitMessage: "feat: add cwd-main merge content",
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
skipEnqueue: true,
|
||||
extraSettings: { mergeIntegrationWorktree: "cwd-main" as const } as Partial<Settings>,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
const auditTypes = (await auditEvents(store, { taskId: task.id })).map((event) => event.mutationType);
|
||||
expect(auditTypes.filter((type) => type.startsWith("merge:reuse-handoff"))).toHaveLength(0);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("worktrunk-enabled reuse mode still acquires reuse handoff", async () => {
|
||||
const { fixture, rootDir, store, task } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-WORKTRUNK",
|
||||
fileName: "packages/engine/src/fn-5279-ri-worktrunk.ts",
|
||||
fileContent: "export const deferred = true;\n",
|
||||
commitMessage: "feat: add worktrunk merge content",
|
||||
extraSettings: { worktrunk: { enabled: true } as any } as Partial<Settings>,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
const auditTypes = (await auditEvents(store, { taskId: task.id })).map((event) => event.mutationType);
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-deferred-to-worktrunk");
|
||||
expect(auditTypes).toContain("merge:reuse-handoff-acquired");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!hasGit || !hasPg)("autoMerge off remains inert and emits no reuse handoff events", async () => {
|
||||
// This case never calls aiMergeTask — it just verifies that turning autoMerge
|
||||
// off keeps the task in `in-review` with no reuse-handoff fanout. We use a
|
||||
// minimal manual setup because the standard helper writes content commits
|
||||
// and enqueues the merge queue, both of which would be misleading here.
|
||||
const fixture = await makeReliabilityFixture({
|
||||
taskId: "FN-5279-RI-AUTO-OFF",
|
||||
settings: {
|
||||
autoMerge: false,
|
||||
baseBranch: "master",
|
||||
mergeIntegrationWorktree: "reuse-task-worktree",
|
||||
} as Partial<Settings>,
|
||||
});
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const actualTask = await store.getTask(task.id);
|
||||
const branch = `fusion/${actualTask!.id.toLowerCase()}`;
|
||||
const worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, actualTask!.id.toLowerCase());
|
||||
git(rootDir, "git branch -m main master");
|
||||
await fixture.createBranch(branch);
|
||||
await fixture.checkout("master");
|
||||
await store.updateTask(task.id, { baseBranch: "master", worktree: worktreePath, branch } as any);
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(branch)}`);
|
||||
|
||||
const latest = await store.getTask(task.id);
|
||||
expect(latest?.column).toBe("in-review");
|
||||
const auditTypes = (await auditEvents(store, { taskId: task.id })).map((event) => event.mutationType);
|
||||
expect(auditTypes.filter((type) => type.startsWith("merge:reuse-handoff"))).toHaveLength(0);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// FN-5345/FN-5377 regression backstop.
|
||||
//
|
||||
// A verification-only task that committed `--allow-empty` produced a branch
|
||||
// with own-commit-count >= 1 but zero net tree change vs merge-base. Combined
|
||||
// with drifted worktree<->branch mapping, the reuse-handoff gate would refuse
|
||||
// with `registered-branch-mismatch` and the task would escalate to
|
||||
// `merge-deadlock-detected: verified content not on main` after FN-4999
|
||||
// completion-handoff-limbo recovery exhausts. The early empty-own-diff
|
||||
// fast-path must finalize this BEFORE any reuse-handoff acquisition runs.
|
||||
it.skipIf(!hasGit || !hasPg)(
|
||||
"FN-5345: empty-own-diff branch auto-finalizes via early fast-path without acquiring reuse handoff",
|
||||
async () => {
|
||||
const { fixture, rootDir, store, task, worktreeRoot } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-EMPTY-OWN-DIFF",
|
||||
emptyOwnDiff: true,
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: join(`${"placeholder"}`, "drifted-missing-path"), // overridden below
|
||||
});
|
||||
// Point the task at a drifted/missing worktree path so the reuse-handoff
|
||||
// gate would normally refuse with FN-5083 branch-registration drift.
|
||||
await store.updateTask(task.id, { worktree: join(worktreeRoot, "drifted-missing-path") } as any);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(result.mergeConfirmed).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const audits = (await auditEvents(store, { taskId: task.id }));
|
||||
const auditTypes = audits.map((event) => event.mutationType);
|
||||
|
||||
// Early fast-path must short-circuit BEFORE any reuse-handoff event.
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
|
||||
expect(auditTypes).not.toContain("merge:reuse-fallback-new-worktree");
|
||||
|
||||
const finalize = audits.find(
|
||||
(event) =>
|
||||
event.mutationType === "task:auto-recover-finalize-already-on-main"
|
||||
&& (event.metadata as any)?.reason === "empty-own-diff-early-fast-path",
|
||||
);
|
||||
expect(finalize).toBeDefined();
|
||||
expect((finalize?.metadata as any)?.aheadCount).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
// FN-5345/FN-5377 backstop variant: reproduce the actual production wedge
|
||||
// geometry where `fusion/<id>` is registered to TWO worktrees simultaneously
|
||||
// (e.g. faint-creek + hazy-quail in the FN-5345 incident). The early
|
||||
// fast-path runs against projectRootDir and is immune to the worktree drift.
|
||||
it.skipIf(!hasGit || !hasPg)(
|
||||
"FN-5345: empty-own-diff fast-path fires even when branch is registered to two worktrees",
|
||||
async () => {
|
||||
const { fixture, rootDir, store, task, branch, worktreeRoot } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-DOUBLE-REG",
|
||||
emptyOwnDiff: true,
|
||||
skipWorktreeAdd: true,
|
||||
worktreeOverride: null,
|
||||
});
|
||||
const pathA = join(worktreeRoot, `${task.id.toLowerCase()}-a`);
|
||||
const pathB = join(worktreeRoot, `${task.id.toLowerCase()}-b`);
|
||||
|
||||
// Register branch at pathA then force-register at pathB — the FN-5345
|
||||
// two-worktrees-one-branch state.
|
||||
await mkdir(worktreeRoot, { recursive: true });
|
||||
git(rootDir, `git worktree add ${JSON.stringify(pathA)} ${JSON.stringify(branch)}`);
|
||||
git(rootDir, `git worktree add -f ${JSON.stringify(pathB)} ${JSON.stringify(branch)}`);
|
||||
|
||||
await store.updateTask(task.id, { worktree: pathA, branch } as any);
|
||||
await store.enqueueMergeQueue(task.id);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(result.mergeConfirmed).toBe(true);
|
||||
expect((await store.getTask(task.id))?.column).toBe("done");
|
||||
|
||||
const auditTypes = (await auditEvents(store, { taskId: task.id })).map((event) => event.mutationType);
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-acquired");
|
||||
expect(auditTypes).not.toContain("merge:reuse-handoff-refused");
|
||||
expect(auditTypes).toContain("task:auto-recover-finalize-already-on-main");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
// FN-5345/FN-5377 cleanup-safety backstop: the fast-path's worktree removal
|
||||
// MUST preserve a worktree that has uncommitted tracked changes.
|
||||
it.skipIf(!hasGit || !hasPg)(
|
||||
"FN-5345: empty-own-diff fast-path preserves worktrees with uncommitted tracked changes",
|
||||
async () => {
|
||||
const { fixture, rootDir, store, task, worktreePath } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-DIRTY-PRESERVE",
|
||||
emptyOwnDiff: true,
|
||||
});
|
||||
// README.md is created by the fixture as a tracked file. Modify it to
|
||||
// produce tracked-dirty status inside the worktree.
|
||||
await writeFile(join(worktreePath, "README.md"), "agent scratch: uncommitted edits\n");
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
|
||||
// Critical: the worktree must NOT be removed because it has tracked
|
||||
// uncommitted changes. result.worktreeRemoved reflects that.
|
||||
expect(result.worktreeRemoved).toBe(false);
|
||||
expect(existsSync(worktreePath)).toBe(true);
|
||||
expect(existsSync(join(worktreePath, "README.md"))).toBe(true);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
// FN-5345/FN-5377 cleanup-noise backstop: untracked junk (.DS_Store, swap
|
||||
// files) must NOT block fast-path cleanup. Only tracked dirt does.
|
||||
it.skipIf(!hasGit || !hasPg)(
|
||||
"FN-5345: empty-own-diff fast-path cleans up worktrees with only untracked noise",
|
||||
async () => {
|
||||
const { fixture, rootDir, store, task, worktreePath } = await setupReuseHandoff({
|
||||
taskId: "FN-5279-RI-UNTRACKED-OK",
|
||||
emptyOwnDiff: true,
|
||||
});
|
||||
await writeFile(join(worktreePath, ".DS_Store"), "binary junk\n");
|
||||
await writeFile(join(worktreePath, "editor.swp"), "swap file\n");
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
// Untracked-only is treated as clean — cleanup proceeds.
|
||||
expect(result.worktreeRemoved).toBe(true);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -404,13 +404,6 @@ export default defineConfig({
|
||||
"src/__tests__/reliability-interactions/branch-group-single-pr-e2e.slow.test.ts",
|
||||
"src/__tests__/reliability-interactions/shared-branch-group-lifecycle.slow.test.ts",
|
||||
// SQLite-path (delete-sqlite-runtime-final PHASE A): uses inMemoryDb via _helpers.ts.
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-18-15:55:
|
||||
Full-suite engine-slow (run 29663725381): FN-5363 queue-head pollution handoff left a
|
||||
leased merge-queue row after successful merge under load without product-bug evidence.
|
||||
Quarantine on sight — mirrored in scripts/lib/test-quarantine.json.
|
||||
*/
|
||||
"src/__tests__/reliability-interactions/merge-reuse-task-worktree.slow.test.ts",
|
||||
],
|
||||
minWorkers: 1,
|
||||
maxWorkers: 1,
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/*
|
||||
FNXC:GrokRuntimeTests 2026-07-19-18:10:
|
||||
The lifecycle registry imports `redactSecrets` only for stderr-capture behavior,
|
||||
but this suite exercises listener ownership and child cleanup. Stub that unrelated
|
||||
core dependency so reset/import coverage cannot spend a shard's transform budget
|
||||
on the full @fusion/core graph.
|
||||
*/
|
||||
vi.mock("@fusion/core", () => ({
|
||||
redactSecrets: (value: string) => value,
|
||||
}));
|
||||
|
||||
const EVENTS = ["exit", "beforeExit", "SIGTERM", "SIGINT"] as const;
|
||||
|
||||
function listenerCounts(): Record<(typeof EVENTS)[number], number> {
|
||||
@@ -15,13 +26,13 @@ describe("Grok plugin process lifecycle", () => {
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:GrokRuntimeTests 2026-07-18-07:40:
|
||||
FNXC:GrokRuntimeTests 2026-07-19-18:10:
|
||||
Prove the process-manager Symbol.for exit-hook guard by re-importing the
|
||||
registry module (lifecycle owner), not the full plugin graph. Full-suite
|
||||
shard transform of @fusion/core via process-manager can still exceed the
|
||||
default 5s budget on cold workers — give the bound stress test 15s.
|
||||
registry module (lifecycle owner), not the full plugin graph. The core
|
||||
dependency is mocked above because stderr redaction is outside this seam;
|
||||
this keeps repeated evaluation a bounded unit test under shard pressure.
|
||||
*/
|
||||
it("keeps its process cleanup owner bounded across repeated module evaluation", { timeout: 15_000 }, async () => {
|
||||
it("keeps its process cleanup owner bounded across repeated module evaluation", async () => {
|
||||
const baseline = listenerCounts();
|
||||
const warnings: Error[] = [];
|
||||
const onWarning = (warning: Error) => warnings.push(warning);
|
||||
|
||||
@@ -13,8 +13,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
// FNXC:TestQuarantine 2026-07-19-04:30: process-lifecycle bound stress timed out on full-suite shard 4 (run 29671930241); ledger entry in scripts/lib/test-quarantine.json.
|
||||
exclude: ["src/__tests__/process-lifecycle.test.ts"],
|
||||
environment: "node",
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
|
||||
@@ -1,36 +1,126 @@
|
||||
/*
|
||||
FNXC:QualityPostgresDurability 2026-07-16-10:30:
|
||||
This behavioral test applies the plugin's declarative PostgreSQL schema itself:
|
||||
core's harness supplies only baseline tables. It proves Quality CRUD uses the
|
||||
project-bound AsyncDataLayer rather than the SQLite route that failed Task QA.
|
||||
FNXC:QualityPostgresDurability 2026-07-20-02:10:
|
||||
FN-8394 replaces the embedded-PostgreSQL quarantine with a query-aware bounded
|
||||
AsyncDataLayer fake. The fake rejects missing project predicates and only applies
|
||||
lifecycle state changes for the SQL contracts the production store must issue,
|
||||
so process pressure cannot hide a lost isolation or terminal-status guarantee.
|
||||
*/
|
||||
import { expect, it } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { AsyncDataLayer } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe } from "../../../../packages/core/src/__test-utils__/pg-test-harness.js";
|
||||
import { qualityPostgresSchema } from "../quality-schema.js";
|
||||
import { AsyncQualityStore } from "../store/async-quality-store.js";
|
||||
|
||||
function projectLayer(layer: AsyncDataLayer, projectId: string): AsyncDataLayer { return { ...layer, projectId }; }
|
||||
const createdRun = {
|
||||
id: "qrun_1",
|
||||
project_id: "quality-a",
|
||||
task_id: null,
|
||||
plan_id: null,
|
||||
source: "hub",
|
||||
preset_id: null,
|
||||
command: "pnpm verify:fast",
|
||||
cwd: "/repo",
|
||||
cwd_kind: "project-root",
|
||||
status: "queued",
|
||||
exit_code: null,
|
||||
error_message: null,
|
||||
timeout_ms: 1_000,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
duration_ms: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
triggered_by: "test",
|
||||
created_at: "2026-07-19T00:00:00.000Z",
|
||||
updated_at: "2026-07-19T00:00:00.000Z",
|
||||
};
|
||||
|
||||
pgDescribe("AsyncQualityStore (PostgreSQL / backend mode)", () => {
|
||||
it("persists Quality lifecycle data and isolates projects", async () => {
|
||||
const h = await createTaskStoreForTest({ prefix: "fusion_quality_async" });
|
||||
try {
|
||||
for (const statement of qualityPostgresSchema.statements) await h.adminDb.execute(sql.raw(statement));
|
||||
const projectA = new AsyncQualityStore(projectLayer(h.layer, "quality-a"));
|
||||
const projectB = new AsyncQualityStore(projectLayer(h.layer, "quality-b"));
|
||||
const created = await projectA.createRun({ projectId: "quality-a", source: "hub", command: "pnpm verify:fast", cwd: "/repo", cwdKind: "project-root", timeoutMs: 1_000, triggeredBy: "test" });
|
||||
const updated = await projectA.updateRun("quality-a", created.id, { status: "passed", exitCode: 0, finishedAt: new Date().toISOString(), durationMs: 1 });
|
||||
expect(updated).toMatchObject({ id: created.id, status: "passed", exitCode: 0 });
|
||||
expect(await projectA.listRuns("quality-a")).toHaveLength(1);
|
||||
expect(await projectB.getRun("quality-b", created.id)).toBeNull();
|
||||
function sqlText(query: unknown): string {
|
||||
const chunks = (query as { queryChunks?: unknown[] }).queryChunks ?? [];
|
||||
return chunks
|
||||
.map((chunk) => {
|
||||
const value = (chunk as { value?: unknown } | null)?.value;
|
||||
return Array.isArray(value) ? value.join("") : String(value ?? "");
|
||||
})
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
const createdPlan = await projectA.createPlan({ projectId: "quality-a", name: "Fast gate", steps: ["verify-fast"] });
|
||||
expect((await projectA.getPlan("quality-a", createdPlan.id))?.steps).toEqual(["verify-fast"]);
|
||||
await projectA.saveSuggestedCases({ projectId: "quality-a", taskId: "FN-8103", cases: [{ id: "case", text: "uses async data layer", done: false, source: "heuristic" }], generatedAt: new Date().toISOString(), method: "heuristic" });
|
||||
expect((await projectA.getSuggestedCases("quality-a", "FN-8103"))?.cases).toHaveLength(1);
|
||||
expect(await projectB.getSuggestedCases("quality-b", "FN-8103")).toBeNull();
|
||||
} finally { await h.teardown(); }
|
||||
});
|
||||
/** A bounded data-layer seam that enforces SQL predicates instead of canned call order. */
|
||||
function makeLayer(): { layer: AsyncDataLayer; statements: string[] } {
|
||||
const statements: string[] = [];
|
||||
let run = { ...createdRun };
|
||||
const plan = {
|
||||
id: "qplan_1", project_id: "quality-a", name: "Fast gate", status: "active",
|
||||
steps_json: '["verify-fast"]', created_at: "2026-07-19T00:00:00.000Z", updated_at: "2026-07-19T00:00:00.000Z",
|
||||
};
|
||||
const cases = {
|
||||
project_id: "quality-a", task_id: "FN-8103",
|
||||
cases_json: '[{"id":"case","text":"uses async data layer","done":false,"source":"heuristic"}]',
|
||||
generated_at: "2026-07-19T00:00:00.000Z", method: "heuristic",
|
||||
};
|
||||
const db = {
|
||||
execute: async (query: unknown) => {
|
||||
const statement = sqlText(query);
|
||||
statements.push(statement);
|
||||
const isProjectTable = statement.includes("project.quality_");
|
||||
if (isProjectTable && statement.includes("SELECT") && !statement.includes("project_id")) {
|
||||
throw new Error(`quality query omitted project predicate: ${statement}`);
|
||||
}
|
||||
if (statement.includes("INSERT INTO project.quality_test_runs")) return [];
|
||||
if (statement.includes("UPDATE project.quality_test_runs")) {
|
||||
/*
|
||||
FNXC:QualityPostgresDurability 2026-07-20-19:05:
|
||||
FN-8394's in-memory lifecycle seam must reject an unscoped UPDATE, not
|
||||
merely observe a project predicate on a later SELECT. State may change
|
||||
only after the production write proves both isolation and sticky-terminal
|
||||
status semantics.
|
||||
*/
|
||||
if (!statement.includes("WHERE project_id =")) {
|
||||
throw new Error("quality run update omitted project predicate");
|
||||
}
|
||||
if (!statement.includes("WHEN status IN ('cancelled', 'passed', 'failed', 'timed_out', 'error')")) {
|
||||
throw new Error("quality run update omitted sticky terminal-status contract");
|
||||
}
|
||||
run = { ...run, status: "passed", exit_code: 0, finished_at: "2026-07-19T00:00:01.000Z", duration_ms: 1 };
|
||||
return [];
|
||||
}
|
||||
if (statement.includes("FROM project.quality_test_runs")) return [run];
|
||||
if (statement.includes("INSERT INTO project.quality_test_plans")) return [];
|
||||
if (statement.includes("FROM project.quality_test_plans")) return [plan];
|
||||
if (statement.includes("INSERT INTO project.quality_suggested_cases")) return [];
|
||||
if (statement.includes("FROM project.quality_suggested_cases")) return [cases];
|
||||
throw new Error(`unexpected quality query: ${statement}`);
|
||||
},
|
||||
};
|
||||
return {
|
||||
statements,
|
||||
layer: {
|
||||
projectId: "quality-a",
|
||||
db: db as AsyncDataLayer["db"],
|
||||
transactionImmediate: async (fn) => fn(db as never),
|
||||
} as AsyncDataLayer,
|
||||
};
|
||||
}
|
||||
|
||||
it("persists a project-scoped async Quality lifecycle through SQL predicate contracts", async () => {
|
||||
const { layer, statements } = makeLayer();
|
||||
const store = new AsyncQualityStore(layer);
|
||||
|
||||
const created = await store.createRun({ projectId: "quality-a", source: "hub", command: "pnpm verify:fast", cwd: "/repo", cwdKind: "project-root", timeoutMs: 1_000, triggeredBy: "test" });
|
||||
const updated = await store.updateRun("quality-a", created.id, { status: "passed", exitCode: 0, finishedAt: "2026-07-19T00:00:01.000Z", durationMs: 1 });
|
||||
expect(updated).toMatchObject({ id: created.id, status: "passed", exitCode: 0 });
|
||||
expect(await store.listRuns("quality-a")).toEqual([expect.objectContaining({ id: created.id, status: "passed" })]);
|
||||
|
||||
await expect(store.getRun("quality-b", created.id)).rejects.toMatchObject({ message: /project mismatch/, statusCode: 403 });
|
||||
|
||||
const createdPlan = await store.createPlan({ projectId: "quality-a", name: "Fast gate", steps: ["verify-fast"] });
|
||||
expect(createdPlan.steps).toEqual(["verify-fast"]);
|
||||
await store.saveSuggestedCases({ projectId: "quality-a", taskId: "FN-8103", cases: [{ id: "case", text: "uses async data layer", done: false, source: "heuristic" }], generatedAt: "2026-07-19T00:00:00.000Z", method: "heuristic" });
|
||||
expect(await store.getSuggestedCases("quality-a", "FN-8103")).toMatchObject({ cases: [expect.objectContaining({ id: "case" })] });
|
||||
|
||||
expect(statements).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("INSERT INTO project.quality_test_runs"),
|
||||
expect.stringMatching(/UPDATE project\.quality_test_runs SET status = CASE.*WHERE project_id =/),
|
||||
expect.stringContaining("INSERT INTO project.quality_test_plans"),
|
||||
expect.stringContaining("INSERT INTO project.quality_suggested_cases"),
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -39,8 +39,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
// Quarantine ledger: scripts/lib/test-quarantine.json — async-quality-store.pg.test.ts (2026-07-18).
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "src/__tests__/async-quality-store.pg.test.ts"],
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
environment: "node",
|
||||
setupFiles: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-setup.ts", import.meta.url))],
|
||||
globalSetup: [fileURLToPath(new URL("../../packages/core/src/__test-utils__/vitest-teardown.ts", import.meta.url))],
|
||||
|
||||
@@ -1,45 +1,4 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet \u2014 see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date \u2014 the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config is the enforcement.",
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/cli/src/commands/__tests__/mcp-lock-retry.test.ts",
|
||||
"reason": "Full-suite shard 4 after FN-8271 restore (runs 29648812375 / 29648952207): 5s timeouts under package-lane shard load without product-bug evidence; getSettings/close races and timer recovery still load-sensitive. Quarantine on sight per AGENTS.md. Mirrored in packages/cli/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "packages/cli/src/commands/__tests__/task-lock-retry.test.ts",
|
||||
"reason": "Full-suite shard 4 after FN-8271 restore (runs 29648812375 / 29648952207): 5s timeouts / store.getTask not a function under package-lane shard load without product-bug evidence; fake-timer board-write mock recovery remains load-sensitive. Quarantine on sight per AGENTS.md. Mirrored in packages/cli/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "plugins/fusion-plugin-quality/src/__tests__/async-quality-store.pg.test.ts",
|
||||
"reason": "Full-suite shard 3 (run 29657633544): 5s timeout + leftover psql child under package-lane load without product-bug evidence; embedded-PG lifecycle remains load-sensitive. Quarantine on sight per AGENTS.md. Mirrored in plugins/fusion-plugin-quality/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/app/components/__tests__/TaskDetailModal.tab-persistence.test.tsx",
|
||||
"reason": "Full-suite shard 2 (run 29660321240): Terminal-guard tab settle race under dashboard-app-quality-backfill load without product-bug evidence; passes focused/local thrice. Quarantine on sight per AGENTS.md. Mirrored in packages/dashboard/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "packages/dashboard/src/__tests__/dev-server-process.test.ts",
|
||||
"reason": "Full-suite shard 4 (run 29661202279): clears fallback probe timer when URL is detected from logs \u2014 detectedEvents length 0 under dashboard-api-quality-backfill load; known timer/stdout race (prior FN-6722 quarantine, FN-6860 rescue). Re-flaked without product-bug evidence. Quarantine on sight per AGENTS.md. Mirrored in packages/dashboard/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "packages/cli/src/__tests__/bundle-output.test.ts",
|
||||
"reason": "Full-suite shard 4 (run 29662476909): pnpm build:package / desktop vite build ENOENT on vendor-reactflow CSS under concurrent package-lane load without product-bug evidence. Quarantine on sight per AGENTS.md. Mirrored in packages/cli/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/reliability-interactions/merge-reuse-task-worktree.slow.test.ts",
|
||||
"reason": "Full-suite engine-slow (run 29663725381): FN-5363 queue-head pollution handoff left leased merge-queue row after merge under load without product-bug evidence. Quarantine on sight per AGENTS.md. Mirrored in packages/engine/vitest.config.ts engine-slow exclude.",
|
||||
"quarantinedAt": "2026-07-18"
|
||||
},
|
||||
{
|
||||
"file": "plugins/fusion-plugin-grok-runtime/src/__tests__/process-lifecycle.test.ts",
|
||||
"reason": "Full-suite shard 4 (run 29671930241): process cleanup bound stress test timed out at 15s under package-lane load without product-bug evidence. Quarantine on sight per AGENTS.md. Mirrored in plugins/fusion-plugin-grok-runtime/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-19"
|
||||
}
|
||||
]
|
||||
"entries": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user