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

@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const commandMocks = vi.hoisted(() => ({ const commandMocks = vi.hoisted(() => ({
runDashboard: vi.fn(), runDashboard: vi.fn(),
runServe: vi.fn(),
runDesktop: vi.fn(), runDesktop: vi.fn(),
runInit: vi.fn(),
runTaskCreate: vi.fn(), runTaskCreate: vi.fn(),
runTaskList: vi.fn(), runTaskList: vi.fn(),
runTaskMove: vi.fn(), runTaskMove: vi.fn(),
@@ -15,6 +18,7 @@ const commandMocks = vi.hoisted(() => ({
runTaskPause: vi.fn(), runTaskPause: vi.fn(),
runTaskUnpause: vi.fn(), runTaskUnpause: vi.fn(),
runTaskImportFromGitHub: vi.fn(), runTaskImportFromGitHub: vi.fn(),
runTaskImportGitHubInteractive: vi.fn(),
runTaskDuplicate: vi.fn(), runTaskDuplicate: vi.fn(),
runTaskArchive: vi.fn(), runTaskArchive: vi.fn(),
runTaskUnarchive: vi.fn(), runTaskUnarchive: vi.fn(),
@@ -26,23 +30,28 @@ const commandMocks = vi.hoisted(() => ({
runTaskComments: vi.fn(), runTaskComments: vi.fn(),
runTaskSteer: vi.fn(), runTaskSteer: vi.fn(),
runTaskPrCreate: vi.fn(), runTaskPrCreate: vi.fn(),
runSettingsShow: vi.fn(), runSettingsShow: vi.fn(),
runSettingsSet: vi.fn(), runSettingsSet: vi.fn(),
runSettingsExport: vi.fn(), runSettingsExport: vi.fn(),
runSettingsImport: vi.fn(), runSettingsImport: vi.fn(),
runGitStatus: vi.fn(), runGitStatus: vi.fn(),
runGitFetch: vi.fn(), runGitFetch: vi.fn(),
runGitPull: vi.fn(), runGitPull: vi.fn(),
runGitPush: vi.fn(), runGitPush: vi.fn(),
runBackupCreate: vi.fn(), runBackupCreate: vi.fn(),
runBackupList: vi.fn(), runBackupList: vi.fn(),
runBackupRestore: vi.fn(), runBackupRestore: vi.fn(),
runBackupCleanup: vi.fn(), runBackupCleanup: vi.fn(),
runMissionCreate: vi.fn(), runMissionCreate: vi.fn(),
runMissionList: vi.fn(), runMissionList: vi.fn(),
runMissionShow: vi.fn(), runMissionShow: vi.fn(),
runMissionDelete: vi.fn(), runMissionDelete: vi.fn(),
runMissionActivateSlice: vi.fn(), runMissionActivateSlice: vi.fn(),
runProjectList: vi.fn(), runProjectList: vi.fn(),
runProjectAdd: vi.fn(), runProjectAdd: vi.fn(),
runProjectRemove: vi.fn(), runProjectRemove: vi.fn(),
@@ -50,10 +59,30 @@ const commandMocks = vi.hoisted(() => ({
runProjectInfo: vi.fn(), runProjectInfo: vi.fn(),
runProjectSetDefault: vi.fn(), runProjectSetDefault: vi.fn(),
runProjectDetect: vi.fn(), runProjectDetect: vi.fn(),
runNodeList: vi.fn(),
runNodeAdd: vi.fn(),
runNodeRemove: vi.fn(),
runNodeShow: vi.fn(),
runNodeHealth: vi.fn(),
runAgentStop: vi.fn(),
runAgentStart: vi.fn(),
runAgentImport: vi.fn(),
runMessageInbox: vi.fn(),
runMessageOutbox: vi.fn(),
runMessageSend: vi.fn(),
runMessageRead: vi.fn(),
runMessageDelete: vi.fn(),
runAgentMailbox: vi.fn(),
})); }));
vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard })); vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
vi.mock("./commands/serve.js", () => ({ runServe: commandMocks.runServe }));
vi.mock("./commands/desktop.js", () => ({ runDesktop: commandMocks.runDesktop })); vi.mock("./commands/desktop.js", () => ({ runDesktop: commandMocks.runDesktop }));
vi.mock("./commands/init.js", () => ({ runInit: commandMocks.runInit }));
vi.mock("./commands/task.js", () => ({ vi.mock("./commands/task.js", () => ({
runTaskCreate: commandMocks.runTaskCreate, runTaskCreate: commandMocks.runTaskCreate,
runTaskList: commandMocks.runTaskList, runTaskList: commandMocks.runTaskList,
@@ -67,6 +96,7 @@ vi.mock("./commands/task.js", () => ({
runTaskPause: commandMocks.runTaskPause, runTaskPause: commandMocks.runTaskPause,
runTaskUnpause: commandMocks.runTaskUnpause, runTaskUnpause: commandMocks.runTaskUnpause,
runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub, runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub,
runTaskImportGitHubInteractive: commandMocks.runTaskImportGitHubInteractive,
runTaskDuplicate: commandMocks.runTaskDuplicate, runTaskDuplicate: commandMocks.runTaskDuplicate,
runTaskArchive: commandMocks.runTaskArchive, runTaskArchive: commandMocks.runTaskArchive,
runTaskUnarchive: commandMocks.runTaskUnarchive, runTaskUnarchive: commandMocks.runTaskUnarchive,
@@ -79,24 +109,28 @@ vi.mock("./commands/task.js", () => ({
runTaskSteer: commandMocks.runTaskSteer, runTaskSteer: commandMocks.runTaskSteer,
runTaskPrCreate: commandMocks.runTaskPrCreate, runTaskPrCreate: commandMocks.runTaskPrCreate,
})); }));
vi.mock("./commands/settings.js", () => ({ vi.mock("./commands/settings.js", () => ({
runSettingsShow: commandMocks.runSettingsShow, runSettingsShow: commandMocks.runSettingsShow,
runSettingsSet: commandMocks.runSettingsSet, runSettingsSet: commandMocks.runSettingsSet,
})); }));
vi.mock("./commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport })); vi.mock("./commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport }));
vi.mock("./commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport })); vi.mock("./commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport }));
vi.mock("./commands/git.js", () => ({ vi.mock("./commands/git.js", () => ({
runGitStatus: commandMocks.runGitStatus, runGitStatus: commandMocks.runGitStatus,
runGitFetch: commandMocks.runGitFetch, runGitFetch: commandMocks.runGitFetch,
runGitPull: commandMocks.runGitPull, runGitPull: commandMocks.runGitPull,
runGitPush: commandMocks.runGitPush, runGitPush: commandMocks.runGitPush,
})); }));
vi.mock("./commands/backup.js", () => ({ vi.mock("./commands/backup.js", () => ({
runBackupCreate: commandMocks.runBackupCreate, runBackupCreate: commandMocks.runBackupCreate,
runBackupList: commandMocks.runBackupList, runBackupList: commandMocks.runBackupList,
runBackupRestore: commandMocks.runBackupRestore, runBackupRestore: commandMocks.runBackupRestore,
runBackupCleanup: commandMocks.runBackupCleanup, runBackupCleanup: commandMocks.runBackupCleanup,
})); }));
vi.mock("./commands/mission.js", () => ({ vi.mock("./commands/mission.js", () => ({
runMissionCreate: commandMocks.runMissionCreate, runMissionCreate: commandMocks.runMissionCreate,
runMissionList: commandMocks.runMissionList, runMissionList: commandMocks.runMissionList,
@@ -104,6 +138,7 @@ vi.mock("./commands/mission.js", () => ({
runMissionDelete: commandMocks.runMissionDelete, runMissionDelete: commandMocks.runMissionDelete,
runMissionActivateSlice: commandMocks.runMissionActivateSlice, runMissionActivateSlice: commandMocks.runMissionActivateSlice,
})); }));
vi.mock("./commands/project.js", () => ({ vi.mock("./commands/project.js", () => ({
runProjectList: commandMocks.runProjectList, runProjectList: commandMocks.runProjectList,
runProjectAdd: commandMocks.runProjectAdd, runProjectAdd: commandMocks.runProjectAdd,
@@ -114,15 +149,42 @@ vi.mock("./commands/project.js", () => ({
runProjectDetect: commandMocks.runProjectDetect, runProjectDetect: commandMocks.runProjectDetect,
})); }));
vi.mock("./commands/node.js", () => ({
runNodeList: commandMocks.runNodeList,
runNodeAdd: commandMocks.runNodeAdd,
runNodeRemove: commandMocks.runNodeRemove,
runNodeShow: commandMocks.runNodeShow,
runNodeHealth: commandMocks.runNodeHealth,
}));
vi.mock("./commands/agent.js", () => ({
runAgentStop: commandMocks.runAgentStop,
runAgentStart: commandMocks.runAgentStart,
}));
vi.mock("./commands/agent-import.js", () => ({
runAgentImport: commandMocks.runAgentImport,
}));
vi.mock("./commands/message.js", () => ({
runMessageInbox: commandMocks.runMessageInbox,
runMessageOutbox: commandMocks.runMessageOutbox,
runMessageSend: commandMocks.runMessageSend,
runMessageRead: commandMocks.runMessageRead,
runMessageDelete: commandMocks.runMessageDelete,
runAgentMailbox: commandMocks.runAgentMailbox,
}));
const originalArgv = process.argv; const originalArgv = process.argv;
const originalExit = process.exit; const originalExit = process.exit;
const originalEnvProject = process.env.FN_PROJECT; const originalSkipMigration = process.env.KB_SKIP_MIGRATION;
let importCounter = 0; let importCounter = 0;
async function runBin(args: string[]) { async function runBin(args: string[]) {
process.argv = ["node", "bin.ts", ...args]; process.argv = ["node", "bin.ts", ...args];
importCounter += 1; importCounter += 1;
if (importCounter === 1) { if (importCounter === 1) {
await import("./bin.ts?test=1"); await import("./bin.ts?test=1");
} else if (importCounter === 2) { } else if (importCounter === 2) {
@@ -133,15 +195,86 @@ async function runBin(args: string[]) {
await import("./bin.ts?test=4"); await import("./bin.ts?test=4");
} else if (importCounter === 5) { } else if (importCounter === 5) {
await import("./bin.ts?test=5"); await import("./bin.ts?test=5");
} else { } else if (importCounter === 6) {
await import("./bin.ts?test=6"); await import("./bin.ts?test=6");
} else if (importCounter === 7) {
await import("./bin.ts?test=7");
} else if (importCounter === 8) {
await import("./bin.ts?test=8");
} else if (importCounter === 9) {
await import("./bin.ts?test=9");
} else if (importCounter === 10) {
await import("./bin.ts?test=10");
} else if (importCounter === 11) {
await import("./bin.ts?test=11");
} else if (importCounter === 12) {
await import("./bin.ts?test=12");
} else if (importCounter === 13) {
await import("./bin.ts?test=13");
} else if (importCounter === 14) {
await import("./bin.ts?test=14");
} else if (importCounter === 15) {
await import("./bin.ts?test=15");
} else if (importCounter === 16) {
await import("./bin.ts?test=16");
} else if (importCounter === 17) {
await import("./bin.ts?test=17");
} else if (importCounter === 18) {
await import("./bin.ts?test=18");
} else if (importCounter === 19) {
await import("./bin.ts?test=19");
} else if (importCounter === 20) {
await import("./bin.ts?test=20");
} else if (importCounter === 21) {
await import("./bin.ts?test=21");
} else if (importCounter === 22) {
await import("./bin.ts?test=22");
} else if (importCounter === 23) {
await import("./bin.ts?test=23");
} else if (importCounter === 24) {
await import("./bin.ts?test=24");
} else if (importCounter === 25) {
await import("./bin.ts?test=25");
} else if (importCounter === 26) {
await import("./bin.ts?test=26");
} else if (importCounter === 27) {
await import("./bin.ts?test=27");
} else if (importCounter === 28) {
await import("./bin.ts?test=28");
} else if (importCounter === 29) {
await import("./bin.ts?test=29");
} else if (importCounter === 30) {
await import("./bin.ts?test=30");
} else if (importCounter === 31) {
await import("./bin.ts?test=31");
} else if (importCounter === 32) {
await import("./bin.ts?test=32");
} else if (importCounter === 33) {
await import("./bin.ts?test=33");
} else if (importCounter === 34) {
await import("./bin.ts?test=34");
} else if (importCounter === 35) {
await import("./bin.ts?test=35");
} else if (importCounter === 36) {
await import("./bin.ts?test=36");
} else if (importCounter === 37) {
await import("./bin.ts?test=37");
} else if (importCounter === 38) {
await import("./bin.ts?test=38");
} else if (importCounter === 39) {
await import("./bin.ts?test=39");
} else {
await import("./bin.ts?test=40");
} }
} }
describe("bin mission command integration", () => { describe("bin command routing and fallbacks", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
delete process.env.FN_PROJECT; process.env.KB_SKIP_MIGRATION = "1";
process.exit = vi.fn(((code?: number) => { process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`); throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit); }) as typeof process.exit);
@@ -150,13 +283,173 @@ describe("bin mission command integration", () => {
afterEach(() => { afterEach(() => {
process.argv = originalArgv; process.argv = originalArgv;
process.exit = originalExit; process.exit = originalExit;
if (originalEnvProject === undefined) { if (originalSkipMigration === undefined) {
delete process.env.FN_PROJECT; delete process.env.KB_SKIP_MIGRATION;
} else { } else {
process.env.FN_PROJECT = originalEnvProject; process.env.KB_SKIP_MIGRATION = originalSkipMigration;
} }
}); });
it("shows help with --help and exits 0", async () => {
await expect(runBin(["--help"])).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("fn — AI-orchestrated task board"));
});
it("shows help when no args are provided", async () => {
await expect(runBin([])).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Usage:"));
});
it("prints an error for unknown top-level command", async () => {
await expect(runBin(["unknown-cmd"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown command: unknown-cmd");
});
it("errors on duplicate --project flags", async () => {
await expect(runBin(["task", "list", "--project", "alpha", "-P", "beta"])).rejects.toThrow(
"Duplicate --project flag",
);
});
it("errors when --project is missing a value", async () => {
await expect(runBin(["task", "list", "--project"])).rejects.toThrow("Usage: --project <name>");
});
it("routes settings export with scope/output/project", async () => {
await runBin(["settings", "export", "--scope", "global", "--output", "./out.json", "-P", "demo"]);
expect(commandMocks.runSettingsExport).toHaveBeenCalledWith({
scope: "global",
output: "./out.json",
projectName: "demo",
});
});
it("routes settings import with file and flags", async () => {
await runBin(["settings", "import", "file.json", "--scope", "global", "--merge", "--yes", "-P", "demo"]);
expect(commandMocks.runSettingsImport).toHaveBeenCalledWith("file.json", {
scope: "global",
merge: true,
yes: true,
projectName: "demo",
});
});
it("errors when settings import file is missing", async () => {
await expect(runBin(["settings", "import"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
"Usage: fn settings import <file> [--scope global|project|both] [--merge] [--yes]",
);
});
it("errors on unknown settings subcommand", async () => {
await expect(runBin(["settings", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown settings subcommand: oops");
});
it("routes git fetch/pull/push with expected options", async () => {
await runBin(["git", "fetch", "origin", "-P", "demo"]);
await runBin(["git", "pull", "--yes", "-P", "demo"]);
await runBin(["git", "push", "--yes", "-P", "demo"]);
expect(commandMocks.runGitFetch).toHaveBeenCalledWith("origin", "demo");
expect(commandMocks.runGitPull).toHaveBeenCalledWith({ skipConfirm: true, projectName: "demo" });
expect(commandMocks.runGitPush).toHaveBeenCalledWith({ skipConfirm: true, projectName: "demo" });
});
it("errors on unknown git subcommand", async () => {
await expect(runBin(["git", "rebase"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: git rebase");
});
it("routes backup create/list/cleanup/restore", async () => {
await runBin(["backup", "--create", "-P", "demo"]);
await runBin(["backup", "--list", "-P", "demo"]);
await runBin(["backup", "--cleanup", "-P", "demo"]);
await runBin(["backup", "--restore", "backup.db", "-P", "demo"]);
expect(commandMocks.runBackupCreate).toHaveBeenCalledWith("demo");
expect(commandMocks.runBackupList).toHaveBeenCalledWith("demo");
expect(commandMocks.runBackupCleanup).toHaveBeenCalledWith("demo");
expect(commandMocks.runBackupRestore).toHaveBeenCalledWith("backup.db", "demo");
});
it("errors when backup flags are missing", async () => {
await expect(runBin(["backup"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
"Usage: fn backup --create | --list | --cleanup | --restore <filename>",
);
});
it("errors for task move missing arguments", async () => {
await expect(runBin(["task", "move"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task move <id> <column>");
});
it("errors for task show missing id", async () => {
await expect(runBin(["task", "show"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn task show <id>");
});
it("routes agent subcommands stop/start/import/mailbox", async () => {
await runBin(["agent", "stop", "agent-1", "-P", "demo"]);
await runBin(["agent", "start", "agent-1", "-P", "demo"]);
await runBin(["agent", "import", "company.md", "--dry-run", "-P", "demo"]);
await runBin(["agent", "mailbox", "agent-1", "-P", "demo"]);
expect(commandMocks.runAgentStop).toHaveBeenCalledWith("agent-1", "demo");
expect(commandMocks.runAgentStart).toHaveBeenCalledWith("agent-1", "demo");
expect(commandMocks.runAgentImport).toHaveBeenCalledWith("company.md", {
dryRun: true,
skipExisting: false,
project: "demo",
});
expect(commandMocks.runAgentMailbox).toHaveBeenCalledWith("agent-1", "demo");
});
it("routes message subcommands send/read/delete/inbox/outbox", async () => {
await runBin(["message", "send", "agent-7", "hello", "there", "-P", "demo"]);
await runBin(["message", "read", "msg-1", "-P", "demo"]);
await runBin(["message", "delete", "msg-1", "-P", "demo"]);
await runBin(["message", "inbox", "-P", "demo"]);
await runBin(["message", "outbox", "-P", "demo"]);
expect(commandMocks.runMessageSend).toHaveBeenCalledWith("agent-7", "hello there", "demo");
expect(commandMocks.runMessageRead).toHaveBeenCalledWith("msg-1", "demo");
expect(commandMocks.runMessageDelete).toHaveBeenCalledWith("msg-1", "demo");
expect(commandMocks.runMessageInbox).toHaveBeenCalledWith("demo");
expect(commandMocks.runMessageOutbox).toHaveBeenCalledWith("demo");
});
it("routes node add with typed option parsing", async () => {
await runBin([
"node",
"add",
"worker-a",
"--url",
"http://x",
"--api-key",
"key",
"--max-concurrent",
"4",
]);
expect(commandMocks.runNodeAdd).toHaveBeenCalledWith("worker-a", {
url: "http://x",
apiKey: "key",
maxConcurrent: 4,
});
});
it("passes extracted --project into command handlers", async () => {
await runBin(["task", "list", "--project", "alpha"]);
await runBin(["settings", "show", "-P", "alpha"]);
expect(commandMocks.runTaskList).toHaveBeenCalledWith("alpha");
expect(commandMocks.runSettingsShow).toHaveBeenCalledWith("alpha");
});
it("routes mission create with multi-word description and project flag", async () => { it("routes mission create with multi-word description and project flag", async () => {
await runBin(["mission", "create", "Test Mission", "Detailed", "mission", "description", "--project", "demo"]); await runBin(["mission", "create", "Test Mission", "Detailed", "mission", "description", "--project", "demo"]);

View File

@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: mockStoreInit,
})),
exportSettings: vi.fn(),
generateExportFilename: vi.fn(),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { runSettingsExport } from "../settings-export.js";
describe("runSettingsExport", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
if ((code ?? 0) === 0) {
return undefined as never;
}
throw new Error(`process.exit:${code ?? 0}`);
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo",
projectPath: "/tmp/demo",
isRegistered: true,
store: {} as any,
});
vi.mocked(generateExportFilename).mockReturnValue("fusion-settings-2026-04-08-120000.json");
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
global: { ntfyEnabled: true, defaultProvider: "anthropic" },
project: { maxConcurrent: 3, maxWorktrees: 4 },
} as any);
vi.mocked(writeFile).mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("exports successfully with an auto-generated filename", async () => {
await runSettingsExport();
const expectedPath = join(process.cwd(), "fusion-settings-2026-04-08-120000.json");
expect(generateExportFilename).toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledWith(expectedPath, expect.any(String));
expect(logSpy).toHaveBeenCalledWith(` ✓ Settings exported to ${expectedPath}`);
expect(logSpy).toHaveBeenCalledWith(" Exported: 2 global setting(s), 2 project setting(s)");
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("exports successfully with a custom --output path", async () => {
await runSettingsExport({ output: "./tmp/exported.json" });
expect(writeFile).toHaveBeenCalledWith(resolve("./tmp/exported.json"), expect.any(String));
});
it("exports only global settings with --scope global", async () => {
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
global: { ntfyEnabled: true },
} as any);
await runSettingsExport({ scope: "global" });
expect(exportSettings).toHaveBeenCalledWith(expect.any(Object), { scope: "global" });
expect(logSpy).toHaveBeenCalledWith(" Exported: 1 global setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("project setting"));
});
it("exports only project settings with --scope project", async () => {
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
project: { maxConcurrent: 6 },
} as any);
await runSettingsExport({ scope: "project" });
expect(exportSettings).toHaveBeenCalledWith(expect.any(Object), { scope: "project" });
expect(logSpy).toHaveBeenCalledWith(" Exported: 1 project setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("global setting"));
});
it("handles export with empty global/project settings", async () => {
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
global: {},
project: {},
} as any);
await runSettingsExport();
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Exported:"));
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("prints an error and exits when exportSettings throws", async () => {
vi.mocked(exportSettings).mockRejectedValue(new Error("export failed"));
await expect(runSettingsExport()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: export failed");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("prints an error and exits when writing the file fails", async () => {
vi.mocked(writeFile).mockRejectedValue(new Error("permission denied"));
await expect(runSettingsExport()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: permission denied");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("resolves project name and initializes store at the resolved path", async () => {
await runSettingsExport({ projectName: "alpha" });
expect(resolveProject).toHaveBeenCalledWith("alpha");
expect(TaskStore).toHaveBeenCalledWith("/tmp/demo");
expect(mockStoreInit).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,194 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { existsSync } from "node:fs";
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: mockStoreInit,
})),
importSettings: vi.fn(),
readExportFile: vi.fn(),
validateImportData: vi.fn(),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { runSettingsImport } from "../settings-import.js";
describe("runSettingsImport", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
if ((code ?? 0) === 0) {
return undefined as never;
}
throw new Error(`process.exit:${code ?? 0}`);
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo",
projectPath: "/tmp/demo",
isRegistered: true,
store: {} as any,
});
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readExportFile).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T00:00:00.000Z",
global: { ntfyEnabled: true, defaultProvider: "anthropic" },
project: { maxConcurrent: 3, autoResolveConflicts: true, maxWorktrees: 4 },
} as any);
vi.mocked(validateImportData).mockReturnValue([]);
vi.mocked(importSettings).mockResolvedValue({
success: true,
globalCount: 2,
projectCount: 3,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("exits when import file does not exist", async () => {
vi.mocked(existsSync).mockReturnValue(false);
await expect(runSettingsImport("./missing.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("File not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits when reading the import file fails", async () => {
vi.mocked(readExportFile).mockRejectedValue(new Error("read denied"));
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Failed to read import file: read denied");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("prints validation errors and exits", async () => {
vi.mocked(validateImportData).mockReturnValue(["bad version", "missing global"]);
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid import file:");
expect(errorSpy).toHaveBeenCalledWith(" - bad version");
expect(errorSpy).toHaveBeenCalledWith(" - missing global");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("fails when selected scope has no settings to import", async () => {
vi.mocked(readExportFile).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T00:00:00.000Z",
global: {},
project: {},
} as any);
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: No settings to import in the specified scope");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("requires --yes confirmation before importing", async () => {
await expect(runSettingsImport("./settings.json")).rejects.toThrow("process.exit:1");
expect(logSpy).toHaveBeenCalledWith(" Use --yes to confirm this import operation");
expect(importSettings).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("imports successfully with --yes", async () => {
await runSettingsImport("./settings.json", { yes: true });
expect(importSettings).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{ scope: "both", merge: true },
);
expect(logSpy).toHaveBeenCalledWith(" ✓ Settings imported successfully");
expect(logSpy).toHaveBeenCalledWith(" Imported 2 global setting(s)");
expect(logSpy).toHaveBeenCalledWith(" Imported 3 project setting(s)");
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("imports only global settings when --scope global is used", async () => {
vi.mocked(importSettings).mockResolvedValue({
success: true,
globalCount: 2,
projectCount: 0,
});
await runSettingsImport("./settings.json", { scope: "global", yes: true });
expect(importSettings).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), {
scope: "global",
merge: true,
});
expect(logSpy).toHaveBeenCalledWith(" Imported 2 global setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(" Imported 3 project setting(s)");
});
it("imports only project settings when --scope project is used", async () => {
vi.mocked(importSettings).mockResolvedValue({
success: true,
globalCount: 0,
projectCount: 3,
});
await runSettingsImport("./settings.json", { scope: "project", yes: true });
expect(importSettings).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), {
scope: "project",
merge: true,
});
expect(logSpy).toHaveBeenCalledWith(" Imported 3 project setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(" Imported 2 global setting(s)");
});
it("prints core import failure and exits", async () => {
vi.mocked(importSettings).mockResolvedValue({
success: false,
globalCount: 0,
projectCount: 0,
error: "DB error",
});
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Import failed: DB error");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("shows replace mode when merge is false", async () => {
await expect(runSettingsImport("./settings.json", { merge: false })).rejects.toThrow("process.exit:1");
expect(logSpy).toHaveBeenCalledWith(" Mode: replace");
expect(importSettings).not.toHaveBeenCalled();
});
it("resolves project name and initializes store at resolved path", async () => {
await runSettingsImport("./settings.json", { projectName: "alpha", yes: true });
expect(resolveProject).toHaveBeenCalledWith("alpha");
expect(TaskStore).toHaveBeenCalledWith("/tmp/demo");
expect(mockStoreInit).toHaveBeenCalledOnce();
});
});

View File

@@ -42,23 +42,37 @@ vi.mock("@fusion/engine", async () => {
}); });
// Import after mocks are set up // Import after mocks are set up
const projectResolver = await import("./project-resolver.js");
const { const {
getCentralCore, getCentralCore,
getProjectManager, getProjectManager,
findKbDir, findKbDir,
resolveProject, resolveProject,
getResolvedProject,
formatResolutionError,
findProjectByPath,
isProjectNameTaken,
listRegisteredProjects,
getProjectByName,
getProjectSummary,
isCentralCoreInitialized,
cleanupProjectResolution,
ProjectResolutionError, ProjectResolutionError,
isKbProject, isKbProject,
suggestProjectName, suggestProjectName,
resolveAbsolutePath, resolveAbsolutePath,
formatLastActivity, formatLastActivity,
resetProjectResolution, resetProjectResolution,
} = await import("./project-resolver.js"); } = projectResolver;
describe("Project Resolver", () => { describe("Project Resolver", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resetProjectResolution(); resetProjectResolution();
vi.mocked(TaskStore).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
}) as any);
}); });
afterEach(() => { afterEach(() => {
@@ -257,6 +271,145 @@ describe("Project Resolver", () => {
resolveProject({ project: "moved-project", interactive: false }), resolveProject({ project: "moved-project", interactive: false }),
).rejects.toThrow(ProjectResolutionError); ).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", () => { 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", () => { describe("formatLastActivity", () => {
it("should format 'just now' for recent timestamps", () => { it("should format 'just now' for recent timestamps", () => {
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -307,6 +583,21 @@ describe("Project Resolver", () => {
expect(formatLastActivity(threeDaysAgo)).toBe("3d ago"); 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", () => { it("should return 'never' for undefined timestamp", () => {
expect(formatLastActivity(undefined)).toBe("never"); expect(formatLastActivity(undefined)).toBe("never");
}); });