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:
@@ -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"]);
|
||||||
|
|
||||||
|
|||||||
146
packages/cli/src/commands/__tests__/settings-export.test.ts
Normal file
146
packages/cli/src/commands/__tests__/settings-export.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
194
packages/cli/src/commands/__tests__/settings-import.test.ts
Normal file
194
packages/cli/src/commands/__tests__/settings-import.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user