test(FN-1283): expand CLI settings and routing coverage

- Add comprehensive settings-import tests for global/project scope handling and error paths
- Add settings-export tests covering output targets, filtering, and serialization behavior
- Expand project-resolver test matrix for flag/default/CWD resolution and fallback cases
- Extend bin entrypoint tests for command routing, argument forwarding, and failure handling
This commit is contained in:
gsxdsm
2026-04-08 10:38:15 -07:00
parent a4bba623fa
commit 110971333f
4 changed files with 932 additions and 8 deletions

View File

@@ -42,23 +42,37 @@ vi.mock("@fusion/engine", async () => {
});
// Import after mocks are set up
const projectResolver = await import("./project-resolver.js");
const {
getCentralCore,
getProjectManager,
findKbDir,
resolveProject,
getResolvedProject,
formatResolutionError,
findProjectByPath,
isProjectNameTaken,
listRegisteredProjects,
getProjectByName,
getProjectSummary,
isCentralCoreInitialized,
cleanupProjectResolution,
ProjectResolutionError,
isKbProject,
suggestProjectName,
resolveAbsolutePath,
formatLastActivity,
resetProjectResolution,
} = await import("./project-resolver.js");
} = projectResolver;
describe("Project Resolver", () => {
beforeEach(() => {
vi.clearAllMocks();
resetProjectResolution();
vi.mocked(TaskStore).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
}) as any);
});
afterEach(() => {
@@ -257,6 +271,145 @@ describe("Project Resolver", () => {
resolveProject({ project: "moved-project", interactive: false }),
).rejects.toThrow(ProjectResolutionError);
});
it("should resolve project from CWD when .fusion path matches registered project", async () => {
const mockProject = {
id: "proj_456",
name: "cwd-match",
path: "/workspace/cwd-match",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
vi.mocked(existsSync).mockImplementation((path) => {
const p = String(path);
return p === "/workspace/cwd-match/.fusion" || p === "/workspace/cwd-match";
});
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
const core = await getCentralCore();
core.listProjects.mockResolvedValue([mockProject]);
const resolved = await resolveProject({ cwd: "/workspace/cwd-match", interactive: false });
expect(resolved.projectId).toBe("proj_456");
expect(resolved.directory).toBe("/workspace/cwd-match");
});
it("should use the only registered project when no .fusion directory is found", async () => {
const onlyProject = {
id: "proj_single",
name: "solo",
path: "/workspace/solo",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/solo");
const core = await getCentralCore();
core.listProjects.mockResolvedValue([onlyProject]);
const resolved = await resolveProject({ cwd: "/nowhere/here", interactive: false });
expect(resolved.projectId).toBe("proj_single");
expect(resolved.name).toBe("solo");
});
it("should throw PATH_MISMATCH for CWD-matched project whose path no longer exists", async () => {
const match = {
id: "proj_missing",
name: "missing-cwd",
path: "/workspace/missing-cwd",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion");
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
const core = await getCentralCore();
core.listProjects.mockResolvedValue([match]);
await expect(
resolveProject({ cwd: "/workspace/missing-cwd", interactive: false }),
).rejects.toMatchObject({ code: "PATH_MISMATCH" });
});
it("should throw PATH_MISMATCH when the only fallback project path is missing", async () => {
const project = {
id: "proj_ghost",
name: "ghost",
path: "/workspace/ghost",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
vi.mocked(existsSync).mockReturnValue(false);
const core = await getCentralCore();
core.listProjects.mockResolvedValue([project]);
await expect(resolveProject({ cwd: "/not-a-project", interactive: false })).rejects.toMatchObject({
code: "PATH_MISMATCH",
});
});
it("should include similar project names in NOT_FOUND suggestions", async () => {
const core = await getCentralCore();
core.listProjects.mockResolvedValue([
{
id: "proj_alpha",
name: "alpha",
path: "/workspace/alpha",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
},
{
id: "proj_beta",
name: "beta",
path: "/workspace/beta",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
},
]);
await expect(resolveProject({ project: "alp", interactive: false })).rejects.toMatchObject({
code: "NOT_FOUND",
message: expect.stringContaining("Did you mean: alpha"),
});
});
it("getResolvedProject should return the same resolution result", async () => {
const explicit = {
id: "proj_get",
name: "get-proj",
path: "/workspace/get-proj",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/get-proj");
const core = await getCentralCore();
core.listProjects.mockResolvedValue([explicit]);
const resolved = await getResolvedProject({ project: "get-proj", interactive: false });
expect(resolved.projectId).toBe("proj_get");
expect(resolved.name).toBe("get-proj");
});
});
describe("ProjectResolutionError", () => {
@@ -286,6 +439,129 @@ describe("Project Resolver", () => {
});
});
describe("formatResolutionError", () => {
it("formats NOT_FOUND errors with available project names", () => {
const error = new ProjectResolutionError("Missing project", "NOT_FOUND", {
availableProjects: ["alpha", "beta"],
});
const formatted = formatResolutionError(error);
expect(formatted).toContain("✗ Missing project");
expect(formatted).toContain("Available projects:");
expect(formatted).toContain("- alpha");
expect(formatted).toContain("- beta");
});
it("formats MULTIPLE_MATCHES without duplicating embedded project list", () => {
const error = new ProjectResolutionError(
"Multiple projects registered. Use --project <name> to specify one.",
"MULTIPLE_MATCHES",
{ availableProjects: [{ name: "alpha", path: "/a" }] },
);
const formatted = formatResolutionError(error);
expect(formatted).toContain("✗ Multiple projects registered");
expect(formatted).not.toContain("Available projects:\n\n Available projects");
});
it("formats NO_PROJECTS errors using the embedded guidance", () => {
const error = new ProjectResolutionError("No projects registered.", "NO_PROJECTS");
const formatted = formatResolutionError(error);
expect(formatted).toContain("✗ No projects registered.");
});
});
describe("utility delegation helpers", () => {
it("findProjectByPath supports explicit central parameter", async () => {
const central = {
listProjects: vi.fn().mockResolvedValue([
{ id: "p1", name: "one", path: "/work/one", status: "active", isolationMode: "in-process" },
]),
} as any;
const found = await findProjectByPath("/work/one", central);
expect(found?.id).toBe("p1");
expect(central.listProjects).toHaveBeenCalledOnce();
});
it("findProjectByPath uses singleton central when not provided", async () => {
const core = await getCentralCore();
core.listProjects.mockResolvedValue([
{ id: "p2", name: "two", path: "/work/two", status: "active", isolationMode: "in-process" },
]);
const found = await findProjectByPath("/work/two");
expect(found?.name).toBe("two");
});
it("isProjectNameTaken performs case-insensitive matching", async () => {
const core = await getCentralCore();
core.listProjects.mockResolvedValue([
{ id: "p3", name: "AlphaProject", path: "/work/alpha", status: "active", isolationMode: "in-process" },
]);
await expect(isProjectNameTaken("alphaproject")).resolves.toBe(true);
await expect(isProjectNameTaken("ALPHAPROJECT")).resolves.toBe(true);
await expect(isProjectNameTaken("beta")).resolves.toBe(false);
});
it("listRegisteredProjects delegates to central.listProjects", async () => {
const projects = [
{ id: "p4", name: "listed", path: "/work/listed", status: "active", isolationMode: "in-process" },
];
const core = await getCentralCore();
core.listProjects.mockResolvedValue(projects);
await expect(listRegisteredProjects()).resolves.toEqual(projects);
expect(core.listProjects).toHaveBeenCalled();
});
it("getProjectByName returns found and undefined when missing", async () => {
const core = await getCentralCore();
core.listProjects.mockResolvedValue([
{ id: "p5", name: "found", path: "/work/found", status: "active", isolationMode: "in-process" },
]);
await expect(getProjectByName("found")).resolves.toMatchObject({ id: "p5" });
await expect(getProjectByName("missing")).resolves.toBeUndefined();
});
it("getProjectSummary returns only name/path/status", async () => {
const core = await getCentralCore();
core.listProjects.mockResolvedValue([
{
id: "p6",
name: "summary",
path: "/work/summary",
status: "paused",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
},
]);
await expect(getProjectSummary()).resolves.toEqual([
{ name: "summary", path: "/work/summary", status: "paused" },
]);
});
it("tracks central initialization state before/after getCentralCore", async () => {
expect(isCentralCoreInitialized()).toBe(false);
await getCentralCore();
expect(isCentralCoreInitialized()).toBe(true);
});
it("cleanupProjectResolution closes central and resets singleton state", async () => {
const core = await getCentralCore();
await getProjectManager();
await cleanupProjectResolution();
expect(core.close).toHaveBeenCalledOnce();
expect(isCentralCoreInitialized()).toBe(false);
});
});
describe("formatLastActivity", () => {
it("should format 'just now' for recent timestamps", () => {
const now = new Date().toISOString();
@@ -307,6 +583,21 @@ describe("Project Resolver", () => {
expect(formatLastActivity(threeDaysAgo)).toBe("3d ago");
});
it("should honor branch boundaries at 59m, 23h, and 6d", () => {
const fiftyNineMinutesAgo = new Date(Date.now() - 59 * 60000).toISOString();
const twentyThreeHoursAgo = new Date(Date.now() - 23 * 3600000).toISOString();
const sixDaysAgo = new Date(Date.now() - 6 * 86400000).toISOString();
expect(formatLastActivity(fiftyNineMinutesAgo)).toBe("59m ago");
expect(formatLastActivity(twentyThreeHoursAgo)).toBe("23h ago");
expect(formatLastActivity(sixDaysAgo)).toBe("6d ago");
});
it("returns locale date string for activity older than seven days", () => {
const oldDate = new Date(Date.now() - 8 * 86400000);
expect(formatLastActivity(oldDate.toISOString())).toBe(oldDate.toLocaleDateString());
});
it("should return 'never' for undefined timestamp", () => {
expect(formatLastActivity(undefined)).toBe("never");
});