diff --git a/packages/cli/src/__tests__/extension-fn-secret-get.test.ts b/packages/cli/src/__tests__/extension-fn-secret-get.test.ts deleted file mode 100644 index 2836705f38..0000000000 --- a/packages/cli/src/__tests__/extension-fn-secret-get.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { workflowAuthoringEngineMock } from "./helpers/engine-workflow-authoring-mock.js"; - -const resolveSecretAccessPolicyMock = vi.hoisted(() => vi.fn()); -const revealSecretMock = vi.hoisted(() => vi.fn()); -const listSecretsMock = vi.hoisted(() => vi.fn()); -const approvalCreateMock = vi.hoisted(() => vi.fn()); -const approvalFindLatestByDedupeKeyMock = vi.hoisted(() => vi.fn()); -const recordRunAuditEventMock = vi.hoisted(() => vi.fn()); -const assertNoSecretPlaintextMock = vi.hoisted(() => vi.fn()); - -vi.mock("@fusion/dashboard", () => ({ - registerGithubTrackingHook: vi.fn(), - // FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate). - GitLabClient: vi.fn(), - resolveGitlabAuth: vi.fn(() => ({})), - buildGitLabTaskProvenance: vi.fn(() => ({})), - isGitLabAlreadyImported: vi.fn(), - buildGitLabTaskDescription: vi.fn(), -})); -vi.mock("@fusion/engine", () => ({ - installBaselineArchiveWorktreeDisposer: vi.fn(), - ...workflowAuthoringEngineMock, - createFnAgent: vi.fn(), - fetchWebContent: vi.fn(), - assertNoSecretPlaintext: assertNoSecretPlaintextMock, - emitGoalRetrievalAudit: vi.fn(), - createWorkflowAuthoringTools: vi.fn(() => ({})), - workflowListParams: {}, - workflowGetParams: {}, - workflowValidateParams: {}, // FNXC:Round10 FN-7911 added this export to @fusion/engine barrel - workflowSelectParams: {}, - workflowCreateParams: {}, - workflowUpdateParams: {}, - workflowDeleteParams: {}, - workflowSettingsParams: {}, - traitListParams: {}, - normalizeAgentLogPaging: vi.fn(() => ({ limit: 100, offset: 0 })), - renderAgentLogEntries: vi.fn(() => ""), -})); - -vi.mock("@fusion/core", async () => { - const actual = await vi.importActual("@fusion/core"); - class MockTaskStore { - async init() {} - async getSecretsStore() { - return { listSecrets: listSecretsMock, revealSecret: revealSecretMock }; - } - getGlobalSettingsStore() { - return { getSettings: async () => ({ secretsAccessPolicy: "prompt" }) }; - } - recordRunAuditEvent = recordRunAuditEventMock; - getDatabase() { - return {} as any; - } - } - class MockApprovalRequestStore { - constructor(_db: unknown) {} - findLatestByDedupeKey = approvalFindLatestByDedupeKeyMock; - create = approvalCreateMock; - } - - return { - ...actual, - TaskStore: MockTaskStore, - ApprovalRequestStore: MockApprovalRequestStore, - resolveSecretAccessPolicy: resolveSecretAccessPolicyMock, - }; -}); - -import kbExtension from "../extension.js"; - -describe("extension fn_secret_get", () => { - beforeEach(() => { - vi.clearAllMocks(); - listSecretsMock.mockImplementation((scope?: "project" | "global") => { - if (scope === "project") return [{ id: "s1", key: "API_KEY", accessPolicy: "auto" }]; - return []; - }); - revealSecretMock.mockResolvedValue({ key: "API_KEY", plaintextValue: "secret-value" }); - resolveSecretAccessPolicyMock.mockReturnValue({ policy: "auto", source: "secret" }); - approvalFindLatestByDedupeKeyMock.mockReturnValue(null); - approvalCreateMock.mockReturnValue({ id: "apr-1", status: "pending" }); - assertNoSecretPlaintextMock.mockImplementation(() => {}); - }); - - it("returns value for auto policy", async () => { - const tools = new Map(); - kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); - const tool = tools.get("fn_secret_get"); - const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" }); - expect(result.details.value).toBe("secret-value"); - expect(approvalCreateMock).not.toHaveBeenCalled(); - const event = recordRunAuditEventMock.mock.calls[0][0]; - expect(event.mutationType).toBe("secret:read"); - expect(event.metadata).toEqual({ key: "API_KEY", scope: "project" }); - expect(event.metadata).not.toHaveProperty("plaintextValue"); - expect(event.metadata).not.toHaveProperty("value"); - expect(event.metadata).not.toHaveProperty("ciphertext"); - expect(event.metadata).not.toHaveProperty("nonce"); - expect(event.metadata).not.toHaveProperty("decrypted"); - expect(JSON.stringify(event)).not.toContain("secret-value"); - }); - - it("returns pending_approval for prompt policy", async () => { - resolveSecretAccessPolicyMock.mockReturnValue({ policy: "prompt", source: "secret" }); - const tools = new Map(); - kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); - const tool = tools.get("fn_secret_get"); - const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" }); - expect(result.details.outcome).toBe("pending_approval"); - expect(approvalCreateMock).toHaveBeenCalled(); - }); - - it("skips audit emission if metadata guard throws", async () => { - assertNoSecretPlaintextMock.mockImplementation(() => { - throw new Error("secret audit metadata may not include plaintext fields"); - }); - - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const tools = new Map(); - kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); - const tool = tools.get("fn_secret_get"); - - const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" }); - expect(result.details.value).toBe("secret-value"); - expect(recordRunAuditEventMock).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - - it("returns denied for deny policy and not found when missing", async () => { - const tools = new Map(); - kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); - const tool = tools.get("fn_secret_get"); - - resolveSecretAccessPolicyMock.mockReturnValue({ policy: "deny", source: "secret" }); - const denied = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" }); - expect(denied.details.error).toBe("denied"); - expect(revealSecretMock).not.toHaveBeenCalled(); - expect(approvalCreateMock).not.toHaveBeenCalled(); - - listSecretsMock.mockReturnValue([]); - const missing = await tool.execute("id", { key: "NOPE" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" }); - expect(missing.details.error).toBe("not-found"); - }); - - it("uses explicit scope when provided, otherwise falls back project then global", async () => { - listSecretsMock.mockImplementation((scope?: "project" | "global") => { - if (scope === "project") return [{ id: "p1", key: "SHARED", accessPolicy: "auto" }]; - if (scope === "global") return [{ id: "g1", key: "SHARED", accessPolicy: "auto" }]; - return []; - }); - - const tools = new Map(); - kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any); - const tool = tools.get("fn_secret_get"); - - await tool.execute("id", { key: "SHARED", scope: "global" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" }); - expect(revealSecretMock).toHaveBeenLastCalledWith("g1", "global", { agentId: "agent-1" }); - - await tool.execute("id", { key: "SHARED" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" }); - expect(revealSecretMock).toHaveBeenLastCalledWith("p1", "project", { agentId: "agent-1" }); - }); -}); diff --git a/packages/cli/src/__tests__/lockstep-cli-quarantine.test.ts b/packages/cli/src/__tests__/lockstep-cli-quarantine.test.ts new file mode 100644 index 0000000000..86a102261a --- /dev/null +++ b/packages/cli/src/__tests__/lockstep-cli-quarantine.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +type QuarantineEntry = { + file: string; + reason: string; + quarantinedAt: string; +}; + +const repoRoot = resolve(import.meta.dirname!, "../../../.."); +const configPath = resolve(repoRoot, "packages/cli/vitest.config.ts"); +const ledgerPath = resolve(repoRoot, "scripts/lib/test-quarantine.json"); +const fn8210BoundaryPath = "src/__tests__/package-config.test.ts"; +const expiredFn8219Paths = new Set([ + "src/__tests__/extension-fn-secret-get.test.ts", + "src/__tests__/skill-sync.test.ts", + "src/__tests__/version.test.ts", + "src/commands/__tests__/dashboard.test.ts", + "src/plugins/__tests__/bundled-plugin-freshness.test.ts", +]); + +function parseQuarantinedCliTests(configSource: string): string[] { + const declaration = configSource.match(/const quarantinedCliTests: string\[\] = \[([\s\S]*?)\n\];/); + expect(declaration, "quarantinedCliTests declaration must remain statically parseable").not.toBeNull(); + + return [...declaration![1].matchAll(/"([^"\n]+)"/g)].map((match) => match[1]); +} + +function countByPath(paths: string[]): Map { + return paths.reduce((counts, path) => counts.set(path, (counts.get(path) ?? 0) + 1), new Map()); +} + +describe("CLI quarantine ledger lockstep", () => { + /* + FNXC:CliTests 2026-07-17-10:00: + FN-8219 deletes five expired 2026-06-25 quarantines instead of rescuing or + re-recording them. This source-level guard prevents those five paths from + returning as config-only or ledger-only entries. FN-8210 must remove the + package-config boundary and widen this to full CLI coverage if it ever adds + or resolves that separate quarantine. + */ + it("keeps the expired FN-8219 scope in bidirectional config-to-ledger lockstep", () => { + const configPaths = parseQuarantinedCliTests(readFileSync(configPath, "utf8")) + .filter((path) => expiredFn8219Paths.has(path)); + const ledger = JSON.parse(readFileSync(ledgerPath, "utf8")) as { entries: QuarantineEntry[] }; + const ledgerEntries = ledger.entries.filter((entry) => + expiredFn8219Paths.has(entry.file.replace(/^packages\/cli\//, "")), + ); + const ledgerPaths = ledgerEntries.map((entry) => entry.file.replace(/^packages\/cli\//, "")); + + expect(configPaths).not.toContain(fn8210BoundaryPath); + expect(countByPath(configPaths)).toEqual(countByPath(ledgerPaths)); + + for (const entry of ledgerEntries) { + expect(entry.reason.trim()).not.toBe(""); + expect(entry.quarantinedAt).toMatch(/^\d{4}-\d{2}-\d{2}(?:T.*Z)?$/); + expect(Number.isNaN(Date.parse(entry.quarantinedAt))).toBe(false); + } + + expect(configPaths).toEqual([]); + expect(ledgerPaths).toEqual([]); + }); +}); diff --git a/packages/cli/src/__tests__/skill-sync.test.ts b/packages/cli/src/__tests__/skill-sync.test.ts deleted file mode 100644 index 4eed2fab06..0000000000 --- a/packages/cli/src/__tests__/skill-sync.test.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync, existsSync, readdirSync } from "node:fs"; -import { resolve, dirname, relative, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const cliRoot = resolve(__dirname, "../.."); -const repoRoot = resolve(cliRoot, "../.."); -const skillDir = resolve(cliRoot, "skill/fusion"); -const extensionPath = resolve(cliRoot, "src/extension.ts"); -const engineAgentToolsPath = resolve(repoRoot, "packages/engine/src/agent-tools.ts"); - -const EXT_TOOLS_BEGIN = - ""; -const EXT_TOOLS_END = ""; -const CAP_TABLE_BEGIN = - ""; -const CAP_TABLE_END = ""; - -const engineToolSourceFiles = [ - resolve(repoRoot, "packages/engine/src/agent-tools.ts"), - resolve(repoRoot, "packages/engine/src/triage.ts"), - resolve(repoRoot, "packages/engine/src/executor.ts"), - resolve(repoRoot, "packages/engine/src/merger.ts"), - resolve(repoRoot, "packages/engine/src/agent-heartbeat.ts"), -]; - -function findMatchingBrace(source: string, openIndex: number): number { - let depth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let inLineComment = false; - let inBlockComment = false; - let escaped = false; - - for (let i = openIndex; i < source.length; i++) { - const char = source[i]; - const next = source[i + 1]; - - if (inLineComment) { - if (char === "\n") inLineComment = false; - continue; - } - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } - - if (inSingle || inDouble || inTemplate) { - if (escaped) { - escaped = false; - continue; - } - if (char === "\\") { - escaped = true; - continue; - } - if (inSingle && char === "'") inSingle = false; - else if (inDouble && char === '"') inDouble = false; - else if (inTemplate && char === "`") inTemplate = false; - continue; - } - - if (char === "/" && next === "/") { - inLineComment = true; - i++; - continue; - } - if (char === "/" && next === "*") { - inBlockComment = true; - i++; - continue; - } - - if (char === "'") { - inSingle = true; - continue; - } - if (char === '"') { - inDouble = true; - continue; - } - if (char === "`") { - inTemplate = true; - continue; - } - - if (char === "{") depth++; - if (char === "}") { - depth--; - if (depth === 0) return i; - } - } - - throw new Error("Unbalanced braces"); -} - -function splitTopLevelProperties(objectBody: string): string[] { - const props: string[] = []; - let start = 0; - let depthParen = 0; - let depthBrace = 0; - let depthBracket = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let inLineComment = false; - let inBlockComment = false; - let escaped = false; - - for (let i = 0; i < objectBody.length; i++) { - const ch = objectBody[i]; - const next = objectBody[i + 1]; - - if (inLineComment) { - if (ch === "\n") inLineComment = false; - continue; - } - if (inBlockComment) { - if (ch === "*" && next === "/") { - inBlockComment = false; - i++; - } - continue; - } - - if (inSingle || inDouble || inTemplate) { - if (escaped) { - escaped = false; - continue; - } - if (ch === "\\") { - escaped = true; - continue; - } - if (inSingle && ch === "'") inSingle = false; - else if (inDouble && ch === '"') inDouble = false; - else if (inTemplate && ch === "`") inTemplate = false; - continue; - } - - if (ch === "/" && next === "/") { - inLineComment = true; - i++; - continue; - } - if (ch === "/" && next === "*") { - inBlockComment = true; - i++; - continue; - } - - if (ch === "'") { - inSingle = true; - continue; - } - if (ch === '"') { - inDouble = true; - continue; - } - if (ch === "`") { - inTemplate = true; - continue; - } - - if (ch === "(") depthParen++; - else if (ch === ")") depthParen--; - else if (ch === "{") depthBrace++; - else if (ch === "}") depthBrace--; - else if (ch === "[") depthBracket++; - else if (ch === "]") depthBracket--; - - if (ch === "," && depthParen === 0 && depthBrace === 0 && depthBracket === 0) { - const prop = objectBody.slice(start, i).trim(); - if (prop) props.push(prop); - start = i + 1; - } - } - - const tail = objectBody.slice(start).trim(); - if (tail) props.push(tail); - return props; -} - -function getWorkflowExtensionSpecBlocks(): Array<{ name: string; paramsRef: string }> { - const src = readFileSync(extensionPath, "utf-8"); - const specStart = src.indexOf("const workflowExtensionToolSpecs"); - if (specStart === -1) return []; - const specEnd = src.indexOf("// ── Extension entry point", specStart); - const body = src.slice(specStart, specEnd === -1 ? undefined : specEnd); - const blocks: Array<{ name: string; paramsRef: string }> = []; - - for (const match of body.matchAll(/\{[\s\S]*?name:\s*"(fn_[a-z_]+)"[\s\S]*?parameters:\s*([A-Za-z_][A-Za-z0-9_]*)[\s\S]*?\}/g)) { - blocks.push({ name: match[1], paramsRef: match[2] }); - } - - return blocks; -} - -function getExportedTypeObjectParamNames(exportName: string): string[] { - const src = readFileSync(engineAgentToolsPath, "utf-8"); - const exportStart = src.indexOf(`export const ${exportName} = Type.Object(`); - if (exportStart === -1) return []; - const braceStart = src.indexOf("{", exportStart); - if (braceStart === -1) return []; - const braceEnd = findMatchingBrace(src, braceStart); - const body = src.slice(braceStart + 1, braceEnd); - - return splitTopLevelProperties(body) - .map((prop) => prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/)?.[1]) - .filter((value): value is string => Boolean(value)) - .sort(); -} - -function getRegisterToolBlocks(): Array<{ name: string; block: string }> { - const src = readFileSync(extensionPath, "utf-8"); - const blocks: Array<{ name: string; block: string }> = []; - const token = "pi.registerTool("; - let from = 0; - - while (true) { - const start = src.indexOf(token, from); - if (start === -1) break; - const braceStart = src.indexOf("{", start); - const braceEnd = findMatchingBrace(src, braceStart); - const block = src.slice(braceStart, braceEnd + 1); - const nameMatch = block.match(/name:\s*"(fn_[a-z_]+)"/); - if (nameMatch) { - blocks.push({ name: nameMatch[1], block }); - } - from = braceEnd + 1; - } - - return blocks; -} - -/** - * Extract all tool names registered via pi.registerTool({ name: "..." }) - * from the extension source code. - */ -function getExtensionToolNames(): string[] { - return [ - ...getWorkflowExtensionSpecBlocks().map((entry) => entry.name), - ...getRegisterToolBlocks().map((entry) => entry.name), - ].sort(); -} - -function getExtensionToolParamNames(): Map { - const result = new Map(); - for (const { name, paramsRef } of getWorkflowExtensionSpecBlocks()) { - result.set(name, getExportedTypeObjectParamNames(paramsRef)); - } - for (const { name, block } of getRegisterToolBlocks()) { - const paramsStart = block.indexOf("parameters:"); - if (paramsStart === -1) { - result.set(name, []); - continue; - } - const objectStart = block.indexOf("Type.Object(", paramsStart); - if (objectStart === -1) { - result.set(name, []); - continue; - } - const braceStart = block.indexOf("{", objectStart); - const braceEnd = findMatchingBrace(block, braceStart); - const body = block.slice(braceStart + 1, braceEnd); - - const params = splitTopLevelProperties(body) - .map((prop) => prop.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:/)?.[1]) - .filter((value): value is string => Boolean(value)) - .sort(); - - result.set(name, params); - } - return result; -} - -/** - * Extract tool names documented in extension-tools.md (### fn_* headings). - */ -function getDocumentedToolNames(): string[] { - const doc = readFileSync( - resolve(skillDir, "references/extension-tools.md"), - "utf-8", - ); - const matches = [...doc.matchAll(/^### (fn_[a-z_]+)/gm)]; - return matches.map((m) => m[1]).sort(); -} - -function getDocumentedToolParamNames(): Map { - const doc = readFileSync(resolve(skillDir, "references/extension-tools.md"), "utf-8"); - const map = new Map(); - - const toolRegex = /^### (fn_[a-z_]+)\n([\s\S]*?)(?=^### fn_|^## [A-Za-z]|^)/gm; - for (const match of doc.matchAll(toolRegex)) { - const [, toolName, section] = match; - const params = [...section.matchAll(/\| `([A-Za-z_][A-Za-z0-9_]*)` \|/g)] - .map((m) => m[1]) - .sort(); - map.set(toolName, params); - } - - return map; -} - -/** - * Extract tool names listed in SKILL.md under the tool categories. - */ -function getSkillMdToolNames(): string[] { - const doc = readFileSync(resolve(skillDir, "SKILL.md"), "utf-8"); - const matches = [...doc.matchAll(/`(fn_[a-z_]+)`/g)]; - return [...new Set(matches.map((m) => m[1]))].sort(); -} - -/** - * Extract tool names from the capabilities catalog table. - */ -function getCapabilitiesToolNames(): string[] { - const doc = readFileSync( - resolve(skillDir, "references/fusion-capabilities.md"), - "utf-8", - ); - const matches = [...doc.matchAll(/\| `(fn_[a-z_]+)` \|/g)]; - return matches.map((m) => m[1]).sort(); -} - -function getEngineSessionToolNames(): string[] { - const names = new Set(); - for (const path of engineToolSourceFiles) { - const src = readFileSync(path, "utf-8"); - for (const match of src.matchAll(/name:\s*"(fn_[a-z_]+)"/g)) { - names.add(match[1]); - } - } - return [...names].sort(); -} - -function getDocumentedEngineToolNames(): string[] { - const doc = readFileSync(resolve(skillDir, "references/engine-tools.md"), "utf-8"); - return [...new Set([...doc.matchAll(/`(fn_[a-z_]+)`/g)].map((m) => m[1]))].sort(); -} - -function collectMarkdownFiles(dir: string): string[] { - const files: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...collectMarkdownFiles(fullPath)); - continue; - } - if (entry.isFile() && entry.name.endsWith(".md")) { - files.push(fullPath); - } - } - return files.sort(); -} - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -describe("Skill-Extension Sync", () => { - it("skill directory structure exists", () => { - expect(existsSync(resolve(skillDir, "SKILL.md"))).toBe(true); - expect(existsSync(resolve(skillDir, "references"))).toBe(true); - expect(existsSync(resolve(skillDir, "workflows"))).toBe(true); - expect( - existsSync(resolve(skillDir, "references/extension-tools.md")), - ).toBe(true); - }); - - it("SKILL.md has valid frontmatter with name and description", () => { - const content = readFileSync(resolve(skillDir, "SKILL.md"), "utf-8"); - expect(content).toMatch(/^---\n/); - expect(content).toMatch(/\nname:\s*fusion\n/); - expect(content).toMatch(/\ndescription:\s*.+\n/); - }); - - it("extension-tools.md documents exactly the same tools as extension.ts", () => { - const extensionTools = getExtensionToolNames(); - const documentedTools = getDocumentedToolNames(); - - const missingFromDocs = extensionTools.filter( - (t) => !documentedTools.includes(t), - ); - const extraInDocs = documentedTools.filter( - (t) => !extensionTools.includes(t), - ); - - expect(missingFromDocs).toEqual([]); - expect(extraInDocs).toEqual([]); - expect(documentedTools).toEqual(extensionTools); - }); - - it("extension-tools.md tool parameter tables match extension.ts registrations", () => { - const extensionTools = getExtensionToolParamNames(); - const documentedTools = getDocumentedToolParamNames(); - - for (const [toolName, extensionParams] of extensionTools.entries()) { - expect(documentedTools.has(toolName), `missing documented section for ${toolName}`).toBe(true); - const documentedParams = documentedTools.get(toolName) ?? []; - expect(documentedParams).toEqual(extensionParams); - } - }); - - it("SKILL.md tool listing includes all registered tools", () => { - const extensionTools = getExtensionToolNames(); - const skillTools = getSkillMdToolNames(); - - const missingFromSkill = extensionTools.filter( - (t) => !skillTools.includes(t), - ); - expect(missingFromSkill).toEqual([]); - }); - - it("SKILL.md documents workflow authoring tools in a dedicated category", () => { - const skillMd = readFileSync(resolve(skillDir, "SKILL.md"), "utf-8"); - const workflowCategory = skillMd.match(/- \*\*Workflow tools\*\* — ([^\n]+)/)?.[1] ?? ""; - - expect(workflowCategory).toContain("`fn_workflow_create`"); - expect(workflowCategory).toContain("`fn_workflow_update`"); - expect(workflowCategory).toContain("`fn_trait_list`"); - }); - - it("fusion-capabilities.md tool table includes all registered tools", () => { - const extensionTools = getExtensionToolNames(); - const capTools = getCapabilitiesToolNames(); - - const missingFromCaps = extensionTools.filter( - (t) => !capTools.includes(t), - ); - expect(missingFromCaps).toEqual([]); - expect(capTools).toEqual(extensionTools); - }); - - it("fusion-capabilities.md tool table is auto-generated with markers", () => { - const doc = readFileSync(resolve(skillDir, "references/fusion-capabilities.md"), "utf-8"); - expect(doc).toContain(CAP_TABLE_BEGIN); - expect(doc).toContain(CAP_TABLE_END); - }); - - it("extension-tools.md has auto-generated markers", () => { - const doc = readFileSync(resolve(skillDir, "references/extension-tools.md"), "utf-8"); - expect(doc).toContain(EXT_TOOLS_BEGIN); - expect(doc).toContain(EXT_TOOLS_END); - }); - - it("extension-tools.md includes workflow tool reference sections", () => { - const doc = readFileSync(resolve(skillDir, "references/extension-tools.md"), "utf-8"); - const workflowSection = doc.match(/## Workflow Tools\n([\s\S]*?)\n## GitHub Tools/)?.[1] ?? ""; - - expect(workflowSection).toContain("### fn_workflow_create"); - expect(workflowSection).toContain("### fn_workflow_settings"); - expect(workflowSection).toContain("### fn_trait_list"); - expect(workflowSection).toContain("| `ir` | unknown | ✓ |"); - }); - - it("engine-tools.md documents all engine session-scoped tools", () => { - const engineTools = getEngineSessionToolNames(); - const documented = getDocumentedEngineToolNames(); - const missing = engineTools.filter((name) => !documented.includes(name)); - // FNXC:SkillSync 2026-06-17-23:06: This test enforces the invariant that every engine session-scoped `fn_*` registration across the engine source set must be mirrored in `engine-tools.md`, so failures must print the exact undocumented names instead of hiding drift behind a generic deep-equality diff. - expect( - missing, - `undocumented engine tools in engine-tools.md: ${missing.join(", ") || "none"}`, - ).toEqual([]); - }); - - it("covers the full Fusion skill markdown surface", () => { - const markdownFiles = collectMarkdownFiles(skillDir).map((filePath) => - relative(skillDir, filePath), - ); - - expect(markdownFiles).toEqual([ - "SKILL.md", - "references/best-practices.md", - "references/cli-commands.md", - "references/engine-tools.md", - "references/extension-tools.md", - "references/fusion-capabilities.md", - "references/skill-patterns.md", - "references/task-structure.md", - "workflows/dashboard-cli.md", - "workflows/specifications.md", - "workflows/task-lifecycle.md", - "workflows/task-management.md", - ]); - }); - - it("enforces fn_* naming across extension + all skill markdown for public tools", () => { - const extensionTools = getExtensionToolNames(); - const publicSuffixes = extensionTools.map((toolName) => - toolName.replace(/^fn_/, ""), - ); - - const allowedUnprefixedInternalTools = new Set([ - "task_create", - "task_update", - "task_log", - "task_done", - "review_step", - "spawn_agent", - ]); - - const forbiddenSuffixes = publicSuffixes.filter( - (suffix) => !allowedUnprefixedInternalTools.has(suffix), - ); - - const filesToScan = [extensionPath, ...collectMarkdownFiles(skillDir)]; - const violations: string[] = []; - - for (const filePath of filesToScan) { - const content = readFileSync(filePath, "utf-8"); - const relativePath = relative(cliRoot, filePath); - - for (const suffix of forbiddenSuffixes) { - const regex = new RegExp(`(? { - const skillMd = readFileSync(resolve(skillDir, "SKILL.md"), "utf-8"); - expect(skillMd).toContain("/fn"); - - const dashboardCli = readFileSync( - resolve(skillDir, "workflows/dashboard-cli.md"), - "utf-8", - ); - expect(dashboardCli).toContain("/fn"); - }); - - it("SKILL.md and reference generated blocks match sync script output (no drift)", () => { - const script = resolve(repoRoot, "scripts/sync-fusion-skill-tools.mjs"); - const result = spawnSync("node", [script, "--check"], { - encoding: "utf-8", - }); - if (result.status !== 0) { - throw new Error( - `sync-fusion-skill-tools --check failed:\n${result.stderr || result.stdout}`, - ); - } - }); - - it("package.json includes skills in pi config and files array", () => { - const pkg = JSON.parse( - readFileSync(resolve(cliRoot, "package.json"), "utf-8"), - ); - expect(pkg.pi.skills).toContain("./skill"); - expect(pkg.files).toContain("skill/**"); - }); -}); diff --git a/packages/cli/src/__tests__/version.test.ts b/packages/cli/src/__tests__/version.test.ts deleted file mode 100644 index d68aaa4ddd..0000000000 --- a/packages/cli/src/__tests__/version.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -const repoRoot = join(import.meta.dirname!, "..", "..", "..", ".."); - -describe("Changeset configuration", () => { - // Release guardrail: These tests protect the @runfusion/fusion release pipeline - // by ensuring changeset configuration required for npm publishing remains intact. - // If these tests fail, the automated release workflow will break. - it("should have a valid .changeset/config.json", () => { - const configPath = join(repoRoot, ".changeset", "config.json"); - expect(existsSync(configPath)).toBe(true); - - const config = JSON.parse(readFileSync(configPath, "utf-8")); - expect(config).toBeDefined(); - expect(typeof config).toBe("object"); - }); - - it("should have baseBranch set to 'main' for the default branch", () => { - const configPath = join(repoRoot, ".changeset", "config.json"); - const config = JSON.parse(readFileSync(configPath, "utf-8")); - expect(config.baseBranch).toBe("main"); - }); - - it("should have changeset scripts in root package.json", () => { - // These scripts drive the changesets CLI workflow: changeset (add), version (bump), release:version (apply + sync workspace version) - const pkgPath = join(repoRoot, "package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - - expect(pkg.scripts.changeset).toBe("changeset"); - expect(pkg.scripts.version).toBe("changeset version"); - // FNXC:ReleasePipeline 2026-06-24-23:50: release:version now includes run-ci-distill.mjs to distill changelog entries after version bump. - expect(pkg.scripts["release:version"]).toBe("changeset version && node scripts/sync-workspace-version.mjs && node scripts/run-ci-distill.mjs"); - }); - - it("should keep the workspace package.json version aligned with the published CLI package", () => { - const workspacePkgPath = join(repoRoot, "package.json"); - const cliPkgPath = join(repoRoot, "packages", "cli", "package.json"); - const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, "utf-8")); - const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf-8")); - - expect(workspacePkg.version).toBe(cliPkg.version); - }); - - it("should have .github/workflows/version.yml configured for manual releases", () => { - const workflowPath = join( - repoRoot, - ".github", - "workflows", - "version.yml", - ); - expect(existsSync(workflowPath)).toBe(true); - - const content = readFileSync(workflowPath, "utf-8"); - // Guardrail: workflow must use changesets/action for npm publishing - expect(content).toContain("changesets/action"); - // Guardrail: workflow must be manually triggered (auto-trigger disabled for safety) - expect(content).toContain("workflow_dispatch"); - expect(content).toContain("Auto-trigger disabled"); - }); -}); diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts deleted file mode 100644 index b22f1405fe..0000000000 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ /dev/null @@ -1,3736 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { EventEmitter } from "node:events"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -function makeConstructibleMock unknown>(impl?: T) { - const mock = vi.fn(function () {}); - const originalMockImplementation = mock.mockImplementation.bind(mock); - const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock); - const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { - return nextImpl(...args); - }; - mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; - mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce; - if (impl) { - mock.mockImplementation(impl); - } - return mock; -} - -const { mockSyncStartupModels, mockShouldUseHybridExecutor, mockHybridExecutorCtor, mockHybridExecutorInitialize, mockHybridExecutorShutdown } = vi.hoisted(() => ({ - mockSyncStartupModels: vi.fn().mockResolvedValue(undefined), - mockShouldUseHybridExecutor: vi.fn().mockResolvedValue({ enabled: false, reason: "single-project-local-only" }), - mockHybridExecutorInitialize: vi.fn().mockResolvedValue(undefined), - mockHybridExecutorShutdown: vi.fn().mockResolvedValue(undefined), - mockHybridExecutorCtor: makeConstructibleMock(function () { - return { - initialize: mockHybridExecutorInitialize, - shutdown: mockHybridExecutorShutdown, - }; - }), -})); - -const { mockSuperviseSpawn } = vi.hoisted(() => ({ - mockSuperviseSpawn: vi.fn(() => ({ - pid: 12345, - pgid: 12345, - child: {}, - kill: vi.fn(), - waitExit: vi.fn().mockResolvedValue({ code: 0, signal: null }), - })), -})); - -const { mockCreateSkillsAdapter } = vi.hoisted(() => ({ - mockCreateSkillsAdapter: vi.fn().mockReturnValue(undefined), -})); - -/* -FNXC:SystemPanel 2026-07-12-14:35: -Fake attached child for runDashboardSupervised: the supervisor now uses a -plain node:child_process spawn (foreground, TUI-safe) instead of the detached -superviseSpawn, so tests mock spawn and complete the loop by emitting a clean -SIGINT close on a microtask (after the supervisor wires its close listener). -*/ -/* -FNXC:SystemPanel 2026-07-12-15:10: -supervisorCloseQueue lets a test script successive child exits (e.g. exit-86 -intentional restart followed by a clean exit-0) so the respawn loop can be -driven deterministically. Each spawn pops one queued close result; when the -queue is empty it defaults to a clean `{ code: 0, signal: null }` exit so the -existing single-spawn supervision tests still terminate the loop. -*/ -const { mockSupervisorSpawn, supervisorCloseQueue } = vi.hoisted(() => { - const supervisorCloseQueue: Array<{ code: number | null; signal: NodeJS.Signals | null }> = []; - return { - supervisorCloseQueue, - mockSupervisorSpawn: vi.fn(() => { - const listeners: Record void>> = {}; - const child = { - on(event: string, cb: (...args: unknown[]) => void) { - (listeners[event] ??= []).push(cb); - return child; - }, - kill: () => true, - }; - const result = supervisorCloseQueue.shift() ?? { code: 0, signal: null }; - queueMicrotask(() => { - for (const cb of listeners["close"] ?? []) cb(result.code, result.signal); - }); - return child; - }), - }; -}); -vi.mock("../startup-model-sync.js", () => ({ - syncStartupModels: mockSyncStartupModels, -})); - -const CLI_PACKAGE_VERSION = ( - JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8")) as { version: string } -).version; - -// ── Capture instances & arguments ─────────────────────────────────── - -let capturedExecutorOpts: Record | undefined; -let capturedSelfHealingOpts: Record | undefined; - -const { - mockAuthStorage, - mockModelRegistry, - mockDiscoverAndLoadExtensions, - mockCreateExtensionRuntime, - mockSelfHealingStart, - mockSelfHealingStop, - mockCheckStuckBudget, - mockStuckCheckNow, - mockResolveGlobalDir, - mockGlobalSettingsGetSettings, - mockGlobalSettingsUpdateSettings, - mockDaemonTokenGetOrCreate, - mockGetCliPackageVersion, - mockRefreshAllCustomProviderModels, -} = vi.hoisted(() => { - delete process.env.FUSION_DASHBOARD_TOKEN; - delete process.env.FUSION_DAEMON_TOKEN; - delete process.env.FUSION_BEARER_TOKEN; - - return { - mockLegacyCredentialStorage: { getAuth: vi.fn(), setAuth: vi.fn(), getApiKey: vi.fn().mockResolvedValue(undefined) }, - mockModelRegistry: { - registerProvider: vi.fn(), - refresh: vi.fn(), - }, - mockDiscoverAndLoadExtensions: vi.fn().mockResolvedValue({ - runtime: { pendingProviderRegistrations: [] }, - errors: [], - }), - mockCreateExtensionRuntime: vi.fn(), - mockSelfHealingStart: vi.fn(), - mockSelfHealingStop: vi.fn(), - mockCheckStuckBudget: vi.fn().mockResolvedValue(true), - mockStuckCheckNow: vi.fn().mockResolvedValue(undefined), - mockResolveGlobalDir: vi.fn(), - mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}), - mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}), - mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"), - mockGetCliPackageVersion: vi.fn(), - mockRefreshAllCustomProviderModels: vi.fn().mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }), - }; -}); - -// Minimal mock store backed by EventEmitter so `store.on` works -function makeMockStore() { - const emitter = new EventEmitter(); - // runDashboard registers several independent settings listeners by design; - // keep the test mock above Node's low default threshold while still asserting - // disposal behavior in the lifecycle cleanup tests below. - emitter.setMaxListeners(20); - const mockMissionStore = { - listMissions: vi.fn().mockReturnValue([]), - getMission: vi.fn(), - updateMission: vi.fn(), - listMilestones: vi.fn().mockReturnValue([]), - listFeatures: vi.fn().mockReturnValue([]), - }; - const mockPluginStore = { - init: vi.fn().mockResolvedValue(undefined), - listPlugins: vi.fn().mockResolvedValue([]), - getPlugin: vi.fn(), - registerPlugin: vi.fn(), - enablePlugin: vi.fn(), - disablePlugin: vi.fn(), - updatePluginSettings: vi.fn(), - unregisterPlugin: vi.fn(), - updatePluginState: vi.fn(), - }; - return { - init: vi.fn().mockResolvedValue(undefined), - watch: vi.fn().mockResolvedValue(undefined), - stopWatching: vi.fn(), - updateSettings: vi.fn().mockResolvedValue(undefined), - getSettings: vi.fn().mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: false, - mergeStrategy: "direct", - pollIntervalMs: 60_000, - }), - listTasks: vi.fn().mockResolvedValue([]), - getTask: vi.fn().mockResolvedValue({ id: "FN-TEST", column: "in-review", paused: false, description: "Test task", log: [] }), - moveTask: vi.fn().mockResolvedValue({}), - updatePrInfo: vi.fn().mockResolvedValue({}), - logEntry: vi.fn().mockResolvedValue(undefined), - updateTask: vi.fn().mockResolvedValue({}), - getRootDir: vi.fn().mockReturnValue("/tmp/test"), - getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"), - getAsyncLayer: vi.fn().mockReturnValue(null), - getGlobalSettingsStore: vi.fn(() => ({ - getSettings: mockGlobalSettingsGetSettings, - updateSettings: mockGlobalSettingsUpdateSettings, - })), - getActiveMergingTask: vi.fn().mockReturnValue(undefined), - getMissionStore: vi.fn().mockReturnValue(mockMissionStore), - getPluginStore: vi.fn().mockReturnValue(mockPluginStore), - close: vi.fn(), - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.on(event, handler); - }), - off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.off(event, handler); - }), - emit: emitter.emit.bind(emitter), - }; -} - -// ── Mock @fusion/core ────────────────────────────────────────────────── - -vi.mock("@fusion/core", async (importOriginal) => { - const { createCliCoreMock } = await import("../../test/mockCoreEngine"); - return createCliCoreMock(() => importOriginal(), { - TaskStore: makeConstructibleMock(() => makeMockStore()), - CentralCore: makeConstructibleMock(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - getProject: vi.fn().mockImplementation((id: string) => - Promise.resolve({ id, name: `Project ${id}`, path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }), - ), - listProjects: vi.fn().mockResolvedValue([ - { id: "project-1", name: "Test Project", path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, - ]), - })), - AutomationStore: makeConstructibleMock(() => ({ - init: vi.fn().mockResolvedValue(undefined), - listSchedules: vi.fn().mockResolvedValue([]), - getSchedule: vi.fn().mockResolvedValue(null), - createSchedule: vi.fn().mockResolvedValue({}), - updateSchedule: vi.fn().mockResolvedValue({}), - deleteSchedule: vi.fn().mockResolvedValue({}), - recordRun: vi.fn().mockResolvedValue({}), - getDueSchedules: vi.fn().mockResolvedValue([]), - })), - AgentStore: makeConstructibleMock(() => ({ - init: vi.fn().mockResolvedValue(undefined), - createAgent: vi.fn(), - updateAgentState: vi.fn(), - listAgents: vi.fn().mockResolvedValue([]), - getAgent: vi.fn().mockResolvedValue(null), - deleteAgent: vi.fn(), - on: vi.fn(), - off: vi.fn(), - getActiveHeartbeatRun: vi.fn().mockResolvedValue(null), - getBudgetStatus: vi.fn().mockResolvedValue({ isOverBudget: false, isOverThreshold: false, usagePercent: 0 }), - getRecentRuns: vi.fn().mockResolvedValue([]), - })), - PluginStore: makeConstructibleMock(function () { - const emitter = new EventEmitter(); - return { - init: vi.fn().mockResolvedValue(undefined), - listPlugins: vi.fn().mockResolvedValue([]), - getPlugin: vi.fn(), - registerPlugin: vi.fn(), - enablePlugin: vi.fn(), - disablePlugin: vi.fn(), - updatePluginSettings: vi.fn(), - unregisterPlugin: vi.fn(), - updatePluginState: vi.fn(), - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.on(event, handler); - }), - off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.off(event, handler); - }), - emit: emitter.emit.bind(emitter), - }; - }), - PluginLoader: makeConstructibleMock(function () { - const emitter = new EventEmitter(); - return { - loadPlugin: vi.fn().mockResolvedValue(undefined), - loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), - getPluginSkills: vi.fn().mockReturnValue([]), - stopAllPlugins: vi.fn().mockResolvedValue(undefined), - stopPlugin: vi.fn().mockResolvedValue(undefined), - reloadPlugin: vi.fn().mockResolvedValue(undefined), - getPluginRoutes: vi.fn().mockReturnValue([]), - getPlugin: vi.fn(), - getLoadedPlugins: vi.fn().mockReturnValue([]), - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.on(event, handler); - }), - off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.off(event, handler); - }), - emit: emitter.emit.bind(emitter), - }; - }), - getEnabledPiExtensionPaths: vi.fn(() => []), - resolveGlobalDir: mockResolveGlobalDir, - GlobalSettingsStore: makeConstructibleMock(() => ({ - init: vi.fn().mockResolvedValue(undefined), - getSettings: mockGlobalSettingsGetSettings, - updateSettings: mockGlobalSettingsUpdateSettings, - })), - DaemonTokenManager: makeConstructibleMock(() => ({ - getOrCreateToken: mockDaemonTokenGetOrCreate, - getToken: vi.fn().mockResolvedValue(undefined), - generateToken: vi.fn().mockResolvedValue("fn_test_dashboard_token"), - })), - superviseSpawn: mockSuperviseSpawn, - getTaskMergeBlocker: vi.fn((task: any) => { - if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`; - if (task.paused) return "task is paused"; - if (task.status === "failed") return "task is marked 'failed'"; - if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) { - return "task has incomplete steps"; - } - if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) { - return "task has incomplete or failed workflow steps"; - } - return undefined; - }), - }); -}); - -// ── Hoisted shared mocks ─────────────────────────────────────────── - -const { - mockExec, - mockExecSync, - mockExecFileSync, - mockFindPrForBranch, - mockCreatePr, - mockGetPrMergeStatus, - mockMergePr, -} = vi.hoisted(() => ({ - mockExec: vi.fn((_command: string, _options?: any, callback?: (err: null, stdout: string, stderr: string) => void) => { - if (typeof callback === "function") { - callback(null, "", ""); - } - // Match child_process.exec's callback-style contract. Returning a Promise - // makes util.promisify(exec) emit DEP0174 in tests. - return { - pid: 12345, - stdout: null, - stderr: null, - on: vi.fn(), - once: vi.fn(), - kill: vi.fn(), - }; - }), - mockExecSync: vi.fn(() => ""), - mockExecFileSync: vi.fn((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { - if (file === "git" && args.join(" ") === "remote get-url origin") { - const effectiveCwd = options?.cwd ?? process.cwd(); - if (effectiveCwd !== "/repo" && effectiveCwd !== process.cwd()) { - throw new Error(`unexpected repository cwd: ${options?.cwd ?? ""}`); - } - return "https://github.com/owner/repo.git\n"; - } - return ""; - }), - mockFindPrForBranch: vi.fn(), - mockCreatePr: vi.fn(), - mockGetPrMergeStatus: vi.fn(), - mockMergePr: vi.fn(), -})); - -// ── Mock node:child_process ──────────────────────────────────────── - -vi.mock("node:child_process", async (importOriginal) => { - const original = await importOriginal(); - // execFile mirrors exec's success-callback contract: the new argv-based git - // probes (pushTaskBranchToOrigin / gitCommandSucceeds) must hit the mock, not - // spawn real git against this test's fake cwds. - const mockExecFile = ((_file: string, _args?: unknown, optsOrCb?: unknown, cbMaybe?: unknown) => { - const callback = [optsOrCb, cbMaybe, _args].find((v) => typeof v === "function") as - | ((err: null, stdout: string, stderr: string) => void) - | undefined; - if (callback) callback(null, "", ""); - return { pid: 12346, stdout: null, stderr: null, on: vi.fn(), once: vi.fn(), kill: vi.fn() }; - }) as unknown as typeof original.execFile; - return { - ...original, - exec: mockExec, - execSync: mockExecSync, - execFile: mockExecFile, - execFileSync: mockExecFileSync, - spawn: mockSupervisorSpawn, - }; -}); - -// ── Mock @fusion/dashboard ───────────────────────────────────────────── - -/** Create a mock server (EventEmitter) that simulates net.Server behavior. */ -function createMockServer(portToReturn: number = 0) { - const emitter = new EventEmitter(); - const server = Object.assign(emitter, { - listen: vi.fn((_port?: number) => { - process.nextTick(() => emitter.emit("listening")); - return server; - }), - address: vi.fn(() => ({ port: portToReturn, family: "IPv4", address: "127.0.0.1" })), - close: vi.fn(), - }); - return server; -} - -const mockListen = vi.fn((port: number) => { - const server = createMockServer(port); - process.nextTick(() => server.emit("listening")); - return server; -}); - -vi.mock("@fusion/dashboard", () => ({ - // FNXC:TestInfrastructure 2026-07-13-10:25: Source files named-import these from @fusion/dashboard barrel; mock must surface them. - registerGithubTrackingHook: vi.fn(), - AttachTicketStore: vi.fn(), - CliInputAttributionLog: vi.fn(), - CliConfirmAdvanceRegistry: vi.fn(), - CliRelaunchRegistry: vi.fn(), - // FNXC:CliTests 2026-07-13-08:10: @fusion/dashboard barrel re-exports cli-package-version helpers; mock must surface them for startup model sync. -isUnresolvedCliPackageVersion: vi.fn(() => false), -resolveCliPackageVersionInfo: vi.fn(() => ({ version: "0.0.0-test", isUnresolved: false })), - createServer: vi.fn((_store: unknown, opts: Record = {}) => { - if (!opts.onMerge) { - if (opts.engine) { - opts.onMerge = (taskId: string) => opts.engine.onMerge(taskId); - } else if (opts.engineManager?.ensureEngine) { - opts.onMerge = async (taskId: string) => { - const engine = await opts.engineManager.ensureEngine("project-1"); - return engine?.onMerge(taskId); - }; - } - } - opts.onProjectFirstAccessed?.("project-1"); - return { listen: mockListen }; - }), - GitHubClient: makeConstructibleMock(() => ({ - findPrForBranch: mockFindPrForBranch, - createPr: mockCreatePr, - getPrMergeStatus: mockGetPrMergeStatus, - mergePr: mockMergePr, - })), - createSkillsAdapter: mockCreateSkillsAdapter, - getCliPackageVersion: mockGetCliPackageVersion, - getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"), - loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined), - refreshAllCustomProviderModels: mockRefreshAllCustomProviderModels, - stopAllDevServers: vi.fn().mockResolvedValue(undefined), - // FNXC:CliTests 2026-07-13-09:40: Missing dashboard barrel exports added for mock completeness (scripts/check-mock-completeness.mjs gate). - AttachTicketStore: vi.fn(), - CliInputAttributionLog: vi.fn(), - CliConfirmAdvanceRegistry: vi.fn(), - CliRelaunchRegistry: vi.fn(), - registerGithubTrackingHook: vi.fn(), -})); - -// ── Mock node:readline ────────────────────────────────────────────── - -vi.mock("node:readline", () => ({ - createInterface: vi.fn(), -})); - -// ── Mock @fusion/engine ──────────────────────────────────────────────── - -// We need the real WorktreePool class so we can assert `instanceof`. -const { WorktreePool } = await import("@fusion/engine"); - -vi.mock("@fusion/engine", async (importOriginal) => { - const original = await importOriginal(); - const { createCliEngineMock } = await import("../../test/mockCoreEngine"); - const coreModule = await import("@fusion/core"); - const taskStoreMock = (coreModule.TaskStore as any); - const TriageProcessor = makeConstructibleMock(() => ({ - start: vi.fn(), - stop: vi.fn(), - })); - const TaskExecutor = vi.fn().mockImplementation((_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { - resumeOrphaned: vi.fn().mockResolvedValue(undefined), - }; - }); - const StuckTaskDetector = makeConstructibleMock(() => ({ - start: vi.fn(), - stop: vi.fn(), - checkNow: mockStuckCheckNow, - trackTask: vi.fn(), - untrackTask: vi.fn(), - markTaskProgress: vi.fn(), - })); - const Scheduler = makeConstructibleMock(() => ({ - start: vi.fn(), - stop: vi.fn(), - })); - const PrMonitor = makeConstructibleMock(() => ({ - onNewComments: vi.fn(), - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - stopAll: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - updatePrInfo: vi.fn(), - drainComments: vi.fn().mockReturnValue([]), - })); - const PrCommentHandler = makeConstructibleMock(() => ({ - handleNewComments: vi.fn().mockResolvedValue(undefined), - createFollowUpTask: vi.fn().mockResolvedValue(undefined), - })); - const aiMergeTask = vi.fn().mockImplementation(() => Promise.resolve({ merged: true })); - const runAiMerge = vi.fn().mockImplementation(() => Promise.resolve({ merged: true })); - const landWorkspaceTask = vi.fn().mockImplementation(() => Promise.resolve({ allLanded: true, repos: [] })); - const CronRunner = makeConstructibleMock(() => ({ - start: vi.fn(), - stop: vi.fn(), - })); - const createAiPromptExecutor = vi.fn().mockResolvedValue({ - execute: vi.fn().mockResolvedValue(undefined), - }); - const SelfHealingManager = vi.fn().mockImplementation((_store: unknown, opts: unknown) => { - capturedSelfHealingOpts = opts as Record; - return { - start: mockSelfHealingStart, - stop: mockSelfHealingStop, - checkStuckBudget: mockCheckStuckBudget, - }; - }); - - class ProjectEngine { - private store: ReturnType; - private cwd: string; - private pool?: InstanceType; - private executor?: { resumeOrphaned?: () => Promise }; - private selfHealing?: { start?: () => void; stop?: () => void }; - private stuckDetector?: { checkNow?: () => Promise }; - private settingsHandlers: Array<(event: any) => void> = []; - private taskMovedHandler?: (event: any) => void; - private mergeQueue: string[] = []; - private mergeActive = new Set(); - private mergeRunning = false; - private activeMergeSession: { dispose: () => void } | null = null; - - constructor( - config: { workingDirectory: string }, - _centralCore: unknown, - private options: { externalTaskStore?: ReturnType; getMergeStrategy?: (settings: any) => string; processPullRequestMerge?: (store: any, cwd: string, taskId: string) => Promise; getTaskMergeBlocker?: (task: any) => string | undefined } = {}, - ) { - this.cwd = config.workingDirectory; - this.store = options.externalTaskStore ?? makeMockStore(); - } - - async start(): Promise { - this.pool = new original.WorktreePool(this.cwd, this.store as any); - const semaphore = new original.AgentSemaphore(1); - const recoverCompletedTask = vi.fn().mockResolvedValue(false); - const getExecutingTaskIds = vi.fn().mockReturnValue(new Set()); - const executorOpts = { - pool: this.pool, - semaphore, - recoverCompletedTask, - getExecutingTaskIds, - }; - - TriageProcessor(this.store, this.cwd, { semaphore }); - this.executor = TaskExecutor(this.store, this.cwd, executorOpts); - this.stuckDetector = StuckTaskDetector(this.store, this.cwd, {}); - this.selfHealing = SelfHealingManager(this.store, { - rootDir: process.cwd(), - recoverCompletedTask, - getExecutingTaskIds, - }); - this.selfHealing.start?.(); - - const prMonitor = PrMonitor(); - const prCommentHandler = PrCommentHandler(this.store); - prMonitor.onNewComments((taskId: string, prInfo: any, comments: any[]) => - prCommentHandler.handleNewComments(taskId, prInfo, comments), - ); - Scheduler(this.store, { - prMonitor, - semaphore, - onClosedPrFeedback: (taskId: string, prInfo: any, comments: any[]) => - prCommentHandler.createFollowUpTask(taskId, prInfo, comments), - }); - CronRunner(); - - this.wireSettingsListeners(); - this.wireAutoMerge(); - await this.executor?.resumeOrphaned?.(); - await this.startupMergeSweep(); - } - - async stop(): Promise { - for (const handler of this.settingsHandlers) { - this.store.off("settings:updated", handler); - } - this.settingsHandlers = []; - if (this.taskMovedHandler) { - this.store.off("task:moved", this.taskMovedHandler); - this.taskMovedHandler = undefined; - } - if (this.activeMergeSession) { - this.activeMergeSession.dispose(); - this.activeMergeSession = null; - } - this.selfHealing?.stop?.(); - } - - getHeartbeatTriggerScheduler(): { stop: () => void } { - return { stop: vi.fn() }; - } - - getCliAgentRuntime(): undefined { - return undefined; - } - - async onMerge(taskId: string): Promise { - return aiMergeTask(this.store, this.cwd, taskId, { - pool: this.pool, - onSession: (session: { dispose: () => void }) => { - this.activeMergeSession = session; - }, - }); - } - - private canMergeTask(task: any): boolean { - if (this.options.getTaskMergeBlocker?.(task)) return false; - return (task.mergeRetries ?? 0) < 3 || this.hasAutoHealableVerificationBufferFailure(task); - } - - private hasAutoHealableVerificationBufferFailure(task: any): boolean { - if (task.column !== "in-review") return false; - if ((task.mergeRetries ?? 0) < 3) return false; - const err = task.error ?? ""; - if ( - !err.includes("Deterministic test verification failed") && - !err.includes("Deterministic build verification failed") && - !err.includes("Build verification failed") && - !err.includes("Test verification failed") - ) { - return false; - } - return task.log?.some((entry: { action?: string }) => - entry.action?.includes("[verification] test command failed (exit 0)") || - entry.action?.includes("[verification] build command failed (exit 0)") || - entry.action?.includes("output exceeded buffer"), - ) ?? false; - } - - private enqueueMerge(taskId: string): void { - if (this.mergeActive.has(taskId)) return; - this.mergeActive.add(taskId); - this.mergeQueue.push(taskId); - void this.drainMergeQueue(); - } - - private async drainMergeQueue(): Promise { - if (this.mergeRunning) return; - this.mergeRunning = true; - try { - while (this.mergeQueue.length > 0) { - const taskId = this.mergeQueue.shift()!; - try { - const settings = await this.store.getSettings(); - if (settings.globalPause || settings.enginePaused || !settings.autoMerge) continue; - - const task = await this.store.getTask(taskId); - if (!task || task.column !== "in-review" || !this.canMergeTask(task)) continue; - - if (this.hasAutoHealableVerificationBufferFailure(task)) { - await this.store.logEntry( - taskId, - "Auto-healing stale deterministic verification buffer failure; retrying merge verification", - ); - await this.store.updateTask(taskId, { mergeRetries: 0, error: null, status: null }); - } - - const mergeStrategy = this.options.getMergeStrategy?.(settings) ?? "direct"; - if (mergeStrategy === "pull-request" && this.options.processPullRequestMerge) { - await this.options.processPullRequestMerge(this.store, this.cwd, taskId); - } else { - await this.onMerge(taskId); - const latestTask = await this.store.getTask(taskId).catch(() => null); - if (latestTask?.mergeRetries && latestTask.mergeRetries > 0) { - await this.store.updateTask(taskId, { mergeRetries: 0 }); - } - } - } catch (err: any) { - const errorMsg = err?.message ?? String(err); - const settings = await this.store.getSettings().catch(() => ({ autoResolveConflicts: true })); - const task = await this.store.getTask(taskId).catch(() => null); - if (errorMsg.includes("conflict") || errorMsg.includes("Conflict")) { - const currentRetries = task?.mergeRetries ?? 0; - if (settings.autoResolveConflicts !== false && currentRetries < 3) { - const nextRetries = currentRetries + 1; - await this.store.updateTask(taskId, { mergeRetries: nextRetries, status: null }); - console.log(`Auto-merge conflict retry ${nextRetries}/3 for ${taskId} in 5s`); - } else { - console.log(`Auto-merge conflict retry skipped for ${taskId}: autoResolveConflicts disabled`); - await this.store.updateTask(taskId, { status: null }); - } - } else { - await this.store.updateTask(taskId, { - status: null, - mergeRetries: 3, - error: errorMsg, - }); - } - } finally { - this.mergeActive.delete(taskId); - } - } - } finally { - this.mergeRunning = false; - } - } - - private wireAutoMerge(): void { - this.taskMovedHandler = async ({ task, to }: { task: any; to: string }) => { - if (to !== "in-review") return; - if (this.options.getTaskMergeBlocker?.(task)) return; - const settings = await this.store.getSettings(); - if (settings.globalPause || settings.enginePaused || !settings.autoMerge) return; - this.enqueueMerge(task.id); - }; - this.store.on("task:moved", this.taskMovedHandler); - } - - private async startupMergeSweep(): Promise { - const settings = await this.store.getSettings(); - if (!settings.autoMerge) return; - const tasks = await this.store.listTasks({ column: "in-review" } as any); - for (const task of tasks) { - if (this.canMergeTask(task)) this.enqueueMerge(task.id); - } - } - - private wireSettingsListeners(): void { - const onGlobalPause = ({ settings, previous }: any) => { - if (settings.globalPause && !previous.globalPause && this.activeMergeSession) { - this.activeMergeSession.dispose(); - this.activeMergeSession = null; - } - }; - const onGlobalUnpause = async ({ settings, previous }: any) => { - if (!previous.globalPause || settings.globalPause) return; - await this.executor?.resumeOrphaned?.(); - if (settings.autoMerge) await this.enqueueInReviewTasks(); - }; - const onEngineUnpause = async ({ settings, previous }: any) => { - if (!previous.enginePaused || settings.enginePaused) return; - await this.executor?.resumeOrphaned?.(); - if (settings.autoMerge) await this.enqueueInReviewTasks(); - }; - const onStuckTimeoutChange = async ({ settings, previous }: any) => { - if (settings.taskStuckTimeoutMs === previous.taskStuckTimeoutMs) return; - try { - await this.stuckDetector?.checkNow?.(); - } catch (err) { - console.error("[stuck-detector] Error during immediate stuck-task check:", err); - } - }; - const onInsightSettingsChange = () => {}; - const onCompatibilityListener = () => {}; - this.settingsHandlers = [ - onGlobalPause, - onGlobalUnpause, - onEngineUnpause, - onStuckTimeoutChange, - onInsightSettingsChange, - onCompatibilityListener, - ]; - for (const handler of this.settingsHandlers) { - this.store.on("settings:updated", handler); - } - } - - private async enqueueInReviewTasks(): Promise { - const tasks = await this.store.listTasks({ column: "in-review" } as any); - for (const task of tasks) { - if (this.canMergeTask(task)) this.enqueueMerge(task.id); - } - } - } - - return createCliEngineMock(async () => original, {}, { - // Keep real WorktreePool & AgentSemaphore - WorktreePool: original.WorktreePool, - AgentSemaphore: original.AgentSemaphore, - createFusionAuthStorage: vi.fn(() => mockAuthStorage), - // Stub heavy classes/functions - ProjectEngine, - ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => { - const engines = new Map(); - const starting = new Map>(); - // Keep the chosen HEAD startEngine/starting async shape from the conflict resolution. - const startEngine = async (id: string, pathHint?: string) => { - const existing = engines.get(id); - if (existing) return existing; - const pending = starting.get(id); - if (pending) return pending; - - const promise = (async () => { - const { TaskStore: TSMock } = await import("@fusion/core"); - const lastStore = (TSMock as any).mock?.results?.at(-1)?.value; - const project = pathHint ? { path: pathHint } : await centralCore.getProject(id); - const engine = new ProjectEngine( - { workingDirectory: project?.path ?? process.cwd() }, - centralCore, - { ...options, externalTaskStore: lastStore, projectId: id }, - ); - await engine.start(); - engines.set(id, engine); - starting.delete(id); - return engine; - })(); - - starting.set(id, promise); - return promise; - }; - - return { - startAll: vi.fn(async () => { - const projects = await centralCore.listProjects(); - for (const project of projects) { - await startEngine(project.id, project.path); - } - }), - getEngine: vi.fn((id: string) => engines.get(id)), - getAllEngines: vi.fn(() => engines), - getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()), - has: vi.fn((id: string) => engines.has(id) || starting.has(id)), - ensureEngine: vi.fn(async (id: string) => startEngine(id)), - stopAll: vi.fn(async () => { - for (const engine of engines.values()) await engine.stop(); - engines.clear(); - starting.clear(); - }), - onProjectAccessed: vi.fn((id: string) => { - void startEngine(id); - }), - startReconciliation: vi.fn(), - }; - }), - ProjectManager: makeConstructibleMock(() => ({ - getRuntime: vi.fn().mockReturnValue(undefined), - addProject: vi.fn().mockResolvedValue(undefined), - stopAll: vi.fn().mockResolvedValue(undefined), - })), - TriageProcessor, - TaskExecutor, - StuckTaskDetector, - Scheduler, - PrMonitor, - PrCommentHandler, - aiMergeTask, - runAiMerge, - landWorkspaceTask, - CronRunner, - createAiPromptExecutor, - SelfHealingManager, - MissionAutopilot: makeConstructibleMock(() => ({ - start: vi.fn(), - stop: vi.fn(), - setScheduler: vi.fn(), - })), - PluginLoader: makeConstructibleMock(() => ({ - loadPlugin: vi.fn().mockResolvedValue(undefined), - stopPlugin: vi.fn().mockResolvedValue(undefined), - reloadPlugin: vi.fn().mockResolvedValue(undefined), - getPluginRoutes: vi.fn().mockReturnValue([]), - getPlugin: vi.fn(), - getLoadedPlugins: vi.fn().mockReturnValue([]), - })), - PeerExchangeService: makeConstructibleMock(() => ({ - start: vi.fn(), - stop: vi.fn().mockResolvedValue(undefined), - updateGlobalSettings: vi.fn(), - })), - shouldUseHybridExecutor: mockShouldUseHybridExecutor, - HybridExecutor: mockHybridExecutorCtor, - scanIdleWorktrees: vi.fn().mockResolvedValue([]), - cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0), - }); -}); - -// ── Mock @earendil-works/pi-coding-agent ────────────────────────────── - -vi.mock("@earendil-works/pi-coding-agent", () => ({ - LegacyCredentialStorage: { - create: vi.fn(() => mockAuthStorage), - }, - DefaultPackageManager: makeConstructibleMock(() => ({ - resolve: vi.fn().mockResolvedValue({ extensions: [] }), - })), - ModelRuntime: { - create: vi.fn(), - }, - ModelRegistry: { - create: vi.fn(() => mockModelRegistry), - inMemory: vi.fn(() => mockModelRegistry), - }, - SettingsManager: { - create: vi.fn(() => ({})), - }, - getAgentDir: vi.fn(() => "/mock/agent/dir"), - discoverAndLoadExtensions: mockDiscoverAndLoadExtensions, - createExtensionRuntime: mockCreateExtensionRuntime, -})); - -// ── Import module under test (after mocks) ────────────────────────── - -const { runDashboard: runDashboardImpl, StreamedLogBuffer } = await import("../dashboard.js"); -const { processPullRequestMergeTask, getMergeStrategy, getTaskBranchName } = await import("../task-lifecycle.js"); -const dashboardDisposables: Array<() => void> = []; - -function disposeTrackedDashboards(): void { - for (const dispose of dashboardDisposables.splice(0)) { - dispose(); - } -} - -const WAIT_FOR_ASYNC_OPTIONS = { timeout: 5000, interval: 10 }; - -async function waitForAsyncExpectation(assertion: () => void | Promise) { - await vi.waitFor(assertion, WAIT_FOR_ASYNC_OPTIONS); -} - -async function runDashboard(...args: Parameters): ReturnType { - disposeTrackedDashboards(); - const result = await runDashboardImpl(...args); - dashboardDisposables.push(result.dispose); - return result; -} - -// ── Tests ─────────────────────────────────────────────────────────── - -describe("runDashboard — project-scoped plugin skills", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("reuses a backend-aware project store instead of constructing a SQLite PluginStore", async () => { - vi.stubEnv("FUSION_NO_EMBEDDED_PG", "1"); - try { - const dashboard = await runDashboard(0, { open: false }); - - const adapterOptions = mockCreateSkillsAdapter.mock.calls.at(-1)?.[0] as - | { getPluginSkills?: (rootDir: string, resolvedProjectStore?: ReturnType) => Promise } - | undefined; - expect(adapterOptions?.getPluginSkills).toBeTypeOf("function"); - - const { PluginLoader, PluginStore, TaskStore } = await import("@fusion/core"); - const scopedStore = makeMockStore(); - vi.mocked(scopedStore.getPluginStore().listPlugins).mockResolvedValue([ - { id: "enabled-plugin", updatedAt: "2026-07-14T00:00:00.000Z" }, - ]); - const taskStoreConstructor = vi.mocked(TaskStore); - taskStoreConstructor.mockClear(); - const pluginStoreConstructor = vi.mocked(PluginStore); - pluginStoreConstructor.mockClear(); - const pluginLoaderConstructor = vi.mocked(PluginLoader); - pluginLoaderConstructor.mockClear(); - - await expect(adapterOptions!.getPluginSkills!("/tmp/other-project", scopedStore)).resolves.toEqual([]); - expect(pluginStoreConstructor).not.toHaveBeenCalled(); - expect(taskStoreConstructor).not.toHaveBeenCalled(); - expect(pluginLoaderConstructor).toHaveBeenCalledWith({ - pluginStore: scopedStore.getPluginStore(), - taskStore: scopedStore, - persistRuntimeState: false, - }); - const scopedPluginLoader = pluginLoaderConstructor.mock.results.at(-1)?.value as { - stopAllPlugins: ReturnType; - }; - expect(scopedPluginLoader.stopAllPlugins).toHaveBeenCalledWith(); - - dashboard.dispose(); - expect(scopedStore.close).not.toHaveBeenCalled(); - } finally { - vi.unstubAllEnvs(); - } - }); -}); - -describe("runDashboard — startup model sync", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("invokes shared startup model sync", async () => { - await runDashboard(0, { open: false }); - expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); - }); - - it("registers built-in zai GLM-5.2 before refreshing models", async () => { - await runDashboard(0, { open: false }); - - expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ - models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), - })); - expect(mockModelRegistry.refresh).toHaveBeenCalled(); - }); - - it("starts dashboard before background custom provider refresh settles", async () => { - mockRefreshAllCustomProviderModels.mockImplementationOnce(() => new Promise(() => undefined)); - mockGlobalSettingsGetSettings.mockResolvedValue({ - customProviders: [{ - id: "cp-1", - name: "Custom Proxy", - apiType: "openai-compatible", - baseUrl: "https://proxy.example.com/v1", - models: [{ id: "configured-model", name: "Configured model" }], - }], - }); - - await runDashboard(0, { open: false }); - - expect(mockRefreshAllCustomProviderModels).toHaveBeenCalledTimes(1); - expect(mockRefreshAllCustomProviderModels).toHaveBeenCalledWith(expect.any(Object), expect.any(Function)); - expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith( - expect.stringContaining("custom-proxy"), - expect.objectContaining({ - baseUrl: "https://proxy.example.com/v1", - models: [expect.objectContaining({ id: "configured-model", name: "Configured model" })], - }), - ); - }); - - it("continues registering custom providers when startup refresh fails", async () => { - mockRefreshAllCustomProviderModels.mockRejectedValueOnce(new Error("provider offline")); - mockGlobalSettingsGetSettings.mockResolvedValue({ - customProviders: [{ - id: "cp-1", - name: "Custom Proxy", - apiType: "openai-compatible", - baseUrl: "https://proxy.example.com/v1", - models: [{ id: "configured-model", name: "Configured model" }], - }], - }); - - await runDashboard(0, { open: false }); - - expect(mockRefreshAllCustomProviderModels).toHaveBeenCalledTimes(1); - expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith( - expect.stringContaining("custom-proxy"), - expect.objectContaining({ models: [expect.objectContaining({ id: "configured-model" })] }), - ); - }); -}); - -function resetGitHubMocks() { - mockFindPrForBranch.mockReset(); - mockCreatePr.mockReset(); - mockGetPrMergeStatus.mockReset(); - mockMergePr.mockReset(); - mockExecFileSync.mockReset(); - - mockExecFileSync.mockImplementation((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { - if (file === "git" && args.join(" ") === "remote get-url origin") { - const effectiveCwd = options?.cwd ?? process.cwd(); - if (effectiveCwd !== "/repo" && effectiveCwd !== process.cwd()) { - throw new Error(`unexpected repository cwd: ${options?.cwd ?? ""}`); - } - return "https://github.com/owner/repo.git\n"; - } - return ""; - }); - - mockFindPrForBranch.mockResolvedValue(null); - mockCreatePr.mockResolvedValue({ - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "open", - title: "FN-TEST", - headBranch: "fusion/fn-test", - baseBranch: "main", - commentCount: 0, - }); - mockGetPrMergeStatus.mockResolvedValue({ - prInfo: { - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "open", - title: "FN-TEST", - headBranch: "fusion/fn-test", - baseBranch: "main", - commentCount: 0, - }, - reviewDecision: null, - checks: [], - mergeReady: false, - blockingReasons: ["required checks not successful: ci (pending)"], - }); - mockMergePr.mockResolvedValue({ - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "merged", - title: "FN-TEST", - headBranch: "fusion/fn-test", - baseBranch: "main", - commentCount: 0, - }); -} - -let updateCacheDir = ""; - -function writeUpdateCache(payload: { updateAvailable: boolean; latestVersion: string; currentVersion: string }): void { - mkdirSync(updateCacheDir, { recursive: true }); - writeFileSync(`${updateCacheDir}/update-check.json`, JSON.stringify(payload), "utf-8"); -} - -beforeEach(() => { - delete process.env.FUSION_DASHBOARD_TOKEN; - delete process.env.FUSION_DAEMON_TOKEN; - delete process.env.FUSION_BEARER_TOKEN; - - resetGitHubMocks(); - mockExecSync.mockReset(); - mockExecSync.mockReturnValue(""); - mockExec.mockClear(); - mockListen.mockReset(); - mockListen.mockImplementation((port: number) => { - const server = createMockServer(port); - process.nextTick(() => server.emit("listening")); - return server; - }); - mockStuckCheckNow.mockReset(); - mockStuckCheckNow.mockResolvedValue(undefined); - if (updateCacheDir) { - rmSync(updateCacheDir, { recursive: true, force: true }); - } - updateCacheDir = mkdtempSync(join(tmpdir(), "fusion-dashboard-test-")); - mockResolveGlobalDir.mockReset(); - mockResolveGlobalDir.mockReturnValue(updateCacheDir); - mockGlobalSettingsGetSettings.mockReset(); - mockGlobalSettingsGetSettings.mockResolvedValue({}); - mockGlobalSettingsUpdateSettings.mockReset(); - mockGlobalSettingsUpdateSettings.mockResolvedValue({}); - mockDaemonTokenGetOrCreate.mockReset(); - mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token"); - mockGetCliPackageVersion.mockReset(); - mockGetCliPackageVersion.mockReturnValue(CLI_PACKAGE_VERSION); - mockRefreshAllCustomProviderModels.mockReset(); - mockRefreshAllCustomProviderModels.mockResolvedValue({ refreshed: 0, failed: 0, skipped: 0 }); -}); - -afterEach(() => { - disposeTrackedDashboards(); - if (updateCacheDir) { - rmSync(updateCacheDir, { recursive: true, force: true }); - } - updateCacheDir = ""; -}); - -describe("PR merge helpers", () => { - it("defaults mergeStrategy to direct when unset", () => { - expect(getMergeStrategy({ mergeStrategy: undefined })).toBe("direct"); - }); - - it("uses pull-request mergeStrategy when configured", () => { - expect(getMergeStrategy({ mergeStrategy: "pull-request" })).toBe("pull-request"); - }); - - it("uses fusion/{task-id-lower} branch naming for pull requests", () => { - expect(getTaskBranchName("FN-093")).toBe("fusion/fn-093"); - }); -}); - -describe("processPullRequestMergeTask", () => { - it("creates and links a PR when task.prInfo is missing", async () => { - const store = makeMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-093", - title: "Add support for creating pull requests", - description: "Implement PR automation", - column: "in-review", - paused: false, - worktree: "/tmp/kb-093", - log: [], - }); - - const mockGetTaskMergeBlocker = (task: any) => { - if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`; - if (task.paused) return "task is paused"; - if (task.status === "failed") return "task is marked 'failed'"; - if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) { - return "task has incomplete steps"; - } - if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) { - return "task has incomplete or failed workflow steps"; - } - return undefined; - }; - - const result = await processPullRequestMergeTask(store as any, "/repo", "FN-093", { - findPrForBranch: mockFindPrForBranch, - createPr: mockCreatePr, - getPrMergeStatus: mockGetPrMergeStatus, - mergePr: mockMergePr, - } as any, mockGetTaskMergeBlocker); - - expect(result).toBe("waiting"); - expect(mockFindPrForBranch).toHaveBeenCalledWith({ head: "fusion/fn-093", state: "all" }); - expect(mockCreatePr).toHaveBeenCalledWith({ - title: "FN-093: Add support for creating pull requests", - body: "Automated PR for FN-093.\n\nImplement PR automation", - head: "fusion/fn-093", - base: "main", - }); - expect(store.updatePrInfo).toHaveBeenCalledWith( - "FN-093", - expect.objectContaining({ number: 42, status: "open" }), - ); - expect(store.updateTask).toHaveBeenCalledWith("FN-093", { status: "awaiting-pr-checks" }); - expect(mockExecFileSync).toHaveBeenCalledWith( - "git", - ["remote", "get-url", "origin"], - expect.objectContaining({ cwd: "/repo" }), - ); - }); - - it("keeps the production missing-repository error when no project remote is resolvable", async () => { - mockExecFileSync.mockImplementationOnce(() => { - throw new Error("not a git repository"); - }); - const store = makeMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-093", - title: "Task", - description: "Description", - column: "in-review", - paused: false, - log: [], - }); - - await expect(processPullRequestMergeTask(store as any, "/repo", "FN-093", { - findPrForBranch: mockFindPrForBranch, - createPr: mockCreatePr, - getPrMergeStatus: mockGetPrMergeStatus, - mergePr: mockMergePr, - } as any, () => undefined)).rejects.toThrow("processPullRequestMergeTask: could not determine repository"); - - expect(mockCreatePr).not.toHaveBeenCalled(); - expect(mockGetPrMergeStatus).not.toHaveBeenCalled(); - }); - - it("links an existing PR instead of creating a duplicate", async () => { - const store = makeMockStore(); - const existingPr = { - url: "https://github.com/owner/repo/pull/7", - number: 7, - status: "open" as const, - title: "Existing PR", - headBranch: "fusion/fn-093", - baseBranch: "main", - commentCount: 0, - }; - mockFindPrForBranch.mockResolvedValue(existingPr); - store.getTask.mockResolvedValue({ - id: "FN-093", - title: "Task", - description: "Description", - column: "in-review", - paused: false, - log: [], - }); - - const mockGetTaskMergeBlocker = (task: any) => { - if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`; - if (task.paused) return "task is paused"; - if (task.status === "failed") return "task is marked 'failed'"; - if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) { - return "task has incomplete steps"; - } - if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) { - return "task has incomplete or failed workflow steps"; - } - return undefined; - }; - - await processPullRequestMergeTask(store as any, "/repo", "FN-093", { - findPrForBranch: mockFindPrForBranch, - createPr: mockCreatePr, - getPrMergeStatus: mockGetPrMergeStatus, - mergePr: mockMergePr, - } as any, mockGetTaskMergeBlocker); - - expect(mockCreatePr).not.toHaveBeenCalled(); - expect(mockGetPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 7); - expect(store.logEntry).toHaveBeenCalledWith( - "FN-093", - "Linked existing PR", - "PR #7: https://github.com/owner/repo/pull/7", - ); - }); - - it("merges a ready PR and finalizes task cleanup", async () => { - const store = makeMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-093", - title: "Task", - description: "Description", - column: "in-review", - paused: false, - worktree: "/tmp/kb-093", - prInfo: { - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "open", - title: "Task", - headBranch: "fusion/fn-093", - baseBranch: "main", - commentCount: 0, - }, - log: [], - }); - mockGetPrMergeStatus.mockResolvedValue({ - prInfo: { - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "open", - title: "Task", - headBranch: "fusion/fn-093", - baseBranch: "main", - commentCount: 0, - }, - reviewDecision: "APPROVED", - checks: [{ name: "ci", required: true, state: "success" }], - mergeReady: true, - blockingReasons: [], - }); - - const mockGetTaskMergeBlocker = (task: any) => { - if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`; - if (task.paused) return "task is paused"; - if (task.status === "failed") return "task is marked 'failed'"; - if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) { - return "task has incomplete steps"; - } - if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) { - return "task has incomplete or failed workflow steps"; - } - return undefined; - }; - - const result = await processPullRequestMergeTask(store as any, "/repo", "FN-093", { - findPrForBranch: mockFindPrForBranch, - createPr: mockCreatePr, - getPrMergeStatus: mockGetPrMergeStatus, - mergePr: mockMergePr, - } as any, mockGetTaskMergeBlocker); - - expect(result).toBe("merged"); - expect(mockGetPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 42); - expect(mockMergePr).toHaveBeenCalledWith({ number: 42, method: "squash" }); - expect(store.moveTask).toHaveBeenCalledWith("FN-093", "done"); - // Check that exec was called with the expected commands (options object and callback may follow) - expect(mockExec.mock.calls.some((call) => call[0] === 'git worktree remove "/tmp/kb-093" --force')).toBe(true); - expect(mockExec.mock.calls.some((call) => call[0] === 'git branch -d "fusion/fn-093"')).toBe(true); - }); - - it("does not merge when required checks or reviews are blocking", async () => { - const store = makeMockStore(); - store.getTask.mockResolvedValue({ - id: "FN-093", - title: "Task", - description: "Description", - column: "in-review", - paused: false, - prInfo: { - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "open", - title: "Task", - headBranch: "fusion/fn-093", - baseBranch: "main", - commentCount: 0, - }, - log: [], - }); - mockGetPrMergeStatus.mockResolvedValue({ - prInfo: { - url: "https://github.com/owner/repo/pull/42", - number: 42, - status: "open", - title: "Task", - headBranch: "fusion/fn-093", - baseBranch: "main", - commentCount: 0, - }, - reviewDecision: "CHANGES_REQUESTED", - checks: [{ name: "ci", required: true, state: "pending" }], - mergeReady: false, - blockingReasons: ["changes requested review is active", "required checks not successful: ci (pending)"], - }); - - const mockGetTaskMergeBlocker = (task: any) => { - if (task.column !== "in-review") return `task is in '${task.column}', must be in 'in-review'`; - if (task.paused) return "task is paused"; - if (task.status === "failed") return "task is marked 'failed'"; - if (task.steps?.some((step: any) => step.status === "pending" || step.status === "in-progress")) { - return "task has incomplete steps"; - } - if (task.workflowStepResults?.some((result: any) => result.status === "pending" || result.status === "failed")) { - return "task has incomplete or failed workflow steps"; - } - return undefined; - }; - - const result = await processPullRequestMergeTask(store as any, "/repo", "FN-093", { - findPrForBranch: mockFindPrForBranch, - createPr: mockCreatePr, - getPrMergeStatus: mockGetPrMergeStatus, - mergePr: mockMergePr, - } as any, mockGetTaskMergeBlocker); - - expect(result).toBe("waiting"); - expect(mockGetPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 42); - expect(mockMergePr).not.toHaveBeenCalled(); - expect(store.moveTask).not.toHaveBeenCalled(); - expect(store.updateTask).toHaveBeenCalledWith("FN-093", { status: "awaiting-pr-checks" }); - }); -}); - -describe("runDashboard — PR-first auto-merge queue", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - mergeStrategy: "pull-request", - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-093", column: "in-review", paused: false }, - ]); - mockStore.getTask.mockResolvedValue({ - id: "FN-093", - title: "Task", - description: "Description", - column: "in-review", - paused: false, - log: [], - }); - - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - }); - - it("uses PR lifecycle instead of aiMergeTask when mergeStrategy is pull-request", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - - await runDashboard(0, { open: false }); - await waitForAsyncExpectation(() => { - expect(mockCreatePr).toHaveBeenCalledWith({ - title: "FN-093: Task", - body: "Automated PR for FN-093.\n\nDescription", - head: "fusion/fn-093", - base: "main", - }); - }); - - expect(mockCreatePr).toHaveBeenCalledWith({ - title: "FN-093: Task", - body: "Automated PR for FN-093.\n\nDescription", - head: "fusion/fn-093", - base: "main", - }); - expect(mockExecFileSync).toHaveBeenCalledWith( - "git", - ["remote", "get-url", "origin"], - expect.objectContaining({ cwd: process.cwd() }), - ); - expect(aiMergeTask).not.toHaveBeenCalled(); - }); - - it("manual onMerge still uses PR lifecycle when autoMerge is disabled", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - const { createServer } = await import("@fusion/dashboard"); - - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: false, - mergeStrategy: "pull-request", - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - - await runDashboard(0, { open: false, noEngine: true }); - - const createServerCall = (createServer as ReturnType).mock.calls[0]; - const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise }; - await serverOpts.onMerge("FN-093"); - - expect(mockCreatePr).toHaveBeenCalledWith({ - title: "FN-093: Task", - body: "Automated PR for FN-093.\n\nDescription", - head: "fusion/fn-093", - base: "main", - }); - expect(mockExecFileSync).toHaveBeenCalledWith( - "git", - ["remote", "get-url", "origin"], - expect.objectContaining({ cwd: process.cwd() }), - ); - expect(aiMergeTask).not.toHaveBeenCalled(); - }); -}); - -describe("runDashboard — WorktreePool wiring", () => { - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - // Re-set TaskStore mock (clearAllMocks wipes implementations) - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => makeMockStore()); - // Re-set engine mocks - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - }); - - it("passes a WorktreePool instance to TaskExecutor", async () => { - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(capturedExecutorOpts).toBeDefined(); - expect(capturedExecutorOpts!.pool).toBeInstanceOf(WorktreePool); - }); - }); - - it("passes a WorktreePool instance to aiMergeTask via rawMerge", async () => { - const { aiMergeTask, ProjectEngineManager } = await import("@fusion/engine"); - - await runDashboard(0, { open: false }); - - const managerInstance = (ProjectEngineManager as unknown as ReturnType).mock.results[0]?.value; - await waitForAsyncExpectation(() => { - expect(managerInstance?.getEngine("project-1")).toBeDefined(); - }); - - await managerInstance.getEngine("project-1").onMerge("FN-TEST"); - - await waitForAsyncExpectation(() => { - expect(aiMergeTask).toHaveBeenCalled(); - }); - const mergeCallOpts = (aiMergeTask as ReturnType).mock.calls[0][3]; - expect(mergeCallOpts.pool).toBeInstanceOf(WorktreePool); - }); - - it("shares the same WorktreePool instance between executor and merger", async () => { - const { aiMergeTask, ProjectEngineManager } = await import("@fusion/engine"); - - await runDashboard(0, { open: false }); - - const managerInstance = (ProjectEngineManager as unknown as ReturnType).mock.results[0]?.value; - await waitForAsyncExpectation(() => { - expect(capturedExecutorOpts).toBeDefined(); - expect(managerInstance?.getEngine("project-1")).toBeDefined(); - }); - - await managerInstance.getEngine("project-1").onMerge("FN-TEST"); - - await waitForAsyncExpectation(() => { - expect(capturedExecutorOpts).toBeDefined(); - expect((aiMergeTask as ReturnType).mock.calls[0]?.[3]?.pool).toBeDefined(); - }); - - const executorPool = capturedExecutorOpts!.pool; - const mergerPool = (aiMergeTask as ReturnType).mock.calls[0][3].pool; - - expect(executorPool).toBeInstanceOf(WorktreePool); - expect(mergerPool).toBeInstanceOf(WorktreePool); - expect(executorPool).toBe(mergerPool); - }); -}); - -describe("runDashboard — auto-merge pause exclusion", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - capturedSelfHealingOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - }); - - it("does not enqueue paused in-review tasks for auto-merge on task:moved", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - }); - - await runDashboard(0, { open: false }); - - const { aiMergeTask } = await import("@fusion/engine"); - - // Emit task:moved with a paused task - mockStore.emit("task:moved", { - task: { id: "FN-PAUSED", column: "in-review", paused: true }, - from: "in-progress", - to: "in-review", - }); - - await Promise.resolve(); - - expect(aiMergeTask).not.toHaveBeenCalled(); - }); - - it("does not enqueue paused in-review tasks during startup sweep", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - }); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-PAUSED", column: "in-review", paused: true }, - { id: "FN-ACTIVE", column: "in-review", paused: false }, - ]); - - const { aiMergeTask } = await import("@fusion/engine"); - // Reset after import - (aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(aiMergeTask).toHaveBeenCalled(); - }); - - // Only the non-paused task should be enqueued - const mergedIds = (aiMergeTask as ReturnType).mock.calls.map( - (call: any[]) => call[2], - ); - expect(mergedIds).not.toContain("FN-PAUSED"); - }); - - it("does not auto-merge failed in-review tasks", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - }); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-FAILED", column: "in-review", paused: false, status: "failed" }, - ]); - mockStore.getTask = vi.fn().mockResolvedValue({ - id: "FN-FAILED", - column: "in-review", - paused: false, - status: "failed", - steps: [{ name: "Step 1", status: "done" }], - }); - - const { aiMergeTask } = await import("@fusion/engine"); - (aiMergeTask as ReturnType).mockClear(); - - await runDashboard(0, { open: false }); - await Promise.resolve(); - - expect(aiMergeTask).not.toHaveBeenCalled(); - }); - - it("does not auto-merge in-review tasks with exhausted merge retries", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - }); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-EXHAUSTED", column: "in-review", paused: false, mergeRetries: 3 }, - ]); - - const { aiMergeTask } = await import("@fusion/engine"); - (aiMergeTask as ReturnType).mockClear(); - - await runDashboard(0, { open: false }); - await Promise.resolve(); - - expect(aiMergeTask).not.toHaveBeenCalled(); - }); - - it("auto-heals stale exit-0 verification buffer failures with exhausted merge retries", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - }); - const task = { - id: "FN-BUFFER", - column: "in-review", - paused: false, - mergeRetries: 3, - error: "Deterministic test verification failed for FN-BUFFER", - steps: [{ name: "Step 1", status: "done" }], - log: [ - { - timestamp: "2026-04-10T20:23:18.691Z", - action: "[verification] test command failed (exit 0): stdout maxBuffer length exceeded", - }, - ], - }; - mockStore.listTasks.mockResolvedValue([task]); - mockStore.getTask = vi.fn().mockResolvedValue(task); - - const { aiMergeTask } = await import("@fusion/engine"); - (aiMergeTask as ReturnType).mockClear(); - - await runDashboard(0, { open: false }); - await waitForAsyncExpectation(() => { - expect(mockStore.logEntry).toHaveBeenCalledWith( - "FN-BUFFER", - "Auto-healing stale deterministic verification buffer failure; retrying merge verification", - ); - }); - - expect(mockStore.logEntry).toHaveBeenCalledWith( - "FN-BUFFER", - "Auto-healing stale deterministic verification buffer failure; retrying merge verification", - ); - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-BUFFER", - { mergeRetries: 0, error: null, status: null }, - ); - expect(aiMergeTask).toHaveBeenCalled(); - }); - - it("does not auto-merge in-review tasks with incomplete steps", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - }); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-INCOMPLETE", column: "in-review", paused: false, steps: [{ name: "Step 1", status: "in-progress" }] }, - ]); - mockStore.getTask = vi.fn().mockResolvedValue({ - id: "FN-INCOMPLETE", - column: "in-review", - paused: false, - steps: [{ name: "Step 1", status: "in-progress" }], - }); - - const { aiMergeTask } = await import("@fusion/engine"); - (aiMergeTask as ReturnType).mockClear(); - - await runDashboard(0, { open: false }); - await Promise.resolve(); - - expect(aiMergeTask).not.toHaveBeenCalled(); - }); -}); - -describe("runDashboard — immediate resume on unpause", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - }); - - it("registers a settings:updated listener on the store", async () => { - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - const settingsUpdatedCalls = mockStore.on.mock.calls.filter( - (call: any[]) => call[0] === "settings:updated", - ); - expect(settingsUpdatedCalls.length).toBeGreaterThanOrEqual(2); - }); - }); - - it("calls executor.resumeOrphaned() when globalPause transitions true → false", async () => { - const { TaskExecutor } = await import("@fusion/engine"); - const resumeOrphaned = vi.fn().mockResolvedValue(undefined); - (TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned }; - }, - ); - - await runDashboard(0, { open: false }); - - // Clear the startup call to resumeOrphaned - resumeOrphaned.mockClear(); - - // Trigger unpause event - mockStore.emit("settings:updated", { - settings: { globalPause: false, maxConcurrent: 1, autoMerge: false }, - previous: { globalPause: true }, - }); - - await waitForAsyncExpectation(() => { - expect(resumeOrphaned).toHaveBeenCalled(); - }); - }); - - it("passes executor recovery callbacks into SelfHealingManager", async () => { - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(capturedSelfHealingOpts).toMatchObject({ - rootDir: process.cwd(), - recoverCompletedTask: expect.any(Function), - getExecutingTaskIds: expect.any(Function), - }); - expect(mockSelfHealingStart).toHaveBeenCalled(); - }); - }); - - it("sweeps merge queue on unpause when autoMerge is enabled", async () => { - const currentSettings = { - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: false, - pollIntervalMs: 60_000, - globalPause: false, - }; - mockStore.getSettings.mockImplementation(async () => ({ ...currentSettings })); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-MQ1", column: "in-review", paused: false }, - { id: "FN-MQ2", column: "in-review", paused: false }, - ]); - // getTask is called inside drainMergeQueue to verify the task - mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({ - id, - column: "in-review", - paused: false, - })); - - const { aiMergeTask } = await import("@fusion/engine"); - (aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - - await runDashboard(0, { open: false }); - - (aiMergeTask as ReturnType).mockClear(); - currentSettings.autoMerge = true; - - mockStore.emit("settings:updated", { - settings: { globalPause: false, maxConcurrent: 1, autoMerge: true }, - previous: { globalPause: true }, - }); - - await waitForAsyncExpectation(() => { - expect(aiMergeTask).toHaveBeenCalled(); - }); - - const mergedIds = (aiMergeTask as ReturnType).mock.calls.map( - (call: any[]) => call[2], - ); - expect(mergedIds).toContain("FN-MQ1"); - }, 10_000); -}); - -describe("runDashboard — engine pause/unpause cycle", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - }); - - it("calls executor.resumeOrphaned() when enginePaused transitions true → false", async () => { - const { TaskExecutor } = await import("@fusion/engine"); - const resumeOrphaned = vi.fn().mockResolvedValue(undefined); - (TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned }; - }, - ); - - await runDashboard(0, { open: false }); - - // Clear the startup call to resumeOrphaned - resumeOrphaned.mockClear(); - - // Trigger engine unpause event - mockStore.emit("settings:updated", { - settings: { enginePaused: false, maxConcurrent: 1, autoMerge: false }, - previous: { enginePaused: true }, - }); - - await waitForAsyncExpectation(() => { - expect(resumeOrphaned).toHaveBeenCalled(); - }); - }); -}); - -describe("runDashboard — stuck task timeout listener guards", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - }); - - it("catches and logs checkNow errors when taskStuckTimeoutMs changes", async () => { - const detectorError = new Error("detector exploded"); - mockStuckCheckNow.mockRejectedValueOnce(detectorError); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const unhandledRejectionSpy = vi.fn(); - process.on("unhandledRejection", unhandledRejectionSpy); - - try { - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - const settingsUpdatedCalls = mockStore.on.mock.calls.filter( - (call: any[]) => call[0] === "settings:updated", - ); - expect(settingsUpdatedCalls.length).toBeGreaterThanOrEqual(1); - }); - - mockStore.emit("settings:updated", { - settings: { taskStuckTimeoutMs: 600_000 }, - previous: { taskStuckTimeoutMs: 1_200_000 }, - }); - - await waitForAsyncExpectation(() => { - expect(mockStuckCheckNow).toHaveBeenCalledTimes(1); - }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[stuck-detector] Error during immediate stuck-task check:", - detectorError, - ); - expect(unhandledRejectionSpy).not.toHaveBeenCalled(); - } finally { - process.off("unhandledRejection", unhandledRejectionSpy); - consoleErrorSpy.mockRestore(); - } - }); -}); - -describe("runDashboard — port fallback on EADDRINUSE", () => { - let consoleSpy: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - resetGitHubMocks(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => makeMockStore()); - const engine = await import("@fusion/engine"); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - () => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }), - ); - consoleSpy = vi.spyOn(console, "log"); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("listens on the requested port when available", async () => { - await runDashboard(4040, { open: false }); - - await waitForAsyncExpectation(() => { - expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1"); - }); - - // mockListen should have been called with the requested port bound to localhost by default. - expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1"); - - // Banner should show the resolved localhost URL from the bound server. - await waitForAsyncExpectation(() => { - expect( - consoleSpy.mock.calls.some( - (call) => typeof call[0] === "string" && call[0].includes("http://localhost:"), - ), - ).toBe(true); - }); - - // No warning should be printed - const warningCalls = consoleSpy.mock.calls.filter( - (args) => typeof args[0] === "string" && args[0].includes("Port 4040 in use"), - ); - expect(warningCalls).toHaveLength(0); - }); - - it("falls back to a random port on EADDRINUSE", async () => { - const fallbackPort = 54321; - const serverEmitter = new EventEmitter(); - - // Mock the server's own listen method (used for the retry with port 0) - const mockServerListen = vi.fn((_port?: number) => { - process.nextTick(() => serverEmitter.emit("listening")); - return serverEmitter; - }); - - Object.assign(serverEmitter, { - listen: mockServerListen, - address: vi.fn(() => ({ port: fallbackPort, family: "IPv4", address: "127.0.0.1" })), - close: vi.fn(), - }); - - // Override mockListen for one call: simulate EADDRINUSE - mockListen.mockImplementationOnce(((_port: number) => { - process.nextTick(() => { - const err = new Error("listen EADDRINUSE: address already in use") as NodeJS.ErrnoException; - err.code = "EADDRINUSE"; - serverEmitter.emit("error", err); - }); - return serverEmitter; - }) as any); - - await runDashboard(4040, { open: false }); - - await waitForAsyncExpectation(() => { - expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1"); - }); - - // Server should have retried with port 0, still bound to localhost. - expect(mockServerListen).toHaveBeenCalledWith(0, "127.0.0.1"); - - // Banner should show the fallback port, not the requested port - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining(`http://localhost:${fallbackPort}`), - ); - }); - - it("prints a warning when port fallback occurs", async () => { - const fallbackPort = 12345; - const serverEmitter = new EventEmitter(); - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const mockServerListen = vi.fn((_port?: number) => { - process.nextTick(() => serverEmitter.emit("listening")); - return serverEmitter; - }); - - Object.assign(serverEmitter, { - listen: mockServerListen, - address: vi.fn(() => ({ port: fallbackPort, family: "IPv4", address: "127.0.0.1" })), - close: vi.fn(), - }); - - mockListen.mockImplementationOnce(((_port: number) => { - process.nextTick(() => { - const err = new Error("listen EADDRINUSE: address already in use") as NodeJS.ErrnoException; - err.code = "EADDRINUSE"; - serverEmitter.emit("error", err); - }); - return serverEmitter; - }) as any); - - await runDashboard(4040, { open: false }); - - await waitForAsyncExpectation(() => { - expect(consoleWarnSpy).toHaveBeenCalledWith( - `[dashboard] Port 4040 in use, using ${fallbackPort} instead`, - ); - }); - - // Should print warning with both the requested and actual ports - expect(consoleWarnSpy).toHaveBeenCalledWith( - `[dashboard] Port 4040 in use, using ${fallbackPort} instead`, - ); - consoleWarnSpy.mockRestore(); - }); -}); - -describe("runDashboard — enginePaused (soft pause)", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - }); - - it("does not enqueue tasks for auto-merge when enginePaused on task:moved", async () => { - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - pollIntervalMs: 60_000, - enginePaused: true, - }); - - await runDashboard(0, { open: false }); - - const { aiMergeTask } = await import("@fusion/engine"); - - // Emit task:moved - mockStore.emit("task:moved", { - task: { id: "FN-EP1", column: "in-review", paused: false }, - from: "in-progress", - to: "in-review", - }); - - await Promise.resolve(); - - expect(aiMergeTask).not.toHaveBeenCalled(); - }); - - it("calls executor.resumeOrphaned() when enginePaused transitions true → false", async () => { - const { TaskExecutor } = await import("@fusion/engine"); - const resumeOrphaned = vi.fn().mockResolvedValue(undefined); - (TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned }; - }, - ); - - await runDashboard(0, { open: false }); - - // Clear the startup call - resumeOrphaned.mockClear(); - - // Trigger engine unpause event - mockStore.emit("settings:updated", { - settings: { enginePaused: false, maxConcurrent: 1, autoMerge: false }, - previous: { enginePaused: true }, - }); - - await waitForAsyncExpectation(() => { - expect(resumeOrphaned).toHaveBeenCalled(); - }); - }); - - it("sweeps merge queue on engine unpause when autoMerge is enabled", async () => { - const currentSettings = { - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: false, - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }; - mockStore.getSettings.mockImplementation(async () => ({ ...currentSettings })); - mockStore.listTasks.mockResolvedValue([ - { id: "FN-EP2", column: "in-review", paused: false }, - ]); - mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({ - id, - column: "in-review", - paused: false, - })); - - const { aiMergeTask } = await import("@fusion/engine"); - (aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - - await runDashboard(0, { open: false }); - - (aiMergeTask as ReturnType).mockClear(); - currentSettings.autoMerge = true; - - mockStore.emit("settings:updated", { - settings: { enginePaused: false, maxConcurrent: 1, autoMerge: true }, - previous: { enginePaused: true }, - }); - - await waitForAsyncExpectation(() => { - expect(aiMergeTask).toHaveBeenCalled(); - }); - - const mergedIds = (aiMergeTask as ReturnType).mock.calls.map( - (call: any[]) => call[2], - ); - expect(mergedIds).toContain("FN-EP2"); - }); -}); - -describe("runDashboard — --paused flag", () => { - let mockStore: ReturnType; - let consoleSpy: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => { - await runDashboard(0, { open: false, paused: true }); - - expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true }); - }); - - it("logs a message when starting in paused mode", async () => { - await runDashboard(0, { open: false, paused: true }); - - expect(consoleSpy).toHaveBeenCalledWith( - "[engine] Starting in paused mode — automation disabled", - ); - }); - - it("does NOT set enginePaused when paused option is absent", async () => { - await runDashboard(0, { open: false }); - - // updateSettings should not be called with enginePaused during normal startup - const enginePausedCalls = mockStore.updateSettings.mock.calls.filter( - (call: any[]) => call[0]?.enginePaused !== undefined, - ); - expect(enginePausedCalls).toHaveLength(0); - }); - - it("does NOT log paused message when starting normally", async () => { - await runDashboard(0, { open: false }); - - const pausedMessageCalls = consoleSpy.mock.calls.filter( - (args) => args[0] === "[engine] Starting in paused mode — automation disabled", - ); - expect(pausedMessageCalls).toHaveLength(0); - }); -}); - -describe("runDashboard — --paused flag", () => { - let mockStore: ReturnType; - let consoleSpy: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - () => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }), - ); - consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => { - await runDashboard(0, { open: false, paused: true }); - - expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true }); - expect(mockStore.updateSettings).toHaveBeenCalledTimes(1); - }); - - it("does NOT call store.updateSettings when paused flag is absent", async () => { - await runDashboard(0, { open: false }); - - expect(mockStore.updateSettings).not.toHaveBeenCalled(); - }); - - it("logs paused mode message when starting with paused: true", async () => { - await runDashboard(0, { open: false, paused: true }); - - expect(consoleSpy).toHaveBeenCalledWith( - "[engine] Starting in paused mode — automation disabled", - ); - }); - - it("does NOT log paused mode message when paused flag is absent", async () => { - await runDashboard(0, { open: false }); - - const pausedMessageCalls = consoleSpy.mock.calls.filter( - (args) => typeof args[0] === "string" && args[0].includes("paused mode"), - ); - expect(pausedMessageCalls).toHaveLength(0); - }); -}); - -describe("runDashboard — --no-engine mode", () => { - let mockStore: ReturnType; - let consoleSpy: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("does NOT start TriageProcessor in no-engine mode", async () => { - const { TriageProcessor } = await import("@fusion/engine"); - await runDashboard(0, { open: false, noEngine: true }); - expect(TriageProcessor).not.toHaveBeenCalled(); - }); - - it("does NOT start TaskExecutor in no-engine mode", async () => { - const { TaskExecutor } = await import("@fusion/engine"); - await runDashboard(0, { open: false, noEngine: true }); - expect(TaskExecutor).not.toHaveBeenCalled(); - }); - - it("does NOT start Scheduler in no-engine mode", async () => { - const { Scheduler } = await import("@fusion/engine"); - await runDashboard(0, { open: false, noEngine: true }); - expect(Scheduler).not.toHaveBeenCalled(); - }); - - it("starts the server correctly in no-engine mode", async () => { - const { createServer } = await import("@fusion/dashboard"); - await runDashboard(4040, { open: false, noEngine: true }); - - await waitForAsyncExpectation(() => { - expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1"); - }); - - // Server should have been created and listen called (localhost default) - expect(createServer).toHaveBeenCalled(); - expect(mockListen).toHaveBeenCalledWith(4040, "127.0.0.1"); - - // Banner should show the resolved localhost URL from the bound server. - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("http://localhost:"), - ); - }); - - it("shows 'AI engine: disabled (--no-engine)' in no-engine mode", async () => { - await runDashboard(0, { open: false, noEngine: true }); - - await waitForAsyncExpectation(() => { - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("✗ disabled (--no-engine)"), - ); - }); - - // Should show disabled message - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("✗ disabled (--no-engine)"), - ); - }); - - it("does NOT show triage/scheduler details in no-engine mode", async () => { - await runDashboard(0, { open: false, noEngine: true }); - - await Promise.resolve(); - - // Should NOT show triage/scheduler details - const triageCall = consoleSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("• triage"), - ); - const schedulerCall = consoleSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("• scheduler"), - ); - expect(triageCall).toBeUndefined(); - expect(schedulerCall).toBeUndefined(); - }); - - it("starts all engine components by default", async () => { - const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine"); - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(TriageProcessor).toHaveBeenCalled(); - expect(TaskExecutor).toHaveBeenCalled(); - expect(Scheduler).toHaveBeenCalled(); - }); - }); - - it("starts all engine components in dev mode unless noEngine is passed", async () => { - const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine"); - await runDashboard(0, { open: false, dev: true }); - - await waitForAsyncExpectation(() => { - expect(TriageProcessor).toHaveBeenCalled(); - expect(TaskExecutor).toHaveBeenCalled(); - expect(Scheduler).toHaveBeenCalled(); - }); - }); - - it("does not start engine components when dev mode is launched with noEngine", async () => { - const { TriageProcessor, TaskExecutor, Scheduler } = await import("@fusion/engine"); - await runDashboard(0, { open: false, dev: true, noEngine: true }); - - expect(TriageProcessor).not.toHaveBeenCalled(); - expect(TaskExecutor).not.toHaveBeenCalled(); - expect(Scheduler).not.toHaveBeenCalled(); - }); - - it("shows 'AI engine: ✓ active' when not in dev mode", async () => { - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("✓ active"), - ); - }); - - // Should show active message - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining("✓ active"), - ); - }); -}); - -describe("runDashboard — plugin auto-load", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - }); - - it("auto-loads installed plugins during startup", async () => { - const { PluginLoader } = await import("@fusion/core"); - - await runDashboard(0, { open: false }); - - const loaderInstance = (PluginLoader as unknown as ReturnType).mock.results[0]?.value as - | { loadAllPlugins: ReturnType } - | undefined; - expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1); - }); - - it("continues startup when plugin auto-load fails", async () => { - const { PluginLoader } = await import("@fusion/core"); - (PluginLoader as unknown as ReturnType).mockImplementationOnce(() => { - const emitter = new EventEmitter(); - return { - loadPlugin: vi.fn().mockResolvedValue(undefined), - loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")), - stopPlugin: vi.fn().mockResolvedValue(undefined), - reloadPlugin: vi.fn().mockResolvedValue(undefined), - getPluginRoutes: vi.fn().mockReturnValue([]), - getPlugin: vi.fn(), - getLoadedPlugins: vi.fn().mockReturnValue([]), - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.on(event, handler); - }), - off: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - emitter.off(event, handler); - }), - emit: emitter.emit.bind(emitter), - }; - }); - - await expect(runDashboard(0, { open: false })).resolves.toBeDefined(); - }); -}); - -describe("runDashboard — merge conflict retry logic", () => { - let mockStore: ReturnType; - let consoleSpy: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - - // Default mock store.getTask implementation - mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({ - id, - column: "in-review", - paused: false, - mergeRetries: 0, - })); - - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - it("increments mergeRetries and re-enqueues on conflict error", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - - // Simulate merge failure with conflict - (aiMergeTask as ReturnType).mockRejectedValue( - new Error("Merge conflict detected in package-lock.json"), - ); - - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - autoResolveConflicts: true, - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - - mockStore.listTasks.mockResolvedValue([ - { id: "FN-RETRY", column: "in-review", paused: false }, - ]); - - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-RETRY", - expect.objectContaining({ mergeRetries: 1 }), - ); - }); - - // Should have incremented mergeRetries - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-RETRY", - expect.objectContaining({ mergeRetries: 1 }), - ); - - // Should log retry attempt - const retryLog = consoleSpy.mock.calls.find( - (call) => typeof call[0] === "string" && call[0].includes("retry 1/3"), - ); - expect(retryLog).toBeDefined(); - }); - - it("gives up after max retries (3) exceeded", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - - (aiMergeTask as ReturnType).mockRejectedValue( - new Error("Merge conflict detected"), - ); - - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - autoResolveConflicts: true, - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - - // Task already has 3 retries - mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({ - id, - column: "in-review", - paused: false, - mergeRetries: 3, - })); - - mockStore.listTasks.mockResolvedValue([ - { id: "FN-MAX", column: "in-review", paused: false, mergeRetries: 3 }, - ]); - - await runDashboard(0, { open: false }); - - await Promise.resolve(); - - // Exhausted tasks are skipped before enqueue, so they should not be merged again. - expect(aiMergeTask).not.toHaveBeenCalled(); - expect(mockStore.updateTask).not.toHaveBeenCalledWith( - "FN-MAX", - expect.objectContaining({ mergeRetries: expect.anything() }), - ); - }); - - it("skips retry when autoResolveConflicts is disabled", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - - (aiMergeTask as ReturnType).mockRejectedValue( - new Error("Merge conflict detected"), - ); - - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - autoResolveConflicts: false, // Disabled - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - - mockStore.listTasks.mockResolvedValue([ - { id: "FN-NO-AUTO", column: "in-review", paused: false }, - ]); - - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - const disabledLog = consoleSpy.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("autoResolveConflicts disabled"), - ); - expect(disabledLog).toBeDefined(); - }); - - // Should log that auto-resolve is disabled - const disabledLog = consoleSpy.mock.calls.find( - (call) => - typeof call[0] === "string" && - call[0].includes("autoResolveConflicts disabled"), - ); - expect(disabledLog).toBeDefined(); - }); - - it("clears mergeRetries on successful merge after retries", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - - (aiMergeTask as ReturnType).mockResolvedValue({ merged: true }); - - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - autoResolveConflicts: true, - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - - // Task had previous retries - mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({ - id, - column: "in-review", - paused: false, - mergeRetries: 2, - })); - - mockStore.listTasks.mockResolvedValue([ - { id: "FN-SUCCESS", column: "in-review", paused: false, mergeRetries: 2 }, - ]); - - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-SUCCESS", - expect.objectContaining({ mergeRetries: 0 }), - ); - }); - - // Should clear mergeRetries on success - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-SUCCESS", - expect.objectContaining({ mergeRetries: 0 }), - ); - }); - - it("marks non-conflict merge failures as exhausted so auto-merge stops retrying", async () => { - const { aiMergeTask } = await import("@fusion/engine"); - - (aiMergeTask as ReturnType).mockRejectedValue( - new Error("Build verification failed for FN-BUILD: Dependency sync failed"), - ); - - mockStore.getSettings.mockResolvedValue({ - maxConcurrent: 1, - maxWorktrees: 2, - autoMerge: true, - autoResolveConflicts: true, - pollIntervalMs: 60_000, - enginePaused: false, - globalPause: false, - }); - - mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({ - id, - column: "in-review", - paused: false, - mergeRetries: 0, - })); - - mockStore.listTasks.mockResolvedValue([ - { id: "FN-BUILD", column: "in-review", paused: false, mergeRetries: 0 }, - ]); - - await runDashboard(0, { open: false }); - await waitForAsyncExpectation(() => { - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-BUILD", - expect.objectContaining({ - status: null, - mergeRetries: 3, - error: "Build verification failed for FN-BUILD: Dependency sync failed", - }), - ); - }); - - expect(mockStore.updateTask).toHaveBeenCalledWith( - "FN-BUILD", - expect.objectContaining({ - status: null, - mergeRetries: 3, - error: "Build verification failed for FN-BUILD: Dependency sync failed", - }), - ); - }); -}); - -describe("runDashboard — PR feedback follow-up wiring", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - capturedExecutorOpts = undefined; - vi.clearAllMocks(); - resetGitHubMocks(); - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - const engine = await import("@fusion/engine"); - (engine.aiMergeTask as ReturnType).mockImplementation(() => - Promise.resolve({ merged: true }), - ); - (engine.TaskExecutor as unknown as ReturnType).mockImplementation( - (_store: unknown, _cwd: unknown, opts: unknown) => { - capturedExecutorOpts = opts as Record; - return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) }; - }, - ); - }); - - it("wires onClosedPrFeedback callback to PrCommentHandler.createFollowUpTask", async () => { - const { PrMonitor, PrCommentHandler, Scheduler } = await import("@fusion/engine"); - - let capturedOnClosedPrFeedback: ((taskId: string, prInfo: any, comments: any[]) => void) | undefined; - - (Scheduler as unknown as ReturnType).mockImplementation( - (_store: unknown, _opts: unknown) => { - capturedOnClosedPrFeedback = _opts.onClosedPrFeedback; - return { start: vi.fn(), stop: vi.fn() }; - }, - ); - - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(capturedOnClosedPrFeedback).toBeDefined(); - }); - - // Invoke it to verify it reaches createFollowUpTask - const mockPrInfo = { status: "merged", number: 42 }; - const mockComments = [ - { id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" }, - ]; - await capturedOnClosedPrFeedback("FN-001", mockPrInfo, mockComments); - - // The PrCommentHandler mock should have been called - const handlerInstance = (PrCommentHandler as unknown as ReturnType).mock.results.at(-1)?.value; - expect(handlerInstance.createFollowUpTask).toHaveBeenCalledWith("FN-001", mockPrInfo, mockComments); - }); - - it("preserves existing onNewComments steering behavior", async () => { - const { PrMonitor, PrCommentHandler } = await import("@fusion/engine"); - - let capturedOnNewComments: ((taskId: string, prInfo: any, comments: any[]) => void) | undefined; - - (PrMonitor as unknown as ReturnType).mockImplementation(() => ({ - onNewComments: vi.fn((cb: any) => { capturedOnNewComments = cb; }), - startMonitoring: vi.fn(), - stopMonitoring: vi.fn(), - stopAll: vi.fn(), - getTrackedPrs: vi.fn().mockReturnValue(new Map()), - updatePrInfo: vi.fn(), - drainComments: vi.fn().mockReturnValue([]), - })); - - await runDashboard(0, { open: false }); - - await waitForAsyncExpectation(() => { - expect(capturedOnNewComments).toBeDefined(); - }); - const handlerInstance = (PrCommentHandler as unknown as ReturnType).mock.results[0].value; - const mockComments = [ - { id: 1, body: "Fix this", user: { login: "reviewer" }, created_at: "2024-01-01", updated_at: "2024-01-01", html_url: "" }, - ]; - const mockPrInfo = { status: "open", number: 42 }; - await capturedOnNewComments("FN-001", mockPrInfo, mockComments); - expect(handlerInstance.handleNewComments).toHaveBeenCalledWith("FN-001", mockPrInfo, mockComments); - }); -}); - -describe("runDashboard — lifecycle listener cleanup", () => { - let mockStore: ReturnType; - - beforeEach(async () => { - mockStore = makeMockStore(); - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - }); - - it("returns a dispose function", async () => { - const { dispose } = await runDashboard(0, { open: false }); - expect(typeof dispose).toBe("function"); - expect(() => dispose()).not.toThrow(); - }); - - it("engine cleans up its own listeners from the shared store on dispose", async () => { - const { dispose } = await runDashboard(0, { open: false }); - await Promise.resolve(); - - dispose(); - - // With ProjectEngineManager, engine.stop() cleans up settings:updated - // and task:moved listeners from the store. This is correct behavior — - // the engine owns these listeners and removes them on shutdown. - // We just verify dispose() doesn't throw. - }); - - it("dispose is idempotent — calling twice does not throw", async () => { - const { dispose } = await runDashboard(0, { open: false }); - - expect(() => dispose()).not.toThrow(); - expect(() => dispose()).not.toThrow(); - }); - - it("does not accumulate process listeners across repeated invocations", async () => { - const baselineSigint = process.listenerCount("SIGINT"); - const baselineSigterm = process.listenerCount("SIGTERM"); - - for (let i = 0; i < 5; i += 1) { - const { dispose } = await runDashboard(0, { open: false }); - dispose(); - } - - expect(process.listenerCount("SIGINT")).toBe(baselineSigint); - expect(process.listenerCount("SIGTERM")).toBe(baselineSigterm); - }); - - it("does not leak process signal listeners after 12 rapid invocations", async () => { - const { TaskStore } = await import("@fusion/core"); - (TaskStore as unknown as ReturnType).mockImplementation(() => makeMockStore()); - - const baselineSigint = process.listenerCount("SIGINT"); - const baselineSigterm = process.listenerCount("SIGTERM"); - - for (let i = 0; i < 12; i += 1) { - const { dispose } = await runDashboard(0, { open: false }); - dispose(); - } - - await new Promise((resolve) => setImmediate(resolve)); - - expect(process.listenerCount("SIGINT")).toBe(baselineSigint); - expect(process.listenerCount("SIGTERM")).toBe(baselineSigterm); - }); -}); - -describe("runDashboard — mesh lifecycle ownership", () => { - function getNewSignalHandler( - signal: "SIGINT" | "SIGTERM", - baseline: Array<(...args: any[]) => unknown>, - ): () => void { - const added = process.listeners(signal).find((listener) => !baseline.includes(listener as (...args: any[]) => unknown)); - expect(added).toBeDefined(); - return added as () => void; - } - - it("starts peer exchange and discovery after the dashboard binds a port", async () => { - const { CentralCore } = await import("@fusion/core"); - const { PeerExchangeService } = await import("@fusion/engine"); - - const startDiscovery = vi.fn().mockResolvedValue(undefined); - const updateNode = vi.fn().mockResolvedValue(undefined); - - (CentralCore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), - listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]), - updateNode, - startDiscovery, - stopDiscovery: vi.fn(), - })); - - const peerExchangeCtor = PeerExchangeService as unknown as ReturnType; - const baselineCalls = peerExchangeCtor.mock.calls.length; - - const { dispose } = await runDashboard(0, { open: false }); - expect(mockHybridExecutorCtor).not.toHaveBeenCalled(); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(peerExchangeCtor.mock.calls.length).toBeGreaterThan(baselineCalls); - const peerExchangeInstance = peerExchangeCtor.mock.results.at(-1)?.value; - expect(peerExchangeInstance.start).toHaveBeenCalledTimes(1); - expect(peerExchangeInstance.updateGlobalSettings).toHaveBeenCalledTimes(1); - expect(startDiscovery).toHaveBeenCalledWith(expect.objectContaining({ - broadcast: true, - listen: true, - serviceType: "_fusion._tcp", - port: 0, - })); - expect(updateNode).toHaveBeenCalledWith("node-local", { status: "online" }); - - dispose(); - }); - - it("skips automatic discovery when local network discovery is disabled", async () => { - const { CentralCore } = await import("@fusion/core"); - const startDiscovery = vi.fn().mockResolvedValue(undefined); - mockGlobalSettingsGetSettings.mockResolvedValue({ localNetworkDiscoveryEnabled: false }); - - (CentralCore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), - listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]), - updateNode: vi.fn().mockResolvedValue(undefined), - startDiscovery, - stopDiscovery: vi.fn(), - })); - - const { dispose } = await runDashboard(0, { open: false }); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(startDiscovery).not.toHaveBeenCalled(); - dispose(); - }); - - it("stops peer exchange and discovery during shutdown", async () => { - const { CentralCore } = await import("@fusion/core"); - const { PeerExchangeService } = await import("@fusion/engine"); - - const stopDiscovery = vi.fn(); - const updateNode = vi.fn().mockResolvedValue(undefined); - - (CentralCore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), - listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]), - updateNode, - startDiscovery: vi.fn().mockResolvedValue(undefined), - stopDiscovery, - })); - - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - const baselineSigintHandlers = process.listeners("SIGINT"); - - try { - await runDashboard(0, { open: false }); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - const sigintHandler = getNewSignalHandler("SIGINT", baselineSigintHandlers); - sigintHandler(); - - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - const peerExchangeInstance = (PeerExchangeService as unknown as ReturnType).mock.results.at(-1)?.value; - expect(peerExchangeInstance.stop).toHaveBeenCalledTimes(1); - expect(stopDiscovery).toHaveBeenCalledTimes(1); - expect(updateNode).toHaveBeenCalledWith("node-local", { status: "offline" }); - expect(exitSpy).toHaveBeenCalledWith(0); - } finally { - exitSpy.mockRestore(); - } - }); - - it("enables HybridExecutor with env override and shuts down before engine stop", async () => { - const { CentralCore } = await import("@fusion/core"); - - process.env.FUSION_HYBRID_EXECUTOR = "1"; - mockShouldUseHybridExecutor.mockResolvedValue({ enabled: true, reason: "env-override" }); - - (CentralCore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), - listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]), - updateNode: vi.fn().mockResolvedValue(undefined), - startDiscovery: vi.fn().mockResolvedValue(undefined), - stopDiscovery: vi.fn(), - })); - - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - const baselineSigtermHandlers = process.listeners("SIGTERM"); - try { - await runDashboard(0, { open: false }); - expect(mockHybridExecutorCtor).toHaveBeenCalledTimes(1); - expect(mockHybridExecutorInitialize).toHaveBeenCalledTimes(1); - - const sigtermHandler = getNewSignalHandler("SIGTERM", baselineSigtermHandlers); - sigtermHandler(); - await new Promise((resolve) => setImmediate(resolve)); - - expect(mockHybridExecutorShutdown).toHaveBeenCalled(); - const { ProjectEngineManager } = await import("@fusion/engine"); - const managerInstance = (ProjectEngineManager as unknown as ReturnType).mock.results.at(-1)?.value; - expect(mockHybridExecutorShutdown.mock.invocationCallOrder[0]).toBeLessThan( - managerInstance.stopAll.mock.invocationCallOrder[0], - ); - } finally { - delete process.env.FUSION_HYBRID_EXECUTOR; - exitSpy.mockRestore(); - } - }); -}); - -describe("runDashboard — CentralCore cleanup diagnostics", () => { - function getNewSignalHandler( - signal: "SIGINT" | "SIGTERM", - baseline: Array<(...args: any[]) => unknown>, - ): () => void { - const added = process.listeners(signal).find((listener) => !baseline.includes(listener as (...args: any[]) => unknown)); - expect(added).toBeDefined(); - return added as () => void; - } - - async function configureCentralCoreCloseFailure(errorMessage: string): Promise<{ - close: ReturnType; - mockStore: ReturnType; - }> { - const { TaskStore, CentralCore } = await import("@fusion/core"); - const mockStore = makeMockStore(); - const close = vi.fn().mockRejectedValue(new Error(errorMessage)); - - (TaskStore as unknown as ReturnType).mockImplementation(() => mockStore); - (CentralCore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close, - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), - startDiscovery: vi.fn().mockResolvedValue(undefined), - stopDiscovery: vi.fn(), - listNodes: vi.fn().mockResolvedValue([]), - updateNode: vi.fn().mockResolvedValue(undefined), - })); - - return { close, mockStore }; - } - - it("logs non-fatal diagnostics when CentralCore.close fails in dispose cleanup", async () => { - const { close } = await configureCentralCoreCloseFailure("dispose close failed"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const unhandledRejectionSpy = vi.fn(); - process.on("unhandledRejection", unhandledRejectionSpy); - - try { - const { dispose } = await runDashboard(0, { open: false }); - dispose(); - - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(close).toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("[dashboard] CentralCore.close() failed during dispose cleanup: dispose close failed"), - ); - expect(unhandledRejectionSpy).not.toHaveBeenCalled(); - } finally { - process.off("unhandledRejection", unhandledRejectionSpy); - warnSpy.mockRestore(); - } - }); - - it("logs shutdown diagnostics and still closes store + exits when CentralCore.close fails", async () => { - const { close, mockStore } = await configureCentralCoreCloseFailure("normal shutdown close failed"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - const baselineSigintHandlers = process.listeners("SIGINT"); - - try { - await runDashboard(0, { open: false }); - const sigintHandler = getNewSignalHandler("SIGINT", baselineSigintHandlers); - sigintHandler(); - - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(close).toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("[dashboard] CentralCore.close() failed during shutdown (SIGINT): normal shutdown close failed"), - ); - expect(mockStore.close).toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); - } finally { - warnSpy.mockRestore(); - exitSpy.mockRestore(); - } - }); - - it("logs dev shutdown diagnostics and still exits when mesh CentralCore.close fails", async () => { - const { close, mockStore } = await configureCentralCoreCloseFailure("dev shutdown close failed"); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never); - const baselineSigtermHandlers = process.listeners("SIGTERM"); - - try { - await runDashboard(0, { open: false, noEngine: true }); - const sigtermHandler = getNewSignalHandler("SIGTERM", baselineSigtermHandlers); - sigtermHandler(); - - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - expect(close).toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("[dashboard] CentralCore.close() failed during dev shutdown (SIGTERM): dev shutdown close failed"), - ); - expect(mockStore.close).toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalledWith(0); - } finally { - warnSpy.mockRestore(); - exitSpy.mockRestore(); - } - }); -}); - -// ── promptForPort tests ─────────────────────────────────────────────── - -import { promptForPort } from "../dashboard.js"; - -describe("promptForPort", () => { - let mockRl: { - question: ReturnType; - close: ReturnType; - }; - - beforeEach(() => { - mockRl = { - question: vi.fn(), - close: vi.fn(), - }; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns default port on empty input", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - // Simulate user pressing Enter (empty input) - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callback(""); - }); - - const result = await promptForPort(4040); - expect(result).toBe(4040); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("returns valid custom port", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callback("8080"); - }); - - const result = await promptForPort(4040); - expect(result).toBe(8080); - expect(mockRl.close).toHaveBeenCalled(); - }); - - it("re-prompts on invalid (non-numeric) input", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - // First call returns invalid input, second call returns valid - let callCount = 0; - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callCount++; - if (callCount === 1) { - callback("abc"); - } else { - callback("3000"); - } - }); - - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const result = await promptForPort(4040); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("not a number")); - expect(result).toBe(3000); - expect(mockRl.question).toHaveBeenCalledTimes(2); - consoleSpy.mockRestore(); - }); - - it("re-prompts on out-of-range port (too low)", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - let callCount = 0; - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callCount++; - if (callCount === 1) { - callback("0"); - } else { - callback("5000"); - } - }); - - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const result = await promptForPort(4040); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("must be between 1 and 65535")); - expect(result).toBe(5000); - expect(mockRl.question).toHaveBeenCalledTimes(2); - consoleSpy.mockRestore(); - }); - - it("re-prompts on out-of-range port (too high)", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - let callCount = 0; - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callCount++; - if (callCount === 1) { - callback("70000"); - } else { - callback("9000"); - } - }); - - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const result = await promptForPort(4040); - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("must be between 1 and 65535")); - expect(result).toBe(9000); - expect(mockRl.question).toHaveBeenCalledTimes(2); - consoleSpy.mockRestore(); - }); - - it("accepts minimum valid port (1)", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callback("1"); - }); - - const result = await promptForPort(4040); - expect(result).toBe(1); - }); - - it("accepts maximum valid port (65535)", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => { - callback("65535"); - }); - - const result = await promptForPort(4040); - expect(result).toBe(65535); - }); - - it("rejects on SIGINT (Ctrl+C)", async () => { - const { createInterface } = await import("node:readline"); - vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType); - - // Simulate that the promise rejects when SIGINT is triggered - const removeListenerSpy = vi.spyOn(process, "removeListener" as any).mockImplementation(() => process); - - // Trigger SIGINT handler immediately to test rejection - let sigintHandler: (() => void) | null = null; - const onSpy = vi.spyOn(process, "on" as never).mockImplementation(((event: string, handler: (...args: unknown[]) => void) => { - if (event === "SIGINT") { - sigintHandler = handler as () => void; - } - return process; - }) as never); - - mockRl.question.mockImplementation(() => { - // Simulate SIGINT during prompt - setTimeout(() => { - if (sigintHandler) sigintHandler(); - }, 10); - }); - - await expect(promptForPort(4040)).rejects.toThrow("Interactive prompt cancelled"); - - onSpy.mockRestore(); - removeListenerSpy.mockRestore(); - }); -}); - -describe("StreamedLogBuffer", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("coalesces partial chunks and flushes after idle timeout", () => { - vi.useFakeTimers(); - const lines: string[] = []; - const buffer = new StreamedLogBuffer((line) => lines.push(line), 100); - - buffer.push("Hel"); - buffer.push("lo"); - - expect(lines).toEqual([]); - - vi.advanceTimersByTime(100); - expect(lines).toEqual(["Hello"]); - }); - - it("flushes complete newline-delimited lines immediately", () => { - const lines: string[] = []; - const buffer = new StreamedLogBuffer((line) => lines.push(line), 100); - - buffer.push("one\ntwo\n"); - - expect(lines).toEqual(["one", "two"]); - buffer.dispose(); - }); -}); - -describe("runDashboard — merge stream sink routing", () => { - it("routes streamed merge deltas through log sink without raw stdout writes", async () => { - vi.clearAllMocks(); - resetGitHubMocks(); - process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; - const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core"); - const { runAiMerge, createFusionAuthStorage } = await import("@fusion/engine"); - const { createServer } = await import("@fusion/dashboard"); - const { DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@earendil-works/pi-coding-agent"); - - (TaskStore as unknown as ReturnType).mockImplementation(() => makeMockStore()); - (AutomationStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - })); - (AgentStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - listAgents: vi.fn().mockResolvedValue([]), - on: vi.fn(), - off: vi.fn(), - })); - (PluginStore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - off: vi.fn(), - })); - (PluginLoader as unknown as ReturnType).mockImplementation(() => ({ - getPluginRoutes: vi.fn().mockReturnValue([]), - on: vi.fn(), - off: vi.fn(), - })); - (CentralCore as unknown as ReturnType).mockImplementation(() => ({ - init: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }), - listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), - })); - - (createFusionAuthStorage as unknown as ReturnType).mockReturnValue({ - getApiKey: vi.fn().mockResolvedValue(undefined), - getAuth: vi.fn(), - setAuth: vi.fn(), - }); - (DefaultPackageManager as unknown as ReturnType).mockImplementation(() => ({ - resolve: vi.fn().mockResolvedValue({ extensions: [] }), - })); - (ModelRuntime.create as unknown as ReturnType).mockImplementation(() => ({ - registerProvider: vi.fn(), - refresh: vi.fn(), - })); - (discoverAndLoadExtensions as unknown as ReturnType).mockResolvedValue({ - runtime: { pendingProviderRegistrations: [] }, - errors: [], - }); - (createExtensionRuntime as unknown as ReturnType).mockReturnValue({}); - - const stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - // FNXC:MergerUnification 2026-06-24-23:04: UI-only dashboard manual merges stream through runAiMerge's onAgentText callback; the test must assert line-buffered merge logs instead of deprecated aiMergeTask or raw stdout writes. - (runAiMerge as ReturnType).mockImplementationOnce( - async (_store: unknown, _cwd: string, _taskId: string, opts: { onAgentText?: (delta: string) => void }) => { - opts.onAgentText?.("Hel"); - opts.onAgentText?.("lo"); - opts.onAgentText?.("\nWorld"); - opts.onAgentText?.("!\nTail"); - return { merged: true }; - }, - ); - - try { - await runDashboard(0, { open: false, noEngine: true }); - consoleLogSpy.mockClear(); - stdoutWriteSpy.mockClear(); - - const createServerCall = (createServer as ReturnType).mock.calls[0]; - const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise }; - - await serverOpts.onMerge("FN-TEST"); - - expect(runAiMerge).toHaveBeenCalledWith(expect.anything(), expect.any(String), "FN-TEST", expect.objectContaining({ - onAgentText: expect.any(Function), - })); - expect(stdoutWriteSpy).not.toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Hello"); - expect(consoleLogSpy).toHaveBeenCalledWith("[merge] World!"); - expect(consoleLogSpy).toHaveBeenCalledWith("[merge] Tail"); - expect(consoleLogSpy).not.toHaveBeenCalledWith("[merge] H"); - } finally { - stdoutWriteSpy.mockRestore(); - consoleLogSpy.mockRestore(); - delete process.env.FUSION_DASHBOARD_TOKEN; - } - }); -}); - -describe("runDashboard — interactiveData remote wiring", () => { - it("keeps remote endpoint wiring and method names aligned", async () => { - const { readFile } = await import("node:fs/promises"); - const source = await readFile(new URL("../dashboard.ts", import.meta.url), "utf8"); - - expect(source).toContain("getSettings: async () =>"); - expect(source).toContain("getStatus: async () =>"); - expect(source).toContain("activateProvider: async"); - expect(source).toContain("startTunnel: async"); - expect(source).toContain("stopTunnel: async"); - expect(source).toContain("regeneratePersistentToken: async"); - expect(source).toContain("generateShortLivedToken: async"); - expect(source).toContain("getRemoteUrl: async"); - expect(source).toContain("getQrPayload: async"); - - expect(source).toContain("/api/remote/settings"); - expect(source).toContain("/api/remote/status"); - expect(source).toContain("/api/remote/provider/activate"); - expect(source).toContain("/api/remote/tunnel/start"); - expect(source).toContain("/api/remote/tunnel/stop"); - expect(source).toContain("/api/remote/token/persistent/regenerate"); - expect(source).toContain("/api/remote/token/short-lived/generate"); - expect(source).toContain("/api/remote/url?"); - expect(source).toContain("/api/remote/qr?"); - }); -}); - -describe("runDashboard runtime logger wiring", () => { - it("injects a runtime logger into createServer and preserves non-TTY console fallback", async () => { - process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; - const { createServer } = await import("@fusion/dashboard"); - const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - await runDashboard(0, { open: false, dev: true }); - - const createServerCall = (createServer as ReturnType).mock.calls.at(-1)!; - const serverOpts = createServerCall[1] as { runtimeLogger?: { info: (message: string, context?: Record) => void } }; - - expect(serverOpts.runtimeLogger).toBeDefined(); - serverOpts.runtimeLogger?.info("runtime diagnostic", { source: "test" }); - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[dashboard] runtime diagnostic {"source":"test"}', - ); - - consoleLogSpy.mockRestore(); - delete process.env.FUSION_DASHBOARD_TOKEN; - }); - - it("routes runtime logger output through DashboardLogSink in TTY mode", async () => { - process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; - const { createServer } = await import("@fusion/dashboard"); - const { DashboardLogSink, DashboardTUI } = await import("../dashboard-tui/index.js"); - - const originalStdoutIsTTY = process.stdout.isTTY; - const originalStdinIsTTY = process.stdin.isTTY; - - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - - const tuiStartSpy = vi.spyOn(DashboardTUI.prototype, "start").mockResolvedValue(undefined); - const tuiStopSpy = vi.spyOn(DashboardTUI.prototype, "stop").mockResolvedValue(undefined); - const tuiLogSpy = vi.spyOn(DashboardTUI.prototype, "log").mockImplementation(() => {}); - const captureConsoleSpy = vi.spyOn(DashboardLogSink.prototype, "captureConsole").mockImplementation(() => {}); - - try { - await runDashboard(0, { open: false, dev: true }); - - expect(captureConsoleSpy).toHaveBeenCalledTimes(1); - - const createServerCall = (createServer as ReturnType).mock.calls.at(-1)!; - const serverOpts = createServerCall[1] as { runtimeLogger?: { info: (message: string, context?: Record) => void } }; - - expect(serverOpts.runtimeLogger).toBeDefined(); - serverOpts.runtimeLogger?.info("tty runtime diagnostic", { source: "test" }); - expect(tuiLogSpy).toHaveBeenCalledWith('tty runtime diagnostic {"source":"test"}', "dashboard"); - expect(tuiStartSpy).toHaveBeenCalled(); - } finally { - Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true }); - Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true }); - tuiStartSpy.mockRestore(); - tuiStopSpy.mockRestore(); - tuiLogSpy.mockRestore(); - captureConsoleSpy.mockRestore(); - delete process.env.FUSION_DASHBOARD_TOKEN; - } - }); -}); - -describe("runDashboard update check wiring", () => { - it("suppresses stale cached update status in the TUI after the installed CLI version changes", async () => { - process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; - writeUpdateCache({ - updateAvailable: true, - currentVersion: "0.0.1", - latestVersion: "9.9.9", - }); - - const { DashboardTUI } = await import("../dashboard-tui/index.js"); - const originalStdoutIsTTY = process.stdout.isTTY; - const originalStdinIsTTY = process.stdin.isTTY; - - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); - - const tuiStartSpy = vi.spyOn(DashboardTUI.prototype, "start").mockResolvedValue(undefined); - const tuiStopSpy = vi.spyOn(DashboardTUI.prototype, "stop").mockResolvedValue(undefined); - const setUpdateStatusSpy = vi.spyOn(DashboardTUI.prototype, "setUpdateStatus"); - - try { - await runDashboard(0, { open: false, dev: true }); - - expect(setUpdateStatusSpy).toHaveBeenCalledWith(null); - } finally { - Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true }); - Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true }); - tuiStartSpy.mockRestore(); - tuiStopSpy.mockRestore(); - setUpdateStatusSpy.mockRestore(); - delete process.env.FUSION_DASHBOARD_TOKEN; - } - }); -}); - -describe("runDashboardSupervised — bounded restart behavior", () => { - beforeEach(() => { - mockSupervisorSpawn.mockClear(); - supervisorCloseQueue.length = 0; - }); - - it("respawns on the intentional restart exit code (86) then exits cleanly", async () => { - const mod = await import("../dashboard.js"); - const originalArgv = process.argv; - process.argv = [ - originalArgv[0] ?? process.execPath, - "/tmp/fn-entry.mjs", - "dashboard", - "--supervise", - ]; - - // First child exits 86 (System-panel restart request → respawn without - // consuming the crash budget); the respawned child exits 0 (clean stop). - supervisorCloseQueue.push({ code: 86, signal: null }, { code: 0, signal: null }); - - try { - await mod.runDashboardSupervised(0); - } finally { - process.argv = originalArgv; - } - - // Two spawns proves the exit-86 respawn happened and the loop then returned - // cleanly (no crash-budget exhaustion / process.exit). - expect(mockSupervisorSpawn).toHaveBeenCalledTimes(2); - }); - - it("spawns an attached child without the supervision flags and advertises the restart contract", async () => { - const mod = await import("../dashboard.js"); - const originalArgv = process.argv; - process.argv = [ - originalArgv[0] ?? process.execPath, - "/tmp/fn-entry.mjs", - "dashboard", - "--host", - "127.0.0.1", - "--port", - "4040", - "--supervise", - ]; - - try { - await mod.runDashboardSupervised(4040); - } finally { - process.argv = originalArgv; - } - - expect(mockSupervisorSpawn).toHaveBeenCalledWith( - process.execPath, - [...process.execArgv, "/tmp/fn-entry.mjs", "dashboard", "--host", "127.0.0.1", "--port", "4040"], - expect.objectContaining({ - stdio: "inherit", - env: expect.objectContaining({ FUSION_RESTART_SUPERVISED: "1" }), - }), - ); - // Attached child (TUI-safe): the supervisor must NOT detach it into a - // background process group. - const spawnOptions = mockSupervisorSpawn.mock.calls[0]![2] as Record; - expect(spawnOptions.detached).toBeUndefined(); - }); - - it("preserves global flags before the dashboard subcommand without duplicating dashboard", async () => { - const mod = await import("../dashboard.js"); - const originalArgv = process.argv; - process.argv = [ - originalArgv[0] ?? process.execPath, - "/tmp/fn-entry.mjs", - "--project", - "atlas-notes", - "dashboard", - "--port", - "4040", - "--no-supervise", - ]; - - try { - await mod.runDashboardSupervised(4040); - } finally { - process.argv = originalArgv; - } - - expect(mockSupervisorSpawn).toHaveBeenCalledWith( - process.execPath, - [...process.execArgv, "/tmp/fn-entry.mjs", "--project", "atlas-notes", "dashboard", "--port", "4040"], - expect.objectContaining({ - stdio: "inherit", - env: expect.objectContaining({ FUSION_RESTART_SUPERVISED: "1" }), - }), - ); - }); -}); diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts deleted file mode 100644 index 9f07e847c2..0000000000 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { BUNDLED_PLUGIN_IDS } from "../bundled-plugin-install.js"; -import { findStaleBundledPlugins } from "../bundled-plugin-freshness.js"; -import { ALL_STAGED_BUNDLED_IDS } from "../staged-bundled-plugin-ids.js"; - -const older = new Date("2026-01-01T00:00:00.000Z"); -const newer = new Date("2026-01-01T00:01:00.000Z"); - -describe("bundled plugin build freshness", () => { - let tempRoot: string | null = null; - - afterEach(() => { - if (tempRoot) { - rmSync(tempRoot, { recursive: true, force: true }); - tempRoot = null; - } - }); - - function makeTempPluginsRoot(): string { - tempRoot = mkdtempSync(join(tmpdir(), "bundled-plugin-freshness-")); - return tempRoot; - } - - function writePluginFile(pluginsRoot: string, pluginId: string, relativePath: string, content = "// test fixture\n") { - const fullPath = join(pluginsRoot, pluginId, relativePath); - mkdirSync(dirname(fullPath), { recursive: true }); - writeFileSync(fullPath, content); - return fullPath; - } - - it("reports stale compiled dist while allowing fresh and dist-absent plugins", () => { - const pluginsRoot = makeTempPluginsRoot(); - - const staleSrc = writePluginFile(pluginsRoot, "fixture-stale", "src/index.ts"); - const staleDist = writePluginFile(pluginsRoot, "fixture-stale", "dist/index.js"); - utimesSync(staleDist, older, older); - utimesSync(staleSrc, newer, newer); - - const freshSrc = writePluginFile(pluginsRoot, "fixture-fresh", "src/index.ts"); - const freshDist = writePluginFile(pluginsRoot, "fixture-fresh", "dist/index.js"); - utimesSync(freshSrc, older, older); - utimesSync(freshDist, newer, newer); - - writePluginFile(pluginsRoot, "fixture-dist-absent", "src/index.ts"); - - const stale = findStaleBundledPlugins(["fixture-stale", "fixture-fresh", "fixture-dist-absent"], { - pluginsRoot, - }); - - expect(stale).toHaveLength(1); - expect(stale[0]).toMatchObject({ id: "fixture-stale" }); - expect(stale[0]?.reason).toContain("run pnpm build"); - }); - - it("keeps the live staged bundled-plugin set fresh after build", () => { - expect(findStaleBundledPlugins(ALL_STAGED_BUNDLED_IDS)).toEqual([]); - }); - - it("keeps the auto-install list covered by the staged bundled-plugin set", () => { - const staged = new Set(ALL_STAGED_BUNDLED_IDS); - const missingFromStagedSet = BUNDLED_PLUGIN_IDS.filter((id) => !staged.has(id)); - - expect(missingFromStagedSet).toEqual([]); - - /* - * FNXC:BundledPlugins 2026-06-17-22:06: - * The staged set intentionally remains a superset today: droid/acp runtimes are shipped for explicit runtime selection but are not part of the default auto-install list. Use subset coverage, not equality, until product requirements say those runtimes should auto-install. - */ - expect(new Set(BUNDLED_PLUGIN_IDS)).not.toEqual(staged); - expect(ALL_STAGED_BUNDLED_IDS).toEqual(expect.arrayContaining(["fusion-plugin-droid-runtime", "fusion-plugin-acp-runtime"])); - }); -}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 4e98bd4619..f4c903ba4f 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -44,23 +44,30 @@ const quarantinedCliTests: string[] = [ /* FNXC:CliTests 2026-06-25-14:00: The SQLite-to-PostgreSQL cutover (feature quarantine-sqlite-internals-tests, retry session) - quarantines pre-existing CLI test failures observed during verify:workspace. Root causes vary: + recorded pre-existing CLI test failures observed during verify:workspace. Root causes varied: - extension-fn-secret-get.test.ts: store.getAsyncLayer mock drift (async-satellite dual-path). - chat.test.ts: MessageStore.getInbox returns non-array under Node 26 node:sqlite (SQLite-path). - skill-sync.test.ts: undocumented engine tools (fn_acquire_repo_worktree, fn_artifact_*). - version.test.ts: changeset script assertion drift (project now uses scripts/release.mjs). - dashboard.test.ts: mesh lifecycle mock assertion drift. - bundled-plugin-freshness.test.ts: bundled plugin build freshness drift. - Quarantined on sight per AGENTS.md flaky-test rule so verify:workspace goes green. + The entries were quarantined on sight per AGENTS.md; FN-8219 deleted the five + in-scope expired entries on 2026-07-17 rather than rescuing or re-recording them. FNXC:CliTests 2026-07-17-09:45: FN-8210 restores package-config.test.ts to the package lane after the direct green run proved its failures were stale tsup plugin-external and verify:workspace expectations, not flaky behavior. Its old exclusion had no matching ledger entry; do not re-quarantine without new root-cause evidence and a lockstep ledger entry. */ - "src/__tests__/extension-fn-secret-get.test.ts", - "src/__tests__/skill-sync.test.ts", - "src/__tests__/version.test.ts", - "src/commands/__tests__/dashboard.test.ts", - "src/plugins/__tests__/bundled-plugin-freshness.test.ts", + /* + FNXC:CliTests 2026-07-17-10:00: + FN-8219 reconciled the five 2026-06-25 config-only quarantines after their + 2026-07-09 deletion deadline. Per docs/testing.md, expired quarantines are + deleted rather than rescued or re-recorded: extension-fn-secret-get + (async-layer mock drift), skill-sync (undocumented engine tools), version + (release-script assertion drift), dashboard (mesh lifecycle mock drift), and + bundled-plugin-freshness (build freshness drift) are now git-history-only. + The quarantine ledger has no packages/cli rows. FN-8210 owns the separate + package-config.test.ts resolution; future lockstep coverage must include it. + */ /* FNXC:CliTests 2026-06-25-16:30: The SQLite-to-PostgreSQL cutover (feature delete-sqlite-runtime-final, PHASE A)