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(() => ({
runDashboard: vi.fn(),
runServe: vi.fn(),
runDesktop: vi.fn(),
runInit: vi.fn(),
runTaskCreate: vi.fn(),
runTaskList: vi.fn(),
runTaskMove: vi.fn(),
@@ -15,6 +18,7 @@ const commandMocks = vi.hoisted(() => ({
runTaskPause: vi.fn(),
runTaskUnpause: vi.fn(),
runTaskImportFromGitHub: vi.fn(),
runTaskImportGitHubInteractive: vi.fn(),
runTaskDuplicate: vi.fn(),
runTaskArchive: vi.fn(),
runTaskUnarchive: vi.fn(),
@@ -26,23 +30,28 @@ const commandMocks = vi.hoisted(() => ({
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
runTaskPrCreate: vi.fn(),
runSettingsShow: vi.fn(),
runSettingsSet: vi.fn(),
runSettingsExport: vi.fn(),
runSettingsImport: vi.fn(),
runGitStatus: vi.fn(),
runGitFetch: vi.fn(),
runGitPull: vi.fn(),
runGitPush: vi.fn(),
runBackupCreate: vi.fn(),
runBackupList: vi.fn(),
runBackupRestore: vi.fn(),
runBackupCleanup: vi.fn(),
runMissionCreate: vi.fn(),
runMissionList: vi.fn(),
runMissionShow: vi.fn(),
runMissionDelete: vi.fn(),
runMissionActivateSlice: vi.fn(),
runProjectList: vi.fn(),
runProjectAdd: vi.fn(),
runProjectRemove: vi.fn(),
@@ -50,10 +59,30 @@ const commandMocks = vi.hoisted(() => ({
runProjectInfo: vi.fn(),
runProjectSetDefault: 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/serve.js", () => ({ runServe: commandMocks.runServe }));
vi.mock("./commands/desktop.js", () => ({ runDesktop: commandMocks.runDesktop }));
vi.mock("./commands/init.js", () => ({ runInit: commandMocks.runInit }));
vi.mock("./commands/task.js", () => ({
runTaskCreate: commandMocks.runTaskCreate,
runTaskList: commandMocks.runTaskList,
@@ -67,6 +96,7 @@ vi.mock("./commands/task.js", () => ({
runTaskPause: commandMocks.runTaskPause,
runTaskUnpause: commandMocks.runTaskUnpause,
runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub,
runTaskImportGitHubInteractive: commandMocks.runTaskImportGitHubInteractive,
runTaskDuplicate: commandMocks.runTaskDuplicate,
runTaskArchive: commandMocks.runTaskArchive,
runTaskUnarchive: commandMocks.runTaskUnarchive,
@@ -79,24 +109,28 @@ vi.mock("./commands/task.js", () => ({
runTaskSteer: commandMocks.runTaskSteer,
runTaskPrCreate: commandMocks.runTaskPrCreate,
}));
vi.mock("./commands/settings.js", () => ({
runSettingsShow: commandMocks.runSettingsShow,
runSettingsSet: commandMocks.runSettingsSet,
}));
vi.mock("./commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport }));
vi.mock("./commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport }));
vi.mock("./commands/git.js", () => ({
runGitStatus: commandMocks.runGitStatus,
runGitFetch: commandMocks.runGitFetch,
runGitPull: commandMocks.runGitPull,
runGitPush: commandMocks.runGitPush,
}));
vi.mock("./commands/backup.js", () => ({
runBackupCreate: commandMocks.runBackupCreate,
runBackupList: commandMocks.runBackupList,
runBackupRestore: commandMocks.runBackupRestore,
runBackupCleanup: commandMocks.runBackupCleanup,
}));
vi.mock("./commands/mission.js", () => ({
runMissionCreate: commandMocks.runMissionCreate,
runMissionList: commandMocks.runMissionList,
@@ -104,6 +138,7 @@ vi.mock("./commands/mission.js", () => ({
runMissionDelete: commandMocks.runMissionDelete,
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
}));
vi.mock("./commands/project.js", () => ({
runProjectList: commandMocks.runProjectList,
runProjectAdd: commandMocks.runProjectAdd,
@@ -114,15 +149,42 @@ vi.mock("./commands/project.js", () => ({
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 originalExit = process.exit;
const originalEnvProject = process.env.FN_PROJECT;
const originalSkipMigration = process.env.KB_SKIP_MIGRATION;
let importCounter = 0;
async function runBin(args: string[]) {
process.argv = ["node", "bin.ts", ...args];
importCounter += 1;
if (importCounter === 1) {
await import("./bin.ts?test=1");
} else if (importCounter === 2) {
@@ -133,15 +195,86 @@ async function runBin(args: string[]) {
await import("./bin.ts?test=4");
} else if (importCounter === 5) {
await import("./bin.ts?test=5");
} else {
} else if (importCounter === 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(() => {
vi.clearAllMocks();
delete process.env.FN_PROJECT;
process.env.KB_SKIP_MIGRATION = "1";
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
@@ -150,13 +283,173 @@ describe("bin mission command integration", () => {
afterEach(() => {
process.argv = originalArgv;
process.exit = originalExit;
if (originalEnvProject === undefined) {
delete process.env.FN_PROJECT;
if (originalSkipMigration === undefined) {
delete process.env.KB_SKIP_MIGRATION;
} 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 () => {
await runBin(["mission", "create", "Test Mission", "Detailed", "mission", "description", "--project", "demo"]);