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:
122
packages/cli/src/commands/__tests__/agent-export.test.ts
Normal file
122
packages/cli/src/commands/__tests__/agent-export.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
671
packages/cli/src/commands/__tests__/agent-import.test.ts
Normal file
671
packages/cli/src/commands/__tests__/agent-import.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
packages/cli/src/commands/__tests__/auth-paths.test.ts
Normal file
45
packages/cli/src/commands/__tests__/auth-paths.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
122
packages/cli/src/commands/__tests__/backup.test.ts
Normal file
122
packages/cli/src/commands/__tests__/backup.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
189
packages/cli/src/commands/__tests__/claude-skills.test.ts
Normal file
189
packages/cli/src/commands/__tests__/claude-skills.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
265
packages/cli/src/commands/__tests__/desktop.test.ts
Normal file
265
packages/cli/src/commands/__tests__/desktop.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
226
packages/cli/src/commands/__tests__/git.test.ts
Normal file
226
packages/cli/src/commands/__tests__/git.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
250
packages/cli/src/commands/__tests__/init.test.ts
Normal file
250
packages/cli/src/commands/__tests__/init.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
786
packages/cli/src/commands/__tests__/mission.test.ts
Normal file
786
packages/cli/src/commands/__tests__/mission.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
433
packages/cli/src/commands/__tests__/project.test.ts
Normal file
433
packages/cli/src/commands/__tests__/project.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
124
packages/cli/src/commands/__tests__/provider-auth.test.ts
Normal file
124
packages/cli/src/commands/__tests__/provider-auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
180
packages/cli/src/commands/__tests__/provider-settings.test.ts
Normal file
180
packages/cli/src/commands/__tests__/provider-settings.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
208
packages/cli/src/commands/__tests__/settings.test.ts
Normal file
208
packages/cli/src/commands/__tests__/settings.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
2483
packages/cli/src/commands/__tests__/task.test.ts
Normal file
2483
packages/cli/src/commands/__tests__/task.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user