chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts

- Move all co-located *.test.* files into sibling __tests__/ directories so the
  layout is consistent across packages (159 renames + content-rewrite moves).
  Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
  path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
  skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
  accidentally committed in a merge and the stale pi.js was masking a real
  test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
  the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
  createFnAgent / promptWithFallback / describeModel from @fusion/engine
  instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
  the two plugin package.jsons that were missing it; exports describeModel
  from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
  ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
  .test.ts; switch three boundary-result toEqual assertions to toMatchObject
  so the new content/isError fields don't trip exact-match comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 18:45:10 -07:00
parent ab98cc3719
commit bce7dbd96f
232 changed files with 1311 additions and 26008 deletions

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { mkdirSync, rmSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
const mockResolveProject = vi.fn();
vi.mock("../../project-context.js", () => ({
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
}));
import { runAgentExport } from "../agent-export.js";
describe("agent-export", () => {
const tmpRoot = join(tmpdir(), `fn-agent-export-test-${process.pid}`);
let projectDir: string;
let outputDir: string;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
beforeEach(async () => {
vi.clearAllMocks();
projectDir = join(tmpRoot, `project-${Date.now()}-${Math.random().toString(16).slice(2)}`);
outputDir = join(projectDir, "exports", "company");
mkdirSync(projectDir, { recursive: true });
mockResolveProject.mockResolvedValue({
projectId: "proj-test",
projectPath: projectDir,
projectName: "proj-test",
isRegistered: true,
store: {},
});
});
afterEach(() => {
rmSync(projectDir, { recursive: true, force: true });
});
afterAll(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
});
async function seedAgents(): Promise<void> {
const store = new AgentStore({ rootDir: join(projectDir, ".fusion") });
await store.init();
const ceo = await store.createAgent({
name: "CEO",
role: "executor",
title: "Chief Executive",
metadata: {
description: "Company lead",
skills: ["strategy"],
},
instructionsText: "Lead company operations.",
});
await store.createAgent({
name: "Reviewer",
role: "reviewer",
reportsTo: ceo.id,
metadata: {
description: "Code reviewer",
skills: ["review"],
},
instructionsText: "Review all changes.",
});
}
it("exports agents and creates package files", async () => {
await seedAgents();
await runAgentExport(outputDir, {
companyName: "Acme Export",
companySlug: "acme-export",
});
expect(existsSync(join(outputDir, "COMPANY.md"))).toBe(true);
expect(existsSync(join(outputDir, "agents", "ceo", "AGENTS.md"))).toBe(true);
expect(existsSync(join(outputDir, "agents", "reviewer", "AGENTS.md"))).toBe(true);
expect(existsSync(join(outputDir, "skills", "strategy", "SKILL.md"))).toBe(true);
expect(existsSync(join(outputDir, "skills", "review", "SKILL.md"))).toBe(true);
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("Agents exported: 2");
});
it("resolves project path when --project is provided", async () => {
await seedAgents();
await runAgentExport(outputDir, {
project: "my-project",
});
expect(mockResolveProject).toHaveBeenCalledWith("my-project");
expect(existsSync(join(outputDir, "COMPANY.md"))).toBe(true);
});
it("exits with an error when there are no agents to export", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as any);
await expect(
runAgentExport(outputDir, {
project: "empty-project",
}),
).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith("No agents found to export");
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
});

View File

@@ -0,0 +1,671 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "@fusion/core";
import { runAgentImport } from "../agent-import.js";
function makeAgentManifest(options: {
name: string;
title?: string;
slug?: string;
reportsTo?: string;
skills?: string[];
body?: string;
}): string {
const lines = ["---", `name: ${options.name}`];
if (options.title) {
lines.push(`title: ${options.title}`);
}
if (options.slug) {
lines.push(`slug: ${options.slug}`);
}
if (options.reportsTo) {
lines.push(`reportsTo: ${options.reportsTo}`);
}
if (options.skills && options.skills.length > 0) {
lines.push("skills:");
for (const skill of options.skills) {
lines.push(` - ${skill}`);
}
}
lines.push("---", options.body ?? `${options.name} instructions`);
return lines.join("\n");
}
function createCompanyDirectory(basePath: string, agentName = "CEO"): string {
mkdirSync(basePath, { recursive: true });
writeFileSync(
join(basePath, "COMPANY.md"),
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
);
const teamDir = join(basePath, "teams", "engineering");
mkdirSync(teamDir, { recursive: true });
writeFileSync(
join(teamDir, "TEAM.md"),
"---\nname: Engineering\nmanager: ../ceo/AGENTS.md\n---",
);
const agentDir = join(basePath, "agents", "ceo");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "AGENTS.md"),
makeAgentManifest({
name: agentName,
title: "Chief Executive",
skills: ["review"],
body: "Lead the company",
}),
);
return basePath;
}
function createHierarchyCompanyDirectory(basePath: string): string {
mkdirSync(basePath, { recursive: true });
writeFileSync(
join(basePath, "COMPANY.md"),
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
);
mkdirSync(join(basePath, "agents", "ceo"), { recursive: true });
writeFileSync(
join(basePath, "agents", "ceo", "AGENTS.md"),
makeAgentManifest({
name: "CEO",
slug: "ceo",
title: "Chief Executive",
body: "Lead the company",
}),
);
mkdirSync(join(basePath, "agents", "vp-eng"), { recursive: true });
writeFileSync(
join(basePath, "agents", "vp-eng", "AGENTS.md"),
makeAgentManifest({
name: "VP Engineering",
slug: "vp-eng",
reportsTo: "ceo",
body: "Lead engineering",
}),
);
mkdirSync(join(basePath, "agents", "staff-eng"), { recursive: true });
writeFileSync(
join(basePath, "agents", "staff-eng", "AGENTS.md"),
makeAgentManifest({
name: "Staff Engineer",
reportsTo: "../vp-eng/AGENTS.md",
body: "Build systems",
}),
);
return basePath;
}
function makeSkillManifest(options: {
name: string;
description?: string;
slug?: string;
version?: string;
license?: string;
authors?: string[];
tags?: string[];
instructionBody?: string;
}): string {
const lines = ["---"];
lines.push(`name: ${options.name}`);
if (options.description) lines.push(`description: ${options.description}`);
if (options.slug) lines.push(`slug: ${options.slug}`);
if (options.version) lines.push(`version: ${options.version}`);
if (options.license) lines.push(`license: ${options.license}`);
if (options.authors && options.authors.length > 0) {
lines.push("authors:");
for (const author of options.authors) {
lines.push(` - ${author}`);
}
}
if (options.tags && options.tags.length > 0) {
lines.push("tags:");
for (const tag of options.tags) {
lines.push(` - ${tag}`);
}
}
lines.push("---");
if (options.instructionBody) {
lines.push(options.instructionBody);
}
return lines.join("\n");
}
function createCompanyDirectoryWithSkills(basePath: string, skills: Array<{
name: string;
description?: string;
instructionBody?: string;
}>): string {
// Create base company structure
mkdirSync(basePath, { recursive: true });
writeFileSync(
join(basePath, "COMPANY.md"),
"---\nname: Example Company\nslug: example-company\n---\nCompany description",
);
const agentDir = join(basePath, "agents", "ceo");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
join(agentDir, "AGENTS.md"),
makeAgentManifest({
name: "CEO",
title: "Chief Executive",
body: "Lead the company",
}),
);
// Create skills
const skillsDir = join(basePath, "skills");
for (const skill of skills) {
const skillDir = join(skillsDir, skill.name.toLowerCase().replace(/\s+/g, "-"));
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
makeSkillManifest({
name: skill.name,
description: skill.description,
instructionBody: skill.instructionBody,
}),
);
}
return basePath;
}
describe("agent-import", () => {
const tmpDir = join(tmpdir(), `fn-agent-import-test-${process.pid}`);
let createAgentMock: ReturnType<typeof vi.fn>;
let listAgentsMock: ReturnType<typeof vi.fn>;
let initMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
mkdirSync(tmpDir, { recursive: true });
createAgentMock = vi.fn().mockImplementation(async (input: any) => ({
id: `agent-${String(input.name).toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
...input,
}));
listAgentsMock = vi.fn().mockResolvedValue([]);
initMock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "init").mockImplementation(initMock);
vi.spyOn(AgentStore.prototype, "listAgents").mockImplementation(listAgentsMock);
vi.spyOn(AgentStore.prototype, "createAgent").mockImplementation(createAgentMock);
});
afterEach(() => {
vi.restoreAllMocks();
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it("reports error on invalid source path", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runAgentImport(join(tmpDir, "missing"))).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Path not found"));
exitSpy.mockRestore();
errorSpy.mockRestore();
});
it("reports parse error on malformed AGENTS.md", async () => {
const manifestPath = join(tmpDir, "AGENTS.md");
writeFileSync(manifestPath, "name: missing frontmatter delimiters");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runAgentImport(manifestPath)).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Parse error"));
exitSpy.mockRestore();
errorSpy.mockRestore();
});
it("handles empty directory gracefully", async () => {
const emptyDir = join(tmpDir, "empty-company");
mkdirSync(emptyDir, { recursive: true });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(emptyDir);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("No agents found"));
logSpy.mockRestore();
});
it("imports agents from an Agent Companies directory", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-dir"));
await runAgentImport(companyDir);
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "CEO", role: "custom", title: "Chief Executive" }),
);
});
it("resolves imported manager hierarchy to created Fusion agent ids", async () => {
const companyDir = createHierarchyCompanyDirectory(join(tmpDir, "company-hierarchy"));
createAgentMock
.mockResolvedValueOnce({ id: "agent-ceo", name: "CEO" })
.mockResolvedValueOnce({ id: "agent-vp-eng", name: "VP Engineering" })
.mockResolvedValueOnce({ id: "agent-staff-eng", name: "Staff Engineer" });
await runAgentImport(companyDir);
expect(createAgentMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
name: "CEO",
role: "custom",
}));
expect(createAgentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
name: "VP Engineering",
role: "custom",
reportsTo: "agent-ceo",
}));
expect(createAgentMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
name: "Staff Engineer",
role: "custom",
reportsTo: "agent-vp-eng",
}));
});
it("resolves skipped existing managers before importing their reports", async () => {
const companyDir = createHierarchyCompanyDirectory(join(tmpDir, "company-existing-manager"));
listAgentsMock.mockResolvedValue([
{
id: "agent-ceo-existing",
name: "CEO",
role: "custom",
metadata: { agentCompaniesSlug: "ceo" },
},
]);
await runAgentImport(companyDir, { skipExisting: true });
expect(createAgentMock).toHaveBeenCalledTimes(2);
expect(createAgentMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
name: "VP Engineering",
reportsTo: "agent-ceo-existing",
}));
expect(createAgentMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
name: "Staff Engineer",
reportsTo: "agent-vp-engineering",
}));
});
it("imports agents from a single AGENTS.md file", async () => {
const manifestPath = join(tmpDir, "AGENTS.md");
writeFileSync(
manifestPath,
makeAgentManifest({
name: "Solo Agent",
title: "Single File Agent",
skills: ["review"],
}),
);
await runAgentImport(manifestPath);
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "Solo Agent", role: "custom" }),
);
});
it("imports agents from a .tar.gz archive", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-archive-src"), "Archive CEO");
const archivePath = join(tmpDir, "company.tar.gz");
execSync(`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(companyDir)} .`);
await runAgentImport(archivePath);
expect(createAgentMock).toHaveBeenCalledTimes(1);
expect(createAgentMock).toHaveBeenCalledWith(
expect.objectContaining({ name: "Archive CEO", role: "custom" }),
);
});
it("supports dry-run mode", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-dry-run"));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir, { dryRun: true });
expect(createAgentMock).not.toHaveBeenCalled();
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("[DRY RUN]");
expect(output).toContain("Agents: 1");
expect(output).toContain("Teams: 1");
logSpy.mockRestore();
});
it("supports skip-existing", async () => {
const companyDir = createCompanyDirectory(join(tmpDir, "company-skip"));
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "CEO", role: "custom" }]);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir, { skipExisting: true });
expect(createAgentMock).not.toHaveBeenCalled();
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("Skipped: 1");
logSpy.mockRestore();
});
it("reports unsupported file formats", async () => {
const unsupportedPath = join(tmpDir, "manifest.json");
writeFileSync(unsupportedPath, JSON.stringify({ name: "Not a manifest" }));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runAgentImport(unsupportedPath)).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Unsupported format"));
exitSpy.mockRestore();
errorSpy.mockRestore();
});
describe("skill import", () => {
const projectDir = join(tmpDir, "test-project");
const originalCwd = process.cwd();
beforeEach(() => {
mkdirSync(projectDir, { recursive: true });
// Create .fusion directory with fusion.db to make it detectable as a project
mkdirSync(join(projectDir, ".fusion"), { recursive: true });
writeFileSync(join(projectDir, ".fusion", "fusion.db"), "");
// Change to project directory so project auto-detection works
process.chdir(projectDir);
});
afterEach(() => {
process.chdir(originalCwd);
});
it("imports skills from directory package to skills/imported directory", async () => {
const companyDir = createCompanyDirectoryWithSkills(
join(tmpDir, "company-with-skills"),
[
{ name: "Code Review", description: "Review code changes", instructionBody: "Review all PRs carefully" },
{ name: "Strategy", instructionBody: "Plan the roadmap" },
],
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir);
// Verify skill files were created
const skillDir = join(projectDir, "skills", "imported", "example-company");
expect(existsSync(join(skillDir, "code-review", "SKILL.md"))).toBe(true);
expect(existsSync(join(skillDir, "strategy", "SKILL.md"))).toBe(true);
// Verify output includes skill results
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("Skills:");
expect(output).toContain("2 imported");
logSpy.mockRestore();
});
it("generates skill markdown with required frontmatter keys", async () => {
const companyDir = createCompanyDirectoryWithSkills(
join(tmpDir, "company-frontmatter-test"),
[{ name: "Test Skill", instructionBody: "Test instructions" }],
);
await runAgentImport(companyDir);
const skillContent = readFileSync(
join(projectDir, "skills", "imported", "example-company", "test-skill", "SKILL.md"),
"utf-8",
);
// Check required frontmatter keys
expect(skillContent).toContain("name: Test Skill");
expect(skillContent).toContain("schema: agentcompanies/v1");
expect(skillContent).toContain("kind: skill");
// Check body
expect(skillContent).toContain("Test instructions");
});
it("includes optional frontmatter fields when present", async () => {
const companyDir = join(tmpDir, "company-optional-frontmatter");
mkdirSync(companyDir, { recursive: true });
writeFileSync(
join(companyDir, "COMPANY.md"),
"---\nname: Test Co\nslug: test-co\n---",
);
mkdirSync(join(companyDir, "agents", "test"), { recursive: true });
writeFileSync(
join(companyDir, "agents", "test", "AGENTS.md"),
makeAgentManifest({ name: "Test Agent" }),
);
const skillDir = join(companyDir, "skills", "my-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
makeSkillManifest({
name: "My Skill",
slug: "custom-slug",
description: "A test skill",
version: "1.0.0",
license: "MIT",
authors: ["Author One", "Author Two"],
tags: ["testing", "example"],
instructionBody: "Do the thing",
}),
);
await runAgentImport(companyDir);
const skillContent = readFileSync(
join(projectDir, "skills", "imported", "test-co", "my-skill", "SKILL.md"),
"utf-8",
);
expect(skillContent).toContain("description: A test skill");
expect(skillContent).toContain("version: 1.0.0");
expect(skillContent).toContain("license: MIT");
expect(skillContent).toContain("authors:");
expect(skillContent).toContain("- Author One");
expect(skillContent).toContain("- Author Two");
expect(skillContent).toContain("tags:");
expect(skillContent).toContain("- testing");
expect(skillContent).toContain("- example");
});
it("uses fallback template for skill without instruction body", async () => {
const companyDir = join(tmpDir, "company-no-body");
mkdirSync(companyDir, { recursive: true });
writeFileSync(
join(companyDir, "COMPANY.md"),
"---\nname: Test Co\nslug: test-co\n---",
);
mkdirSync(join(companyDir, "agents", "test"), { recursive: true });
writeFileSync(
join(companyDir, "agents", "test", "AGENTS.md"),
makeAgentManifest({ name: "Test Agent" }),
);
const skillDir = join(companyDir, "skills", "bare-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
"---\nname: Bare Skill\n---\n",
);
await runAgentImport(companyDir);
const skillContent = readFileSync(
join(projectDir, "skills", "imported", "test-co", "bare-skill", "SKILL.md"),
"utf-8",
);
expect(skillContent).toContain("# Bare Skill");
});
it("skips existing skill files and reports them", async () => {
const companyDir = createCompanyDirectoryWithSkills(
join(tmpDir, "company-existing-skill"),
[{ name: "Existing Skill", instructionBody: "Original content" }],
);
// Pre-create the skill file
const existingSkillDir = join(projectDir, "skills", "imported", "example-company", "existing-skill");
mkdirSync(existingSkillDir, { recursive: true });
writeFileSync(join(existingSkillDir, "SKILL.md"), "---\nname: Existing Skill\n---\nAlready exists");
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir);
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("1 skipped");
expect(output).toContain("Existing Skill");
// Verify file was not overwritten
const skillContent = readFileSync(join(existingSkillDir, "SKILL.md"), "utf-8");
expect(skillContent).toContain("Already exists");
logSpy.mockRestore();
});
it("does not write skill files in dry-run mode", async () => {
const companyDir = createCompanyDirectoryWithSkills(
join(tmpDir, "company-dry-run-skills"),
[{ name: "Dry Run Skill", instructionBody: "Should not be written" }],
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(companyDir, { dryRun: true });
// Verify skill file was NOT created
const skillPath = join(projectDir, "skills", "imported", "example-company", "dry-run-skill", "SKILL.md");
expect(existsSync(skillPath)).toBe(false);
// Verify output shows what would be imported
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("[DRY RUN]");
expect(output).toContain("1 imported");
logSpy.mockRestore();
});
it("does not import skills for single AGENTS.md file", async () => {
const manifestPath = join(tmpDir, "solo-agent-with-skill.md");
writeFileSync(
manifestPath,
makeAgentManifest({
name: "Solo Agent",
skills: ["some-skill"],
}),
);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(manifestPath);
const output = logSpy.mock.calls.flat().join(" ");
// Should not have a Skills section for single file imports
expect(output).not.toContain("Skills:");
logSpy.mockRestore();
});
it("imports skills from tar.gz archive", async () => {
const companyDir = createCompanyDirectoryWithSkills(
join(tmpDir, "company-archive-skills"),
[{ name: "Archived Skill", instructionBody: "From archive" }],
);
const archivePath = join(tmpDir, "company-with-skills.tar.gz");
execSync(`tar czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(companyDir)} .`);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await runAgentImport(archivePath);
// Verify skill file was created
const skillDir = join(projectDir, "skills", "imported", "example-company");
expect(existsSync(join(skillDir, "archived-skill", "SKILL.md"))).toBe(true);
const output = logSpy.mock.calls.flat().join(" ");
expect(output).toContain("Skills:");
expect(output).toContain("1 imported");
logSpy.mockRestore();
});
it("handles company without slug using fallback directory name", async () => {
const companyDir = join(tmpDir, "company-no-slug");
mkdirSync(companyDir, { recursive: true });
writeFileSync(
join(companyDir, "COMPANY.md"),
"---\nname: Company Without Slug\n---\nNo slug provided",
);
mkdirSync(join(companyDir, "agents", "test"), { recursive: true });
writeFileSync(
join(companyDir, "agents", "test", "AGENTS.md"),
makeAgentManifest({ name: "Test Agent" }),
);
const skillDir = join(companyDir, "skills", "no-slug-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
makeSkillManifest({ name: "No Slug Skill" }),
);
await runAgentImport(companyDir);
// Should use "unknown-company" fallback
const skillDir2 = join(projectDir, "skills", "imported", "unknown-company", "no-slug-skill");
expect(existsSync(join(skillDir2, "SKILL.md"))).toBe(true);
});
it("uses company slug for directory naming", async () => {
const companyDir = join(tmpDir, "company-custom-slug");
mkdirSync(companyDir, { recursive: true });
writeFileSync(
join(companyDir, "COMPANY.md"),
"---\nname: Custom Name\nslug: my-custom-slug\n---",
);
mkdirSync(join(companyDir, "agents", "test"), { recursive: true });
writeFileSync(
join(companyDir, "agents", "test", "AGENTS.md"),
makeAgentManifest({ name: "Test Agent" }),
);
const skillDir = join(companyDir, "skills", "slugged-skill");
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, "SKILL.md"),
makeSkillManifest({ name: "Slugged Skill" }),
);
await runAgentImport(companyDir);
// Should use the custom slug
const skillDir2 = join(projectDir, "skills", "imported", "my-custom-slug", "slugged-skill");
expect(existsSync(join(skillDir2, "SKILL.md"))).toBe(true);
});
});
});

View File

@@ -0,0 +1,45 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import { getFusionAgentDir, getLegacyAgentDir, getPackageManagerAgentDir } from "../auth-paths.js";
function writeJson(path: string, value: Record<string, unknown>): void {
writeFileSync(path, JSON.stringify(value, null, 2));
}
describe("getPackageManagerAgentDir", () => {
it("falls back to legacy Pi settings when Fusion settings only contain Fusion metadata", () => {
const home = tempWorkspace("fusion-agent-dir-");
const fusionAgentDir = getFusionAgentDir(home);
const legacyAgentDir = getLegacyAgentDir(home);
mkdirSync(fusionAgentDir, { recursive: true });
mkdirSync(legacyAgentDir, { recursive: true });
writeJson(join(fusionAgentDir, "settings.json"), {
fusionDisabledExtensions: ["/Users/example/.pi/agent/extensions/browse.ts"],
});
writeJson(join(legacyAgentDir, "settings.json"), {
packages: ["npm:pi-claude-cli"],
});
expect(getPackageManagerAgentDir(home)).toBe(legacyAgentDir);
});
it("prefers Fusion settings when they contain package-manager settings", () => {
const home = tempWorkspace("fusion-agent-dir-");
const fusionAgentDir = getFusionAgentDir(home);
const legacyAgentDir = getLegacyAgentDir(home);
mkdirSync(fusionAgentDir, { recursive: true });
mkdirSync(legacyAgentDir, { recursive: true });
writeJson(join(fusionAgentDir, "settings.json"), {
packages: ["npm:pi-claude-cli"],
});
writeJson(join(legacyAgentDir, "settings.json"), {
packages: ["npm:legacy-only"],
});
expect(getPackageManagerAgentDir(home)).toBe(fusionAgentDir);
});
});

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const {
mockListBackups,
mockRestoreBackup,
mockCleanupOldBackups,
mockGetSettings,
mockRunBackupCommand,
mockResolveProject,
} = vi.hoisted(() => ({
mockListBackups: vi.fn(),
mockRestoreBackup: vi.fn(),
mockCleanupOldBackups: vi.fn(),
mockGetSettings: vi.fn(),
mockRunBackupCommand: vi.fn(),
mockResolveProject: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
BackupManager: vi.fn(),
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGetSettings,
fusionDir: "/cwd/.fusion",
})),
createBackupManager: vi.fn(() => ({
listBackups: mockListBackups,
restoreBackup: mockRestoreBackup,
cleanupOldBackups: mockCleanupOldBackups,
})),
runBackupCommand: mockRunBackupCommand,
}));
vi.mock("../../project-context.js", () => ({
resolveProject: mockResolveProject,
}));
import { TaskStore } from "@fusion/core";
import { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } from "../backup.js";
describe("backup commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetSettings.mockResolvedValue({ autoBackupDir: ".fusion/backups" });
mockRunBackupCommand.mockResolvedValue({ success: true, output: "backup created" });
mockListBackups.mockResolvedValue([]);
mockRestoreBackup.mockResolvedValue(undefined);
mockCleanupOldBackups.mockResolvedValue(0);
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings: mockGetSettings, fusionDir: "/projects/demo/.fusion" },
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("runBackupCreate uses resolved project store with --project", async () => {
await expect(runBackupCreate("demo-project")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockRunBackupCommand).toHaveBeenCalledWith("/projects/demo/.fusion", expect.anything());
});
it("runBackupList uses resolved project store with --project", async () => {
mockListBackups.mockResolvedValue([{ filename: "fusion.db.bak", size: 1024, createdAt: new Date().toISOString() }]);
await runBackupList("demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 backup"));
});
it("runBackupRestore uses resolved project store with --project", async () => {
await runBackupRestore("fusion.db.bak", "demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockRestoreBackup).toHaveBeenCalledWith("fusion.db.bak", { createPreRestoreBackup: true });
});
it("runBackupCleanup uses resolved project store with --project", async () => {
mockCleanupOldBackups.mockResolvedValue(2);
await runBackupCleanup("demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith("Removed 2 old backup(s).");
});
it("runBackupList without project uses shared resolution flow", async () => {
await runBackupList();
expect(mockResolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).not.toHaveBeenCalled();
});
it("runBackupList without project falls back to current cwd task store when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
mockResolveProject.mockRejectedValueOnce(new Error("No fn project found"));
await runBackupList();
expect(mockResolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/local/project");
cwdSpy.mockRestore();
});
it("falls back to current cwd task store when project resolution fails for project-targeted commands", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/fallback/project");
mockResolveProject.mockRejectedValue(new Error("Project 'missing' not found. Run 'fn project list' to see registered projects."));
await runBackupList("missing");
expect(TaskStore).toHaveBeenCalledWith("/fallback/project");
cwdSpy.mockRestore();
});
});

View File

@@ -0,0 +1,94 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import {
resolveClaudeCliExtension,
resolveClaudeCliExtensionPaths,
} from "../claude-cli-extension.js";
describe("resolveClaudeCliExtension", () => {
it("finds the bundled @fusion/pi-claude-cli package", () => {
const result = resolveClaudeCliExtension();
// In the monorepo test environment, the workspace package MUST resolve.
// If this fails, the vendored package's package.json or pi.extensions
// entry has been broken — a real regression worth surfacing.
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toMatch(/pi-claude-cli[\/\\]index\.ts$/);
expect(result.packageVersion).toMatch(/^\d+\.\d+\.\d+$/);
}
});
});
describe("resolveClaudeCliExtensionPaths", () => {
it("returns empty when useClaudeCli is off (default)", () => {
const result = resolveClaudeCliExtensionPaths({});
expect(result.paths).toEqual([]);
expect(result.warning).toBeUndefined();
expect(result.resolution).toBeNull();
});
it("returns empty when useClaudeCli is explicitly false", () => {
const result = resolveClaudeCliExtensionPaths({ useClaudeCli: false });
expect(result.paths).toEqual([]);
expect(result.resolution).toBeNull();
});
it("returns empty when useClaudeCli is a non-boolean truthy value", () => {
// Defensive: API might pass strings, numbers — we only activate on true.
const result = resolveClaudeCliExtensionPaths({
useClaudeCli: "true" as unknown as boolean,
});
expect(result.paths).toEqual([]);
});
it("returns the resolved path when useClaudeCli is on", () => {
const result = resolveClaudeCliExtensionPaths({ useClaudeCli: true });
expect(result.paths).toHaveLength(1);
expect(result.paths[0]).toMatch(/pi-claude-cli[\/\\]index\.ts$/);
expect(result.resolution?.status).toBe("ok");
});
it("surfaces a warning but does not throw on weird inputs", () => {
// Exercises the defensive null/undefined/garbage handling — callers
// pass settings from disk that could be corrupt.
// @ts-expect-error intentionally bad shape
const result = resolveClaudeCliExtensionPaths(null);
expect(result.paths).toEqual([]);
});
});
describe("cached resolution roundtrip", () => {
it("set/get preserves the snapshot", async () => {
const { setCachedClaudeCliResolution, getCachedClaudeCliResolution } =
await import("../claude-cli-extension.js");
setCachedClaudeCliResolution({ status: "not-installed" });
expect(getCachedClaudeCliResolution()).toEqual({ status: "not-installed" });
setCachedClaudeCliResolution(null);
expect(getCachedClaudeCliResolution()).toBeNull();
});
});
// Directory-fixture smoke test: give the resolver a minimal "fake" package
// layout to prove it handles malformed installs gracefully. This doesn't
// use the resolver directly (it's hard-coded to look up
// @fusion/pi-claude-cli), but proves the package.json parsing logic is
// robust when we refactor later.
describe("package.json edge cases (documentation)", () => {
it("fixture layout documents what a broken install looks like", () => {
const root = tempWorkspace("claude-cli-ext-");
// This fixture is not exercised by the current implementation but
// captures the shape we'd need to test if resolveClaudeCliExtension
// accepted a custom search path. Keeping it here so the next person
// refactoring has a template.
const pkgDir = join(root, "fake", "node_modules", "@fusion", "pi-claude-cli");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ pi: { extensions: ["index.ts"] }, version: "0.0.0" }),
);
// No index.ts — would trigger missing-entry if we pointed the resolver here.
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,189 @@
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import {
ensureFusionSkillForProjects,
installFusionSkillIntoProject,
isPiClaudeCliConfigured,
} from "../claude-skills.js";
function makeSourceSkill(root: string, body = "---\nname: fusion\n---\n# hi\n"): string {
const dir = join(root, "src-skill", "fusion");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "SKILL.md"), body);
return dir;
}
describe("isPiClaudeCliConfigured", () => {
it("returns false for null or empty settings", () => {
expect(isPiClaudeCliConfigured(null)).toBe(false);
expect(isPiClaudeCliConfigured(undefined)).toBe(false);
expect(isPiClaudeCliConfigured({})).toBe(false);
});
it("respects explicit useClaudeCli=true", () => {
expect(isPiClaudeCliConfigured({ useClaudeCli: true })).toBe(true);
});
it("respects explicit useClaudeCli=false even when package is present", () => {
expect(
isPiClaudeCliConfigured({
useClaudeCli: false,
packages: ["npm:pi-claude-cli"],
}),
).toBe(false);
});
it("detects pi-claude-cli in packages array", () => {
expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli"] })).toBe(true);
expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli@0.3.1"] })).toBe(true);
expect(isPiClaudeCliConfigured({ packages: ["github:owner/pi-claude-cli"] })).toBe(true);
});
it("ignores unrelated packages", () => {
expect(
isPiClaudeCliConfigured({ packages: ["npm:some-other", "npm:pi-ai"] }),
).toBe(false);
});
});
describe("installFusionSkillIntoProject", () => {
it("is a no-op when disabled", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
const result = installFusionSkillIntoProject(projectPath, { source, enabled: false });
expect(result.outcome).toBe("skipped");
expect(existsSync(join(projectPath, ".claude"))).toBe(false);
});
it("creates a symlink on first install", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("installed");
const target = join(projectPath, ".claude", "skills", "fusion");
expect(lstatSync(target).isSymbolicLink()).toBe(true);
expect(readlinkSync(target)).toBe(source);
expect(readFileSync(join(target, "SKILL.md"), "utf-8")).toContain("name: fusion");
});
it("is idempotent when the correct symlink already exists", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
installFusionSkillIntoProject(projectPath, { source, enabled: true });
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("already-installed");
});
it("replaces a stale symlink that points elsewhere", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const source = makeSourceSkill(root);
// Seed a stale symlink pointing at a different dir.
const stale = join(root, "stale");
mkdirSync(stale, { recursive: true });
writeFileSync(join(stale, "SKILL.md"), "# stale");
const target = join(projectPath, ".claude", "skills", "fusion");
mkdirSync(join(projectPath, ".claude", "skills"), { recursive: true });
symlinkSync(stale, target, "dir");
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("replaced");
expect(readlinkSync(target)).toBe(source);
});
it("replaces a prior copy-install (plain dir with SKILL.md)", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
const source = makeSourceSkill(root);
// Seed a prior copy — looks like a fusion skill install.
const target = join(projectPath, ".claude", "skills", "fusion");
mkdirSync(target, { recursive: true });
writeFileSync(join(target, "SKILL.md"), "# old copy\n");
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("replaced");
expect(lstatSync(target).isSymbolicLink()).toBe(true);
});
it("refuses to clobber a foreign directory without SKILL.md", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
const source = makeSourceSkill(root);
const target = join(projectPath, ".claude", "skills", "fusion");
mkdirSync(target, { recursive: true });
writeFileSync(join(target, "random.txt"), "user data");
const result = installFusionSkillIntoProject(projectPath, { source, enabled: true });
expect(result.outcome).toBe("failed");
expect(readFileSync(join(target, "random.txt"), "utf-8")).toBe("user data");
});
it("reports failure when source is missing", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projectPath = join(root, "project");
mkdirSync(projectPath, { recursive: true });
const result = installFusionSkillIntoProject(projectPath, {
source: join(root, "nonexistent"),
enabled: true,
});
// Source missing -> symlink may succeed on POSIX (to a nonexistent path)
// then later fail to resolve. The function still creates the symlink;
// that's acceptable since fs reads will surface the broken link clearly.
expect(["installed", "failed"]).toContain(result.outcome);
});
});
describe("ensureFusionSkillForProjects", () => {
it("skips all when disabled", () => {
const root = tempWorkspace("fusion-claude-skills-");
const projects = [
{ id: "a", name: "a", path: join(root, "a") },
{ id: "b", name: "b", path: join(root, "b") },
];
for (const p of projects) mkdirSync(p.path, { recursive: true });
const results = ensureFusionSkillForProjects(projects, { enabled: false });
expect(results.map((r) => r.outcome)).toEqual(["skipped", "skipped"]);
});
it("installs for all when enabled", () => {
const root = tempWorkspace("fusion-claude-skills-");
const source = makeSourceSkill(root);
const projects = [
{ id: "a", name: "a", path: join(root, "a") },
{ id: "b", name: "b", path: join(root, "b") },
];
for (const p of projects) mkdirSync(p.path, { recursive: true });
const results = ensureFusionSkillForProjects(projects, { enabled: true, source });
expect(results.map((r) => r.outcome)).toEqual(["installed", "installed"]);
for (const p of projects) {
expect(
lstatSync(join(p.path, ".claude", "skills", "fusion")).isSymbolicLink(),
).toBe(true);
}
});
});

View File

@@ -0,0 +1,265 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
type Listener = (...args: any[]) => void;
interface MockEmitter {
on(event: string, listener: Listener): MockEmitter;
once(event: string, listener: Listener): MockEmitter;
off(event: string, listener: Listener): MockEmitter;
emit(event: string, ...args: any[]): boolean;
}
interface MockChild extends MockEmitter {
kill: ReturnType<typeof vi.fn>;
killed: boolean;
}
const mocks = vi.hoisted(() => {
function createEmitter(): MockEmitter {
const listeners = new Map<string, Set<Listener>>();
const add = (event: string, listener: Listener) => {
const eventListeners = listeners.get(event) ?? new Set<Listener>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
};
const remove = (event: string, listener: Listener) => {
const eventListeners = listeners.get(event);
if (!eventListeners) return;
eventListeners.delete(listener);
if (eventListeners.size === 0) {
listeners.delete(event);
}
};
return {
on(event: string, listener: Listener) {
add(event, listener);
return this;
},
once(event: string, listener: Listener) {
const wrapped: Listener = (...args: any[]) => {
remove(event, wrapped);
listener(...args);
};
add(event, wrapped);
return this;
},
off(event: string, listener: Listener) {
remove(event, listener);
return this;
},
emit(event: string, ...args: any[]) {
const eventListeners = listeners.get(event);
if (!eventListeners || eventListeners.size === 0) {
return false;
}
for (const listener of [...eventListeners]) {
listener(...args);
}
return true;
},
};
}
function createMockChild(): MockChild {
const emitter = createEmitter();
const child = emitter as MockChild;
child.killed = false;
child.kill = vi.fn((() => {
child.killed = true;
return true;
}) as unknown as MockChild["kill"]);
return child;
}
const state = {
buildChild: createMockChild(),
electronChild: createMockChild(),
};
const store = {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
};
const server = Object.assign(createEmitter(), {
address: vi.fn(() => ({ port: 4545 })),
close: vi.fn((callback?: () => void) => {
callback?.();
}),
});
const app = {
listen: vi.fn(() => {
queueMicrotask(() => {
server.emit("listening");
});
return server;
}),
};
const spawn = vi.fn((command: string) => {
if (command === "pnpm") {
queueMicrotask(() => {
state.buildChild.emit("exit", 0);
});
return state.buildChild;
}
return state.electronChild;
});
return {
state,
createMockChild,
store,
server,
app,
spawn,
taskStoreCtor: vi.fn(() => store),
createServer: vi.fn(() => app),
};
});
vi.mock("node:child_process", () => ({
spawn: mocks.spawn,
}));
vi.mock("@fusion/core", () => ({
TaskStore: mocks.taskStoreCtor,
}));
vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServer,
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
}));
import { runDesktop } from "../desktop.js";
describe("runDesktop", () => {
const originalCwd = process.cwd;
const originalExit = process.exit;
const originalElectronBinary = process.env.FUSION_ELECTRON_BINARY;
const originalDashboardUrl = process.env.FUSION_DASHBOARD_URL;
beforeEach(() => {
vi.clearAllMocks();
process.env.FUSION_ELECTRON_BINARY = "electron-bin";
delete process.env.FUSION_DASHBOARD_URL;
mocks.state.buildChild = mocks.createMockChild();
mocks.state.electronChild = mocks.createMockChild();
mocks.server.address.mockReturnValue({ port: 4545 });
mocks.app.listen.mockImplementation(() => {
queueMicrotask(() => {
mocks.server.emit("listening");
});
return mocks.server;
});
mocks.server.close.mockImplementation((callback?: () => void) => {
callback?.();
});
vi.spyOn(process, "cwd").mockReturnValue("/repo");
process.exit = vi.fn() as never;
});
afterEach(() => {
vi.restoreAllMocks();
process.cwd = originalCwd;
process.exit = originalExit;
if (originalElectronBinary === undefined) {
delete process.env.FUSION_ELECTRON_BINARY;
} else {
process.env.FUSION_ELECTRON_BINARY = originalElectronBinary;
}
if (originalDashboardUrl === undefined) {
delete process.env.FUSION_DASHBOARD_URL;
} else {
process.env.FUSION_DASHBOARD_URL = originalDashboardUrl;
}
});
it("builds desktop app, starts dashboard on random port, and launches Electron", async () => {
await runDesktop({ paused: true });
expect(mocks.spawn).toHaveBeenCalledWith(
"pnpm",
["--filter", "@fusion/desktop", "build"],
expect.objectContaining({ cwd: "/repo" }),
);
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.store.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
expect(mocks.app.listen).toHaveBeenCalledWith(0);
// In production mode (not dev), renderer uses embedded assets, so no FUSION_DASHBOARD_URL
expect(mocks.spawn).toHaveBeenCalledWith(
"electron-bin",
["--enable-source-maps", "/repo/packages/desktop/dist/main.js"],
expect.objectContaining({
cwd: "/repo",
env: expect.objectContaining({
// No FUSION_DASHBOARD_URL in production
FUSION_SERVER_PORT: "4545",
}),
}),
);
mocks.state.electronChild.emit("exit", 0);
await new Promise((resolve) => setTimeout(resolve, 0));
});
it("supports --dev mode by skipping build and pointing at Vite URL", async () => {
process.env.FUSION_DASHBOARD_URL = "http://localhost:5173";
await runDesktop({ dev: true });
const buildCalls = mocks.spawn.mock.calls.filter(([command]) => command === "pnpm");
expect(buildCalls).toHaveLength(0);
expect(mocks.spawn).toHaveBeenCalledWith(
"electron-bin",
["--enable-source-maps", "/repo/packages/desktop/dist/main.js", "--dev"],
expect.objectContaining({
env: expect.objectContaining({
NODE_ENV: "development",
FUSION_DASHBOARD_URL: "http://localhost:5173",
}),
}),
);
mocks.state.electronChild.emit("exit", 0);
await new Promise((resolve) => setTimeout(resolve, 0));
});
it("cleans up dashboard runtime when Electron exits", async () => {
await runDesktop();
mocks.state.electronChild.emit("exit", 7);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.server.close).toHaveBeenCalledTimes(1);
expect(mocks.store.close).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(7);
});
it("handles SIGINT by terminating Electron and shutting down services", async () => {
await runDesktop();
process.emit("SIGINT");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.state.electronChild.kill).toHaveBeenCalledWith("SIGTERM");
expect(mocks.server.close).toHaveBeenCalledTimes(1);
expect(mocks.store.close).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(0);
});
});

View File

@@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Store for mock results - shared between callback and promisified paths
let mockResults: (string | Error)[] = [];
let resultIndex = 0;
const execCalls: [string, object | undefined][] = [];
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn: typeof vi.fn = vi.fn((cmd: string, opts: object | undefined, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
// Track the call for assertion purposes
execCalls.push([cmd, opts]);
const callback = typeof opts === "function" ? opts : cb;
// promisify path - callback is undefined
if (callback === undefined) {
return; // promisify.custom handles the Promise
}
try {
const result = mockResults[resultIndex++] || "";
const stdout = result instanceof Error ? "" : result.toString();
callback(null, stdout, "");
} catch (err) {
callback(err as Error, "", "");
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[promisify.custom] = (cmd: string, opts?: object) => {
// Track the call for assertion purposes
execCalls.push([cmd, opts]);
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const result = mockResults[resultIndex++] || "";
if (result instanceof Error) {
reject(result);
} else {
resolve({ stdout: result.toString(), stderr: "" });
}
});
};
return { exec: execFn, execSync: vi.fn() };
});
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
question: vi.fn(),
close: vi.fn(),
})),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../../project-context.js";
import {
isGitRepo,
isValidBranchName,
runGitStatus,
runGitFetch,
runGitPull,
runGitPush,
} from "../git.js";
const mockCreateInterface = vi.mocked(createInterface);
// Helper to set up sequential mock results
function mockNextResult(result: string) {
mockResults.push(result);
}
// Helper to check if exec was called with specific command
function wasExecCalled(cmd: string): boolean {
return execCalls.some(([c]) => c === cmd);
}
// Helper to get last exec call
function getLastExecCall(): [string, object | undefined] | undefined {
return execCalls[execCalls.length - 1];
}
describe("git commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
mockResults = [];
resultIndex = 0;
execCalls.length = 0;
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: {} as ReturnType<typeof vi.fn>,
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("core helpers work", async () => {
mockNextResult(".git");
expect(await isGitRepo()).toBe(true);
expect(isValidBranchName("main")).toBe(true);
expect(isValidBranchName("--bad")).toBe(false);
});
it("runGitStatus uses resolved project path", async () => {
// isGitRepo, branch, commit, status, rev-list, dirty count
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult(" M file.ts\n");
mockNextResult("0\t0\n");
mockNextResult(" M file.ts\n");
await runGitStatus("demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(wasExecCalled("git status --porcelain")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("runGitStatus without project uses shared resolution flow", async () => {
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
await runGitStatus();
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(wasExecCalled("git rev-parse --git-dir")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("runGitStatus without project falls back to current working directory when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
vi.mocked(resolveProject).mockRejectedValueOnce(new Error("No fusion project found"));
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
await runGitStatus();
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(wasExecCalled("git rev-parse --git-dir")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/local/project" });
cwdSpy.mockRestore();
});
it("runGitFetch uses resolved project path", async () => {
mockNextResult(".git");
mockNextResult("Fetch completed");
await runGitFetch("origin", "demo-project");
expect(wasExecCalled("git fetch origin")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("propagates project resolution errors for git commands", async () => {
vi.mocked(resolveProject).mockRejectedValue(new Error("Project 'missing' not found. Run 'fn project list' to see registered projects."));
await expect(runGitFetch("origin", "missing")).rejects.toThrow("Project 'missing' not found");
});
it("runGitPull uses resolved project path", async () => {
const question = vi.fn().mockResolvedValue("y");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as ReturnType<typeof createInterface>);
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
mockNextResult("Already up to date.");
mockNextResult("Already up to date.");
await runGitPull({ projectName: "demo-project" });
expect(wasExecCalled("git pull")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
it("runGitPush uses resolved project path", async () => {
const question = vi.fn().mockResolvedValue("y");
mockCreateInterface.mockReturnValue({ question, close: vi.fn() } as ReturnType<typeof createInterface>);
mockNextResult(".git");
mockNextResult("main\n");
mockNextResult("a1b2c3d\n");
mockNextResult("");
mockNextResult("0\t0\n");
mockNextResult("");
mockNextResult("");
await runGitPush({ projectName: "demo-project" });
expect(wasExecCalled("git push")).toBe(true);
const lastCall = getLastExecCall();
expect(lastCall).toBeDefined();
expect(lastCall![1]).toMatchObject({ cwd: "/projects/demo" });
});
});

View File

@@ -0,0 +1,250 @@
/**
* Tests for the init command
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInit } from "../init.js";
const mockCentralInit = vi.fn();
const mockCentralClose = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockRegisterProject = vi.fn();
const mockUpdateProject = vi.fn().mockResolvedValue({});
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
getProjectByPath: mockGetProjectByPath,
registerProject: mockRegisterProject,
updateProject: mockUpdateProject,
})),
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd",
resolveGlobalDir: vi.fn(),
}));
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
describe("init command", () => {
let tempProjectDir: string;
let tempHomeDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
tempProjectDir = tempDir("fn-init-test-");
tempHomeDir = tempDir("fn-init-home-");
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempHomeDir;
process.env.USERPROFILE = tempHomeDir;
mockCentralInit.mockResolvedValue(undefined);
mockCentralClose.mockResolvedValue(undefined);
mockGetProjectByPath.mockResolvedValue(undefined);
mockRegisterProject.mockResolvedValue({
id: "proj_test",
name: "test-project",
path: tempProjectDir,
isolationMode: "in-process",
});
});
afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
}
if (existsSync(tempProjectDir)) {
rmSync(tempProjectDir, { recursive: true, force: true });
}
if (existsSync(tempHomeDir)) {
rmSync(tempHomeDir, { recursive: true, force: true });
}
});
it("should create .fusion/ directory when initializing", async () => {
const fusionDir = join(tempProjectDir, ".fusion");
expect(existsSync(fusionDir)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(fusionDir)).toBe(true);
});
it("should create fusion.db when initializing", async () => {
const dbPath = join(tempProjectDir, ".fusion", "fusion.db");
expect(existsSync(dbPath)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(dbPath)).toBe(true);
});
it("should be idempotent - report already initialized", async () => {
// First init
await runInit({ path: tempProjectDir });
mockGetProjectByPath.mockResolvedValue({
id: "proj_test",
name: "registered-project",
path: tempProjectDir,
isolationMode: "in-process",
});
// Capture console output for second run
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
// Second init - should report already initialized
await runInit({ path: tempProjectDir });
const logString = logs.join("\n");
expect(logString).toContain("already initialized");
} finally {
console.log = originalLog;
}
});
it("should use provided name option", async () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir, name: "custom-name" });
const logString = logs.join("\n");
expect(logString).toContain("custom-name");
} finally {
console.log = originalLog;
}
});
it("should not require .fusion directory to exist before init", async () => {
const fusionDir = join(tempProjectDir, ".fusion");
expect(existsSync(fusionDir)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(fusionDir)).toBe(true);
expect(existsSync(join(fusionDir, "fusion.db"))).toBe(true);
});
it("should add local storage directories to .gitignore when it doesn't exist", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
expect(existsSync(gitignorePath)).toBe(false);
await runInit({ path: tempProjectDir });
expect(existsSync(gitignorePath)).toBe(true);
const content = readFileSync(gitignorePath, "utf-8");
expect(content).toContain(".fusion");
expect(content).toContain(".pi");
});
it("should append local storage directories to existing .gitignore", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\ndist\n");
await runInit({ path: tempProjectDir });
const content = readFileSync(gitignorePath, "utf-8");
expect(content).toContain("node_modules");
expect(content).toContain("dist");
expect(content).toContain(".fusion");
expect(content).toContain(".pi");
});
it("should not duplicate local storage directories in .gitignore (idempotent)", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.fusion\n.pi\n");
await runInit({ path: tempProjectDir });
const content = readFileSync(gitignorePath, "utf-8");
const fusionMatches = content.match(/\.fusion/g);
const piMatches = content.match(/\.pi/g);
expect(fusionMatches).toHaveLength(1);
expect(piMatches).toHaveLength(1);
});
it("installs the bundled Fusion skill into Claude, Codex, and Gemini homes", async () => {
await runInit({ path: tempProjectDir });
const skillTargets = [
join(tempHomeDir, ".claude", "skills", "fusion"),
join(tempHomeDir, ".codex", "skills", "fusion"),
join(tempHomeDir, ".gemini", "skills", "fusion"),
];
for (const target of skillTargets) {
expect(existsSync(join(target, "SKILL.md"))).toBe(true);
expect(existsSync(join(target, "references", "extension-tools.md"))).toBe(true);
expect(existsSync(join(target, "workflows", "task-management.md"))).toBe(true);
}
});
it("preserves existing Fusion skill directories instead of overwriting", async () => {
const existingSkillDir = join(tempHomeDir, ".claude", "skills", "fusion");
mkdirSync(existingSkillDir, { recursive: true });
writeFileSync(join(existingSkillDir, "SKILL.md"), "custom skill content\n");
await runInit({ path: tempProjectDir });
expect(readFileSync(join(existingSkillDir, "SKILL.md"), "utf-8")).toBe("custom skill content\n");
expect(existsSync(join(existingSkillDir, "references", "extension-tools.md"))).toBe(false);
});
it("logs skill install warnings without aborting init", async () => {
const blockedClaudePath = join(tempHomeDir, ".claude");
writeFileSync(blockedClaudePath, "blocked");
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (...args: unknown[]) => {
warnings.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir });
} finally {
console.warn = originalWarn;
}
expect(existsSync(join(tempProjectDir, ".fusion", "fusion.db"))).toBe(true);
expect(warnings.some((warning) => warning.includes("Could not install bundled Fusion skill for Claude"))).toBe(true);
expect(existsSync(join(tempHomeDir, ".codex", "skills", "fusion", "SKILL.md"))).toBe(true);
expect(existsSync(join(tempHomeDir, ".gemini", "skills", "fusion", "SKILL.md"))).toBe(true);
});
it("should add .pi when .fusion is already ignored", async () => {
const gitignorePath = join(tempProjectDir, ".gitignore");
writeFileSync(gitignorePath, "node_modules\n.fusion\n");
await runInit({ path: tempProjectDir });
const content = readFileSync(gitignorePath, "utf-8");
const fusionMatches = content.match(/\.fusion/g);
const piMatches = content.match(/\.pi/g);
expect(fusionMatches).toHaveLength(1);
expect(piMatches).toHaveLength(1);
});
});

View File

@@ -0,0 +1,786 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock node:readline/promises before importing the module under test
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(),
}));
// Mock @fusion/core before importing the module under test
vi.mock("@fusion/core", () => {
return {
MissionStore: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
};
});
// Mock project-resolver
vi.mock("../../project-resolver.js", () => ({
getStore: vi.fn().mockResolvedValue({
getMissionStore: vi.fn().mockReturnValue({}),
}),
}));
import { createInterface } from "node:readline/promises";
import { getStore } from "../../project-resolver.js";
// Import after mocks
const {
runMissionCreate,
runMissionList,
runMissionShow,
runMissionDelete,
runMissionActivateSlice,
runMilestoneAdd,
runSliceAdd,
runFeatureAdd,
runFeatureLinkTask,
} = await import("../mission.js");
// Helper to mock console output
function captureConsole() {
const logs: string[] = [];
const originalLog = console.log;
const originalError = console.error;
console.log = (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
};
console.error = (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
};
return {
logs,
restore() {
console.log = originalLog;
console.error = originalError;
},
};
}
// Helper to create mock MissionStore
function createMockMissionStore(overrides = {}) {
return {
createMission: vi.fn().mockReturnValue({
id: "M-001",
title: "Test Mission",
status: "planning",
description: "Test description",
}),
listMissions: vi.fn().mockReturnValue([
{ id: "M-001", title: "Mission 1", status: "active" },
{ id: "M-002", title: "Mission 2", status: "planning" },
]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
title: "Test Mission",
status: "active",
description: "Test description",
milestones: [
{
id: "MS-001",
title: "Milestone 1",
status: "active",
slices: [
{
id: "SL-001",
title: "Slice 1",
status: "active",
features: [
{ id: "F-001", title: "Feature 1", status: "done", taskId: "FN-001" },
],
},
],
},
],
}),
getMission: vi.fn().mockReturnValue({
id: "M-001",
title: "Test Mission",
status: "active",
}),
addMilestone: vi.fn().mockReturnValue({
id: "MS-001",
title: "New Milestone",
status: "planning",
}),
getMilestone: vi.fn().mockReturnValue({
id: "MS-001",
title: "Milestone 1",
status: "active",
}),
addSlice: vi.fn().mockReturnValue({
id: "SL-001",
title: "New Slice",
status: "pending",
}),
getSlice: vi.fn().mockReturnValue({
id: "SL-001",
title: "Test Slice",
status: "pending",
}),
addFeature: vi.fn().mockReturnValue({
id: "F-001",
title: "New Feature",
status: "defined",
acceptanceCriteria: undefined,
}),
getFeature: vi.fn().mockReturnValue({
id: "F-001",
title: "Feature 1",
status: "defined",
}),
linkFeatureToTask: vi.fn().mockImplementation((featureId: string, taskId: string) => ({
id: featureId,
title: "Feature 1",
status: "triaged",
taskId,
})),
deleteMission: vi.fn(),
activateSlice: vi.fn().mockReturnValue({
id: "SL-001",
title: "Test Slice",
status: "active",
activatedAt: "2026-04-01T00:00:00Z",
}),
...overrides,
};
}
function mockResolvedProjectStore(
missionStore: ReturnType<typeof createMockMissionStore>,
overrides: Partial<{ getTask: ReturnType<typeof vi.fn> }> = {},
) {
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => missionStore,
getTask: vi.fn().mockResolvedValue({ id: "FN-001" }),
...overrides,
} as any);
}
describe("mission commands", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("runMissionCreate", () => {
it("creates mission with correct data", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
await runMissionCreate("Test Mission", "Test description");
expect(mockMissionStore.createMission).toHaveBeenCalledWith({
title: "Test Mission",
description: "Test description",
});
expect(consoleCapture.logs).toContain(" ✓ Created M-001: Test Mission");
} finally {
consoleCapture.restore();
}
});
it("creates mission with title only (no description)", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
await runMissionCreate("Test Mission", undefined);
expect(mockMissionStore.createMission).toHaveBeenCalledWith({
title: "Test Mission",
description: undefined,
});
} finally {
consoleCapture.restore();
}
});
it("prompts interactively when title not provided", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockRl = {
question: vi.fn()
.mockResolvedValueOnce("Interactive Title")
.mockResolvedValueOnce("Interactive Description"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValue(mockRl as any);
const consoleCapture = captureConsole();
try {
await runMissionCreate(undefined, undefined);
expect(createInterface).toHaveBeenCalled();
expect(mockRl.question).toHaveBeenCalledWith("Mission title: ");
expect(mockMissionStore.createMission).toHaveBeenCalledWith({
title: "Interactive Title",
description: "Interactive Description",
});
} finally {
consoleCapture.restore();
}
});
it("exits with error when interactive title is empty", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockRl = {
question: vi.fn().mockResolvedValueOnce(""), // Empty title
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValue(mockRl as any);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runMissionCreate(undefined, undefined);
} catch (e) {
// Expected
}
expect(mockError).toHaveBeenCalledWith("Title is required");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
});
describe("runMissionList", () => {
it("displays missions in formatted output", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
// Override process.exit for this test
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch (e) {
// Expected process.exit(0)
}
expect(mockMissionStore.listMissions).toHaveBeenCalled();
expect(consoleCapture.logs.some(log => log.includes("Mission 1"))).toBe(true);
expect(consoleCapture.logs.some(log => log.includes("Mission 2"))).toBe(true);
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
it("shows empty message when no missions", async () => {
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([]),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
await runMissionList();
} catch (e) {
// Expected
}
expect(consoleCapture.logs.some(log => log.includes("No missions yet"))).toBe(true);
mockExit.mockRestore();
} finally {
consoleCapture.restore();
}
});
});
describe("runMissionShow", () => {
it("displays hierarchy correctly", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
await runMissionShow("M-001");
expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001");
expect(consoleCapture.logs.some(log => log.includes("Test Mission"))).toBe(true);
expect(consoleCapture.logs.some(log => log.includes("Milestone 1"))).toBe(true);
expect(consoleCapture.logs.some(log => log.includes("Slice 1"))).toBe(true);
expect(consoleCapture.logs.some(log => log.includes("Feature 1"))).toBe(true);
} finally {
consoleCapture.restore();
}
});
it("exits with error when mission not found", async () => {
const mockMissionStore = createMockMissionStore({
getMissionWithHierarchy: vi.fn().mockReturnValue(undefined),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runMissionShow("M-999");
} catch (e) {
// Expected
}
expect(mockError).toHaveBeenCalledWith("Mission M-999 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
it("exits with error when id not provided", async () => {
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runMissionShow("");
} catch (e) {
// Expected
}
expect(mockError).toHaveBeenCalledWith("Usage: fn mission show <id>");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
});
describe("runMissionDelete", () => {
it("requires confirmation without --force", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockRl = {
question: vi.fn().mockResolvedValueOnce("n"), // User says no
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValue(mockRl as any);
const consoleCapture = captureConsole();
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
try {
try {
await runMissionDelete("M-001", false);
} catch (e) {
// Expected
}
expect(mockRl.question).toHaveBeenCalledWith(
expect.stringContaining("Are you sure you want to delete")
);
expect(mockMissionStore.deleteMission).not.toHaveBeenCalled();
} finally {
consoleCapture.restore();
mockExit.mockRestore();
}
});
it("deletes mission with --force", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
await runMissionDelete("M-001", true);
expect(mockMissionStore.deleteMission).toHaveBeenCalledWith("M-001");
expect(consoleCapture.logs.some(log => log.includes("Deleted M-001"))).toBe(true);
} finally {
consoleCapture.restore();
}
});
it("exits with error when mission not found", async () => {
const mockMissionStore = createMockMissionStore({
getMission: vi.fn().mockReturnValue(undefined),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runMissionDelete("M-999", true);
} catch (e) {
// Expected
}
expect(mockError).toHaveBeenCalledWith("✗ Mission M-999 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
});
describe("runMissionActivateSlice", () => {
it("calls MissionStore.activateSlice()", async () => {
const mockMissionStore = createMockMissionStore();
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const consoleCapture = captureConsole();
try {
await runMissionActivateSlice("SL-001");
expect(mockMissionStore.getSlice).toHaveBeenCalledWith("SL-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-001");
expect(consoleCapture.logs.some(log => log.includes("Activated SL-001"))).toBe(true);
} finally {
consoleCapture.restore();
}
});
it("exits with error when slice not found", async () => {
const mockMissionStore = createMockMissionStore({
getSlice: vi.fn().mockReturnValue(undefined),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runMissionActivateSlice("SL-999");
} catch (e) {
// Expected
}
expect(mockError).toHaveBeenCalledWith("✗ Slice SL-999 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
it("exits with error when slice is not pending", async () => {
const mockMissionStore = createMockMissionStore({
getSlice: vi.fn().mockReturnValue({ id: "SL-001", status: "active" }),
});
vi.mocked(getStore).mockResolvedValue({
getMissionStore: () => mockMissionStore,
} as any);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runMissionActivateSlice("SL-001");
} catch (e) {
// Expected
}
expect(mockError).toHaveBeenCalledWith("✗ Slice SL-001 is not pending (status: active)");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
});
describe("runMilestoneAdd", () => {
it("adds a milestone successfully", async () => {
const mockMissionStore = createMockMissionStore({
addMilestone: vi.fn().mockReturnValue({ id: "MS-010", title: "M2", status: "planning" }),
});
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
try {
await runMilestoneAdd("M-001", "M2", "Details");
expect(mockMissionStore.addMilestone).toHaveBeenCalledWith("M-001", {
title: "M2",
description: "Details",
});
expect(consoleCapture.logs.some((line) => line.includes("Added MS-010"))).toBe(true);
} finally {
consoleCapture.restore();
}
});
it("exits when mission does not exist", async () => {
const mockMissionStore = createMockMissionStore({ getMission: vi.fn().mockReturnValue(undefined) });
mockResolvedProjectStore(mockMissionStore);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runMilestoneAdd("M-404", "M2")).rejects.toThrow("process.exit");
expect(mockError).toHaveBeenCalledWith("✗ Mission M-404 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
it("prompts interactively when title is omitted", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore);
const mockRl = {
question: vi.fn().mockResolvedValueOnce("Interactive milestone").mockResolvedValueOnce("Interactive desc"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValue(mockRl as any);
await runMilestoneAdd("M-001");
expect(mockRl.question).toHaveBeenCalledWith("Milestone title: ");
expect(mockMissionStore.addMilestone).toHaveBeenCalledWith("M-001", {
title: "Interactive milestone",
description: "Interactive desc",
});
});
});
describe("runSliceAdd", () => {
it("adds a slice successfully", async () => {
const mockMissionStore = createMockMissionStore({
addSlice: vi.fn().mockReturnValue({ id: "SL-010", title: "Slice", status: "pending" }),
});
mockResolvedProjectStore(mockMissionStore);
const consoleCapture = captureConsole();
try {
await runSliceAdd("MS-001", "Slice", "Slice details");
expect(mockMissionStore.addSlice).toHaveBeenCalledWith("MS-001", {
title: "Slice",
description: "Slice details",
});
expect(consoleCapture.logs.some((line) => line.includes("Added SL-010"))).toBe(true);
} finally {
consoleCapture.restore();
}
});
it("exits when milestone does not exist", async () => {
const mockMissionStore = createMockMissionStore({ getMilestone: vi.fn().mockReturnValue(undefined) });
mockResolvedProjectStore(mockMissionStore);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runSliceAdd("MS-404", "Slice")).rejects.toThrow("process.exit");
expect(mockError).toHaveBeenCalledWith("✗ Milestone MS-404 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
it("prompts interactively when title is omitted", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore);
const mockRl = {
question: vi.fn().mockResolvedValueOnce("Interactive slice").mockResolvedValueOnce("Interactive slice desc"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValue(mockRl as any);
await runSliceAdd("MS-001");
expect(mockRl.question).toHaveBeenCalledWith("Slice title: ");
expect(mockMissionStore.addSlice).toHaveBeenCalledWith("MS-001", {
title: "Interactive slice",
description: "Interactive slice desc",
});
});
});
describe("runFeatureAdd", () => {
it("adds a feature with acceptance criteria", async () => {
const mockMissionStore = createMockMissionStore({
addFeature: vi.fn().mockReturnValue({
id: "F-010",
title: "Feature",
status: "defined",
acceptanceCriteria: "Ship works",
}),
});
mockResolvedProjectStore(mockMissionStore);
await runFeatureAdd("SL-001", "Feature", "Feature details", "Ship works");
expect(mockMissionStore.addFeature).toHaveBeenCalledWith("SL-001", {
title: "Feature",
description: "Feature details",
acceptanceCriteria: "Ship works",
});
});
it("exits when slice does not exist", async () => {
const mockMissionStore = createMockMissionStore({ getSlice: vi.fn().mockReturnValue(undefined) });
mockResolvedProjectStore(mockMissionStore);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runFeatureAdd("SL-404", "Feature")).rejects.toThrow("process.exit");
expect(mockError).toHaveBeenCalledWith("✗ Slice SL-404 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
it("prompts interactively when title is omitted", async () => {
const mockMissionStore = createMockMissionStore();
mockResolvedProjectStore(mockMissionStore);
const mockRl = {
question: vi.fn()
.mockResolvedValueOnce("Interactive feature")
.mockResolvedValueOnce("Interactive feature desc")
.mockResolvedValueOnce("Interactive acceptance"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValue(mockRl as any);
await runFeatureAdd("SL-001");
expect(mockRl.question).toHaveBeenCalledWith("Feature title: ");
expect(mockMissionStore.addFeature).toHaveBeenCalledWith("SL-001", {
title: "Interactive feature",
description: "Interactive feature desc",
acceptanceCriteria: "Interactive acceptance",
});
});
});
describe("runFeatureLinkTask", () => {
it("links a feature to a task", async () => {
const mockMissionStore = createMockMissionStore();
const getTask = vi.fn().mockResolvedValue({ id: "FN-001" });
mockResolvedProjectStore(mockMissionStore, { getTask });
await runFeatureLinkTask("F-001", "FN-001");
expect(getTask).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-001", "FN-001");
});
it("exits when feature does not exist", async () => {
const mockMissionStore = createMockMissionStore({ getFeature: vi.fn().mockReturnValue(undefined) });
mockResolvedProjectStore(mockMissionStore);
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runFeatureLinkTask("F-404", "FN-001")).rejects.toThrow("process.exit");
expect(mockError).toHaveBeenCalledWith("✗ Feature F-404 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
it("exits when task does not exist", async () => {
const mockMissionStore = createMockMissionStore();
const getTask = vi.fn().mockRejectedValue(new Error("missing"));
mockResolvedProjectStore(mockMissionStore, { getTask });
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(runFeatureLinkTask("F-001", "FN-404")).rejects.toThrow("process.exit");
expect(mockError).toHaveBeenCalledWith("✗ Task FN-404 not found");
expect(mockExit).toHaveBeenCalledWith(1);
mockExit.mockRestore();
mockError.mockRestore();
});
});
});

View File

@@ -0,0 +1,433 @@
/**
* Tests for project.ts commands
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockListProjects = vi.fn();
const mockRegisterProject = vi.fn();
const mockUpdateProject = vi.fn().mockResolvedValue({});
const mockUnregisterProject = vi.fn();
const mockGetProject = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockGetProjectHealth = vi.fn();
const mockInit = vi.fn();
const mockClose = vi.fn();
const mockQuestion = vi.fn();
const mockRlClose = vi.fn();
const mockSetDefaultProject = vi.fn();
const mockDetectProjectFromCwd = vi.fn();
const mockFormatProjectLine = vi.fn();
const mockGetSettings = vi.fn();
const mockGlobalInit = vi.fn();
const mockTaskStoreInit = vi.fn();
const mockTaskStoreListTasks = vi.fn();
const mockEnsureMemoryFileWithBackend = vi.fn();
// Mock @fusion/core
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockInit.mockResolvedValue(undefined),
close: mockClose.mockResolvedValue(undefined),
listProjects: mockListProjects,
registerProject: mockRegisterProject,
updateProject: mockUpdateProject,
unregisterProject: mockUnregisterProject,
getProject: mockGetProject,
getProjectByPath: mockGetProjectByPath,
getProjectHealth: mockGetProjectHealth,
})),
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
init: mockGlobalInit.mockResolvedValue(undefined),
getSettings: mockGetSettings,
})),
TaskStore: vi.fn().mockImplementation(() => ({
init: mockTaskStoreInit,
listTasks: mockTaskStoreListTasks,
})),
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "To Do",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
}));
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(() => ({
question: mockQuestion,
close: mockRlClose,
})),
}));
vi.mock("../../project-context.js", () => ({
formatProjectLine: mockFormatProjectLine,
detectProjectFromCwd: mockDetectProjectFromCwd,
setDefaultProject: mockSetDefaultProject,
resolveProject: vi.fn(),
}));
describe("project commands", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetSettings.mockResolvedValue({});
mockFormatProjectLine.mockImplementation((project, isDefault) => `${isDefault ? "* " : " "}${project.name}`);
mockQuestion.mockResolvedValue("y");
mockGetProjectHealth.mockResolvedValue(undefined);
mockTaskStoreInit.mockResolvedValue(undefined);
mockTaskStoreListTasks.mockResolvedValue([]);
});
afterEach(() => {
consoleSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
exitSpy.mockRestore();
});
it("exports all project command functions", async () => {
const project = await import("../project.js");
expect(typeof project.runProjectList).toBe("function");
expect(typeof project.runProjectAdd).toBe("function");
expect(typeof project.runProjectRemove).toBe("function");
expect(typeof project.runProjectShow).toBe("function");
expect(typeof project.runProjectInfo).toBe("function");
expect(typeof project.runProjectSetDefault).toBe("function");
expect(typeof project.runProjectDetect).toBe("function");
});
it("runProjectList prints registered projects and summary", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
{ id: "proj-2", name: "app-two", path: "/tmp/app-two", status: "paused", isolationMode: "child-process" },
]);
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
mockGetProject.mockImplementation(async (id: string) => (
id === "proj-1"
? { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }
: undefined
));
const { runProjectList } = await import("../project.js");
await runProjectList();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 projects registered, 1 active"));
// Check that projects are displayed in output
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("app-one");
expect(output).toContain("app-two");
});
it("runProjectList with --json flag outputs JSON", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
]);
mockGetSettings.mockResolvedValue({});
const { runProjectList } = await import("../project.js");
await runProjectList({ json: true });
// Should output JSON
const jsonOutput = consoleSpy.mock.calls.map((call) => String(call[0])).join("");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed[0].name).toBe("app-one");
});
it("runProjectAdd registers project and prints sanitized path output", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", isolationMode: "in-process" });
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", ".", { force: true });
expect(mockRegisterProject).toHaveBeenCalled();
const lines = consoleSpy.mock.calls.map((call) => String(call[0]));
expect(lines.some((line) => line.includes("Registered project 'demo'"))).toBe(true);
expect(lines.some((line) => line.includes("Location:"))).toBe(true);
expect(lines.some((line) => line.includes("/tmp/demo"))).toBe(false);
});
it("runProjectRemove unregisters project after confirmation", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
const { runProjectRemove } = await import("../project.js");
await runProjectRemove("proj-1", { force: false });
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered project 'demo'"));
});
it("runProjectRemove with --force skips confirmation", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
const { runProjectRemove } = await import("../project.js");
await runProjectRemove("proj-1", { force: true });
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
// Question should not be called when force is true
expect(mockQuestion).not.toHaveBeenCalled();
});
it("runProjectShow prints detailed project metadata without absolute path leakage", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "child-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
mockTaskStoreListTasks.mockResolvedValue([]);
const { runProjectShow } = await import("../project.js");
await runProjectShow("proj-1");
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Project: demo (default)");
expect(output).toContain("Isolation: child-process");
expect(output).toContain("Created:");
expect(output).not.toContain("/tmp/demo");
});
it("runProjectInfo is alias for runProjectShow", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({});
mockTaskStoreListTasks.mockResolvedValue([]);
const { runProjectInfo } = await import("../project.js");
await runProjectInfo("proj-1");
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Project: demo");
});
it("runProjectSetDefault sets default project", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
const { runProjectSetDefault } = await import("../project.js");
await runProjectSetDefault("proj-1");
expect(mockSetDefaultProject).toHaveBeenCalledWith("proj-1");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Set 'demo' as default project"));
});
it("runProjectDetect prints detected project without absolute path leakage", async () => {
mockDetectProjectFromCwd.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo" });
const { runProjectDetect } = await import("../project.js");
await runProjectDetect();
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Detected: demo");
expect(output).toContain("Location:");
expect(output).not.toContain("/tmp/demo");
});
it("runProjectList shows task counts for projects", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
]);
mockGetSettings.mockResolvedValue({});
// Mock task store to return some tasks - return 3 tasks
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "in-progress" },
{ id: "FN-003", column: "done" },
]);
const { runProjectList } = await import("../project.js");
await runProjectList();
// Verify TaskStore.listTasks was called
expect(mockTaskStoreListTasks).toHaveBeenCalled();
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("3"); // Total task count
});
it("runProjectShow shows task counts in output", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({});
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "todo" },
{ id: "FN-003", column: "in-progress" },
]);
const { runProjectShow } = await import("../project.js");
await runProjectShow("proj-1");
// Verify TaskStore.listTasks was called
expect(mockTaskStoreListTasks).toHaveBeenCalled();
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Total: 3");
expect(output).toContain("To Do: 2");
expect(output).toContain("In Progress: 1");
});
it("runProjectShow shows health info when available", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({});
mockGetProjectHealth.mockResolvedValue({
projectId: "proj-1",
status: "active",
activeTaskCount: 2,
inFlightAgentCount: 1,
totalTasksCompleted: 10,
totalTasksFailed: 1,
lastActivityAt: new Date().toISOString(),
});
mockTaskStoreListTasks.mockResolvedValue([]);
const { runProjectShow } = await import("../project.js");
await runProjectShow("proj-1");
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Health:");
expect(output).toContain("Active Tasks: 2");
expect(output).toContain("In-Flight Agents: 1");
expect(output).toContain("Completed: 10");
});
it("validation exits on missing required args for runProjectAdd", async () => {
const { runProjectAdd } = await import("../project.js");
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
});
it("validation exits on missing required args for runProjectRemove", async () => {
const { runProjectRemove } = await import("../project.js");
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
});
it("validation exits on missing required args for runProjectSetDefault", async () => {
const { runProjectSetDefault } = await import("../project.js");
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
});
describe("runProjectAdd memory bootstrap", () => {
// Use "." as path like the existing runProjectAdd test - it resolves to cwd which exists
const testPath = ".";
beforeEach(() => {
mockEnsureMemoryFileWithBackend.mockReset();
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
});
it("calls ensureMemoryFileWithBackend after project registration", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/fake/demo",
isolationMode: "in-process",
});
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", testPath, { force: true });
expect(mockEnsureMemoryFileWithBackend).toHaveBeenCalled();
// Verify it was called with an absolute path
const callArg = mockEnsureMemoryFileWithBackend.mock.calls[0][0];
expect(callArg).toBe(process.cwd());
});
it("shows memory initialized message when memory files are created", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/fake/demo",
isolationMode: "in-process",
});
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", testPath, { force: true });
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Memory: initialized");
});
it("does not show memory message when memory files already exist", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/fake/demo",
isolationMode: "in-process",
});
mockEnsureMemoryFileWithBackend.mockResolvedValue(false); // Files already exist
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", testPath, { force: true });
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).not.toContain("Memory: initialized");
});
it("does not block project registration when memory bootstrap fails", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/fake/demo",
isolationMode: "in-process",
});
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
const { runProjectAdd } = await import("../project.js");
await runProjectAdd("demo", testPath, { force: true });
// Project should still be registered
expect(mockRegisterProject).toHaveBeenCalled();
expect(mockUpdateProject).toHaveBeenCalledWith("proj-1", { status: "active" });
// Should show warning about memory failure on console.warn
const warnOutput = consoleWarnSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(warnOutput).toContain("Could not initialize project memory");
});
});
});

View File

@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tempWorkspace } from "@fusion/test-utils";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "../provider-auth.js";
function makeAuthStorage(credentials: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number }> = {}) {
return {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => []),
hasAuth: vi.fn((provider: string) => Boolean(credentials[provider])),
login: vi.fn(),
logout: vi.fn(),
set: vi.fn((provider: string, credential: { type: string; key?: string }) => {
credentials[provider] = credential;
}),
remove: vi.fn((provider: string) => {
delete credentials[provider];
}),
get: vi.fn((provider: string) => credentials[provider]),
getAll: vi.fn(() => ({ ...credentials })),
list: vi.fn(() => Object.keys(credentials)),
getApiKey: vi.fn(async (provider: string) => credentials[provider]?.key),
} as any;
}
describe("wrapAuthStorageWithApiKeyProviders", () => {
it("reads API keys from Fusion auth first and legacy auth fallbacks second", async () => {
const fusionAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "fusion-key" },
});
const legacyAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "legacy-openrouter-key" },
minimax: { type: "api_key", key: "legacy-minimax-key" },
});
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
expect(await wrapped.getApiKey("openrouter")).toBe("fusion-key");
expect(await wrapped.getApiKey("minimax")).toBe("legacy-minimax-key");
expect(wrapped.hasApiKey("minimax")).toBe(true);
expect(wrapped.get("minimax")).toEqual({ type: "api_key", key: "legacy-minimax-key" });
});
it("writes API keys only to Fusion auth storage", () => {
const fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "legacy-key" },
});
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
wrapped.setApiKey("openrouter", "fusion-key");
expect(fusionAuth.set).toHaveBeenCalledWith("openrouter", { type: "api_key", key: "fusion-key" });
expect(legacyAuth.set).not.toHaveBeenCalled();
});
it("reloads all read stores so status reflects both locations", () => {
const fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]);
wrapped.reload();
expect(fusionAuth.reload).toHaveBeenCalledTimes(1);
expect(legacyAuth.reload).toHaveBeenCalledTimes(1);
});
it("creates an AuthStorage-compatible merged reader for ModelRegistry", async () => {
const fusionAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "fusion-key" },
});
const legacyAuth = makeAuthStorage({
minimax: { type: "api_key", key: "legacy-minimax-key" },
});
const merged = mergeAuthStorageReads(fusionAuth, [legacyAuth]);
expect(await merged.getApiKey("openrouter")).toBe("fusion-key");
expect(await merged.getApiKey("minimax")).toBe("legacy-minimax-key");
expect(merged.get("minimax")).toEqual({ type: "api_key", key: "legacy-minimax-key" });
expect(merged.list()).toEqual(expect.arrayContaining(["openrouter", "minimax"]));
});
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = tempWorkspace("fusion-provider-auth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");
const legacyAgentAuth = join(legacyAgentDir, "auth.json");
const missingLegacyAuth = join(tempDir, ".pi", "auth.json");
mkdirSync(legacyAgentDir, { recursive: true });
writeFileSync(legacyAgentAuth, JSON.stringify({ openrouter: { type: "api_key", key: "legacy-key" } }));
const storage = createReadOnlyAuthFileStorage([legacyAgentAuth, missingLegacyAuth]);
expect(await storage.getApiKey("openrouter")).toBe("legacy-key");
expect(existsSync(missingLegacyAuth)).toBe(false);
});
it("reads non-expired OAuth credentials from legacy auth JSON", async () => {
const tempDir = tempWorkspace("fusion-provider-auth-oauth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");
const legacyAgentAuth = join(legacyAgentDir, "auth.json");
mkdirSync(legacyAgentDir, { recursive: true });
writeFileSync(
legacyAgentAuth,
JSON.stringify({
"openai-codex": {
type: "oauth",
access: "legacy-access-token",
refresh: "legacy-refresh-token",
expires: Date.now() + 60_000,
},
}),
);
const storage = createReadOnlyAuthFileStorage([legacyAgentAuth]);
expect(await storage.getApiKey("openai-codex")).toBe("legacy-access-token");
});
});

View File

@@ -0,0 +1,180 @@
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "../provider-settings.js";
function writeJson(path: string, value: Record<string, unknown>): void {
writeFileSync(path, JSON.stringify(value, null, 2));
}
describe("createReadOnlyProviderSettingsView", () => {
it("reads provider package settings from .fusion/settings.json", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(join(cwd, ".fusion"), { recursive: true });
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, "settings.json"), {
npmCommand: ["pnpm"],
globalOnly: true,
});
writeJson(join(cwd, ".fusion", "settings.json"), {
extensions: [{ name: "fusion-provider", enabled: true }],
shared: "fusion",
});
const view = createReadOnlyProviderSettingsView(cwd, agentDir);
expect(view.getGlobalSettings()).toMatchObject({
npmCommand: ["pnpm"],
globalOnly: true,
});
expect(view.getProjectSettings()).toMatchObject({
extensions: [{ name: "fusion-provider", enabled: true }],
shared: "fusion",
});
expect(view.getNpmCommand()).toEqual(["pnpm"]);
});
it("returns empty project settings when .fusion/settings.json does not exist", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
const agentDir = join(root, "agent");
mkdirSync(agentDir, { recursive: true });
writeJson(join(agentDir, "settings.json"), {
npmCommand: ["pnpm"],
});
const view = createReadOnlyProviderSettingsView(cwd, agentDir);
expect(view.getProjectSettings()).toEqual({});
expect(view.getNpmCommand()).toEqual(["pnpm"]);
});
it("merges legacy Pi and Fusion agent settings with Fusion taking precedence", () => {
const home = tempWorkspace("fusion-provider-settings-");
const cwd = join(home, "project");
const fusionAgentDir = join(home, ".fusion", "agent");
const legacyAgentDir = join(home, ".pi", "agent");
mkdirSync(join(cwd, ".fusion"), { recursive: true });
mkdirSync(fusionAgentDir, { recursive: true });
mkdirSync(legacyAgentDir, { recursive: true });
writeJson(join(legacyAgentDir, "settings.json"), {
packages: ["npm:pi-claude-cli"],
npmCommand: ["npm"],
shared: "legacy",
});
writeJson(join(fusionAgentDir, "settings.json"), {
fusionDisabledExtensions: ["/tmp/disabled.ts"],
npmCommand: ["pnpm"],
shared: "fusion",
});
const view = createReadOnlyProviderSettingsView(cwd, fusionAgentDir);
expect(view.getGlobalSettings()).toMatchObject({
packages: ["npm:pi-claude-cli"],
fusionDisabledExtensions: ["/tmp/disabled.ts"],
npmCommand: ["pnpm"],
shared: "fusion",
});
expect(view.getNpmCommand()).toEqual(["pnpm"]);
});
});
describe("createProjectSettingsPersistence", () => {
it("reads from .fusion/settings.json when it exists", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(join(cwd, ".fusion"), { recursive: true });
writeJson(join(cwd, ".fusion", "settings.json"), {
skills: ["+my-skill"],
maxConcurrent: 4,
});
const persistence = createProjectSettingsPersistence(cwd);
const settings = persistence.read();
expect(settings).toEqual({
skills: ["+my-skill"],
maxConcurrent: 4,
});
});
it("returns empty object when .fusion/settings.json does not exist", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
const persistence = createProjectSettingsPersistence(cwd);
const settings = persistence.read();
expect(settings).toEqual({});
});
it("writes to .fusion/settings.json", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
const persistence = createProjectSettingsPersistence(cwd);
persistence.write({ skills: ["+new-skill"], maxConcurrent: 2 });
const written = JSON.parse(readFileSync(join(cwd, ".fusion", "settings.json"), "utf-8"));
expect(written).toEqual({ skills: ["+new-skill"], maxConcurrent: 2 });
});
it("replaces existing settings when writing (read before write for merge)", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(join(cwd, ".fusion"), { recursive: true });
writeJson(join(cwd, ".fusion", "settings.json"), {
skills: ["+existing"],
npmCommand: ["pnpm"],
});
const persistence = createProjectSettingsPersistence(cwd);
// Write completely replaces - caller must read first for merge behavior
persistence.write({ skills: ["+new", "+another"] });
const written = JSON.parse(readFileSync(join(cwd, ".fusion", "settings.json"), "utf-8"));
expect(written).toEqual({ skills: ["+new", "+another"] });
expect(written).not.toHaveProperty("npmCommand");
});
it("creates .fusion directory if it does not exist", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
const persistence = createProjectSettingsPersistence(cwd);
persistence.write({ maxConcurrent: 3 });
const settingsPath = join(cwd, ".fusion", "settings.json");
expect(readFileSync(settingsPath, "utf-8")).toContain("maxConcurrent");
});
it("returns correct settings path via getSettingsPath", () => {
const root = tempWorkspace("fusion-provider-settings-");
const cwd = join(root, "project");
mkdirSync(cwd, { recursive: true });
const persistence = createProjectSettingsPersistence(cwd);
const settingsPath = persistence.getSettingsPath();
expect(settingsPath).toBe(join(cwd, ".fusion", "settings.json"));
});
});

View File

@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@fusion/core", () => {
const DEFAULT_SETTINGS = {
maxConcurrent: 2,
maxWorktrees: 4,
autoResolveConflicts: true,
smartConflictResolution: true,
requirePlanApproval: false,
ntfyEnabled: false,
ntfyTopic: undefined,
worktreeNaming: "random",
githubTokenConfigured: false,
defaultProvider: undefined,
defaultModelId: undefined,
};
return {
GlobalSettingsStore: vi.fn(),
DEFAULT_SETTINGS,
};
});
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { GlobalSettingsStore, DEFAULT_SETTINGS } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
import { runSettingsShow, runSettingsSet, parseValue, VALID_SETTINGS } from "../settings.js";
function makeSettings(overrides: Record<string, unknown> = {}) {
return { ...DEFAULT_SETTINGS, ...overrides };
}
describe("settings commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("exposes expected valid settings and parser behavior", () => {
expect(VALID_SETTINGS).toContain("maxConcurrent");
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
expect(parseValue("maxConcurrent", "4")).toBe(4);
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
});
it("runSettingsShow without project uses global settings even if a project could resolve", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true }));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings,
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings: vi.fn() } as any,
});
await runSettingsShow();
expect(getSettings).toHaveBeenCalled();
expect(resolveProject).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(" fn Global Settings");
});
it("runSettingsShow with project uses project store", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 5 }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings } as any,
});
await runSettingsShow("demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith(" fn Settings for project 'demo-project'");
});
it("runSettingsSet without project updates global-only settings", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ ntfyEnabled: true }));
(GlobalSettingsStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
updateSettings,
getSettings,
}));
await runSettingsSet("ntfyEnabled", "true");
expect(updateSettings).toHaveBeenCalledWith({ ntfyEnabled: true });
expect(resolveProject).not.toHaveBeenCalled();
});
it("runSettingsSet with project updates project-only settings", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxConcurrent: 6 }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettings } as any,
});
await runSettingsSet("maxConcurrent", "6", "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(updateSettings).toHaveBeenCalledWith({ maxConcurrent: 6 });
});
it("rejects global-only settings for project scope", async () => {
await expect(runSettingsSet("ntfyEnabled", "true", "demo-project")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith('Error: Setting "ntfyEnabled" is global-only. Omit --project to update it.');
});
it("rejects project-only settings without explicit project scope", async () => {
await expect(runSettingsSet("maxConcurrent", "4")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith('Error: Setting "maxConcurrent" is project-only. Use --project or run from a project directory.');
expect(resolveProject).not.toHaveBeenCalled();
});
it("runSettingsSet with project updates runStepsInNewSessions", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ runStepsInNewSessions: true }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettings } as any,
});
await runSettingsSet("runStepsInNewSessions", "true", "demo-project");
expect(updateSettings).toHaveBeenCalledWith({ runStepsInNewSessions: true });
});
it("runSettingsSet with project updates maxParallelSteps", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ maxParallelSteps: 3 }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettings } as any,
});
await runSettingsSet("maxParallelSteps", "3", "demo-project");
expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 });
});
it("rejects maxParallelSteps values outside range", async () => {
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings: vi.fn(), getSettings: vi.fn() } as any,
});
await expect(runSettingsSet("maxParallelSteps", "5", "demo-project")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Value out of range for maxParallelSteps"));
});
it("runSettingsShow displays Execution section with step-session settings", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({
runStepsInNewSessions: true,
maxParallelSteps: 3,
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings } as any,
});
await runSettingsShow("demo-project");
const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n");
expect(output).toContain("Execution");
expect(output).toContain("Run Steps In New Sessions");
expect(output).toContain("Max Parallel Steps");
});
});

File diff suppressed because it is too large Load Diff