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:
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user