chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts
- Move all co-located *.test.* files into sibling __tests__/ directories so the
layout is consistent across packages (159 renames + content-rewrite moves).
Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
accidentally committed in a merge and the stale pi.js was masking a real
test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
createFnAgent / promptWithFallback / describeModel from @fusion/engine
instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
the two plugin package.jsons that were missing it; exports describeModel
from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
.test.ts; switch three boundary-result toEqual assertions to toMatchObject
so the new content/isError fields don't trip exact-match comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +1,16 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
const runTaskCreate = vi.fn();
|
const commandMocks = vi.hoisted(() => ({
|
||||||
const runTaskList = vi.fn();
|
|
||||||
const runDesktop = vi.fn();
|
|
||||||
const runServe = vi.fn();
|
|
||||||
const runDaemon = vi.fn();
|
|
||||||
const runTaskPlan = vi.fn();
|
|
||||||
const runTaskImportFromGitHub = vi.fn();
|
|
||||||
const runSettingsShow = vi.fn();
|
|
||||||
const runSettingsExport = vi.fn();
|
|
||||||
const runSettingsImport = vi.fn();
|
|
||||||
const runGitStatus = vi.fn();
|
|
||||||
const runGitFetch = vi.fn();
|
|
||||||
const runBackupList = vi.fn();
|
|
||||||
const runProjectList = vi.fn();
|
|
||||||
const runProjectAdd = vi.fn();
|
|
||||||
const runProjectRemove = vi.fn();
|
|
||||||
const runProjectShow = vi.fn();
|
|
||||||
const runProjectInfo = vi.fn();
|
|
||||||
const runProjectSetDefault = vi.fn();
|
|
||||||
const runProjectDetect = vi.fn();
|
|
||||||
const runNodeList = vi.fn();
|
|
||||||
const runNodeConnect = vi.fn();
|
|
||||||
const runNodeDisconnect = vi.fn();
|
|
||||||
const runNodeShow = vi.fn();
|
|
||||||
const runNodeHealth = vi.fn();
|
|
||||||
const runMeshStatus = vi.fn();
|
|
||||||
// Legacy aliases
|
|
||||||
const runNodeAdd = vi.fn();
|
|
||||||
const runNodeRemove = vi.fn();
|
|
||||||
const runAgentStop = vi.fn();
|
|
||||||
const runAgentStart = vi.fn();
|
|
||||||
const runAgentMailbox = vi.fn();
|
|
||||||
const runAgentImport = vi.fn();
|
|
||||||
const runMessageInbox = vi.fn();
|
|
||||||
const runMessageOutbox = vi.fn();
|
|
||||||
const runMessageSend = vi.fn();
|
|
||||||
const runMessageRead = vi.fn();
|
|
||||||
const runMessageDelete = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../commands/dashboard.js", () => ({
|
|
||||||
runDashboard: vi.fn(),
|
runDashboard: vi.fn(),
|
||||||
}));
|
runServe: vi.fn(),
|
||||||
|
runDaemon: vi.fn(),
|
||||||
|
runDesktop: vi.fn(),
|
||||||
|
runInit: vi.fn(),
|
||||||
|
|
||||||
vi.mock("../commands/desktop.js", () => ({
|
runTaskCreate: vi.fn(),
|
||||||
runDesktop,
|
runTaskList: vi.fn(),
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../commands/serve.js", () => ({
|
|
||||||
runServe,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../commands/daemon.js", () => ({
|
|
||||||
runDaemon,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../commands/task.js", () => ({
|
|
||||||
runTaskCreate,
|
|
||||||
runTaskList,
|
|
||||||
runTaskMove: vi.fn(),
|
runTaskMove: vi.fn(),
|
||||||
runTaskMerge: vi.fn(),
|
runTaskMerge: vi.fn(),
|
||||||
runTaskUpdate: vi.fn(),
|
runTaskUpdate: vi.fn(),
|
||||||
@@ -67,301 +20,418 @@ vi.mock("../commands/task.js", () => ({
|
|||||||
runTaskAttach: vi.fn(),
|
runTaskAttach: vi.fn(),
|
||||||
runTaskPause: vi.fn(),
|
runTaskPause: vi.fn(),
|
||||||
runTaskUnpause: vi.fn(),
|
runTaskUnpause: vi.fn(),
|
||||||
runTaskImportFromGitHub,
|
runTaskImportFromGitHub: vi.fn(),
|
||||||
runTaskImportGitHubInteractive: vi.fn(),
|
runTaskImportGitHubInteractive: vi.fn(),
|
||||||
runTaskDuplicate: vi.fn(),
|
runTaskDuplicate: vi.fn(),
|
||||||
runTaskArchive: vi.fn(),
|
runTaskArchive: vi.fn(),
|
||||||
runTaskUnarchive: vi.fn(),
|
runTaskUnarchive: vi.fn(),
|
||||||
runTaskRefine: vi.fn(),
|
runTaskRefine: vi.fn(),
|
||||||
runTaskPlan,
|
runTaskPlan: vi.fn(),
|
||||||
runTaskDelete: vi.fn(),
|
runTaskDelete: vi.fn(),
|
||||||
runTaskRetry: vi.fn(),
|
runTaskRetry: vi.fn(),
|
||||||
runTaskComment: vi.fn(),
|
runTaskComment: vi.fn(),
|
||||||
runTaskComments: vi.fn(),
|
runTaskComments: vi.fn(),
|
||||||
runTaskSteer: vi.fn(),
|
runTaskSteer: vi.fn(),
|
||||||
runTaskPrCreate: 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(),
|
||||||
|
runProjectShow: vi.fn(),
|
||||||
|
runProjectInfo: vi.fn(),
|
||||||
|
runProjectSetDefault: vi.fn(),
|
||||||
|
runProjectDetect: vi.fn(),
|
||||||
|
|
||||||
|
runNodeList: vi.fn(),
|
||||||
|
runNodeConnect: vi.fn(),
|
||||||
|
runNodeDisconnect: vi.fn(),
|
||||||
|
runNodeShow: vi.fn(),
|
||||||
|
runNodeHealth: vi.fn(),
|
||||||
|
runMeshStatus: vi.fn(),
|
||||||
|
// Legacy aliases
|
||||||
|
runNodeAdd: vi.fn(),
|
||||||
|
runNodeRemove: 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/daemon.js", () => ({ runDaemon: commandMocks.runDaemon }));
|
||||||
|
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,
|
||||||
|
runTaskMove: commandMocks.runTaskMove,
|
||||||
|
runTaskMerge: commandMocks.runTaskMerge,
|
||||||
|
runTaskUpdate: commandMocks.runTaskUpdate,
|
||||||
|
runTaskLog: commandMocks.runTaskLog,
|
||||||
|
runTaskLogs: commandMocks.runTaskLogs,
|
||||||
|
runTaskShow: commandMocks.runTaskShow,
|
||||||
|
runTaskAttach: commandMocks.runTaskAttach,
|
||||||
|
runTaskPause: commandMocks.runTaskPause,
|
||||||
|
runTaskUnpause: commandMocks.runTaskUnpause,
|
||||||
|
runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub,
|
||||||
|
runTaskImportGitHubInteractive: commandMocks.runTaskImportGitHubInteractive,
|
||||||
|
runTaskDuplicate: commandMocks.runTaskDuplicate,
|
||||||
|
runTaskArchive: commandMocks.runTaskArchive,
|
||||||
|
runTaskUnarchive: commandMocks.runTaskUnarchive,
|
||||||
|
runTaskRefine: commandMocks.runTaskRefine,
|
||||||
|
runTaskPlan: commandMocks.runTaskPlan,
|
||||||
|
runTaskDelete: commandMocks.runTaskDelete,
|
||||||
|
runTaskRetry: commandMocks.runTaskRetry,
|
||||||
|
runTaskComment: commandMocks.runTaskComment,
|
||||||
|
runTaskComments: commandMocks.runTaskComments,
|
||||||
|
runTaskSteer: commandMocks.runTaskSteer,
|
||||||
|
runTaskPrCreate: commandMocks.runTaskPrCreate,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/settings.js", () => ({
|
vi.mock("../commands/settings.js", () => ({
|
||||||
runSettingsShow,
|
runSettingsShow: commandMocks.runSettingsShow,
|
||||||
runSettingsSet: vi.fn(),
|
runSettingsSet: commandMocks.runSettingsSet,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport }));
|
||||||
vi.mock("../commands/settings-export.js", () => ({ runSettingsExport }));
|
vi.mock("../commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport }));
|
||||||
vi.mock("../commands/settings-import.js", () => ({ runSettingsImport }));
|
|
||||||
|
|
||||||
vi.mock("../commands/git.js", () => ({
|
vi.mock("../commands/git.js", () => ({
|
||||||
runGitStatus,
|
runGitStatus: commandMocks.runGitStatus,
|
||||||
runGitFetch,
|
runGitFetch: commandMocks.runGitFetch,
|
||||||
runGitPull: vi.fn(),
|
runGitPull: commandMocks.runGitPull,
|
||||||
runGitPush: vi.fn(),
|
runGitPush: commandMocks.runGitPush,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/backup.js", () => ({
|
vi.mock("../commands/backup.js", () => ({
|
||||||
runBackupCreate: vi.fn(),
|
runBackupCreate: commandMocks.runBackupCreate,
|
||||||
runBackupList,
|
runBackupList: commandMocks.runBackupList,
|
||||||
runBackupRestore: vi.fn(),
|
runBackupRestore: commandMocks.runBackupRestore,
|
||||||
runBackupCleanup: vi.fn(),
|
runBackupCleanup: commandMocks.runBackupCleanup,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/mission.js", () => ({
|
||||||
|
runMissionCreate: commandMocks.runMissionCreate,
|
||||||
|
runMissionList: commandMocks.runMissionList,
|
||||||
|
runMissionShow: commandMocks.runMissionShow,
|
||||||
|
runMissionDelete: commandMocks.runMissionDelete,
|
||||||
|
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/project.js", () => ({
|
vi.mock("../commands/project.js", () => ({
|
||||||
runProjectList,
|
runProjectList: commandMocks.runProjectList,
|
||||||
runProjectAdd,
|
runProjectAdd: commandMocks.runProjectAdd,
|
||||||
runProjectRemove,
|
runProjectRemove: commandMocks.runProjectRemove,
|
||||||
runProjectShow,
|
runProjectShow: commandMocks.runProjectShow,
|
||||||
runProjectInfo,
|
runProjectInfo: commandMocks.runProjectInfo,
|
||||||
runProjectSetDefault,
|
runProjectSetDefault: commandMocks.runProjectSetDefault,
|
||||||
runProjectDetect,
|
runProjectDetect: commandMocks.runProjectDetect,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/node.js", () => ({
|
vi.mock("../commands/node.js", () => ({
|
||||||
runNodeList,
|
runNodeList: commandMocks.runNodeList,
|
||||||
runNodeConnect,
|
runNodeConnect: commandMocks.runNodeConnect,
|
||||||
runNodeDisconnect,
|
runNodeDisconnect: commandMocks.runNodeDisconnect,
|
||||||
runNodeShow,
|
runNodeShow: commandMocks.runNodeShow,
|
||||||
runNodeHealth,
|
runNodeHealth: commandMocks.runNodeHealth,
|
||||||
runMeshStatus,
|
runMeshStatus: commandMocks.runMeshStatus,
|
||||||
// Legacy aliases
|
// Legacy aliases
|
||||||
runNodeAdd,
|
runNodeAdd: commandMocks.runNodeAdd,
|
||||||
runNodeRemove,
|
runNodeRemove: commandMocks.runNodeRemove,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/agent.js", () => ({
|
vi.mock("../commands/agent.js", () => ({
|
||||||
runAgentStop,
|
runAgentStop: commandMocks.runAgentStop,
|
||||||
runAgentStart,
|
runAgentStart: commandMocks.runAgentStart,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/agent-import.js", () => ({
|
vi.mock("../commands/agent-import.js", () => ({
|
||||||
runAgentImport,
|
runAgentImport: commandMocks.runAgentImport,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/message.js", () => ({
|
vi.mock("../commands/message.js", () => ({
|
||||||
runMessageInbox,
|
runMessageInbox: commandMocks.runMessageInbox,
|
||||||
runMessageOutbox,
|
runMessageOutbox: commandMocks.runMessageOutbox,
|
||||||
runMessageSend,
|
runMessageSend: commandMocks.runMessageSend,
|
||||||
runMessageRead,
|
runMessageRead: commandMocks.runMessageRead,
|
||||||
runMessageDelete,
|
runMessageDelete: commandMocks.runMessageDelete,
|
||||||
runAgentMailbox,
|
runAgentMailbox: commandMocks.runAgentMailbox,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("bin", () => {
|
const originalArgv = process.argv;
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
const originalExit = process.exit;
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
|
||||||
let exitSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|
||||||
let originalArgv: string[];
|
let importCounter = 0;
|
||||||
|
|
||||||
|
async function runBin(args: string[]) {
|
||||||
|
process.argv = ["node", "bin.ts", ...args];
|
||||||
|
importCounter += 1;
|
||||||
|
await import(/* @vite-ignore */ `../bin.ts?test=${importCounter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bin command routing and fallbacks", () => {
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
originalArgv = process.argv;
|
delete process.env.PI_PACKAGE_DIR;
|
||||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
process.exit = vi.fn(((code?: number) => {
|
||||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
vi.spyOn(process, "exit").mockImplementation(((code?: number | string | null) => {
|
|
||||||
throw new Error(`process.exit:${code ?? 0}`);
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
}) as never);
|
}) as typeof process.exit);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.argv = originalArgv;
|
process.argv = originalArgv;
|
||||||
logSpy.mockRestore();
|
process.exit = originalExit;
|
||||||
errorSpy.mockRestore();
|
|
||||||
vi.restoreAllMocks();
|
if (originalPiPackageDir === undefined) {
|
||||||
|
delete process.env.PI_PACKAGE_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runBin(args: string[]) {
|
it("configures pi to use .fusion as its project config directory", async () => {
|
||||||
process.argv = ["node", "bin", ...args];
|
await expect(runBin(["--help"])).rejects.toThrow("process.exit:0");
|
||||||
vi.resetModules();
|
|
||||||
return import("../bin.ts");
|
const piPackageDir = process.env.PI_PACKAGE_DIR;
|
||||||
}
|
expect(piPackageDir).toBeTruthy();
|
||||||
|
const pkg = JSON.parse(readFileSync(join(piPackageDir!, "package.json"), "utf-8")) as {
|
||||||
|
piConfig?: { configDir?: string };
|
||||||
|
};
|
||||||
|
expect(pkg.piConfig?.configDir).toBe(".fusion");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("launches dashboard when no args are provided", async () => {
|
||||||
|
commandMocks.runDashboard.mockResolvedValue({ dispose: vi.fn() });
|
||||||
|
await runBin([]);
|
||||||
|
expect(commandMocks.runDashboard).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"routes task list with --project before subcommand",
|
"prints an error for unknown top-level command",
|
||||||
async () => {
|
async () => {
|
||||||
await runBin(["--project", "my-app", "task", "list"]);
|
await expect(runBin(["unknown-cmd"])).rejects.toThrow("process.exit:1");
|
||||||
expect(runTaskList).toHaveBeenCalledWith("my-app");
|
expect(errorSpy).toHaveBeenCalledWith("Unknown command: unknown-cmd");
|
||||||
},
|
},
|
||||||
15000,
|
15000,
|
||||||
);
|
);
|
||||||
|
|
||||||
it("preserves legacy task list behavior when project flag is absent", async () => {
|
it("errors on duplicate --project flags", async () => {
|
||||||
await runBin(["task", "list"]);
|
await expect(runBin(["task", "list", "--project", "alpha", "-P", "beta"])).rejects.toThrow(
|
||||||
expect(runTaskList).toHaveBeenCalledWith(undefined);
|
"Duplicate --project flag",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes task import with short -P and preserves other flags", async () => {
|
it("errors when --project is missing a value", async () => {
|
||||||
await runBin(["task", "import", "owner/repo", "-P", "my-app", "--limit", "10", "--labels", "bug,help-wanted"]);
|
await expect(runBin(["task", "list", "--project"])).rejects.toThrow("Usage: --project <name>");
|
||||||
expect(runTaskImportFromGitHub).toHaveBeenCalledWith("owner/repo", { limit: 10, labels: ["bug", "help-wanted"] }, "my-app");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips --project before parsing free-form task create arguments", async () => {
|
it("routes settings export with scope/output/project", async () => {
|
||||||
await runBin(["task", "create", "Fix", "login", "--attach", "spec.md", "--project", "demo"]);
|
await runBin(["settings", "export", "--scope", "global", "--output", "./out.json", "-P", "demo"]);
|
||||||
expect(runTaskCreate).toHaveBeenCalledWith("Fix login", ["spec.md"], undefined, "demo");
|
|
||||||
|
expect(commandMocks.runSettingsExport).toHaveBeenCalledWith({
|
||||||
|
scope: "global",
|
||||||
|
output: "./out.json",
|
||||||
|
projectName: "demo",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips --project before parsing free-form task plan arguments", async () => {
|
it("routes settings import with file and flags", async () => {
|
||||||
await runBin(["task", "plan", "Fix", "auth", "--project", "demo", "--yes"]);
|
await runBin(["settings", "import", "file.json", "--scope", "global", "--merge", "--yes", "-P", "demo"]);
|
||||||
expect(runTaskPlan).toHaveBeenCalledWith("Fix auth", true, "demo");
|
|
||||||
|
expect(commandMocks.runSettingsImport).toHaveBeenCalledWith("file.json", {
|
||||||
|
scope: "global",
|
||||||
|
merge: true,
|
||||||
|
yes: true,
|
||||||
|
projectName: "demo",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes projectName to settings, git, and backup handlers", async () => {
|
it("errors when settings import file is missing", async () => {
|
||||||
await runBin(["settings", "--project", "my-app"]);
|
await expect(runBin(["settings", "import"])).rejects.toThrow("process.exit:1");
|
||||||
expect(runSettingsShow).toHaveBeenCalledWith("my-app");
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"Usage: fn settings import <file> [--scope global|project|both] [--merge] [--yes]",
|
||||||
await runBin(["git", "status", "--project", "my-app"]);
|
);
|
||||||
expect(runGitStatus).toHaveBeenCalledWith("my-app");
|
|
||||||
|
|
||||||
await runBin(["backup", "--list", "--project", "my-app"]);
|
|
||||||
expect(runBackupList).toHaveBeenCalledWith("my-app");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes projectName through to settings export and import handlers", async () => {
|
it("errors on unknown settings subcommand", async () => {
|
||||||
await runBin(["settings", "export", "--project", "demo", "--scope", "project"]);
|
await expect(runBin(["settings", "oops"])).rejects.toThrow("process.exit:1");
|
||||||
expect(runSettingsExport).toHaveBeenCalledWith({ scope: "project", output: undefined, projectName: "demo" });
|
expect(errorSpy).toHaveBeenCalledWith("Unknown settings subcommand: oops");
|
||||||
|
|
||||||
await runBin(["settings", "import", "settings.json", "--project", "demo", "--yes"]);
|
|
||||||
expect(runSettingsImport).toHaveBeenCalledWith("settings.json", { scope: "both", merge: false, yes: true, projectName: "demo" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes projectName through to git fetch handler without leaking args", async () => {
|
it("routes git fetch/pull/push with expected options", async () => {
|
||||||
await runBin(["git", "fetch", "origin", "--project", "demo"]);
|
await runBin(["git", "fetch", "origin", "-P", "demo"]);
|
||||||
expect(runGitFetch).toHaveBeenCalledWith("origin", "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("passes projectName through to agent import handler", async () => {
|
it("errors on unknown git subcommand", async () => {
|
||||||
await runBin(["agent", "import", "./agents.sh", "--dry-run", "--skip-existing", "--project", "demo"]);
|
await expect(runBin(["git", "rebase"])).rejects.toThrow("process.exit:1");
|
||||||
expect(runAgentImport).toHaveBeenCalledWith("./agents.sh", {
|
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,
|
dryRun: true,
|
||||||
skipExisting: true,
|
skipExisting: false,
|
||||||
project: "demo",
|
project: "demo",
|
||||||
});
|
});
|
||||||
|
expect(commandMocks.runAgentMailbox).toHaveBeenCalledWith("agent-1", "demo");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses multi-word message send content and project flag", async () => {
|
it("routes message subcommands send/read/delete/inbox/outbox", async () => {
|
||||||
await runBin(["message", "send", "agent-123", "Hello", "from", "CLI", "--project", "demo"]);
|
await runBin(["message", "send", "agent-7", "hello", "there", "-P", "demo"]);
|
||||||
expect(runMessageSend).toHaveBeenCalledWith("agent-123", "Hello from CLI", "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 project subcommands and aliases", async () => {
|
it("routes node add with typed option parsing", async () => {
|
||||||
await runBin(["project", "list"]);
|
await runBin([
|
||||||
await runBin(["project", "ls"]);
|
"node",
|
||||||
expect(runProjectList).toHaveBeenCalledTimes(2);
|
"add",
|
||||||
|
"worker-a",
|
||||||
|
"--url",
|
||||||
|
"http://x",
|
||||||
|
"--api-key",
|
||||||
|
"key",
|
||||||
|
"--max-concurrent",
|
||||||
|
"4",
|
||||||
|
]);
|
||||||
|
|
||||||
await runBin(["project", "add", "my-app", "/tmp/my-app", "--isolation", "child-process", "--force"]);
|
expect(commandMocks.runNodeConnect).toHaveBeenCalledWith("worker-a", {
|
||||||
expect(runProjectAdd).toHaveBeenCalledWith("my-app", "/tmp/my-app", { isolation: "child-process", force: true, interactive: false });
|
url: "http://x",
|
||||||
|
apiKey: "key",
|
||||||
await runBin(["project", "remove", "my-app", "--force"]);
|
maxConcurrent: 4,
|
||||||
await runBin(["project", "rm", "my-app", "--force"]);
|
|
||||||
expect(runProjectRemove).toHaveBeenCalledWith("my-app", { force: true });
|
|
||||||
|
|
||||||
await runBin(["project", "show", "my-app"]);
|
|
||||||
expect(runProjectShow).toHaveBeenCalledWith("my-app");
|
|
||||||
|
|
||||||
await runBin(["project", "set-default", "my-app"]);
|
|
||||||
await runBin(["project", "default", "my-app"]);
|
|
||||||
expect(runProjectSetDefault).toHaveBeenCalledWith("my-app");
|
|
||||||
|
|
||||||
await runBin(["project", "detect"]);
|
|
||||||
expect(runProjectDetect).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown project subcommands", async () => {
|
|
||||||
await expect(runBin(["project", "wat"])).rejects.toThrow("process.exit:1");
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: project wat");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes node subcommands and aliases", async () => {
|
|
||||||
await runBin(["node", "list"]);
|
|
||||||
await runBin(["node", "ls", "--json"]);
|
|
||||||
expect(runNodeList).toHaveBeenNthCalledWith(1, { json: false });
|
|
||||||
expect(runNodeList).toHaveBeenNthCalledWith(2, { json: true });
|
|
||||||
|
|
||||||
// connect is the primary command
|
|
||||||
await runBin(["node", "connect", "my-node", "--url", "https://node.example.com", "--api-key", "abc", "--max-concurrent", "3"]);
|
|
||||||
expect(runNodeConnect).toHaveBeenCalledWith("my-node", {
|
|
||||||
url: "https://node.example.com",
|
|
||||||
apiKey: "abc",
|
|
||||||
maxConcurrent: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
// add is a legacy alias for connect
|
|
||||||
await runBin(["node", "add", "my-node2", "--url", "https://node2.example.com"]);
|
|
||||||
expect(runNodeConnect).toHaveBeenCalledWith("my-node2", {
|
|
||||||
url: "https://node2.example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
await runBin(["node", "disconnect", "my-node", "--force"]);
|
|
||||||
expect(runNodeDisconnect).toHaveBeenCalledWith("my-node", { force: true });
|
|
||||||
|
|
||||||
await runBin(["node", "remove", "my-node", "--force"]);
|
|
||||||
await runBin(["node", "rm", "my-node", "--force"]);
|
|
||||||
expect(runNodeDisconnect).toHaveBeenCalledWith("my-node", { force: true });
|
|
||||||
|
|
||||||
await runBin(["node", "show", "my-node"]);
|
|
||||||
await runBin(["node", "info", "my-node"]);
|
|
||||||
expect(runNodeShow).toHaveBeenCalledWith("my-node", { json: false });
|
|
||||||
|
|
||||||
await runBin(["node", "show", "my-node", "--json"]);
|
|
||||||
expect(runNodeShow).toHaveBeenCalledWith("my-node", { json: true });
|
|
||||||
|
|
||||||
await runBin(["node", "health", "my-node"]);
|
|
||||||
expect(runNodeHealth).toHaveBeenCalledWith("my-node");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes mesh status command", async () => {
|
|
||||||
await runBin(["mesh", "status"]);
|
|
||||||
expect(runMeshStatus).toHaveBeenCalledWith({ json: false });
|
|
||||||
|
|
||||||
await runBin(["mesh", "status", "--json"]);
|
|
||||||
expect(runMeshStatus).toHaveBeenCalledWith({ json: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown node subcommands", async () => {
|
|
||||||
await expect(runBin(["node", "wat"])).rejects.toThrow("process.exit:1");
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: node wat");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown mesh subcommands", async () => {
|
|
||||||
await expect(runBin(["mesh", "wat"])).rejects.toThrow("process.exit:1");
|
|
||||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: mesh wat");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes serve command with port, host, paused, and interactive flags", async () => {
|
|
||||||
await runBin(["serve", "--port", "5050", "--host", "127.0.0.1", "--paused", "--interactive"]);
|
|
||||||
|
|
||||||
expect(runServe).toHaveBeenCalledWith(5050, {
|
|
||||||
paused: true,
|
|
||||||
interactive: true,
|
|
||||||
host: "127.0.0.1",
|
|
||||||
daemon: false,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes serve command with --daemon flag", async () => {
|
it("passes extracted --project into command handlers", async () => {
|
||||||
await runBin(["serve", "--daemon"]);
|
await runBin(["task", "list", "--project", "alpha"]);
|
||||||
|
await runBin(["settings", "show", "-P", "alpha"]);
|
||||||
|
|
||||||
expect(runServe).toHaveBeenCalledWith(4040, {
|
expect(commandMocks.runTaskList).toHaveBeenCalledWith("alpha");
|
||||||
paused: false,
|
expect(commandMocks.runSettingsShow).toHaveBeenCalledWith("alpha");
|
||||||
interactive: false,
|
|
||||||
host: undefined,
|
|
||||||
daemon: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes serve command with --daemon flag combined with other options", async () => {
|
it("routes mission create with multi-word description and project flag", async () => {
|
||||||
await runBin(["serve", "--port", "6060", "--daemon", "--host", "0.0.0.0"]);
|
await runBin(["mission", "create", "Test Mission", "Detailed", "mission", "description", "--project", "demo"]);
|
||||||
|
|
||||||
expect(runServe).toHaveBeenCalledWith(6060, {
|
expect(commandMocks.runMissionCreate).toHaveBeenCalledWith(
|
||||||
paused: false,
|
"Test Mission",
|
||||||
interactive: false,
|
"Detailed mission description",
|
||||||
host: "0.0.0.0",
|
"demo",
|
||||||
daemon: true,
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes daemon command with port, host, token, paused, and token-only flags", async () => {
|
it("routes mission list alias", async () => {
|
||||||
|
await runBin(["mission", "ls"]);
|
||||||
|
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes mission show alias", async () => {
|
||||||
|
await runBin(["mission", "info", "M-001"]);
|
||||||
|
expect(commandMocks.runMissionShow).toHaveBeenCalledWith("M-001", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes mission delete with force flag", async () => {
|
||||||
|
await runBin(["mission", "delete", "M-001", "--force"]);
|
||||||
|
expect(commandMocks.runMissionDelete).toHaveBeenCalledWith("M-001", true, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes mission activate-slice", async () => {
|
||||||
|
await runBin(["mission", "activate-slice", "SL-001"]);
|
||||||
|
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes daemon command with all flags", async () => {
|
||||||
await runBin(["daemon", "--port", "4040", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);
|
await runBin(["daemon", "--port", "4040", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);
|
||||||
|
|
||||||
expect(runDaemon).toHaveBeenCalledWith({
|
expect(commandMocks.runDaemon).toHaveBeenCalledWith({
|
||||||
port: 4040,
|
port: 4040,
|
||||||
paused: true,
|
paused: true,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
@@ -371,10 +441,10 @@ describe("bin", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes daemon command with default port 0 for random assignment", async () => {
|
it("routes daemon command with defaults", async () => {
|
||||||
await runBin(["daemon"]);
|
await runBin(["daemon"]);
|
||||||
|
|
||||||
expect(runDaemon).toHaveBeenCalledWith({
|
expect(commandMocks.runDaemon).toHaveBeenCalledWith({
|
||||||
port: 0,
|
port: 0,
|
||||||
paused: false,
|
paused: false,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
@@ -384,54 +454,12 @@ describe("bin", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes desktop command flags", async () => {
|
it("routes desktop flags to runDesktop", async () => {
|
||||||
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
||||||
|
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
||||||
expect(runDesktop).toHaveBeenCalledWith({
|
|
||||||
paused: true,
|
paused: true,
|
||||||
dev: true,
|
dev: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate --project flags", async () => {
|
|
||||||
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
|
|
||||||
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects missing --project value", async () => {
|
|
||||||
await expect(runBin(["task", "list", "--project"]))
|
|
||||||
.rejects.toThrow("Usage: --project <name>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows help when --project is combined with global help", async () => {
|
|
||||||
await expect(runBin(["--project", "demo", "--help"]))
|
|
||||||
.rejects.toThrow("process.exit:0");
|
|
||||||
|
|
||||||
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
||||||
expect(help).toContain("fn project list | ls");
|
|
||||||
expect(help).toContain("fn node list | ls");
|
|
||||||
expect(runTaskList).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prioritizes subcommand help after stripping --project", async () => {
|
|
||||||
await expect(runBin(["task", "list", "--project", "demo", "-h"]))
|
|
||||||
.rejects.toThrow("process.exit:0");
|
|
||||||
|
|
||||||
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
||||||
expect(help).toContain("--project, -P <name>");
|
|
||||||
expect(runTaskList).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("help output documents project commands, task comments, and project flag", async () => {
|
|
||||||
await expect(runBin(["--help"]))
|
|
||||||
.rejects.toThrow("process.exit:0");
|
|
||||||
|
|
||||||
const help = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
||||||
expect(help).toContain("fn project list | ls");
|
|
||||||
expect(help).toContain("fn node list | ls");
|
|
||||||
expect(help).toContain("fn serve [--port <port>] [--host <host>] [--paused] [--daemon]");
|
|
||||||
expect(help).toContain("fn task comments <id>");
|
|
||||||
expect(help).toContain("--project, -P <name>");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const workspaceRoot = resolve(import.meta.dirname, "../../..");
|
const workspaceRoot = resolve(import.meta.dirname, "../../../..");
|
||||||
const dockerfilePath = resolve(workspaceRoot, "Dockerfile");
|
const dockerfilePath = resolve(workspaceRoot, "Dockerfile");
|
||||||
const dockerignorePath = resolve(workspaceRoot, ".dockerignore");
|
const dockerignorePath = resolve(workspaceRoot, ".dockerignore");
|
||||||
const dockerDocsPath = resolve(workspaceRoot, "docs", "docker.md");
|
const dockerDocsPath = resolve(workspaceRoot, "docs", "docker.md");
|
||||||
@@ -42,7 +42,7 @@ 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 projectResolver = await import("../project-resolver.js");
|
||||||
const {
|
const {
|
||||||
getCentralCore,
|
getCentralCore,
|
||||||
getProjectManager,
|
getProjectManager,
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const commandMocks = vi.hoisted(() => ({
|
|
||||||
runDashboard: vi.fn(),
|
|
||||||
runServe: vi.fn(),
|
|
||||||
runDaemon: vi.fn(),
|
|
||||||
runDesktop: vi.fn(),
|
|
||||||
runInit: vi.fn(),
|
|
||||||
|
|
||||||
runTaskCreate: vi.fn(),
|
|
||||||
runTaskList: vi.fn(),
|
|
||||||
runTaskMove: vi.fn(),
|
|
||||||
runTaskMerge: vi.fn(),
|
|
||||||
runTaskUpdate: vi.fn(),
|
|
||||||
runTaskLog: vi.fn(),
|
|
||||||
runTaskLogs: vi.fn(),
|
|
||||||
runTaskShow: vi.fn(),
|
|
||||||
runTaskAttach: vi.fn(),
|
|
||||||
runTaskPause: vi.fn(),
|
|
||||||
runTaskUnpause: vi.fn(),
|
|
||||||
runTaskImportFromGitHub: vi.fn(),
|
|
||||||
runTaskImportGitHubInteractive: vi.fn(),
|
|
||||||
runTaskDuplicate: vi.fn(),
|
|
||||||
runTaskArchive: vi.fn(),
|
|
||||||
runTaskUnarchive: vi.fn(),
|
|
||||||
runTaskRefine: vi.fn(),
|
|
||||||
runTaskPlan: vi.fn(),
|
|
||||||
runTaskDelete: vi.fn(),
|
|
||||||
runTaskRetry: vi.fn(),
|
|
||||||
runTaskComment: vi.fn(),
|
|
||||||
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(),
|
|
||||||
runProjectShow: vi.fn(),
|
|
||||||
runProjectInfo: vi.fn(),
|
|
||||||
runProjectSetDefault: vi.fn(),
|
|
||||||
runProjectDetect: vi.fn(),
|
|
||||||
|
|
||||||
runNodeList: vi.fn(),
|
|
||||||
runNodeConnect: vi.fn(),
|
|
||||||
runNodeDisconnect: vi.fn(),
|
|
||||||
runNodeShow: vi.fn(),
|
|
||||||
runNodeHealth: vi.fn(),
|
|
||||||
runMeshStatus: vi.fn(),
|
|
||||||
// Legacy aliases
|
|
||||||
runNodeAdd: vi.fn(),
|
|
||||||
runNodeRemove: 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/daemon.js", () => ({ runDaemon: commandMocks.runDaemon }));
|
|
||||||
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,
|
|
||||||
runTaskMove: commandMocks.runTaskMove,
|
|
||||||
runTaskMerge: commandMocks.runTaskMerge,
|
|
||||||
runTaskUpdate: commandMocks.runTaskUpdate,
|
|
||||||
runTaskLog: commandMocks.runTaskLog,
|
|
||||||
runTaskLogs: commandMocks.runTaskLogs,
|
|
||||||
runTaskShow: commandMocks.runTaskShow,
|
|
||||||
runTaskAttach: commandMocks.runTaskAttach,
|
|
||||||
runTaskPause: commandMocks.runTaskPause,
|
|
||||||
runTaskUnpause: commandMocks.runTaskUnpause,
|
|
||||||
runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub,
|
|
||||||
runTaskImportGitHubInteractive: commandMocks.runTaskImportGitHubInteractive,
|
|
||||||
runTaskDuplicate: commandMocks.runTaskDuplicate,
|
|
||||||
runTaskArchive: commandMocks.runTaskArchive,
|
|
||||||
runTaskUnarchive: commandMocks.runTaskUnarchive,
|
|
||||||
runTaskRefine: commandMocks.runTaskRefine,
|
|
||||||
runTaskPlan: commandMocks.runTaskPlan,
|
|
||||||
runTaskDelete: commandMocks.runTaskDelete,
|
|
||||||
runTaskRetry: commandMocks.runTaskRetry,
|
|
||||||
runTaskComment: commandMocks.runTaskComment,
|
|
||||||
runTaskComments: commandMocks.runTaskComments,
|
|
||||||
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,
|
|
||||||
runMissionShow: commandMocks.runMissionShow,
|
|
||||||
runMissionDelete: commandMocks.runMissionDelete,
|
|
||||||
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./commands/project.js", () => ({
|
|
||||||
runProjectList: commandMocks.runProjectList,
|
|
||||||
runProjectAdd: commandMocks.runProjectAdd,
|
|
||||||
runProjectRemove: commandMocks.runProjectRemove,
|
|
||||||
runProjectShow: commandMocks.runProjectShow,
|
|
||||||
runProjectInfo: commandMocks.runProjectInfo,
|
|
||||||
runProjectSetDefault: commandMocks.runProjectSetDefault,
|
|
||||||
runProjectDetect: commandMocks.runProjectDetect,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./commands/node.js", () => ({
|
|
||||||
runNodeList: commandMocks.runNodeList,
|
|
||||||
runNodeConnect: commandMocks.runNodeConnect,
|
|
||||||
runNodeDisconnect: commandMocks.runNodeDisconnect,
|
|
||||||
runNodeShow: commandMocks.runNodeShow,
|
|
||||||
runNodeHealth: commandMocks.runNodeHealth,
|
|
||||||
runMeshStatus: commandMocks.runMeshStatus,
|
|
||||||
// Legacy aliases
|
|
||||||
runNodeAdd: commandMocks.runNodeAdd,
|
|
||||||
runNodeRemove: commandMocks.runNodeRemove,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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 originalPiPackageDir = process.env.PI_PACKAGE_DIR;
|
|
||||||
|
|
||||||
let importCounter = 0;
|
|
||||||
|
|
||||||
async function runBin(args: string[]) {
|
|
||||||
process.argv = ["node", "bin.ts", ...args];
|
|
||||||
importCounter += 1;
|
|
||||||
await import(/* @vite-ignore */ `./bin.ts?test=${importCounter}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.PI_PACKAGE_DIR;
|
|
||||||
process.exit = vi.fn(((code?: number) => {
|
|
||||||
throw new Error(`process.exit:${code ?? 0}`);
|
|
||||||
}) as typeof process.exit);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.argv = originalArgv;
|
|
||||||
process.exit = originalExit;
|
|
||||||
|
|
||||||
if (originalPiPackageDir === undefined) {
|
|
||||||
delete process.env.PI_PACKAGE_DIR;
|
|
||||||
} else {
|
|
||||||
process.env.PI_PACKAGE_DIR = originalPiPackageDir;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("configures pi to use .fusion as its project config directory", async () => {
|
|
||||||
await expect(runBin(["--help"])).rejects.toThrow("process.exit:0");
|
|
||||||
|
|
||||||
const piPackageDir = process.env.PI_PACKAGE_DIR;
|
|
||||||
expect(piPackageDir).toBeTruthy();
|
|
||||||
const pkg = JSON.parse(readFileSync(join(piPackageDir!, "package.json"), "utf-8")) as {
|
|
||||||
piConfig?: { configDir?: string };
|
|
||||||
};
|
|
||||||
expect(pkg.piConfig?.configDir).toBe(".fusion");
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
},
|
|
||||||
15000,
|
|
||||||
);
|
|
||||||
|
|
||||||
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.runNodeConnect).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"]);
|
|
||||||
|
|
||||||
expect(commandMocks.runMissionCreate).toHaveBeenCalledWith(
|
|
||||||
"Test Mission",
|
|
||||||
"Detailed mission description",
|
|
||||||
"demo",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes mission list alias", async () => {
|
|
||||||
await runBin(["mission", "ls"]);
|
|
||||||
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes mission show alias", async () => {
|
|
||||||
await runBin(["mission", "info", "M-001"]);
|
|
||||||
expect(commandMocks.runMissionShow).toHaveBeenCalledWith("M-001", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes mission delete with force flag", async () => {
|
|
||||||
await runBin(["mission", "delete", "M-001", "--force"]);
|
|
||||||
expect(commandMocks.runMissionDelete).toHaveBeenCalledWith("M-001", true, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes mission activate-slice", async () => {
|
|
||||||
await runBin(["mission", "activate-slice", "SL-001"]);
|
|
||||||
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes daemon command with all flags", async () => {
|
|
||||||
await runBin(["daemon", "--port", "4040", "--host", "127.0.0.1", "--token", "fn_abc123", "--paused", "--token-only"]);
|
|
||||||
|
|
||||||
expect(commandMocks.runDaemon).toHaveBeenCalledWith({
|
|
||||||
port: 4040,
|
|
||||||
paused: true,
|
|
||||||
interactive: false,
|
|
||||||
host: "127.0.0.1",
|
|
||||||
token: "fn_abc123",
|
|
||||||
tokenOnly: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes daemon command with defaults", async () => {
|
|
||||||
await runBin(["daemon"]);
|
|
||||||
|
|
||||||
expect(commandMocks.runDaemon).toHaveBeenCalledWith({
|
|
||||||
port: 0,
|
|
||||||
paused: false,
|
|
||||||
interactive: false,
|
|
||||||
host: undefined,
|
|
||||||
token: undefined,
|
|
||||||
tokenOnly: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes desktop flags to runDesktop", async () => {
|
|
||||||
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
|
||||||
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
|
||||||
paused: true,
|
|
||||||
dev: true,
|
|
||||||
interactive: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,11 +7,11 @@ import { AgentStore } from "@fusion/core";
|
|||||||
|
|
||||||
const mockResolveProject = vi.fn();
|
const mockResolveProject = vi.fn();
|
||||||
|
|
||||||
vi.mock("../project-context.js", () => ({
|
vi.mock("../../project-context.js", () => ({
|
||||||
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
resolveProject: (...args: unknown[]) => mockResolveProject(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runAgentExport } from "./agent-export.js";
|
import { runAgentExport } from "../agent-export.js";
|
||||||
|
|
||||||
describe("agent-export", () => {
|
describe("agent-export", () => {
|
||||||
const tmpRoot = join(tmpdir(), `fn-agent-export-test-${process.pid}`);
|
const tmpRoot = join(tmpdir(), `fn-agent-export-test-${process.pid}`);
|
||||||
@@ -4,7 +4,7 @@ import { execSync } from "node:child_process";
|
|||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { AgentStore } from "@fusion/core";
|
import { AgentStore } from "@fusion/core";
|
||||||
import { runAgentImport } from "./agent-import.js";
|
import { runAgentImport } from "../agent-import.js";
|
||||||
|
|
||||||
function makeAgentManifest(options: {
|
function makeAgentManifest(options: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { tempWorkspace } from "@fusion/test-utils";
|
import { tempWorkspace } from "@fusion/test-utils";
|
||||||
import { getFusionAgentDir, getLegacyAgentDir, getPackageManagerAgentDir } from "./auth-paths.js";
|
import { getFusionAgentDir, getLegacyAgentDir, getPackageManagerAgentDir } from "../auth-paths.js";
|
||||||
|
|
||||||
function writeJson(path: string, value: Record<string, unknown>): void {
|
function writeJson(path: string, value: Record<string, unknown>): void {
|
||||||
writeFileSync(path, JSON.stringify(value, null, 2));
|
writeFileSync(path, JSON.stringify(value, null, 2));
|
||||||
@@ -31,12 +31,12 @@ vi.mock("@fusion/core", () => ({
|
|||||||
runBackupCommand: mockRunBackupCommand,
|
runBackupCommand: mockRunBackupCommand,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../project-context.js", () => ({
|
vi.mock("../../project-context.js", () => ({
|
||||||
resolveProject: mockResolveProject,
|
resolveProject: mockResolveProject,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TaskStore } from "@fusion/core";
|
import { TaskStore } from "@fusion/core";
|
||||||
import { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } from "./backup.js";
|
import { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } from "../backup.js";
|
||||||
|
|
||||||
describe("backup commands", () => {
|
describe("backup commands", () => {
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
@@ -5,7 +5,7 @@ import { tempWorkspace } from "@fusion/test-utils";
|
|||||||
import {
|
import {
|
||||||
resolveClaudeCliExtension,
|
resolveClaudeCliExtension,
|
||||||
resolveClaudeCliExtensionPaths,
|
resolveClaudeCliExtensionPaths,
|
||||||
} from "./claude-cli-extension.js";
|
} from "../claude-cli-extension.js";
|
||||||
|
|
||||||
describe("resolveClaudeCliExtension", () => {
|
describe("resolveClaudeCliExtension", () => {
|
||||||
it("finds the bundled @fusion/pi-claude-cli package", () => {
|
it("finds the bundled @fusion/pi-claude-cli package", () => {
|
||||||
@@ -62,7 +62,7 @@ describe("resolveClaudeCliExtensionPaths", () => {
|
|||||||
describe("cached resolution roundtrip", () => {
|
describe("cached resolution roundtrip", () => {
|
||||||
it("set/get preserves the snapshot", async () => {
|
it("set/get preserves the snapshot", async () => {
|
||||||
const { setCachedClaudeCliResolution, getCachedClaudeCliResolution } =
|
const { setCachedClaudeCliResolution, getCachedClaudeCliResolution } =
|
||||||
await import("./claude-cli-extension.js");
|
await import("../claude-cli-extension.js");
|
||||||
setCachedClaudeCliResolution({ status: "not-installed" });
|
setCachedClaudeCliResolution({ status: "not-installed" });
|
||||||
expect(getCachedClaudeCliResolution()).toEqual({ status: "not-installed" });
|
expect(getCachedClaudeCliResolution()).toEqual({ status: "not-installed" });
|
||||||
setCachedClaudeCliResolution(null);
|
setCachedClaudeCliResolution(null);
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ensureFusionSkillForProjects,
|
ensureFusionSkillForProjects,
|
||||||
installFusionSkillIntoProject,
|
installFusionSkillIntoProject,
|
||||||
isPiClaudeCliConfigured,
|
isPiClaudeCliConfigured,
|
||||||
} from "./claude-skills.js";
|
} from "../claude-skills.js";
|
||||||
|
|
||||||
function makeSourceSkill(root: string, body = "---\nname: fusion\n---\n# hi\n"): string {
|
function makeSourceSkill(root: string, body = "---\nname: fusion\n---\n# hi\n"): string {
|
||||||
const dir = join(root, "src-skill", "fusion");
|
const dir = join(root, "src-skill", "fusion");
|
||||||
@@ -140,7 +140,7 @@ vi.mock("@fusion/dashboard", () => ({
|
|||||||
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { runDesktop } from "./desktop.js";
|
import { runDesktop } from "../desktop.js";
|
||||||
|
|
||||||
describe("runDesktop", () => {
|
describe("runDesktop", () => {
|
||||||
const originalCwd = process.cwd;
|
const originalCwd = process.cwd;
|
||||||
@@ -46,12 +46,12 @@ vi.mock("node:readline/promises", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../project-context.js", () => ({
|
vi.mock("../../project-context.js", () => ({
|
||||||
resolveProject: vi.fn(),
|
resolveProject: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../../project-context.js";
|
||||||
import {
|
import {
|
||||||
isGitRepo,
|
isGitRepo,
|
||||||
isValidBranchName,
|
isValidBranchName,
|
||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
runGitFetch,
|
runGitFetch,
|
||||||
runGitPull,
|
runGitPull,
|
||||||
runGitPush,
|
runGitPush,
|
||||||
} from "./git.js";
|
} from "../git.js";
|
||||||
|
|
||||||
const mockCreateInterface = vi.mocked(createInterface);
|
const mockCreateInterface = vi.mocked(createInterface);
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||||||
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { runInit } from "./init.js";
|
import { runInit } from "../init.js";
|
||||||
|
|
||||||
const mockCentralInit = vi.fn();
|
const mockCentralInit = vi.fn();
|
||||||
const mockCentralClose = vi.fn();
|
const mockCentralClose = vi.fn();
|
||||||
@@ -22,14 +22,14 @@ vi.mock("@fusion/core", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mock project-resolver
|
// Mock project-resolver
|
||||||
vi.mock("../project-resolver.js", () => ({
|
vi.mock("../../project-resolver.js", () => ({
|
||||||
getStore: vi.fn().mockResolvedValue({
|
getStore: vi.fn().mockResolvedValue({
|
||||||
getMissionStore: vi.fn().mockReturnValue({}),
|
getMissionStore: vi.fn().mockReturnValue({}),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { getStore } from "../project-resolver.js";
|
import { getStore } from "../../project-resolver.js";
|
||||||
|
|
||||||
// Import after mocks
|
// Import after mocks
|
||||||
const {
|
const {
|
||||||
@@ -42,7 +42,7 @@ const {
|
|||||||
runSliceAdd,
|
runSliceAdd,
|
||||||
runFeatureAdd,
|
runFeatureAdd,
|
||||||
runFeatureLinkTask,
|
runFeatureLinkTask,
|
||||||
} = await import("./mission.js");
|
} = await import("../mission.js");
|
||||||
|
|
||||||
// Helper to mock console output
|
// Helper to mock console output
|
||||||
function captureConsole() {
|
function captureConsole() {
|
||||||
@@ -63,7 +63,7 @@ vi.mock("node:readline/promises", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../project-context.js", () => ({
|
vi.mock("../../project-context.js", () => ({
|
||||||
formatProjectLine: mockFormatProjectLine,
|
formatProjectLine: mockFormatProjectLine,
|
||||||
detectProjectFromCwd: mockDetectProjectFromCwd,
|
detectProjectFromCwd: mockDetectProjectFromCwd,
|
||||||
setDefaultProject: mockSetDefaultProject,
|
setDefaultProject: mockSetDefaultProject,
|
||||||
@@ -101,7 +101,7 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exports all project command functions", async () => {
|
it("exports all project command functions", async () => {
|
||||||
const project = await import("./project.js");
|
const project = await import("../project.js");
|
||||||
expect(typeof project.runProjectList).toBe("function");
|
expect(typeof project.runProjectList).toBe("function");
|
||||||
expect(typeof project.runProjectAdd).toBe("function");
|
expect(typeof project.runProjectAdd).toBe("function");
|
||||||
expect(typeof project.runProjectRemove).toBe("function");
|
expect(typeof project.runProjectRemove).toBe("function");
|
||||||
@@ -123,7 +123,7 @@ describe("project commands", () => {
|
|||||||
: undefined
|
: undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
const { runProjectList } = await import("./project.js");
|
const { runProjectList } = await import("../project.js");
|
||||||
await runProjectList();
|
await runProjectList();
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 projects registered, 1 active"));
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 projects registered, 1 active"));
|
||||||
@@ -139,7 +139,7 @@ describe("project commands", () => {
|
|||||||
]);
|
]);
|
||||||
mockGetSettings.mockResolvedValue({});
|
mockGetSettings.mockResolvedValue({});
|
||||||
|
|
||||||
const { runProjectList } = await import("./project.js");
|
const { runProjectList } = await import("../project.js");
|
||||||
await runProjectList({ json: true });
|
await runProjectList({ json: true });
|
||||||
|
|
||||||
// Should output JSON
|
// Should output JSON
|
||||||
@@ -154,7 +154,7 @@ describe("project commands", () => {
|
|||||||
mockListProjects.mockResolvedValue([]);
|
mockListProjects.mockResolvedValue([]);
|
||||||
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", isolationMode: "in-process" });
|
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", isolationMode: "in-process" });
|
||||||
|
|
||||||
const { runProjectAdd } = await import("./project.js");
|
const { runProjectAdd } = await import("../project.js");
|
||||||
await runProjectAdd("demo", ".", { force: true });
|
await runProjectAdd("demo", ".", { force: true });
|
||||||
|
|
||||||
expect(mockRegisterProject).toHaveBeenCalled();
|
expect(mockRegisterProject).toHaveBeenCalled();
|
||||||
@@ -167,7 +167,7 @@ describe("project commands", () => {
|
|||||||
it("runProjectRemove unregisters project after confirmation", async () => {
|
it("runProjectRemove unregisters project after confirmation", async () => {
|
||||||
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
||||||
|
|
||||||
const { runProjectRemove } = await import("./project.js");
|
const { runProjectRemove } = await import("../project.js");
|
||||||
await runProjectRemove("proj-1", { force: false });
|
await runProjectRemove("proj-1", { force: false });
|
||||||
|
|
||||||
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
|
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
|
||||||
@@ -177,7 +177,7 @@ describe("project commands", () => {
|
|||||||
it("runProjectRemove with --force skips confirmation", async () => {
|
it("runProjectRemove with --force skips confirmation", async () => {
|
||||||
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
||||||
|
|
||||||
const { runProjectRemove } = await import("./project.js");
|
const { runProjectRemove } = await import("../project.js");
|
||||||
await runProjectRemove("proj-1", { force: true });
|
await runProjectRemove("proj-1", { force: true });
|
||||||
|
|
||||||
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
|
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
|
||||||
@@ -198,7 +198,7 @@ describe("project commands", () => {
|
|||||||
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
|
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
|
||||||
mockTaskStoreListTasks.mockResolvedValue([]);
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
||||||
|
|
||||||
const { runProjectShow } = await import("./project.js");
|
const { runProjectShow } = await import("../project.js");
|
||||||
await runProjectShow("proj-1");
|
await runProjectShow("proj-1");
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
@@ -221,7 +221,7 @@ describe("project commands", () => {
|
|||||||
mockGetSettings.mockResolvedValue({});
|
mockGetSettings.mockResolvedValue({});
|
||||||
mockTaskStoreListTasks.mockResolvedValue([]);
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
||||||
|
|
||||||
const { runProjectInfo } = await import("./project.js");
|
const { runProjectInfo } = await import("../project.js");
|
||||||
await runProjectInfo("proj-1");
|
await runProjectInfo("proj-1");
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
@@ -231,7 +231,7 @@ describe("project commands", () => {
|
|||||||
it("runProjectSetDefault sets default project", async () => {
|
it("runProjectSetDefault sets default project", async () => {
|
||||||
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
||||||
|
|
||||||
const { runProjectSetDefault } = await import("./project.js");
|
const { runProjectSetDefault } = await import("../project.js");
|
||||||
await runProjectSetDefault("proj-1");
|
await runProjectSetDefault("proj-1");
|
||||||
|
|
||||||
expect(mockSetDefaultProject).toHaveBeenCalledWith("proj-1");
|
expect(mockSetDefaultProject).toHaveBeenCalledWith("proj-1");
|
||||||
@@ -241,7 +241,7 @@ describe("project commands", () => {
|
|||||||
it("runProjectDetect prints detected project without absolute path leakage", async () => {
|
it("runProjectDetect prints detected project without absolute path leakage", async () => {
|
||||||
mockDetectProjectFromCwd.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo" });
|
mockDetectProjectFromCwd.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo" });
|
||||||
|
|
||||||
const { runProjectDetect } = await import("./project.js");
|
const { runProjectDetect } = await import("../project.js");
|
||||||
await runProjectDetect();
|
await runProjectDetect();
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
@@ -263,7 +263,7 @@ describe("project commands", () => {
|
|||||||
{ id: "FN-003", column: "done" },
|
{ id: "FN-003", column: "done" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { runProjectList } = await import("./project.js");
|
const { runProjectList } = await import("../project.js");
|
||||||
await runProjectList();
|
await runProjectList();
|
||||||
|
|
||||||
// Verify TaskStore.listTasks was called
|
// Verify TaskStore.listTasks was called
|
||||||
@@ -289,7 +289,7 @@ describe("project commands", () => {
|
|||||||
{ id: "FN-003", column: "in-progress" },
|
{ id: "FN-003", column: "in-progress" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { runProjectShow } = await import("./project.js");
|
const { runProjectShow } = await import("../project.js");
|
||||||
await runProjectShow("proj-1");
|
await runProjectShow("proj-1");
|
||||||
|
|
||||||
// Verify TaskStore.listTasks was called
|
// Verify TaskStore.listTasks was called
|
||||||
@@ -322,7 +322,7 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
mockTaskStoreListTasks.mockResolvedValue([]);
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
||||||
|
|
||||||
const { runProjectShow } = await import("./project.js");
|
const { runProjectShow } = await import("../project.js");
|
||||||
await runProjectShow("proj-1");
|
await runProjectShow("proj-1");
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
@@ -333,17 +333,17 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("validation exits on missing required args for runProjectAdd", async () => {
|
it("validation exits on missing required args for runProjectAdd", async () => {
|
||||||
const { runProjectAdd } = await import("./project.js");
|
const { runProjectAdd } = await import("../project.js");
|
||||||
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
|
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validation exits on missing required args for runProjectRemove", async () => {
|
it("validation exits on missing required args for runProjectRemove", async () => {
|
||||||
const { runProjectRemove } = await import("./project.js");
|
const { runProjectRemove } = await import("../project.js");
|
||||||
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
|
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validation exits on missing required args for runProjectSetDefault", async () => {
|
it("validation exits on missing required args for runProjectSetDefault", async () => {
|
||||||
const { runProjectSetDefault } = await import("./project.js");
|
const { runProjectSetDefault } = await import("../project.js");
|
||||||
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
|
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ describe("project commands", () => {
|
|||||||
isolationMode: "in-process",
|
isolationMode: "in-process",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { runProjectAdd } = await import("./project.js");
|
const { runProjectAdd } = await import("../project.js");
|
||||||
await runProjectAdd("demo", testPath, { force: true });
|
await runProjectAdd("demo", testPath, { force: true });
|
||||||
|
|
||||||
expect(mockEnsureMemoryFileWithBackend).toHaveBeenCalled();
|
expect(mockEnsureMemoryFileWithBackend).toHaveBeenCalled();
|
||||||
@@ -384,7 +384,7 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
||||||
|
|
||||||
const { runProjectAdd } = await import("./project.js");
|
const { runProjectAdd } = await import("../project.js");
|
||||||
await runProjectAdd("demo", testPath, { force: true });
|
await runProjectAdd("demo", testPath, { force: true });
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
@@ -401,7 +401,7 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(false); // Files already exist
|
mockEnsureMemoryFileWithBackend.mockResolvedValue(false); // Files already exist
|
||||||
|
|
||||||
const { runProjectAdd } = await import("./project.js");
|
const { runProjectAdd } = await import("../project.js");
|
||||||
await runProjectAdd("demo", testPath, { force: true });
|
await runProjectAdd("demo", testPath, { force: true });
|
||||||
|
|
||||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||||
@@ -418,7 +418,7 @@ describe("project commands", () => {
|
|||||||
});
|
});
|
||||||
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
|
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
|
||||||
|
|
||||||
const { runProjectAdd } = await import("./project.js");
|
const { runProjectAdd } = await import("../project.js");
|
||||||
await runProjectAdd("demo", testPath, { force: true });
|
await runProjectAdd("demo", testPath, { force: true });
|
||||||
|
|
||||||
// Project should still be registered
|
// Project should still be registered
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tempWorkspace } from "@fusion/test-utils";
|
import { tempWorkspace } from "@fusion/test-utils";
|
||||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "../provider-auth.js";
|
||||||
|
|
||||||
function makeAuthStorage(credentials: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number }> = {}) {
|
function makeAuthStorage(credentials: Record<string, { type: string; key?: string; access?: string; refresh?: string; expires?: number }> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { tempWorkspace } from "@fusion/test-utils";
|
import { tempWorkspace } from "@fusion/test-utils";
|
||||||
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
|
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "../provider-settings.js";
|
||||||
|
|
||||||
function writeJson(path: string, value: Record<string, unknown>): void {
|
function writeJson(path: string, value: Record<string, unknown>): void {
|
||||||
writeFileSync(path, JSON.stringify(value, null, 2));
|
writeFileSync(path, JSON.stringify(value, null, 2));
|
||||||
@@ -21,13 +21,13 @@ vi.mock("@fusion/core", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../project-context.js", () => ({
|
vi.mock("../../project-context.js", () => ({
|
||||||
resolveProject: vi.fn(),
|
resolveProject: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { GlobalSettingsStore, DEFAULT_SETTINGS } from "@fusion/core";
|
import { GlobalSettingsStore, DEFAULT_SETTINGS } from "@fusion/core";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../../project-context.js";
|
||||||
import { runSettingsShow, runSettingsSet, parseValue, VALID_SETTINGS } from "./settings.js";
|
import { runSettingsShow, runSettingsSet, parseValue, VALID_SETTINGS } from "../settings.js";
|
||||||
|
|
||||||
function makeSettings(overrides: Record<string, unknown> = {}) {
|
function makeSettings(overrides: Record<string, unknown> = {}) {
|
||||||
return { ...DEFAULT_SETTINGS, ...overrides };
|
return { ...DEFAULT_SETTINGS, ...overrides };
|
||||||
@@ -71,7 +71,7 @@ vi.mock("@fusion/core/gh-cli", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock project-context
|
// Mock project-context
|
||||||
vi.mock("../project-context.js", () => ({
|
vi.mock("../../project-context.js", () => ({
|
||||||
resolveProject: vi.fn().mockRejectedValue(new Error("No project context")),
|
resolveProject: vi.fn().mockRejectedValue(new Error("No project context")),
|
||||||
getStore: vi.fn().mockResolvedValue({}),
|
getStore: vi.fn().mockResolvedValue({}),
|
||||||
getDefaultProject: vi.fn().mockResolvedValue(undefined),
|
getDefaultProject: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -81,7 +81,7 @@ vi.mock("../project-context.js", () => ({
|
|||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { TaskStore } from "@fusion/core";
|
import { TaskStore } from "@fusion/core";
|
||||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "./task.js";
|
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
|
||||||
import {
|
import {
|
||||||
getCurrentRepo,
|
getCurrentRepo,
|
||||||
isGhAuthenticated,
|
isGhAuthenticated,
|
||||||
@@ -90,7 +90,7 @@ import {
|
|||||||
} from "@fusion/core/gh-cli";
|
} from "@fusion/core/gh-cli";
|
||||||
import { GitHubClient } from "@fusion/dashboard";
|
import { GitHubClient } from "@fusion/dashboard";
|
||||||
import { createSession, submitResponse } from "@fusion/dashboard/planning";
|
import { createSession, submitResponse } from "@fusion/dashboard/planning";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../../project-context.js";
|
||||||
import { aiMergeTask } from "@fusion/engine";
|
import { aiMergeTask } from "@fusion/engine";
|
||||||
|
|
||||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||||
@@ -849,7 +849,7 @@ describe("runTaskCreate with --depends", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { runTaskImportGitHubInteractive } from "./task.js";
|
import { runTaskImportGitHubInteractive } from "../task.js";
|
||||||
|
|
||||||
describe("runTaskImportGitHubInteractive", () => {
|
describe("runTaskImportGitHubInteractive", () => {
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
@@ -1062,7 +1062,7 @@ describe("runTaskImportGitHubInteractive", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GitHub Import Tests
|
// GitHub Import Tests
|
||||||
import { fetchGitHubIssues, runTaskImportFromGitHub, type GitHubIssue } from "./task.js";
|
import { fetchGitHubIssues, runTaskImportFromGitHub, type GitHubIssue } from "../task.js";
|
||||||
|
|
||||||
describe("fetchGitHubIssues", () => {
|
describe("fetchGitHubIssues", () => {
|
||||||
const mockIssue: GitHubIssue = {
|
const mockIssue: GitHubIssue = {
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,15 @@ import { dirname, join } from "node:path";
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { parseYamlFrontmatter } from "./agent-companies-parser.js";
|
import { parseYamlFrontmatter } from "../agent-companies-parser.js";
|
||||||
import {
|
import {
|
||||||
agentToCompaniesManifest,
|
agentToCompaniesManifest,
|
||||||
exportAgentsToDirectory,
|
exportAgentsToDirectory,
|
||||||
generateAgentMd,
|
generateAgentMd,
|
||||||
generateCompanyMd,
|
generateCompanyMd,
|
||||||
slugify,
|
slugify,
|
||||||
} from "./agent-companies-exporter.js";
|
} from "../agent-companies-exporter.js";
|
||||||
import type { Agent } from "./types.js";
|
import type { Agent } from "../types.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
parseTaskManifest,
|
parseTaskManifest,
|
||||||
parseTeamManifest,
|
parseTeamManifest,
|
||||||
parseYamlFrontmatter,
|
parseYamlFrontmatter,
|
||||||
} from "./agent-companies-parser.js";
|
} from "../agent-companies-parser.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
SourceReference,
|
SourceReference,
|
||||||
TaskManifest,
|
TaskManifest,
|
||||||
TeamManifest,
|
TeamManifest,
|
||||||
} from "./agent-companies-types.js";
|
} from "../agent-companies-types.js";
|
||||||
|
|
||||||
describe("agent-companies-types", () => {
|
describe("agent-companies-types", () => {
|
||||||
it("supports schema and kind literals", () => {
|
it("supports schema and kind literals", () => {
|
||||||
@@ -3,9 +3,9 @@ import {
|
|||||||
computeAccessState,
|
computeAccessState,
|
||||||
isValidPermission,
|
isValidPermission,
|
||||||
normalizePermissions,
|
normalizePermissions,
|
||||||
} from "./agent-permissions.js";
|
} from "../agent-permissions.js";
|
||||||
import { AGENT_PERMISSIONS } from "./types.js";
|
import { AGENT_PERMISSIONS } from "../types.js";
|
||||||
import type { Agent, AgentCapability, AgentPermission } from "./types.js";
|
import type { Agent, AgentCapability, AgentPermission } from "../types.js";
|
||||||
|
|
||||||
function makeAgent(role: AgentCapability, permissions?: Record<string, boolean>): Agent {
|
function makeAgent(role: AgentCapability, permissions?: Record<string, boolean>): Agent {
|
||||||
return {
|
return {
|
||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
getAvailableTemplates,
|
getAvailableTemplates,
|
||||||
getTemplatesForRole,
|
getTemplatesForRole,
|
||||||
} from "./agent-prompts.js";
|
} from "../agent-prompts.js";
|
||||||
import type { AgentPromptsConfig, AgentPromptTemplate } from "./types.js";
|
import type { AgentPromptsConfig, AgentPromptTemplate } from "../types.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// resolveAgentPrompt
|
// resolveAgentPrompt
|
||||||
@@ -11,15 +11,15 @@
|
|||||||
* validation, concurrency locking, and SQLite persistence.
|
* validation, concurrency locking, and SQLite persistence.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { AgentStore } from "./agent-store.js";
|
import { AgentStore } from "../agent-store.js";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { CheckoutConflictError, type AgentCapability, type AgentState } from "./types.js";
|
import { CheckoutConflictError, type AgentCapability, type AgentState } from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
|
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
RateLimitError,
|
RateLimitError,
|
||||||
AiServiceError,
|
AiServiceError,
|
||||||
__resetSummarizeState,
|
__resetSummarizeState,
|
||||||
} from "./ai-summarize.js";
|
} from "../ai-summarize.js";
|
||||||
|
|
||||||
describe("ai-summarize", () => {
|
describe("ai-summarize", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|||||||
import { readFileSync, existsSync } from "node:fs";
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { getAppVersion, parseSemver } from "./app-version.js";
|
import { getAppVersion, parseSemver } from "../app-version.js";
|
||||||
|
|
||||||
describe("getAppVersion", () => {
|
describe("getAppVersion", () => {
|
||||||
it("should return a non-empty string", () => {
|
it("should return a non-empty string", () => {
|
||||||
@@ -20,10 +20,10 @@ describe("getAppVersion", () => {
|
|||||||
it("should return the actual package version from package.json", () => {
|
it("should return the actual package version from package.json", () => {
|
||||||
const version = getAppVersion();
|
const version = getAppVersion();
|
||||||
// Read the actual version from package.json for verification
|
// Read the actual version from package.json for verification
|
||||||
// The test file is at packages/core/src/app-version.test.ts
|
// The test file is at packages/core/src/__tests__/app-version.test.ts
|
||||||
// Walk up from this file to find packages/core/package.json
|
// Walk up from this file to find packages/core/package.json
|
||||||
const testFileDir = dirname(fileURLToPath(import.meta.url));
|
const testFileDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const coreDir = join(testFileDir, "..");
|
const coreDir = join(testFileDir, "..", "..");
|
||||||
const pkgPath = join(coreDir, "package.json");
|
const pkgPath = join(coreDir, "package.json");
|
||||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
expect(version).toBe(pkg.version);
|
expect(version).toBe(pkg.version);
|
||||||
@@ -36,7 +36,7 @@ describe("getAppVersion", () => {
|
|||||||
expect(version1).toBe(version2);
|
expect(version1).toBe(version2);
|
||||||
// Verify cached version matches the actual package version
|
// Verify cached version matches the actual package version
|
||||||
const testFileDir = dirname(fileURLToPath(import.meta.url));
|
const testFileDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const coreDir = join(testFileDir, "..");
|
const coreDir = join(testFileDir, "..", "..");
|
||||||
const pkgPath = join(coreDir, "package.json");
|
const pkgPath = join(coreDir, "package.json");
|
||||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||||
expect(version1).toBe(pkg.version);
|
expect(version1).toBe(pkg.version);
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { AutomationStore } from "./automation-store.js";
|
import { AutomationStore } from "../automation-store.js";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { ScheduledTask, AutomationRunResult, AutomationStep } from "./automation.js";
|
import type { ScheduledTask, AutomationRunResult, AutomationStep } from "../automation.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
/** Create a test automation step. */
|
/** Create a test automation step. */
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type ScheduleType,
|
type ScheduleType,
|
||||||
type ScheduledTask,
|
type ScheduledTask,
|
||||||
type ScheduledTaskCreateInput,
|
type ScheduledTaskCreateInput,
|
||||||
} from "./automation.js";
|
} from "../automation.js";
|
||||||
|
|
||||||
const expectedPresetMap = {
|
const expectedPresetMap = {
|
||||||
hourly: "0 * * * *",
|
hourly: "0 * * * *",
|
||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
validateBackupDir,
|
validateBackupDir,
|
||||||
runBackupCommand,
|
runBackupCommand,
|
||||||
syncBackupRoutine,
|
syncBackupRoutine,
|
||||||
} from "./backup.js";
|
} from "../backup.js";
|
||||||
import { RoutineStore } from "./routine-store.js";
|
import { RoutineStore } from "../routine-store.js";
|
||||||
import type { ProjectSettings } from "./types.js";
|
import type { ProjectSettings } from "../types.js";
|
||||||
|
|
||||||
describe("BackupManager", () => {
|
describe("BackupManager", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
import { canTransition, getValidTransitions, resolveDependencyOrder } from "../board.js";
|
||||||
import { VALID_TRANSITIONS, type Task, type Column } from "./types.js";
|
import { VALID_TRANSITIONS, type Task, type Column } from "../types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Board logic tests
|
* Board logic tests
|
||||||
@@ -2,11 +2,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||||||
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { CentralCore } from "./central-core.js";
|
import { CentralCore } from "../central-core.js";
|
||||||
import { NodeDiscovery } from "./node-discovery.js";
|
import { NodeDiscovery } from "../node-discovery.js";
|
||||||
import { NodeConnection, type ConnectionResult } from "./node-connection.js";
|
import { NodeConnection, type ConnectionResult } from "../node-connection.js";
|
||||||
import { getAppVersion } from "./app-version.js";
|
import { getAppVersion } from "../app-version.js";
|
||||||
import * as systemMetrics from "./system-metrics.js";
|
import * as systemMetrics from "../system-metrics.js";
|
||||||
import type {
|
import type {
|
||||||
RegisteredProject,
|
RegisteredProject,
|
||||||
ProjectHealth,
|
ProjectHealth,
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
SystemMetrics,
|
SystemMetrics,
|
||||||
DiscoveryConfig,
|
DiscoveryConfig,
|
||||||
DiscoveredNode,
|
DiscoveredNode,
|
||||||
} from "./types.js";
|
} from "../types.js";
|
||||||
|
|
||||||
describe("CentralCore", () => {
|
describe("CentralCore", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -2673,7 +2673,7 @@ describe("CentralCore", () => {
|
|||||||
url: "http://localhost:9992",
|
url: "http://localhost:9992",
|
||||||
});
|
});
|
||||||
|
|
||||||
let emittedPayload: { nodeId: string; remoteNodeId: string; state: import("./types.js").SettingsSyncState } | undefined;
|
let emittedPayload: { nodeId: string; remoteNodeId: string; state: import("../types.js").SettingsSyncState } | undefined;
|
||||||
central.on("settings:sync:completed", (payload) => {
|
central.on("settings:sync:completed", (payload) => {
|
||||||
emittedPayload = payload;
|
emittedPayload = payload;
|
||||||
});
|
});
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { CentralDatabase, createCentralDatabase, toJson, fromJson } from "./central-db.js";
|
import { CentralDatabase, createCentralDatabase, toJson, fromJson } from "../central-db.js";
|
||||||
|
|
||||||
describe("CentralDatabase", () => {
|
describe("CentralDatabase", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -3,13 +3,13 @@ import { rm } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { GlobalSettingsStore } from "./global-settings.js";
|
import { GlobalSettingsStore } from "../global-settings.js";
|
||||||
import {
|
import {
|
||||||
DaemonTokenManager,
|
DaemonTokenManager,
|
||||||
DAEMON_TOKEN_PREFIX,
|
DAEMON_TOKEN_PREFIX,
|
||||||
DAEMON_TOKEN_HEX_LENGTH,
|
DAEMON_TOKEN_HEX_LENGTH,
|
||||||
isDaemonTokenFormat,
|
isDaemonTokenFormat,
|
||||||
} from "./daemon-token.js";
|
} from "../daemon-token.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "fn-daemon-token-test-"));
|
return mkdtempSync(join(tmpdir(), "fn-daemon-token-test-"));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
|
import { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "../db-migrate.js";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { mkdir, writeFile, rm, readdir, appendFile } from "node:fs/promises";
|
import { mkdir, writeFile, rm, readdir, appendFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, existsSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "./db.js";
|
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js";
|
||||||
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
|
import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
|
||||||
import { mkdtempSync, existsSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -17,9 +17,9 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { ArchiveDatabase } from "./archive-db.js";
|
import { ArchiveDatabase } from "../archive-db.js";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-fts5-guard-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-fts5-guard-test-"));
|
||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getGhErrorMessage,
|
getGhErrorMessage,
|
||||||
parseRepoFromRemote,
|
parseRepoFromRemote,
|
||||||
} from "./gh-cli.js";
|
} from "../gh-cli.js";
|
||||||
|
|
||||||
// Tests for pure functions (no child_process dependency)
|
// Tests for pure functions (no child_process dependency)
|
||||||
describe("getGhErrorMessage", () => {
|
describe("getGhErrorMessage", () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { GlobalSettingsStore, defaultGlobalDir } from "./global-settings.js";
|
import { GlobalSettingsStore, defaultGlobalDir } from "../global-settings.js";
|
||||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
import { DEFAULT_GLOBAL_SETTINGS } from "../types.js";
|
||||||
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, existsSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { Database, createDatabase, fromJson } from "./db.js";
|
import { Database, createDatabase, fromJson } from "../db.js";
|
||||||
import { InsightStore, computeInsightFingerprint } from "./insight-store.js";
|
import { InsightStore, computeInsightFingerprint } from "../insight-store.js";
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -24,7 +24,7 @@ import type {
|
|||||||
InsightProvenance,
|
InsightProvenance,
|
||||||
InsightRunTrigger,
|
InsightRunTrigger,
|
||||||
InsightRunStatus,
|
InsightRunStatus,
|
||||||
} from "./insight-types.js";
|
} from "../insight-types.js";
|
||||||
|
|
||||||
// ── Test Fixtures ────────────────────────────────────────────────────
|
// ── Test Fixtures ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "../logger.js";
|
||||||
|
|
||||||
describe("core createLogger", () => {
|
describe("core createLogger", () => {
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||||
@@ -34,8 +34,8 @@ import {
|
|||||||
listAgentMemoryFiles,
|
listAgentMemoryFiles,
|
||||||
readAgentMemoryFile,
|
readAgentMemoryFile,
|
||||||
writeAgentMemoryFile,
|
writeAgentMemoryFile,
|
||||||
} from "./memory-backend.js";
|
} from "../memory-backend.js";
|
||||||
import type { MemoryBackend } from "./memory-backend.js";
|
import type { MemoryBackend } from "../memory-backend.js";
|
||||||
|
|
||||||
describe("memory-backend", () => {
|
describe("memory-backend", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DEFAULT_AUTO_SUMMARIZE_SCHEDULE,
|
DEFAULT_AUTO_SUMMARIZE_SCHEDULE,
|
||||||
AiServiceError,
|
AiServiceError,
|
||||||
__resetCompactionState,
|
__resetCompactionState,
|
||||||
} from "./memory-compaction.js";
|
} from "../memory-compaction.js";
|
||||||
|
|
||||||
describe("memory-compaction", () => {
|
describe("memory-compaction", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
MEMORY_DREAMS_SCHEDULE_NAME,
|
MEMORY_DREAMS_SCHEDULE_NAME,
|
||||||
processAgentMemoryDreams,
|
processAgentMemoryDreams,
|
||||||
syncMemoryDreamsAutomation,
|
syncMemoryDreamsAutomation,
|
||||||
} from "./memory-dreams.js";
|
} from "../memory-dreams.js";
|
||||||
|
|
||||||
describe("memory-dreams automation", () => {
|
describe("memory-dreams automation", () => {
|
||||||
it("creates a scheduled dream processor automation with defaults", () => {
|
it("creates a scheduled dream processor automation with defaults", () => {
|
||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
createInsightExtractionAutomation,
|
createInsightExtractionAutomation,
|
||||||
validatePruneCandidate,
|
validatePruneCandidate,
|
||||||
applyMemoryPruning,
|
applyMemoryPruning,
|
||||||
} from "./memory-insights.js";
|
} from "../memory-insights.js";
|
||||||
import type { MemoryInsight, InsightExtractionResult } from "./memory-insights.js";
|
import type { MemoryInsight, InsightExtractionResult } from "../memory-insights.js";
|
||||||
import type { ProjectSettings } from "./types.js";
|
import type { ProjectSettings } from "../types.js";
|
||||||
|
|
||||||
describe("memory-insights", () => {
|
describe("memory-insights", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -630,7 +630,7 @@ import {
|
|||||||
MEMORY_AUDIT_PATH,
|
MEMORY_AUDIT_PATH,
|
||||||
readMemoryAudit,
|
readMemoryAudit,
|
||||||
writeMemoryAudit,
|
writeMemoryAudit,
|
||||||
} from "./memory-insights.js";
|
} from "../memory-insights.js";
|
||||||
|
|
||||||
describe("memory-insights audit file operations", () => {
|
describe("memory-insights audit file operations", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -690,7 +690,7 @@ describe("memory-insights audit file operations", () => {
|
|||||||
import {
|
import {
|
||||||
processInsightExtractionRun,
|
processInsightExtractionRun,
|
||||||
processAndAuditInsightExtraction,
|
processAndAuditInsightExtraction,
|
||||||
} from "./memory-insights.js";
|
} from "../memory-insights.js";
|
||||||
|
|
||||||
describe("memory-insights run processing", () => {
|
describe("memory-insights run processing", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -1021,7 +1021,7 @@ Durable content.`;
|
|||||||
import {
|
import {
|
||||||
generateMemoryAudit,
|
generateMemoryAudit,
|
||||||
renderMemoryAuditMarkdown,
|
renderMemoryAuditMarkdown,
|
||||||
} from "./memory-insights.js";
|
} from "../memory-insights.js";
|
||||||
|
|
||||||
describe("memory-insights audit generation", () => {
|
describe("memory-insights audit generation", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -2,9 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { MessageStore } from "./message-store.js";
|
import { MessageStore } from "../message-store.js";
|
||||||
import type { Message, Mailbox } from "./types.js";
|
import type { Message, Mailbox } from "../types.js";
|
||||||
|
|
||||||
describe("MessageStore", () => {
|
describe("MessageStore", () => {
|
||||||
let store: MessageStore;
|
let store: MessageStore;
|
||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
BackwardCompat,
|
BackwardCompat,
|
||||||
ProjectRequiredError,
|
ProjectRequiredError,
|
||||||
type ProjectSetupInput,
|
type ProjectSetupInput,
|
||||||
} from "./migration.js";
|
} from "../migration.js";
|
||||||
import { CentralCore } from "./central-core.js";
|
import { CentralCore } from "../central-core.js";
|
||||||
|
|
||||||
// Helper to create a fake kb project
|
// Helper to create a fake kb project
|
||||||
function createFakeKbProject(dir: string): void {
|
function createFakeKbProject(dir: string): void {
|
||||||
@@ -16,7 +16,7 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-mission-factory-parity-"));
|
return mkdtempSync(join(tmpdir(), "kb-mission-factory-parity-"));
|
||||||
@@ -3,8 +3,8 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-mission-integration-"));
|
return mkdtempSync(join(tmpdir(), "kb-mission-integration-"));
|
||||||
@@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-mission-planning-"));
|
return mkdtempSync(join(tmpdir(), "kb-mission-planning-"));
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { MissionStore } from "./mission-store.js";
|
import { MissionStore } from "../mission-store.js";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -1484,10 +1484,10 @@ describe("MissionStore", () => {
|
|||||||
const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!;
|
const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!;
|
||||||
expect(m1Data.slices).toHaveLength(2);
|
expect(m1Data.slices).toHaveLength(2);
|
||||||
|
|
||||||
const s1Data = m1Data.slices.find((s) => s.id === s1.id)! as import("./mission-types.js").SliceWithFeatures;
|
const s1Data = m1Data.slices.find((s) => s.id === s1.id)! as import("../mission-types.js").SliceWithFeatures;
|
||||||
expect(s1Data.features).toHaveLength(2);
|
expect(s1Data.features).toHaveLength(2);
|
||||||
expect(s1Data.features.find((f: import("./mission-types.js").MissionFeature) => f.id === f1.id)).toBeDefined();
|
expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f1.id)).toBeDefined();
|
||||||
expect(s1Data.features.find((f: import("./mission-types.js").MissionFeature) => f.id === f2.id)).toBeDefined();
|
expect(s1Data.features.find((f: import("../mission-types.js").MissionFeature) => f.id === f2.id)).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1660,7 +1660,7 @@ describe("MissionStore", () => {
|
|||||||
|
|
||||||
it("throws if feature not found", async () => {
|
it("throws if feature not found", async () => {
|
||||||
// Need a TaskStore reference for this test
|
// Need a TaskStore reference for this test
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1670,7 +1670,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws if feature is already triaged", async () => {
|
it("throws if feature is already triaged", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1690,7 +1690,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates a task and links it to the feature", async () => {
|
it("creates a task and links it to the feature", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1722,7 +1722,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses provided title and description overrides", async () => {
|
it("uses provided title and description overrides", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1743,7 +1743,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("emits feature:linked event", async () => {
|
it("emits feature:linked event", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1778,7 +1778,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws if slice not found", async () => {
|
it("throws if slice not found", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1788,7 +1788,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("triages all defined features in a slice", async () => {
|
it("triages all defined features in a slice", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1815,7 +1815,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips already triaged features", async () => {
|
it("skips already triaged features", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1837,7 +1837,7 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array if no defined features", async () => {
|
it("returns empty array if no defined features", async () => {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const msWithTs = ts.getMissionStore();
|
const msWithTs = ts.getMissionStore();
|
||||||
|
|
||||||
@@ -1855,10 +1855,10 @@ describe("MissionStore", () => {
|
|||||||
describe("activateSlice with autoAdvance", () => {
|
describe("activateSlice with autoAdvance", () => {
|
||||||
/** Helper to create a MissionStore with a real TaskStore reference */
|
/** Helper to create a MissionStore with a real TaskStore reference */
|
||||||
async function createStoreWithTaskStore(): Promise<{
|
async function createStoreWithTaskStore(): Promise<{
|
||||||
ts: import("./store.js").TaskStore;
|
ts: import("../store.js").TaskStore;
|
||||||
ms: MissionStore;
|
ms: MissionStore;
|
||||||
}> {
|
}> {
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
const ts = new TaskStore(fusionDir, join(fusionDir, ".fusion-global-settings"));
|
||||||
const ms = ts.getMissionStore();
|
const ms = ts.getMissionStore();
|
||||||
return { ts, ms };
|
return { ts, ms };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { NodeConnection } from "./node-connection.js";
|
import { NodeConnection } from "../node-connection.js";
|
||||||
import type { CentralCore } from "./central-core.js";
|
import type { CentralCore } from "../central-core.js";
|
||||||
import type { NodeConfig } from "./types.js";
|
import type { NodeConfig } from "../types.js";
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200): Response {
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import type { DiscoveryConfig, DiscoveredNode } from "./types.js";
|
import type { DiscoveryConfig, DiscoveredNode } from "../types.js";
|
||||||
|
|
||||||
interface MockBrowser {
|
interface MockBrowser {
|
||||||
on: ReturnType<typeof vi.fn>;
|
on: ReturnType<typeof vi.fn>;
|
||||||
@@ -20,7 +20,7 @@ vi.mock("bonjour-service", () => ({
|
|||||||
default: BonjourMock,
|
default: BonjourMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { NodeDiscovery } from "./node-discovery.js";
|
import { NodeDiscovery } from "../node-discovery.js";
|
||||||
|
|
||||||
function createMockBrowser(): MockBrowser {
|
function createMockBrowser(): MockBrowser {
|
||||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||||
@@ -3,9 +3,9 @@ import { writeFile, mkdir } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, existsSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { PluginLoader } from "./plugin-loader.js";
|
import { PluginLoader } from "../plugin-loader.js";
|
||||||
import { PluginStore } from "./plugin-store.js";
|
import { PluginStore } from "../plugin-store.js";
|
||||||
import type { FusionPlugin, PluginManifest } from "./plugin-types.js";
|
import type { FusionPlugin, PluginManifest } from "../plugin-types.js";
|
||||||
|
|
||||||
// Test plugin manifest
|
// Test plugin manifest
|
||||||
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
||||||
@@ -131,11 +131,11 @@ async function loadPluginLoaderWithMockedLogger() {
|
|||||||
return logger;
|
return logger;
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.doMock("./logger.js", () => ({
|
vi.doMock("../logger.js", () => ({
|
||||||
createLogger: createLoggerMock,
|
createLogger: createLoggerMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { PluginLoader: MockedPluginLoader } = await import("./plugin-loader.js");
|
const { PluginLoader: MockedPluginLoader } = await import("../plugin-loader.js");
|
||||||
return { MockedPluginLoader, createLoggerMock, loggerMap };
|
return { MockedPluginLoader, createLoggerMock, loggerMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { PluginStore } from "./plugin-store.js";
|
import { PluginStore } from "../plugin-store.js";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { PluginManifest, PluginState } from "./plugin-types.js";
|
import type { PluginManifest, PluginState } from "../plugin-types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-plugin-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-plugin-test-"));
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { validatePluginManifest } from "./plugin-types.js";
|
import { validatePluginManifest } from "../plugin-types.js";
|
||||||
|
|
||||||
describe("validatePluginManifest", () => {
|
describe("validatePluginManifest", () => {
|
||||||
// ── Valid Manifests ─────────────────────────────────────────────────
|
// ── Valid Manifests ─────────────────────────────────────────────────
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
readProjectMemoryWithBackend,
|
readProjectMemoryWithBackend,
|
||||||
searchProjectMemory,
|
searchProjectMemory,
|
||||||
resolveMemoryInstructionContext,
|
resolveMemoryInstructionContext,
|
||||||
} from "./project-memory.js";
|
} from "../project-memory.js";
|
||||||
|
|
||||||
describe("project-memory", () => {
|
describe("project-memory", () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
isValidPromptKey,
|
isValidPromptKey,
|
||||||
isValidPromptOverrideMap,
|
isValidPromptOverrideMap,
|
||||||
assertValidPromptOverrideMap,
|
assertValidPromptOverrideMap,
|
||||||
} from "./prompt-overrides.js";
|
} from "../prompt-overrides.js";
|
||||||
|
|
||||||
describe("prompt-overrides", () => {
|
describe("prompt-overrides", () => {
|
||||||
describe("PROMPT_KEY_CATALOG", () => {
|
describe("PROMPT_KEY_CATALOG", () => {
|
||||||
@@ -3,8 +3,8 @@ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { ReflectionStore } from "./reflection-store.js";
|
import { ReflectionStore } from "../reflection-store.js";
|
||||||
import type { AgentReflection, ReflectionTrigger } from "./types.js";
|
import type { AgentReflection, ReflectionTrigger } from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-reflection-store-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-reflection-store-test-"));
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
mapRoadmapToMissionHandoff,
|
mapRoadmapToMissionHandoff,
|
||||||
mapRoadmapWithHierarchyToMissionHandoff,
|
mapRoadmapWithHierarchyToMissionHandoff,
|
||||||
mapAllFeaturesToTaskHandoffs,
|
mapAllFeaturesToTaskHandoffs,
|
||||||
} from "./roadmap-handoff.js";
|
} from "../roadmap-handoff.js";
|
||||||
import { normalizeRoadmapMilestoneOrder } from "./roadmap-ordering.js";
|
import { normalizeRoadmapMilestoneOrder } from "../roadmap-ordering.js";
|
||||||
import type {
|
import type {
|
||||||
Roadmap,
|
Roadmap,
|
||||||
RoadmapMilestone,
|
RoadmapMilestone,
|
||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
RoadmapWithHierarchy,
|
RoadmapWithHierarchy,
|
||||||
RoadmapFeatureTaskPlanningHandoff,
|
RoadmapFeatureTaskPlanningHandoff,
|
||||||
RoadmapMissionPlanningHandoff,
|
RoadmapMissionPlanningHandoff,
|
||||||
} from "./roadmap-types.js";
|
} from "../roadmap-types.js";
|
||||||
|
|
||||||
// ── Test Fixtures ─────────────────────────────────────────────────────────────
|
// ── Test Fixtures ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
moveRoadmapFeature,
|
moveRoadmapFeature,
|
||||||
normalizeRoadmapFeatureOrder,
|
normalizeRoadmapFeatureOrder,
|
||||||
normalizeRoadmapMilestoneOrder,
|
normalizeRoadmapMilestoneOrder,
|
||||||
} from "./roadmap-ordering.js";
|
} from "../roadmap-ordering.js";
|
||||||
import type { RoadmapFeature, RoadmapMilestone } from "./roadmap-types.js";
|
import type { RoadmapFeature, RoadmapMilestone } from "../roadmap-types.js";
|
||||||
|
|
||||||
function createMilestone(
|
function createMilestone(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { Database, createDatabase } from "./db.js";
|
import { Database, createDatabase } from "../db.js";
|
||||||
import { RoadmapStore } from "./roadmap-store.js";
|
import { RoadmapStore } from "../roadmap-store.js";
|
||||||
import type {
|
import type {
|
||||||
RoadmapCreateInput,
|
RoadmapCreateInput,
|
||||||
RoadmapUpdateInput,
|
RoadmapUpdateInput,
|
||||||
@@ -11,7 +11,7 @@ import type {
|
|||||||
RoadmapMilestoneReorderInput,
|
RoadmapMilestoneReorderInput,
|
||||||
RoadmapFeatureReorderInput,
|
RoadmapFeatureReorderInput,
|
||||||
RoadmapFeatureMoveInput,
|
RoadmapFeatureMoveInput,
|
||||||
} from "./roadmap-types.js";
|
} from "../roadmap-types.js";
|
||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { RoutineStore } from "./routine-store.js";
|
import { RoutineStore } from "../routine-store.js";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, existsSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
RoutineCreateInput,
|
RoutineCreateInput,
|
||||||
RoutineExecutionResult,
|
RoutineExecutionResult,
|
||||||
RoutineTrigger,
|
RoutineTrigger,
|
||||||
} from "./routine.js";
|
} from "../routine.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-routine-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-routine-test-"));
|
||||||
@@ -16,9 +16,9 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
import type { RunAuditEventInput, RunAuditEvent } from "./types.js";
|
import type { RunAuditEventInput, RunAuditEvent } from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "fn-run-audit-integration-test-"));
|
return mkdtempSync(join(tmpdir(), "fn-run-audit-integration-test-"));
|
||||||
@@ -3,9 +3,9 @@ import { mkdtempSync } from "node:fs";
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { Database } from "./db.js";
|
import { Database } from "../db.js";
|
||||||
import { TaskStore } from "./store.js";
|
import { TaskStore } from "../store.js";
|
||||||
import type { RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
import type { RunAuditEventInput, RunAuditEventFilter } from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "fn-run-audit-test-"));
|
return mkdtempSync(join(tmpdir(), "fn-run-audit-test-"));
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { runCommandAsync } from "./run-command.js";
|
import { runCommandAsync } from "../run-command.js";
|
||||||
|
|
||||||
function isProcessAlive(pid: number): boolean {
|
function isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -2,9 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs";
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { TaskStore } from "./store.js";
|
import type { TaskStore } from "../store.js";
|
||||||
import type { GlobalSettingsStore } from "./global-settings.js";
|
import type { GlobalSettingsStore } from "../global-settings.js";
|
||||||
import type { Settings, GlobalSettings, ProjectSettings } from "./types.js";
|
import type { Settings, GlobalSettings, ProjectSettings } from "../types.js";
|
||||||
import {
|
import {
|
||||||
exportSettings,
|
exportSettings,
|
||||||
importSettings,
|
importSettings,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type SettingsExportData,
|
type SettingsExportData,
|
||||||
type ExportSettingsOptions,
|
type ExportSettingsOptions,
|
||||||
type ImportSettingsOptions,
|
type ImportSettingsOptions,
|
||||||
} from "./settings-export.js";
|
} from "../settings-export.js";
|
||||||
|
|
||||||
// Helper to create a temporary test environment
|
// Helper to create a temporary test environment
|
||||||
function createTestEnv() {
|
function createTestEnv() {
|
||||||
@@ -57,7 +57,7 @@ describe("settings-export", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
env = createTestEnv();
|
env = createTestEnv();
|
||||||
const { TaskStore } = await import("./store.js");
|
const { TaskStore } = await import("../store.js");
|
||||||
store = new TaskStore(env.tempDir, env.globalSettingsDir);
|
store = new TaskStore(env.tempDir, env.globalSettingsDir);
|
||||||
await store.init();
|
await store.init();
|
||||||
});
|
});
|
||||||
@@ -10,8 +10,8 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("./run-command.js", async (importOriginal) => {
|
vi.mock("../run-command.js", async (importOriginal) => {
|
||||||
const mod = await importOriginal<typeof import("./run-command.js")>();
|
const mod = await importOriginal<typeof import("../run-command.js")>();
|
||||||
return {
|
return {
|
||||||
...mod,
|
...mod,
|
||||||
runCommandAsync: vi.fn((...args: Parameters<typeof mod.runCommandAsync>) => mod.runCommandAsync(...args)),
|
runCommandAsync: vi.fn((...args: Parameters<typeof mod.runCommandAsync>) => mod.runCommandAsync(...args)),
|
||||||
@@ -20,16 +20,16 @@ vi.mock("./run-command.js", async (importOriginal) => {
|
|||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
import { runCommandAsync } from "./run-command.js";
|
import { runCommandAsync } from "../run-command.js";
|
||||||
const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
||||||
|
|
||||||
import { TaskStore, TaskHasDependentsError } from "./store.js";
|
import { TaskStore, TaskHasDependentsError } from "../store.js";
|
||||||
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdtempSync, existsSync } from "node:fs";
|
import { mkdtempSync, existsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import * as projectMemory from "./project-memory.js";
|
import * as projectMemory from "../project-memory.js";
|
||||||
import type { Task } from "./types.js";
|
import type { Task } from "../types.js";
|
||||||
|
|
||||||
function makeTmpDir(): string {
|
function makeTmpDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
return mkdtempSync(join(tmpdir(), "kb-store-test-"));
|
||||||
@@ -9113,7 +9113,7 @@ Task with acceptance criteria
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
mockedRunCommandAsync.mockImplementation((...args: Parameters<typeof runCommandAsync>) =>
|
mockedRunCommandAsync.mockImplementation((...args: Parameters<typeof runCommandAsync>) =>
|
||||||
vi.importActual<typeof import("./run-command.js")>("./run-command.js").then((mod) =>
|
vi.importActual<typeof import("../run-command.js")>("../run-command.js").then((mod) =>
|
||||||
mod.runCommandAsync(...args),
|
mod.runCommandAsync(...args),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { collectSystemMetrics } from "./system-metrics.js";
|
import { collectSystemMetrics } from "../system-metrics.js";
|
||||||
|
|
||||||
const { checkDiskSpaceMock, cpusMock, totalmemMock, freememMock, uptimeMock } = vi.hoisted(() => ({
|
const { checkDiskSpaceMock, cpusMock, totalmemMock, freememMock, uptimeMock } = vi.hoisted(() => ({
|
||||||
checkDiskSpaceMock: vi.fn(),
|
checkDiskSpaceMock: vi.fn(),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { StepStatus } from "./types.js";
|
import type { StepStatus } from "../types.js";
|
||||||
import { getTaskCompletionBlocker, getTaskMergeBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
import { getTaskCompletionBlocker, getTaskMergeBlocker, isTaskReadyForMerge } from "../task-merge.js";
|
||||||
|
|
||||||
const baseTask = {
|
const baseTask = {
|
||||||
column: "in-review" as const,
|
column: "in-review" as const,
|
||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
isTaskPriority,
|
isTaskPriority,
|
||||||
normalizeTaskPriority,
|
normalizeTaskPriority,
|
||||||
sortTasksByPriorityThenAgeAndId,
|
sortTasksByPriorityThenAgeAndId,
|
||||||
} from "./task-priority.js";
|
} from "../task-priority.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TASK_PRIORITY,
|
DEFAULT_TASK_PRIORITY,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
type TaskPriority,
|
type TaskPriority,
|
||||||
} from "./types.js";
|
} from "../types.js";
|
||||||
import * as core from "./index.js";
|
import * as core from "../index.js";
|
||||||
|
|
||||||
describe("task-priority", () => {
|
describe("task-priority", () => {
|
||||||
it("defines the bounded priority contract in order", () => {
|
it("defines the bounded priority contract in order", () => {
|
||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
pullNodeSettings,
|
pullNodeSettings,
|
||||||
fetchNodeSettingsSyncStatus,
|
fetchNodeSettingsSyncStatus,
|
||||||
syncNodeAuth,
|
syncNodeAuth,
|
||||||
} from "./api-node";
|
} from "../api-node";
|
||||||
import * as apiModule from "./api";
|
import * as apiModule from "../api";
|
||||||
|
|
||||||
vi.mock("./api", () => ({
|
vi.mock("../api", () => ({
|
||||||
proxyApi: vi.fn(),
|
proxyApi: vi.fn(),
|
||||||
api: vi.fn(),
|
api: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type DiscoveredSkill,
|
type DiscoveredSkill,
|
||||||
type CatalogFetchResult,
|
type CatalogFetchResult,
|
||||||
type ToggleSkillResult,
|
type ToggleSkillResult,
|
||||||
} from "./api";
|
} from "../api";
|
||||||
|
|
||||||
function mockFetchResponse(
|
function mockFetchResponse(
|
||||||
ok: boolean,
|
ok: boolean,
|
||||||
@@ -73,9 +73,9 @@ import {
|
|||||||
type GlobalConcurrencyState,
|
type GlobalConcurrencyState,
|
||||||
type ExecutorStats,
|
type ExecutorStats,
|
||||||
type ExecutorState,
|
type ExecutorState,
|
||||||
} from "./api";
|
} from "../api";
|
||||||
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
|
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
|
||||||
import { clearAuthToken } from "./auth";
|
import { clearAuthToken } from "../auth";
|
||||||
|
|
||||||
const FAKE_DETAIL: TaskDetail = {
|
const FAKE_DETAIL: TaskDetail = {
|
||||||
id: "FN-001",
|
id: "FN-001",
|
||||||
@@ -688,7 +688,7 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
mockFetchResponse(true, mockResponse)
|
mockFetchResponse(true, mockResponse)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { batchUpdateTaskModels } = await import("./api");
|
const { batchUpdateTaskModels } = await import("../api");
|
||||||
const result = await batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o");
|
const result = await batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o");
|
||||||
|
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
@@ -713,7 +713,7 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
mockFetchResponse(true, mockResponse)
|
mockFetchResponse(true, mockResponse)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { batchUpdateTaskModels } = await import("./api");
|
const { batchUpdateTaskModels } = await import("../api");
|
||||||
await batchUpdateTaskModels(
|
await batchUpdateTaskModels(
|
||||||
["FN-001", "FN-002"],
|
["FN-001", "FN-002"],
|
||||||
undefined,
|
undefined,
|
||||||
@@ -740,7 +740,7 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
mockFetchResponse(true, mockResponse)
|
mockFetchResponse(true, mockResponse)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { batchUpdateTaskModels } = await import("./api");
|
const { batchUpdateTaskModels } = await import("../api");
|
||||||
await batchUpdateTaskModels(["FN-001"], null, null);
|
await batchUpdateTaskModels(["FN-001"], null, null);
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
@@ -760,7 +760,7 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
|
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { batchUpdateTaskModels } = await import("./api");
|
const { batchUpdateTaskModels } = await import("../api");
|
||||||
await expect(batchUpdateTaskModels([], "openai", "gpt-4o")).rejects.toThrow(
|
await expect(batchUpdateTaskModels([], "openai", "gpt-4o")).rejects.toThrow(
|
||||||
"taskIds must be an array"
|
"taskIds must be an array"
|
||||||
);
|
);
|
||||||
@@ -771,7 +771,7 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
mockFetchResponse(false, { error: "Task KB-999 not found" }, 404)
|
mockFetchResponse(false, { error: "Task KB-999 not found" }, 404)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { batchUpdateTaskModels } = await import("./api");
|
const { batchUpdateTaskModels } = await import("../api");
|
||||||
await expect(batchUpdateTaskModels(["KB-999"], "openai", "gpt-4o")).rejects.toThrow(
|
await expect(batchUpdateTaskModels(["KB-999"], "openai", "gpt-4o")).rejects.toThrow(
|
||||||
"Task KB-999 not found"
|
"Task KB-999 not found"
|
||||||
);
|
);
|
||||||
@@ -780,7 +780,7 @@ describe("batchUpdateTaskModels", () => {
|
|||||||
it("throws on network error", async () => {
|
it("throws on network error", async () => {
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network failed"));
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network failed"));
|
||||||
|
|
||||||
const { batchUpdateTaskModels } = await import("./api");
|
const { batchUpdateTaskModels } = await import("../api");
|
||||||
await expect(batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o")).rejects.toThrow(
|
await expect(batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o")).rejects.toThrow(
|
||||||
"Network failed"
|
"Network failed"
|
||||||
);
|
);
|
||||||
@@ -961,7 +961,7 @@ import {
|
|||||||
removeGitRemote,
|
removeGitRemote,
|
||||||
renameGitRemote,
|
renameGitRemote,
|
||||||
updateGitRemoteUrl,
|
updateGitRemoteUrl,
|
||||||
} from "./api";
|
} from "../api";
|
||||||
|
|
||||||
describe("fetchGitRemotesDetailed", () => {
|
describe("fetchGitRemotesDetailed", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -1147,7 +1147,7 @@ describe("updateGitRemoteUrl", () => {
|
|||||||
|
|
||||||
// --- Plan approval API tests ---
|
// --- Plan approval API tests ---
|
||||||
|
|
||||||
import { approvePlan, rejectPlan } from "./api";
|
import { approvePlan, rejectPlan } from "../api";
|
||||||
|
|
||||||
describe("approvePlan", () => {
|
describe("approvePlan", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -1289,7 +1289,7 @@ import {
|
|||||||
fetchRemote,
|
fetchRemote,
|
||||||
pullBranch,
|
pullBranch,
|
||||||
pushBranch,
|
pushBranch,
|
||||||
} from "./api";
|
} from "../api";
|
||||||
|
|
||||||
describe("agent API wrappers", () => {
|
describe("agent API wrappers", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -1418,7 +1418,7 @@ describe("fetchAgentChildren", () => {
|
|||||||
];
|
];
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockChildren));
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockChildren));
|
||||||
|
|
||||||
const { fetchAgentChildren } = await import("./api");
|
const { fetchAgentChildren } = await import("../api");
|
||||||
const result = await fetchAgentChildren("agent-001");
|
const result = await fetchAgentChildren("agent-001");
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
@@ -1431,7 +1431,7 @@ describe("fetchAgentChildren", () => {
|
|||||||
it("passes projectId as query param", async () => {
|
it("passes projectId as query param", async () => {
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||||
|
|
||||||
const { fetchAgentChildren } = await import("./api");
|
const { fetchAgentChildren } = await import("../api");
|
||||||
await fetchAgentChildren("agent-001", "proj_123");
|
await fetchAgentChildren("agent-001", "proj_123");
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children?projectId=proj_123", {
|
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children?projectId=proj_123", {
|
||||||
@@ -1444,7 +1444,7 @@ describe("fetchAgentChildren", () => {
|
|||||||
mockFetchResponse(false, { error: "Agent not found" }, 404),
|
mockFetchResponse(false, { error: "Agent not found" }, 404),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fetchAgentChildren } = await import("./api");
|
const { fetchAgentChildren } = await import("../api");
|
||||||
const result = await fetchAgentChildren("agent-999");
|
const result = await fetchAgentChildren("agent-999");
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
@@ -1455,7 +1455,7 @@ describe("fetchAgentChildren", () => {
|
|||||||
mockFetchResponse(false, { error: "Internal server error" }, 500),
|
mockFetchResponse(false, { error: "Internal server error" }, 500),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { fetchAgentChildren } = await import("./api");
|
const { fetchAgentChildren } = await import("../api");
|
||||||
await expect(fetchAgentChildren("agent-001")).rejects.toThrow("Internal server error");
|
await expect(fetchAgentChildren("agent-001")).rejects.toThrow("Internal server error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2135,7 +2135,7 @@ describe("Git Management API", () => {
|
|||||||
|
|
||||||
// --- Planning Mode API Tests ---
|
// --- Planning Mode API Tests ---
|
||||||
|
|
||||||
import { startPlanning, respondToPlanning, cancelPlanning, createTaskFromPlanning } from "./api";
|
import { startPlanning, respondToPlanning, cancelPlanning, createTaskFromPlanning } from "../api";
|
||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||||
|
|
||||||
describe("Planning Mode API", () => {
|
describe("Planning Mode API", () => {
|
||||||
@@ -2445,7 +2445,7 @@ describe("API Error Handling", () => {
|
|||||||
|
|
||||||
// ── AI Text Refinement API Tests ───────────────────────────────────────────
|
// ── AI Text Refinement API Tests ───────────────────────────────────────────
|
||||||
|
|
||||||
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "./api";
|
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "../api";
|
||||||
|
|
||||||
describe("refineText", () => {
|
describe("refineText", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -3273,7 +3273,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { deleteMission } = await import("./api");
|
const { deleteMission } = await import("../api");
|
||||||
const result = await deleteMission("M-LZ7DN0-A2B5");
|
const result = await deleteMission("M-LZ7DN0-A2B5");
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -3286,7 +3286,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { deleteMilestone } = await import("./api");
|
const { deleteMilestone } = await import("../api");
|
||||||
const result = await deleteMilestone("MS-M3N8QR-C9F1");
|
const result = await deleteMilestone("MS-M3N8QR-C9F1");
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -3299,7 +3299,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { deleteSlice } = await import("./api");
|
const { deleteSlice } = await import("../api");
|
||||||
const result = await deleteSlice("SL-P4T2WX-D5E8");
|
const result = await deleteSlice("SL-P4T2WX-D5E8");
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -3312,7 +3312,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { deleteFeature } = await import("./api");
|
const { deleteFeature } = await import("../api");
|
||||||
const result = await deleteFeature("F-J6K9AB-G7H3");
|
const result = await deleteFeature("F-J6K9AB-G7H3");
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -3325,7 +3325,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { reorderMilestones } = await import("./api");
|
const { reorderMilestones } = await import("../api");
|
||||||
const result = await reorderMilestones("M-LZ7DN0-A2B5", ["MS-1", "MS-2"]);
|
const result = await reorderMilestones("M-LZ7DN0-A2B5", ["MS-1", "MS-2"]);
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -3338,7 +3338,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { reorderSlices } = await import("./api");
|
const { reorderSlices } = await import("../api");
|
||||||
const result = await reorderSlices("MS-M3N8QR-C9F1", ["SL-1", "SL-2"]);
|
const result = await reorderSlices("MS-M3N8QR-C9F1", ["SL-1", "SL-2"]);
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -3351,7 +3351,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
text: () => Promise.resolve(""),
|
text: () => Promise.resolve(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { deleteMission } = await import("./api");
|
const { deleteMission } = await import("../api");
|
||||||
const result = await deleteMission("M-LZ7DN0-A2B5", "my-project");
|
const result = await deleteMission("M-LZ7DN0-A2B5", "my-project");
|
||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
@@ -3365,7 +3365,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
mockFetchResponse(false, { error: "Mission not found" }, 404)
|
mockFetchResponse(false, { error: "Mission not found" }, 404)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { deleteMission } = await import("./api");
|
const { deleteMission } = await import("../api");
|
||||||
await expect(deleteMission("M-999")).rejects.toThrow("Mission not found");
|
await expect(deleteMission("M-999")).rejects.toThrow("Mission not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3374,7 +3374,7 @@ describe("Mission mutation coverage with 204 responses", () => {
|
|||||||
mockFetchResponse(false, { error: "Invalid mission ID format" }, 400)
|
mockFetchResponse(false, { error: "Invalid mission ID format" }, 400)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { deleteMission } = await import("./api");
|
const { deleteMission } = await import("../api");
|
||||||
await expect(deleteMission("bad-id")).rejects.toThrow("Invalid mission ID format");
|
await expect(deleteMission("bad-id")).rejects.toThrow("Invalid mission ID format");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4073,7 +4073,7 @@ describe("fetchMemoryBackendStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetches memory backend status without projectId", async () => {
|
it("fetches memory backend status without projectId", async () => {
|
||||||
const { fetchMemoryBackendStatus } = await import("./api");
|
const { fetchMemoryBackendStatus } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4096,7 +4096,7 @@ describe("fetchMemoryBackendStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetches memory backend status with projectId", async () => {
|
it("fetches memory backend status with projectId", async () => {
|
||||||
const { fetchMemoryBackendStatus } = await import("./api");
|
const { fetchMemoryBackendStatus } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4120,7 +4120,7 @@ describe("fetchMemoryBackendStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws on error response", async () => {
|
it("throws on error response", async () => {
|
||||||
const { fetchMemoryBackendStatus } = await import("./api");
|
const { fetchMemoryBackendStatus } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -4138,7 +4138,7 @@ describe("fetchMemoryBackendStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles readonly backend response", async () => {
|
it("handles readonly backend response", async () => {
|
||||||
const { fetchMemoryBackendStatus } = await import("./api");
|
const { fetchMemoryBackendStatus } = await import("../api");
|
||||||
|
|
||||||
const readonlyStatus = {
|
const readonlyStatus = {
|
||||||
currentBackend: "readonly",
|
currentBackend: "readonly",
|
||||||
@@ -4171,7 +4171,7 @@ describe("fetchMemoryBackendStatus", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles qmd backend response", async () => {
|
it("handles qmd backend response", async () => {
|
||||||
const { fetchMemoryBackendStatus } = await import("./api");
|
const { fetchMemoryBackendStatus } = await import("../api");
|
||||||
|
|
||||||
const qmdStatus = {
|
const qmdStatus = {
|
||||||
currentBackend: "qmd",
|
currentBackend: "qmd",
|
||||||
@@ -4217,7 +4217,7 @@ describe("installQmd", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls POST /api/memory/install-qmd without projectId", async () => {
|
it("calls POST /api/memory/install-qmd without projectId", async () => {
|
||||||
const { installQmd } = await import("./api");
|
const { installQmd } = await import("../api");
|
||||||
const response = {
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
qmdAvailable: true,
|
qmdAvailable: true,
|
||||||
@@ -4246,7 +4246,7 @@ describe("installQmd", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes projectId when installing qmd for a project context", async () => {
|
it("includes projectId when installing qmd for a project context", async () => {
|
||||||
const { installQmd } = await import("./api");
|
const { installQmd } = await import("../api");
|
||||||
const response = {
|
const response = {
|
||||||
success: true,
|
success: true,
|
||||||
qmdAvailable: true,
|
qmdAvailable: true,
|
||||||
@@ -4285,7 +4285,7 @@ describe("compactMemory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls POST /api/memory/compact without projectId", async () => {
|
it("calls POST /api/memory/compact without projectId", async () => {
|
||||||
const { compactMemory } = await import("./api");
|
const { compactMemory } = await import("../api");
|
||||||
|
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
path: ".fusion/memory/DREAMS.md",
|
path: ".fusion/memory/DREAMS.md",
|
||||||
@@ -4314,7 +4314,7 @@ describe("compactMemory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls POST /api/memory/compact with projectId", async () => {
|
it("calls POST /api/memory/compact with projectId", async () => {
|
||||||
const { compactMemory } = await import("./api");
|
const { compactMemory } = await import("../api");
|
||||||
|
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
path: ".fusion/memory/MEMORY.md",
|
path: ".fusion/memory/MEMORY.md",
|
||||||
@@ -4344,7 +4344,7 @@ describe("compactMemory", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws on error response", async () => {
|
it("throws on error response", async () => {
|
||||||
const { compactMemory } = await import("./api");
|
const { compactMemory } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -4364,7 +4364,7 @@ describe("compactMemory", () => {
|
|||||||
|
|
||||||
describe("fetchMemoryInsights", () => {
|
describe("fetchMemoryInsights", () => {
|
||||||
it("calls GET /api/memory/insights without projectId", async () => {
|
it("calls GET /api/memory/insights without projectId", async () => {
|
||||||
const { fetchMemoryInsights } = await import("./api");
|
const { fetchMemoryInsights } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4385,7 +4385,7 @@ describe("fetchMemoryInsights", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls GET /api/memory/insights with projectId", async () => {
|
it("calls GET /api/memory/insights with projectId", async () => {
|
||||||
const { fetchMemoryInsights } = await import("./api");
|
const { fetchMemoryInsights } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4409,7 +4409,7 @@ describe("fetchMemoryInsights", () => {
|
|||||||
|
|
||||||
describe("saveMemoryInsights", () => {
|
describe("saveMemoryInsights", () => {
|
||||||
it("calls PUT /api/memory/insights without projectId", async () => {
|
it("calls PUT /api/memory/insights without projectId", async () => {
|
||||||
const { saveMemoryInsights } = await import("./api");
|
const { saveMemoryInsights } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4432,7 +4432,7 @@ describe("saveMemoryInsights", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls PUT /api/memory/insights with projectId", async () => {
|
it("calls PUT /api/memory/insights with projectId", async () => {
|
||||||
const { saveMemoryInsights } = await import("./api");
|
const { saveMemoryInsights } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4457,7 +4457,7 @@ describe("saveMemoryInsights", () => {
|
|||||||
|
|
||||||
describe("triggerInsightExtraction", () => {
|
describe("triggerInsightExtraction", () => {
|
||||||
it("calls POST /api/memory/extract without projectId", async () => {
|
it("calls POST /api/memory/extract without projectId", async () => {
|
||||||
const { triggerInsightExtraction } = await import("./api");
|
const { triggerInsightExtraction } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4479,7 +4479,7 @@ describe("triggerInsightExtraction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls POST /api/memory/extract with projectId", async () => {
|
it("calls POST /api/memory/extract with projectId", async () => {
|
||||||
const { triggerInsightExtraction } = await import("./api");
|
const { triggerInsightExtraction } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4504,7 +4504,7 @@ describe("triggerInsightExtraction", () => {
|
|||||||
|
|
||||||
describe("fetchMemoryAudit", () => {
|
describe("fetchMemoryAudit", () => {
|
||||||
it("calls GET /api/memory/audit without projectId", async () => {
|
it("calls GET /api/memory/audit without projectId", async () => {
|
||||||
const { fetchMemoryAudit } = await import("./api");
|
const { fetchMemoryAudit } = await import("../api");
|
||||||
const mockReport = {
|
const mockReport = {
|
||||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
workingMemory: { exists: true, size: 100, sectionCount: 2 },
|
||||||
@@ -4534,7 +4534,7 @@ describe("fetchMemoryAudit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls GET /api/memory/audit with projectId", async () => {
|
it("calls GET /api/memory/audit with projectId", async () => {
|
||||||
const { fetchMemoryAudit } = await import("./api");
|
const { fetchMemoryAudit } = await import("../api");
|
||||||
const mockReport = {
|
const mockReport = {
|
||||||
generatedAt: "2024-01-01T00:00:00.000Z",
|
generatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
workingMemory: { exists: false, size: 0, sectionCount: 0 },
|
workingMemory: { exists: false, size: 0, sectionCount: 0 },
|
||||||
@@ -4567,7 +4567,7 @@ describe("fetchMemoryAudit", () => {
|
|||||||
|
|
||||||
describe("fetchMemoryStats", () => {
|
describe("fetchMemoryStats", () => {
|
||||||
it("calls GET /api/memory/stats without projectId", async () => {
|
it("calls GET /api/memory/stats without projectId", async () => {
|
||||||
const { fetchMemoryStats } = await import("./api");
|
const { fetchMemoryStats } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4588,7 +4588,7 @@ describe("fetchMemoryStats", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls GET /api/memory/stats with projectId", async () => {
|
it("calls GET /api/memory/stats with projectId", async () => {
|
||||||
const { fetchMemoryStats } = await import("./api");
|
const { fetchMemoryStats } = await import("../api");
|
||||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -4668,7 +4668,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it("fetchRoadmaps sends GET and propagates projectId", async () => {
|
it("fetchRoadmaps sends GET and propagates projectId", async () => {
|
||||||
const { fetchRoadmaps } = await import("./api");
|
const { fetchRoadmaps } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4691,7 +4691,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createRoadmap sends POST with input payload", async () => {
|
it("createRoadmap sends POST with input payload", async () => {
|
||||||
const { createRoadmap } = await import("./api");
|
const { createRoadmap } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4716,7 +4716,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchRoadmap returns roadmap with hierarchy", async () => {
|
it("fetchRoadmap returns roadmap with hierarchy", async () => {
|
||||||
const { fetchRoadmap } = await import("./api");
|
const { fetchRoadmap } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4739,7 +4739,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateRoadmap sends PATCH with updates", async () => {
|
it("updateRoadmap sends PATCH with updates", async () => {
|
||||||
const { updateRoadmap } = await import("./api");
|
const { updateRoadmap } = await import("../api");
|
||||||
|
|
||||||
const updatedRoadmap = { ...mockRoadmap, title: "Updated Roadmap" };
|
const updatedRoadmap = { ...mockRoadmap, title: "Updated Roadmap" };
|
||||||
|
|
||||||
@@ -4763,7 +4763,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deleteRoadmap sends DELETE and returns void", async () => {
|
it("deleteRoadmap sends DELETE and returns void", async () => {
|
||||||
const { deleteRoadmap } = await import("./api");
|
const { deleteRoadmap } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4785,7 +4785,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createRoadmapMilestone sends POST with milestone input", async () => {
|
it("createRoadmapMilestone sends POST with milestone input", async () => {
|
||||||
const { createRoadmapMilestone } = await import("./api");
|
const { createRoadmapMilestone } = await import("../api");
|
||||||
|
|
||||||
const mockMilestone = {
|
const mockMilestone = {
|
||||||
id: "RMS-001",
|
id: "RMS-001",
|
||||||
@@ -4817,7 +4817,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateRoadmapMilestone sends PATCH", async () => {
|
it("updateRoadmapMilestone sends PATCH", async () => {
|
||||||
const { updateRoadmapMilestone } = await import("./api");
|
const { updateRoadmapMilestone } = await import("../api");
|
||||||
|
|
||||||
const updatedMilestone = {
|
const updatedMilestone = {
|
||||||
id: "RMS-001",
|
id: "RMS-001",
|
||||||
@@ -4848,7 +4848,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deleteRoadmapMilestone sends DELETE", async () => {
|
it("deleteRoadmapMilestone sends DELETE", async () => {
|
||||||
const { deleteRoadmapMilestone } = await import("./api");
|
const { deleteRoadmapMilestone } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4870,7 +4870,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createRoadmapFeature sends POST with feature input", async () => {
|
it("createRoadmapFeature sends POST with feature input", async () => {
|
||||||
const { createRoadmapFeature } = await import("./api");
|
const { createRoadmapFeature } = await import("../api");
|
||||||
|
|
||||||
const mockFeature = {
|
const mockFeature = {
|
||||||
id: "RF-001",
|
id: "RF-001",
|
||||||
@@ -4902,7 +4902,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateRoadmapFeature sends PATCH", async () => {
|
it("updateRoadmapFeature sends PATCH", async () => {
|
||||||
const { updateRoadmapFeature } = await import("./api");
|
const { updateRoadmapFeature } = await import("../api");
|
||||||
|
|
||||||
const updatedFeature = {
|
const updatedFeature = {
|
||||||
id: "RF-001",
|
id: "RF-001",
|
||||||
@@ -4933,7 +4933,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deleteRoadmapFeature sends DELETE", async () => {
|
it("deleteRoadmapFeature sends DELETE", async () => {
|
||||||
const { deleteRoadmapFeature } = await import("./api");
|
const { deleteRoadmapFeature } = await import("../api");
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -4955,7 +4955,7 @@ describe("Roadmap API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchRoadmapFeatures returns features for a milestone", async () => {
|
it("fetchRoadmapFeatures returns features for a milestone", async () => {
|
||||||
const { fetchRoadmapFeatures } = await import("./api");
|
const { fetchRoadmapFeatures } = await import("../api");
|
||||||
|
|
||||||
const mockFeatures = [
|
const mockFeatures = [
|
||||||
{
|
{
|
||||||
@@ -5013,7 +5013,7 @@ describe("Settings API wrappers", () => {
|
|||||||
|
|
||||||
describe("fetchSettingsByScope", () => {
|
describe("fetchSettingsByScope", () => {
|
||||||
it("calls /api/settings/scopes with no query string when projectId is omitted", async () => {
|
it("calls /api/settings/scopes with no query string when projectId is omitted", async () => {
|
||||||
const { fetchSettingsByScope } = await import("./api");
|
const { fetchSettingsByScope } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5039,7 +5039,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("calls /api/settings/scopes?projectId=proj_123 when projectId is provided", async () => {
|
it("calls /api/settings/scopes?projectId=proj_123 when projectId is provided", async () => {
|
||||||
const { fetchSettingsByScope } = await import("./api");
|
const { fetchSettingsByScope } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5065,7 +5065,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns the { global, project } shape", async () => {
|
it("returns the { global, project } shape", async () => {
|
||||||
const { fetchSettingsByScope } = await import("./api");
|
const { fetchSettingsByScope } = await import("../api");
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||||||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||||||
@@ -5093,7 +5093,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws with server error message on failure", async () => {
|
it("throws with server error message on failure", async () => {
|
||||||
const { fetchSettingsByScope } = await import("./api");
|
const { fetchSettingsByScope } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -5113,7 +5113,7 @@ describe("Settings API wrappers", () => {
|
|||||||
|
|
||||||
describe("updateGlobalSettings", () => {
|
describe("updateGlobalSettings", () => {
|
||||||
it("sends PUT to /api/settings/global with the provided payload", async () => {
|
it("sends PUT to /api/settings/global with the provided payload", async () => {
|
||||||
const { updateGlobalSettings } = await import("./api");
|
const { updateGlobalSettings } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5137,7 +5137,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns the settings object on success", async () => {
|
it("returns the settings object on success", async () => {
|
||||||
const { updateGlobalSettings } = await import("./api");
|
const { updateGlobalSettings } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5158,7 +5158,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws with server error message on failure", async () => {
|
it("throws with server error message on failure", async () => {
|
||||||
const { updateGlobalSettings } = await import("./api");
|
const { updateGlobalSettings } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -5178,7 +5178,7 @@ describe("Settings API wrappers", () => {
|
|||||||
|
|
||||||
describe("updateSettings scope rejection", () => {
|
describe("updateSettings scope rejection", () => {
|
||||||
it("forwards payload to PUT /api/settings and surfaces resulting 400 error", async () => {
|
it("forwards payload to PUT /api/settings and surfaces resulting 400 error", async () => {
|
||||||
const { updateSettings } = await import("./api");
|
const { updateSettings } = await import("../api");
|
||||||
|
|
||||||
// The backend rejects global keys on PUT /api/settings
|
// The backend rejects global keys on PUT /api/settings
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
@@ -5204,7 +5204,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends PUT to /api/settings with project-scoped payload on success", async () => {
|
it("sends PUT to /api/settings with project-scoped payload on success", async () => {
|
||||||
const { updateSettings } = await import("./api");
|
const { updateSettings } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5233,7 +5233,7 @@ describe("Settings API wrappers", () => {
|
|||||||
|
|
||||||
describe("fetchGlobalSettings", () => {
|
describe("fetchGlobalSettings", () => {
|
||||||
it("calls GET /api/settings/global with no query string", async () => {
|
it("calls GET /api/settings/global with no query string", async () => {
|
||||||
const { fetchGlobalSettings } = await import("./api");
|
const { fetchGlobalSettings } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5259,7 +5259,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns GlobalSettings with known keys like themeMode", async () => {
|
it("returns GlobalSettings with known keys like themeMode", async () => {
|
||||||
const { fetchGlobalSettings } = await import("./api");
|
const { fetchGlobalSettings } = await import("../api");
|
||||||
const mockSettings = {
|
const mockSettings = {
|
||||||
themeMode: "light",
|
themeMode: "light",
|
||||||
defaultProvider: "anthropic",
|
defaultProvider: "anthropic",
|
||||||
@@ -5285,7 +5285,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws with server error message on failure", async () => {
|
it("throws with server error message on failure", async () => {
|
||||||
const { fetchGlobalSettings } = await import("./api");
|
const { fetchGlobalSettings } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -5305,7 +5305,7 @@ describe("Settings API wrappers", () => {
|
|||||||
|
|
||||||
describe("roadmap reorder APIs", () => {
|
describe("roadmap reorder APIs", () => {
|
||||||
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
|
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
|
||||||
const { reorderRoadmapMilestones } = await import("./api");
|
const { reorderRoadmapMilestones } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5329,7 +5329,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reorderRoadmapMilestones includes projectId when provided", async () => {
|
it("reorderRoadmapMilestones includes projectId when provided", async () => {
|
||||||
const { reorderRoadmapMilestones } = await import("./api");
|
const { reorderRoadmapMilestones } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5348,7 +5348,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
|
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
|
||||||
const { reorderRoadmapFeatures } = await import("./api");
|
const { reorderRoadmapFeatures } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5372,7 +5372,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
|
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
|
||||||
const { moveRoadmapFeature } = await import("./api");
|
const { moveRoadmapFeature } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5397,7 +5397,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("moveRoadmapFeature includes projectId when provided", async () => {
|
it("moveRoadmapFeature includes projectId when provided", async () => {
|
||||||
const { moveRoadmapFeature } = await import("./api");
|
const { moveRoadmapFeature } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5416,7 +5416,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
|
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
|
||||||
const { generateFeatureSuggestions } = await import("./api");
|
const { generateFeatureSuggestions } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5439,7 +5439,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("generateFeatureSuggestions includes input parameters in body", async () => {
|
it("generateFeatureSuggestions includes input parameters in body", async () => {
|
||||||
const { generateFeatureSuggestions } = await import("./api");
|
const { generateFeatureSuggestions } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5462,7 +5462,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("generateFeatureSuggestions includes projectId when provided", async () => {
|
it("generateFeatureSuggestions includes projectId when provided", async () => {
|
||||||
const { generateFeatureSuggestions } = await import("./api");
|
const { generateFeatureSuggestions } = await import("../api");
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -5486,7 +5486,7 @@ describe("Settings API wrappers", () => {
|
|||||||
|
|
||||||
describe("roadmap export/handoff APIs", () => {
|
describe("roadmap export/handoff APIs", () => {
|
||||||
it("exportRoadmap sends GET to export endpoint", async () => {
|
it("exportRoadmap sends GET to export endpoint", async () => {
|
||||||
const { exportRoadmap } = await import("./api");
|
const { exportRoadmap } = await import("../api");
|
||||||
const exportData = {
|
const exportData = {
|
||||||
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
|
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
|
||||||
milestones: [],
|
milestones: [],
|
||||||
@@ -5514,7 +5514,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exportRoadmap includes projectId when provided", async () => {
|
it("exportRoadmap includes projectId when provided", async () => {
|
||||||
const { exportRoadmap } = await import("./api");
|
const { exportRoadmap } = await import("../api");
|
||||||
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
|
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
@@ -5537,7 +5537,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
|
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
|
||||||
const { getRoadmapMissionHandoff } = await import("./api");
|
const { getRoadmapMissionHandoff } = await import("../api");
|
||||||
const handoffData = {
|
const handoffData = {
|
||||||
sourceRoadmapId: "RM-001",
|
sourceRoadmapId: "RM-001",
|
||||||
title: "Test Roadmap",
|
title: "Test Roadmap",
|
||||||
@@ -5565,7 +5565,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
|
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
|
||||||
const { getRoadmapFeatureHandoff } = await import("./api");
|
const { getRoadmapFeatureHandoff } = await import("../api");
|
||||||
const handoffData = {
|
const handoffData = {
|
||||||
source: {
|
source: {
|
||||||
roadmapId: "RM-001",
|
roadmapId: "RM-001",
|
||||||
@@ -5600,7 +5600,7 @@ describe("Settings API wrappers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
|
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
|
||||||
const { getRoadmapFeatureHandoff } = await import("./api");
|
const { getRoadmapFeatureHandoff } = await import("../api");
|
||||||
const handoffData = {
|
const handoffData = {
|
||||||
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
|
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
|
||||||
title: "F",
|
title: "F",
|
||||||
@@ -5657,7 +5657,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchAutomations sends GET to /automations without scope by default", async () => {
|
it("fetchAutomations sends GET to /automations without scope by default", async () => {
|
||||||
const { fetchAutomations } = await import("./api");
|
const { fetchAutomations } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||||
|
|
||||||
await fetchAutomations();
|
await fetchAutomations();
|
||||||
@@ -5668,7 +5668,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchAutomations includes scope=global when specified", async () => {
|
it("fetchAutomations includes scope=global when specified", async () => {
|
||||||
const { fetchAutomations } = await import("./api");
|
const { fetchAutomations } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||||
|
|
||||||
await fetchAutomations({ scope: "global" });
|
await fetchAutomations({ scope: "global" });
|
||||||
@@ -5678,7 +5678,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchAutomations includes scope=project and projectId when project-scoped", async () => {
|
it("fetchAutomations includes scope=project and projectId when project-scoped", async () => {
|
||||||
const { fetchAutomations } = await import("./api");
|
const { fetchAutomations } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||||
|
|
||||||
await fetchAutomations({ scope: "project", projectId: "proj-123" });
|
await fetchAutomations({ scope: "project", projectId: "proj-123" });
|
||||||
@@ -5690,7 +5690,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createAutomation forwards scope context in query params", async () => {
|
it("createAutomation forwards scope context in query params", async () => {
|
||||||
const { createAutomation } = await import("./api");
|
const { createAutomation } = await import("../api");
|
||||||
const fakeSchedule = {
|
const fakeSchedule = {
|
||||||
id: "sched-001",
|
id: "sched-001",
|
||||||
name: "Test",
|
name: "Test",
|
||||||
@@ -5724,7 +5724,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createAutomation forwards scope context without projectId for global scope", async () => {
|
it("createAutomation forwards scope context without projectId for global scope", async () => {
|
||||||
const { createAutomation } = await import("./api");
|
const { createAutomation } = await import("../api");
|
||||||
const fakeSchedule = {
|
const fakeSchedule = {
|
||||||
id: "sched-001",
|
id: "sched-001",
|
||||||
name: "Test",
|
name: "Test",
|
||||||
@@ -5756,7 +5756,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("runAutomation forwards scope context", async () => {
|
it("runAutomation forwards scope context", async () => {
|
||||||
const { runAutomation } = await import("./api");
|
const { runAutomation } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { schedule: {}, result: { success: true } }));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { schedule: {}, result: { success: true } }));
|
||||||
|
|
||||||
await runAutomation("sched-001", { scope: "project", projectId: "proj-123" });
|
await runAutomation("sched-001", { scope: "project", projectId: "proj-123" });
|
||||||
@@ -5766,7 +5766,7 @@ describe("Automation API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("toggleAutomation forwards scope context", async () => {
|
it("toggleAutomation forwards scope context", async () => {
|
||||||
const { toggleAutomation } = await import("./api");
|
const { toggleAutomation } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "sched-001", enabled: false }));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "sched-001", enabled: false }));
|
||||||
|
|
||||||
await toggleAutomation("sched-001", { scope: "global" });
|
await toggleAutomation("sched-001", { scope: "global" });
|
||||||
@@ -5784,7 +5784,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchRoutines sends GET to /routines without scope by default", async () => {
|
it("fetchRoutines sends GET to /routines without scope by default", async () => {
|
||||||
const { fetchRoutines } = await import("./api");
|
const { fetchRoutines } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||||
|
|
||||||
await fetchRoutines();
|
await fetchRoutines();
|
||||||
@@ -5795,7 +5795,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchRoutines includes scope=global when specified", async () => {
|
it("fetchRoutines includes scope=global when specified", async () => {
|
||||||
const { fetchRoutines } = await import("./api");
|
const { fetchRoutines } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||||
|
|
||||||
await fetchRoutines({ scope: "global" });
|
await fetchRoutines({ scope: "global" });
|
||||||
@@ -5805,7 +5805,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("fetchRoutines includes scope=project and projectId when project-scoped", async () => {
|
it("fetchRoutines includes scope=project and projectId when project-scoped", async () => {
|
||||||
const { fetchRoutines } = await import("./api");
|
const { fetchRoutines } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, []));
|
||||||
|
|
||||||
await fetchRoutines({ scope: "project", projectId: "proj-456" });
|
await fetchRoutines({ scope: "project", projectId: "proj-456" });
|
||||||
@@ -5817,7 +5817,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("createRoutine forwards scope context in query params", async () => {
|
it("createRoutine forwards scope context in query params", async () => {
|
||||||
const { createRoutine } = await import("./api");
|
const { createRoutine } = await import("../api");
|
||||||
const fakeRoutine = {
|
const fakeRoutine = {
|
||||||
id: "routine-001",
|
id: "routine-001",
|
||||||
name: "Test Routine",
|
name: "Test Routine",
|
||||||
@@ -5849,7 +5849,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("updateRoutine forwards scope context", async () => {
|
it("updateRoutine forwards scope context", async () => {
|
||||||
const { updateRoutine } = await import("./api");
|
const { updateRoutine } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "routine-001", name: "Updated" }));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { id: "routine-001", name: "Updated" }));
|
||||||
|
|
||||||
await updateRoutine("routine-001", { name: "Updated" }, { scope: "project", projectId: "proj-456" });
|
await updateRoutine("routine-001", { name: "Updated" }, { scope: "project", projectId: "proj-456" });
|
||||||
@@ -5859,7 +5859,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("deleteRoutine forwards scope context", async () => {
|
it("deleteRoutine forwards scope context", async () => {
|
||||||
const { deleteRoutine } = await import("./api");
|
const { deleteRoutine } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue({
|
globalThis.fetch = vi.fn().mockReturnValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 204,
|
status: 204,
|
||||||
@@ -5876,7 +5876,7 @@ describe("Routine API scope forwarding", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("runRoutine forwards scope context", async () => {
|
it("runRoutine forwards scope context", async () => {
|
||||||
const { runRoutine } = await import("./api");
|
const { runRoutine } = await import("../api");
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { routine: {}, result: { success: true } }));
|
globalThis.fetch = vi.fn().mockReturnValue(mockSchedulingFetchResponse(true, { routine: {}, result: { success: true } }));
|
||||||
|
|
||||||
await runRoutine("routine-001", { scope: "project", projectId: "proj-789" });
|
await runRoutine("routine-001", { scope: "project", projectId: "proj-789" });
|
||||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
async function loadAuthModule() {
|
async function loadAuthModule() {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
return import("./auth");
|
return import("../auth");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("auth helpers", () => {
|
describe("auth helpers", () => {
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
import { AgentImportModal } from "./AgentImportModal";
|
|
||||||
|
|
||||||
interface MockResponse {
|
|
||||||
ok: boolean;
|
|
||||||
status: number;
|
|
||||||
body: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockFetchResponse({ ok, status, body }: MockResponse): Promise<Response> {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
json: async () => body,
|
|
||||||
} as Response);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AgentImportModal", () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
const onImported = vi.fn();
|
|
||||||
const originalFileReader = globalThis.FileReader;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
class MockFileReader {
|
|
||||||
onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
|
|
||||||
onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null = null;
|
|
||||||
|
|
||||||
readAsText(file: Blob): void {
|
|
||||||
const content = (file as any).__content ?? "";
|
|
||||||
this.onload?.call(this as unknown as FileReader, {
|
|
||||||
target: { result: content },
|
|
||||||
} as ProgressEvent<FileReader>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
globalThis.FileReader = MockFileReader as unknown as typeof FileReader;
|
|
||||||
globalThis.fetch = vi.fn();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.FileReader = originalFileReader;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the input step with file upload, directory button, and textarea", () => {
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Import Agents")).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Choose File" })).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Select Directory" })).toBeTruthy();
|
|
||||||
expect(screen.getByLabelText("Manifest content")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the Browse Catalog button", () => {
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Browse Catalog" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads selected .md file content into the manifest textarea", async () => {
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
const fileInput = screen.getByLabelText("Upload agent manifest file") as HTMLInputElement;
|
|
||||||
const file = new File(["---\nname: CEO\n---\nLead"], "AGENTS.md", { type: "text/markdown" });
|
|
||||||
(file as any).__content = "---\nname: CEO\n---\nLead";
|
|
||||||
|
|
||||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const textarea = screen.getByLabelText("Manifest content") as HTMLTextAreaElement;
|
|
||||||
expect(textarea.value).toContain("name: CEO");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows parse preview using API-provided agents array", async () => {
|
|
||||||
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
dryRun: true,
|
|
||||||
companyName: "Acme Co",
|
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
name: "CEO",
|
|
||||||
role: "executor",
|
|
||||||
title: "Chief Executive",
|
|
||||||
skills: ["review"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
created: ["CEO"],
|
|
||||||
skipped: [],
|
|
||||||
errors: [],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Manifest content"), {
|
|
||||||
target: { value: "---\nname: CEO\n---\nLead" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("CEO")).toBeTruthy();
|
|
||||||
expect(screen.getByText(/executor/)).toBeTruthy();
|
|
||||||
expect(screen.getByText(/Chief Executive/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("imports agents from preview step and shows result summary", async () => {
|
|
||||||
vi.mocked(globalThis.fetch)
|
|
||||||
.mockImplementationOnce(() => mockFetchResponse({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
dryRun: true,
|
|
||||||
companyName: "Acme Co",
|
|
||||||
agents: [{ name: "CEO", role: "executor", title: "Chief Executive", skills: ["review"] }],
|
|
||||||
created: ["CEO"],
|
|
||||||
skipped: [],
|
|
||||||
errors: [],
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
.mockImplementationOnce(() => mockFetchResponse({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
companyName: "Acme Co",
|
|
||||||
created: [{ id: "agent-1", name: "CEO" }],
|
|
||||||
skipped: [],
|
|
||||||
errors: [],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Manifest content"), {
|
|
||||||
target: { value: "---\nname: CEO\n---\nLead" },
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole("button", { name: /Import 1 Agent/i })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Import 1 Agent/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Import Complete")).toBeTruthy();
|
|
||||||
expect(screen.getByText(/1 created/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onImported).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows API errors to the user", async () => {
|
|
||||||
vi.mocked(globalThis.fetch).mockImplementationOnce(() => mockFetchResponse({
|
|
||||||
ok: false,
|
|
||||||
status: 400,
|
|
||||||
body: { error: "No agents found" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Manifest content"), {
|
|
||||||
target: { value: "invalid content" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Preview" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("No agents found")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to browse mode when Browse Catalog button is clicked", () => {
|
|
||||||
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Browse Catalog" }));
|
|
||||||
|
|
||||||
// The browse mode should render the search input (the fetch for companies is async)
|
|
||||||
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,295 +0,0 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
|
||||||
|
|
||||||
const mockStartMissionInterview = vi.fn();
|
|
||||||
const mockRespondToMissionInterview = vi.fn();
|
|
||||||
const mockRetryMissionInterviewSession = vi.fn();
|
|
||||||
const mockCancelMissionInterview = vi.fn();
|
|
||||||
const mockCreateMissionFromInterview = vi.fn();
|
|
||||||
const mockConnectMissionInterviewStream = vi.fn();
|
|
||||||
const mockFetchAiSession = vi.fn();
|
|
||||||
const mockParseConversationHistory = vi.fn();
|
|
||||||
const mockAcquireSessionLock = vi.fn();
|
|
||||||
const mockReleaseSessionLock = vi.fn();
|
|
||||||
const mockForceAcquireSessionLock = vi.fn();
|
|
||||||
const mockFetchModels = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
|
||||||
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
|
|
||||||
respondToMissionInterview: (...args: any[]) => mockRespondToMissionInterview(...args),
|
|
||||||
retryMissionInterviewSession: (...args: any[]) => mockRetryMissionInterviewSession(...args),
|
|
||||||
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
|
|
||||||
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
|
|
||||||
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
|
|
||||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
|
||||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
|
||||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
|
||||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
|
||||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
|
||||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../hooks/modalPersistence", () => ({
|
|
||||||
saveMissionGoal: vi.fn(),
|
|
||||||
getMissionGoal: vi.fn(() => ""),
|
|
||||||
clearMissionGoal: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const SAMPLE_QUESTION = {
|
|
||||||
id: "scope",
|
|
||||||
type: "single_select" as const,
|
|
||||||
question: "What is the target scope?",
|
|
||||||
description: "Pick the size for this mission.",
|
|
||||||
options: [
|
|
||||||
{ id: "mvp", label: "MVP" },
|
|
||||||
{ id: "full", label: "Full" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("MissionInterviewModal", () => {
|
|
||||||
let streamHandlers: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
streamHandlers = undefined;
|
|
||||||
|
|
||||||
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
|
|
||||||
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
|
|
||||||
mockFetchAiSession.mockResolvedValue(null);
|
|
||||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
||||||
streamHandlers = handlers;
|
|
||||||
return {
|
|
||||||
close: vi.fn(),
|
|
||||||
isConnected: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
|
||||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
|
||||||
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
|
||||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderModal() {
|
|
||||||
return render(
|
|
||||||
<MissionInterviewModal
|
|
||||||
isOpen={true}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onMissionCreated={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("shows lock overlay and allows take-control", async () => {
|
|
||||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
|
||||||
mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" });
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
||||||
target: { value: "Build a mission planning workflow" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("Start Interview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("session-lock-overlay")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Take Control"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("mission-session-1", "tab-self");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("session-lock-overlay")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows reconnecting indicator without clearing current question", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
||||||
target: { value: "Build a mission planning workflow" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("Start Interview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockStartMissionInterview).toHaveBeenCalledWith("Build a mission planning workflow", undefined, undefined);
|
|
||||||
expect(streamHandlers).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText("What is the target scope?")).toBeInTheDocument();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamHandlers.onConnectionStateChange?.("connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("Reconnecting…")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves streaming thinking output while reconnecting", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
||||||
target: { value: "Build a mission planning workflow" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("Start Interview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(streamHandlers).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamHandlers.onThinking?.("Analyzing mission goals...");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText("Analyzing mission goals...")).toBeInTheDocument();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamHandlers.onConnectionStateChange?.("reconnecting");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Reconnecting…")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Analyzing mission goals...")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error panel with retry action when stream fails", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
||||||
target: { value: "Build a mission planning workflow" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("Start Interview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(streamHandlers).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamHandlers.onError?.("Temporary outage");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText("Temporary outage")).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries interview session from error view", async () => {
|
|
||||||
let attempt = 0;
|
|
||||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
||||||
streamHandlers = handlers;
|
|
||||||
attempt += 1;
|
|
||||||
if (attempt === 1) {
|
|
||||||
setTimeout(() => handlers.onError?.("Try again"), 10);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => handlers.onQuestion?.(SAMPLE_QUESTION), 10);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
close: vi.fn(),
|
|
||||||
isConnected: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
||||||
target: { value: "Build a mission planning workflow" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("Start Interview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("What is the target scope?")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers retry from connection-loss when interview session is still generating", async () => {
|
|
||||||
let attempt = 0;
|
|
||||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
|
||||||
streamHandlers = handlers;
|
|
||||||
attempt += 1;
|
|
||||||
if (attempt === 1) {
|
|
||||||
setTimeout(() => handlers.onError?.("Connection lost"), 10);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
close: vi.fn(),
|
|
||||||
isConnected: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
mockRetryMissionInterviewSession.mockRejectedValueOnce(
|
|
||||||
new Error("Mission interview session mission-session-1 is not in an error state"),
|
|
||||||
);
|
|
||||||
mockFetchAiSession.mockResolvedValueOnce({
|
|
||||||
id: "mission-session-1",
|
|
||||||
type: "mission_interview",
|
|
||||||
status: "generating",
|
|
||||||
title: "Build a mission planning workflow",
|
|
||||||
inputPayload: JSON.stringify({ goal: "Build a mission planning workflow" }),
|
|
||||||
conversationHistory: "[]",
|
|
||||||
currentQuestion: null,
|
|
||||||
result: null,
|
|
||||||
thinkingOutput: "Continuing...",
|
|
||||||
error: null,
|
|
||||||
projectId: null,
|
|
||||||
lockedByTab: null,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
lockedAt: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
|
|
||||||
target: { value: "Build a mission planning workflow" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("Start Interview"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Connection lost")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRetryMissionInterviewSession).toHaveBeenCalledWith("mission-session-1", undefined, expect.any(String));
|
|
||||||
expect(mockFetchAiSession).toHaveBeenCalledWith("mission-session-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText("AI is thinking...")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Continuing...")).toBeInTheDocument();
|
|
||||||
expect(mockConnectMissionInterviewStream).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,510 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
|
||||||
|
|
||||||
// Mock the API functions
|
|
||||||
const mockFetchScripts = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
|
||||||
fetchScripts: () => mockFetchScripts(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockOnOpenScripts = vi.fn();
|
|
||||||
const mockOnRunScript = vi.fn();
|
|
||||||
|
|
||||||
function renderDropdown(props = {}) {
|
|
||||||
return render(
|
|
||||||
<QuickScriptsDropdown
|
|
||||||
onOpenScripts={mockOnOpenScripts}
|
|
||||||
onRunScript={mockOnRunScript}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("QuickScriptsDropdown", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rendering", () => {
|
|
||||||
it("renders the trigger button", () => {
|
|
||||||
renderDropdown();
|
|
||||||
expect(screen.getByTestId("scripts-btn")).toBeDefined();
|
|
||||||
expect(screen.getByTitle("Scripts")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show dropdown menu initially", () => {
|
|
||||||
renderDropdown();
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("dropdown open/close", () => {
|
|
||||||
it("opens dropdown when trigger is clicked", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes dropdown when clicking outside", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.mouseDown(document.body);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes dropdown on Escape key", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes dropdown when trigger is clicked again", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetching and displaying scripts", () => {
|
|
||||||
it("shows loading state while fetching", async () => {
|
|
||||||
mockFetchScripts.mockImplementation(() => new Promise(() => {}));
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
expect(screen.getByTestId("quick-scripts-loading")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetches and displays scripts", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
test: "npm test",
|
|
||||||
lint: "npm run lint",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
|
||||||
expect(screen.getByTestId("quick-script-item-lint")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays script names and truncated commands", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
"long-command": "this is a very long command that should be truncated",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const item = screen.getByTestId("quick-script-item-long-command");
|
|
||||||
expect(item.textContent).toContain("long-command");
|
|
||||||
expect(item.textContent).toContain("this is a very long command that should be truncat...");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles short commands without truncation", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
short: "echo hi",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const item = screen.getByTestId("quick-script-item-short");
|
|
||||||
expect(item.textContent).toContain("short");
|
|
||||||
expect(item.textContent).toContain("echo hi");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sorts scripts alphabetically", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
zebra: "echo zebra",
|
|
||||||
alpha: "echo alpha",
|
|
||||||
beta: "echo beta",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const items = screen.getAllByRole("option");
|
|
||||||
expect(items[0].textContent).toContain("alpha");
|
|
||||||
expect(items[1].textContent).toContain("beta");
|
|
||||||
expect(items[2].textContent).toContain("zebra");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("running scripts", () => {
|
|
||||||
it("calls onRunScript when a script is clicked", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("quick-script-item-build"));
|
|
||||||
|
|
||||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes dropdown after running script", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
test: "npm test",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-test")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("quick-script-item-test"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("manage scripts link", () => {
|
|
||||||
it("shows 'Manage Scripts...' link when scripts exist", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls onOpenScripts when 'Manage Scripts...' is clicked", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
|
||||||
|
|
||||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes dropdown when 'Manage Scripts...' is clicked", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("quick-scripts-manage"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty state", () => {
|
|
||||||
it("shows empty state when no scripts configured", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty state shows 'Add your first script' button", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clicking 'Add your first script' calls onOpenScripts", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Add your first script"));
|
|
||||||
|
|
||||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes dropdown when empty state action is clicked", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Add your first script")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Add your first script"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("keyboard navigation", () => {
|
|
||||||
it("supports ArrowDown to highlight items", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
alpha: "echo alpha",
|
|
||||||
beta: "echo beta",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
// First ArrowDown highlights first item
|
|
||||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
|
||||||
|
|
||||||
// Second ArrowDown highlights second item
|
|
||||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
|
||||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports ArrowUp to highlight items", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
alpha: "echo alpha",
|
|
||||||
beta: "echo beta",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
// Go to bottom first with End key
|
|
||||||
fireEvent.keyDown(menu, { key: "End" });
|
|
||||||
|
|
||||||
// ArrowUp moves to previous item
|
|
||||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
|
||||||
expect(screen.getByTestId("quick-script-item-beta").className).toContain("highlighted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wraps around with arrow keys", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
alpha: "echo alpha",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
// ArrowUp from start wraps to end (Manage Scripts...)
|
|
||||||
fireEvent.keyDown(menu, { key: "ArrowUp" });
|
|
||||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
|
||||||
|
|
||||||
// ArrowDown from end wraps to start
|
|
||||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs script with Enter key", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-build")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
// Highlight and press Enter
|
|
||||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
|
||||||
fireEvent.keyDown(menu, { key: "Enter" });
|
|
||||||
|
|
||||||
expect(mockOnRunScript).toHaveBeenCalledWith("build", "npm run build");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens manage scripts with Enter key on manage button", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
build: "npm run build",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-manage")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
// Navigate to last item (Manage Scripts...) and press Enter
|
|
||||||
fireEvent.keyDown(menu, { key: "End" });
|
|
||||||
fireEvent.keyDown(menu, { key: "Enter" });
|
|
||||||
|
|
||||||
expect(mockOnOpenScripts).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports Home key to go to first item", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
alpha: "echo alpha",
|
|
||||||
beta: "echo beta",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
// Go to end first
|
|
||||||
fireEvent.keyDown(menu, { key: "End" });
|
|
||||||
// Home goes to first
|
|
||||||
fireEvent.keyDown(menu, { key: "Home" });
|
|
||||||
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha").className).toContain("highlighted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports End key to go to last item", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({
|
|
||||||
alpha: "echo alpha",
|
|
||||||
beta: "echo beta",
|
|
||||||
});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-script-item-alpha")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
|
|
||||||
fireEvent.keyDown(menu, { key: "End" });
|
|
||||||
|
|
||||||
expect(screen.getByTestId("quick-scripts-manage").className).toContain("highlighted");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("error handling", () => {
|
|
||||||
it("handles fetch errors gracefully", async () => {
|
|
||||||
mockFetchScripts.mockRejectedValue(new Error("Failed to fetch"));
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Should show empty state since scripts will be empty object on error
|
|
||||||
expect(screen.getByTestId("quick-scripts-empty")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("focus management", () => {
|
|
||||||
it("menu is focusable with tabIndex", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = screen.getByTestId("quick-scripts-dropdown");
|
|
||||||
expect(menu).toHaveAttribute("tabIndex", "-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("focus moves to trigger when Escape is pressed", async () => {
|
|
||||||
mockFetchScripts.mockResolvedValue({});
|
|
||||||
renderDropdown();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("scripts-btn"));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("quick-scripts-dropdown")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("quick-scripts-dropdown")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger should have focus
|
|
||||||
expect(document.activeElement).toBe(screen.getByTestId("scripts-btn"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,858 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { SettingsModal } from "./SettingsModal";
|
|
||||||
import type { SettingsExportData } from "../api";
|
|
||||||
|
|
||||||
// --- API mocks ---
|
|
||||||
const mockFetchSettings = vi.fn();
|
|
||||||
const mockFetchSettingsByScope = vi.fn();
|
|
||||||
const mockExportSettings = vi.fn();
|
|
||||||
const mockUpdateSettings = vi.fn();
|
|
||||||
const mockUpdateGlobalSettings = vi.fn();
|
|
||||||
const mockFetchAuthStatus = vi.fn();
|
|
||||||
const mockLoginProvider = vi.fn();
|
|
||||||
const mockLogoutProvider = vi.fn();
|
|
||||||
const mockFetchModels = vi.fn();
|
|
||||||
const mockTestNtfyNotification = vi.fn();
|
|
||||||
const mockFetchBackups = vi.fn();
|
|
||||||
const mockCreateBackup = vi.fn();
|
|
||||||
const mockImportSettings = vi.fn();
|
|
||||||
const mockFetchMemoryFiles = vi.fn();
|
|
||||||
const mockFetchMemoryFile = vi.fn();
|
|
||||||
const mockSaveMemoryFile = vi.fn();
|
|
||||||
const mockCompactMemory = vi.fn();
|
|
||||||
const mockFetchGlobalConcurrency = vi.fn();
|
|
||||||
const mockUpdateGlobalConcurrency = vi.fn();
|
|
||||||
const mockFetchMemoryBackendStatus = vi.fn();
|
|
||||||
const mockTestMemoryRetrieval = vi.fn();
|
|
||||||
const mockInstallQmd = vi.fn();
|
|
||||||
const mockFetchGitRemotesDetailed = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
|
||||||
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
|
|
||||||
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
|
|
||||||
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
|
|
||||||
updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args),
|
|
||||||
exportSettings: (...args: unknown[]) => mockExportSettings(...args),
|
|
||||||
importSettings: (...args: unknown[]) => mockImportSettings(...args),
|
|
||||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
|
||||||
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
|
|
||||||
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
|
||||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
|
||||||
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
|
|
||||||
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
|
|
||||||
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
|
|
||||||
fetchMemoryFiles: (...args: unknown[]) => mockFetchMemoryFiles(...args),
|
|
||||||
fetchMemoryFile: (...args: unknown[]) => mockFetchMemoryFile(...args),
|
|
||||||
saveMemoryFile: (...args: unknown[]) => mockSaveMemoryFile(...args),
|
|
||||||
compactMemory: (...args: unknown[]) => mockCompactMemory(...args),
|
|
||||||
fetchGlobalConcurrency: (...args: unknown[]) => mockFetchGlobalConcurrency(...args),
|
|
||||||
updateGlobalConcurrency: (...args: unknown[]) => mockUpdateGlobalConcurrency(...args),
|
|
||||||
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
|
|
||||||
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
|
|
||||||
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
|
|
||||||
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the hook
|
|
||||||
const mockUseMemoryBackendStatus = vi.fn();
|
|
||||||
vi.mock("../hooks/useMemoryBackendStatus", () => ({
|
|
||||||
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
const defaultSettings = {
|
|
||||||
maxConcurrent: 2,
|
|
||||||
maxWorktrees: 4,
|
|
||||||
pollIntervalMs: 15000,
|
|
||||||
groupOverlappingFiles: true,
|
|
||||||
autoMerge: true,
|
|
||||||
mergeStrategy: "direct",
|
|
||||||
pushAfterMerge: false,
|
|
||||||
pushRemote: "origin",
|
|
||||||
recycleWorktrees: false,
|
|
||||||
worktreeNaming: "random",
|
|
||||||
includeTaskIdInCommit: true,
|
|
||||||
worktreeInitCommand: "",
|
|
||||||
ntfyEnabled: false,
|
|
||||||
ntfyTopic: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderModal(props = {}) {
|
|
||||||
return render(
|
|
||||||
<SettingsModal
|
|
||||||
onClose={noop}
|
|
||||||
addToast={noop}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("SettingsModal", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockFetchSettings.mockResolvedValue(defaultSettings);
|
|
||||||
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
|
||||||
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
|
||||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
|
||||||
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
|
|
||||||
mockFetchMemoryFiles.mockResolvedValue({
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: ".fusion/memory/MEMORY.md",
|
|
||||||
label: "Long-term memory",
|
|
||||||
layer: "long-term",
|
|
||||||
size: 42,
|
|
||||||
updatedAt: "2026-04-17T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ".fusion/memory/DREAMS.md",
|
|
||||||
label: "Dreams",
|
|
||||||
layer: "dreams",
|
|
||||||
size: 21,
|
|
||||||
updatedAt: "2026-04-17T12:00:00.000Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
mockFetchMemoryFile.mockImplementation((path: string) =>
|
|
||||||
Promise.resolve({
|
|
||||||
path,
|
|
||||||
content: path.endsWith("DREAMS.md")
|
|
||||||
? "## Existing dreams\n- Pattern from daily notes"
|
|
||||||
: "## Existing memory\n- Learned pattern",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
mockSaveMemoryFile.mockResolvedValue({ success: true });
|
|
||||||
mockCompactMemory.mockResolvedValue({
|
|
||||||
path: ".fusion/memory/DREAMS.md",
|
|
||||||
content: "# Compacted Memory\n\nImportant content.",
|
|
||||||
});
|
|
||||||
mockTestMemoryRetrieval.mockResolvedValue({
|
|
||||||
query: "pattern",
|
|
||||||
qmdAvailable: true,
|
|
||||||
usedFallback: false,
|
|
||||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
||||||
results: [],
|
|
||||||
});
|
|
||||||
mockInstallQmd.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
qmdAvailable: true,
|
|
||||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
||||||
});
|
|
||||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
|
||||||
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
|
|
||||||
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
|
||||||
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
|
|
||||||
mockFetchMemoryBackendStatus.mockResolvedValue({
|
|
||||||
currentBackend: "file",
|
|
||||||
capabilities: {
|
|
||||||
readable: true,
|
|
||||||
writable: true,
|
|
||||||
supportsAtomicWrite: true,
|
|
||||||
hasConflictResolution: false,
|
|
||||||
persistent: true,
|
|
||||||
},
|
|
||||||
availableBackends: ["file", "readonly", "qmd"],
|
|
||||||
qmdAvailable: true,
|
|
||||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
||||||
});
|
|
||||||
mockUseMemoryBackendStatus.mockReturnValue({
|
|
||||||
status: {
|
|
||||||
currentBackend: "qmd",
|
|
||||||
capabilities: {
|
|
||||||
readable: true,
|
|
||||||
writable: true,
|
|
||||||
supportsAtomicWrite: false,
|
|
||||||
hasConflictResolution: false,
|
|
||||||
persistent: true,
|
|
||||||
},
|
|
||||||
availableBackends: ["file", "readonly", "qmd"],
|
|
||||||
qmdAvailable: true,
|
|
||||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
||||||
},
|
|
||||||
currentBackend: "file",
|
|
||||||
capabilities: {
|
|
||||||
readable: true,
|
|
||||||
writable: true,
|
|
||||||
supportsAtomicWrite: true,
|
|
||||||
hasConflictResolution: false,
|
|
||||||
persistent: true,
|
|
||||||
},
|
|
||||||
availableBackends: ["file", "readonly", "qmd"],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
refresh: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// jsdom doesn't provide URL.createObjectURL — polyfill it
|
|
||||||
if (!URL.createObjectURL) {
|
|
||||||
URL.createObjectURL = vi.fn(() => "blob:http://localhost/mock") as any;
|
|
||||||
}
|
|
||||||
if (!URL.revokeObjectURL) {
|
|
||||||
URL.revokeObjectURL = vi.fn() as any;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("settings export filename", () => {
|
|
||||||
it("uses fusion-settings- prefix for exported filename", async () => {
|
|
||||||
const mockExportData: SettingsExportData = {
|
|
||||||
version: 1,
|
|
||||||
exportedAt: "2026-04-04T12:00:00.000Z",
|
|
||||||
global: undefined,
|
|
||||||
project: { maxConcurrent: 2 },
|
|
||||||
};
|
|
||||||
mockExportSettings.mockResolvedValue(mockExportData);
|
|
||||||
|
|
||||||
// Spy on createElement to capture the download link's filename
|
|
||||||
const originalCreateElement = document.createElement.bind(document);
|
|
||||||
const createdElements: { tagName: string; download: string; href: string }[] = [];
|
|
||||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
|
||||||
const el = originalCreateElement(tagName);
|
|
||||||
if (tagName.toLowerCase() === "a") {
|
|
||||||
// Capture the download attribute when set
|
|
||||||
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
|
|
||||||
HTMLAnchorElement.prototype,
|
|
||||||
"download"
|
|
||||||
);
|
|
||||||
Object.defineProperty(el, "download", {
|
|
||||||
set(v: string) {
|
|
||||||
createdElements.push({ tagName, download: v, href: (el as HTMLAnchorElement).href });
|
|
||||||
origDownloadDescriptor?.set?.call(el, v);
|
|
||||||
},
|
|
||||||
get() {
|
|
||||||
return origDownloadDescriptor?.get?.call(el) ?? "";
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock URL.createObjectURL and revokeObjectURL
|
|
||||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
|
|
||||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
// Wait for settings to load
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find and click the Export button
|
|
||||||
const exportButton = screen.getByTitle("Export settings to JSON file");
|
|
||||||
expect(exportButton).toBeDefined();
|
|
||||||
|
|
||||||
fireEvent.click(exportButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockExportSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assert the filename uses fusion-settings- prefix
|
|
||||||
expect(createdElements.length).toBeGreaterThanOrEqual(1);
|
|
||||||
const anchorElement = createdElements[0];
|
|
||||||
expect(anchorElement.download).toMatch(/^fusion-settings-/);
|
|
||||||
expect(anchorElement.download).toMatch(/^fusion-settings-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not use kb-settings- prefix for exported filename", async () => {
|
|
||||||
const mockExportData: SettingsExportData = {
|
|
||||||
version: 1,
|
|
||||||
exportedAt: "2026-04-04T12:00:00.000Z",
|
|
||||||
global: undefined,
|
|
||||||
project: { maxConcurrent: 2 },
|
|
||||||
};
|
|
||||||
mockExportSettings.mockResolvedValue(mockExportData);
|
|
||||||
|
|
||||||
// Capture filenames set on dynamically-created anchor elements
|
|
||||||
const capturedFilenames: string[] = [];
|
|
||||||
const originalCreateElement = document.createElement.bind(document);
|
|
||||||
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
|
||||||
const el = originalCreateElement(tagName);
|
|
||||||
if (tagName.toLowerCase() === "a") {
|
|
||||||
const origDownloadDescriptor = Object.getOwnPropertyDescriptor(
|
|
||||||
HTMLAnchorElement.prototype,
|
|
||||||
"download"
|
|
||||||
);
|
|
||||||
Object.defineProperty(el, "download", {
|
|
||||||
set(v: string) {
|
|
||||||
capturedFilenames.push(v);
|
|
||||||
origDownloadDescriptor?.set?.call(el, v);
|
|
||||||
},
|
|
||||||
get() {
|
|
||||||
return origDownloadDescriptor?.get?.call(el) ?? "";
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:http://localhost/mock");
|
|
||||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle("Export settings to JSON file"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockExportSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Negative assertion: filename must NOT use the old kb- prefix
|
|
||||||
expect(capturedFilenames.length).toBeGreaterThanOrEqual(1);
|
|
||||||
for (const filename of capturedFilenames) {
|
|
||||||
expect(filename).not.toMatch(/^kb-settings-/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Number input clearing", () => {
|
|
||||||
it("allows clearing maxConcurrent without leaving a stuck zero", async () => {
|
|
||||||
renderModal();
|
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
||||||
|
|
||||||
// Open Scheduling section
|
|
||||||
fireEvent.click(screen.getByText("Scheduling"));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText("Max Concurrent Tasks") as HTMLInputElement;
|
|
||||||
expect(input).toBeDefined();
|
|
||||||
|
|
||||||
// Clear the input - the input should be empty, not show "0"
|
|
||||||
await userEvent.clear(input);
|
|
||||||
expect(input.value).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows clearing globalMaxConcurrent without leaving a stuck zero", async () => {
|
|
||||||
renderModal();
|
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
||||||
|
|
||||||
// Open Scheduling section
|
|
||||||
fireEvent.click(screen.getByText("Scheduling"));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText("Global Max Concurrent") as HTMLInputElement;
|
|
||||||
expect(input).toBeDefined();
|
|
||||||
|
|
||||||
// Clear the input - the input should be empty, not show "0"
|
|
||||||
await userEvent.clear(input);
|
|
||||||
expect(input.value).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows clearing pollIntervalMs without leaving a stuck zero", async () => {
|
|
||||||
renderModal();
|
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
||||||
|
|
||||||
// Open Scheduling section
|
|
||||||
fireEvent.click(screen.getByText("Scheduling"));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText("Poll Interval (ms)") as HTMLInputElement;
|
|
||||||
expect(input).toBeDefined();
|
|
||||||
|
|
||||||
// Clear the input - the input should be empty, not show "0"
|
|
||||||
await userEvent.clear(input);
|
|
||||||
expect(input.value).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows clearing maxWorktrees without leaving a stuck zero", async () => {
|
|
||||||
renderModal();
|
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
|
||||||
|
|
||||||
// Open Worktrees section
|
|
||||||
fireEvent.click(screen.getByText("Worktrees"));
|
|
||||||
|
|
||||||
const input = screen.getByLabelText("Max Worktrees") as HTMLInputElement;
|
|
||||||
expect(input).toBeDefined();
|
|
||||||
|
|
||||||
// Clear the input - the input should be empty, not show "0"
|
|
||||||
await userEvent.clear(input);
|
|
||||||
expect(input.value).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Memory section", () => {
|
|
||||||
it("renders the Memory section in the sidebar", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("Memory")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the memory toggle with default enabled", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the Memory section in the sidebar
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
|
||||||
expect(checkbox).toBeDefined();
|
|
||||||
// Default is enabled, so checkbox should be checked
|
|
||||||
expect(checkbox).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows memory toggle unchecked when memoryEnabled is false", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
memoryEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the Memory section in the sidebar
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
|
||||||
expect(checkbox).toBeDefined();
|
|
||||||
expect(checkbox).not.toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggles the memory setting when checkbox is clicked", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click the Memory section in the sidebar
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
const checkbox = screen.getByRole("checkbox", { name: /enable memory tools/i });
|
|
||||||
expect(checkbox).toBeChecked();
|
|
||||||
|
|
||||||
// Uncheck it
|
|
||||||
await userEvent.click(checkbox);
|
|
||||||
expect(checkbox).not.toBeChecked();
|
|
||||||
|
|
||||||
// Check it again
|
|
||||||
await userEvent.click(checkbox);
|
|
||||||
expect(checkbox).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("installs qmd from the missing qmd prompt", async () => {
|
|
||||||
const addToast = vi.fn();
|
|
||||||
const refresh = vi.fn(() => Promise.resolve());
|
|
||||||
mockUseMemoryBackendStatus.mockReturnValue({
|
|
||||||
status: {
|
|
||||||
currentBackend: "qmd",
|
|
||||||
capabilities: {
|
|
||||||
readable: true,
|
|
||||||
writable: true,
|
|
||||||
supportsAtomicWrite: false,
|
|
||||||
hasConflictResolution: false,
|
|
||||||
persistent: true,
|
|
||||||
},
|
|
||||||
availableBackends: ["file", "readonly", "qmd"],
|
|
||||||
qmdAvailable: false,
|
|
||||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
|
||||||
},
|
|
||||||
currentBackend: "qmd",
|
|
||||||
capabilities: {
|
|
||||||
readable: true,
|
|
||||||
writable: true,
|
|
||||||
supportsAtomicWrite: false,
|
|
||||||
hasConflictResolution: false,
|
|
||||||
persistent: true,
|
|
||||||
},
|
|
||||||
availableBackends: ["file", "readonly", "qmd"],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
refresh,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal({ addToast });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Install qmd" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockInstallQmd).toHaveBeenCalledWith(undefined);
|
|
||||||
});
|
|
||||||
expect(refresh).toHaveBeenCalled();
|
|
||||||
expect(addToast).toHaveBeenCalledWith("qmd installed successfully", "success");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads and shows memory editor content when navigating to Memory", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockFetchMemoryFiles).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchMemoryFiles).toHaveBeenCalledWith(undefined);
|
|
||||||
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
||||||
expect(editor.value).toContain("Existing dreams");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows loading state while memory is being fetched", async () => {
|
|
||||||
let resolveMemory: ((value: { content: string }) => void) | undefined;
|
|
||||||
mockFetchMemoryFile.mockReturnValueOnce(
|
|
||||||
new Promise<{ content: string }>((resolve) => {
|
|
||||||
resolveMemory = resolve;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
expect(screen.getByText("Loading memory…")).toBeDefined();
|
|
||||||
|
|
||||||
resolveMemory?.({ content: "# Loaded" });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Editor for .fusion/memory/DREAMS.md")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports editing and saving memory content", async () => {
|
|
||||||
const addToast = vi.fn();
|
|
||||||
renderModal({ addToast });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const select = await screen.findByLabelText("Memory File");
|
|
||||||
await userEvent.selectOptions(select, ".fusion/memory/MEMORY.md");
|
|
||||||
|
|
||||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md");
|
|
||||||
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
|
|
||||||
|
|
||||||
const saveButton = await screen.findByRole("button", { name: "Save Memory" });
|
|
||||||
await userEvent.click(saveButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSaveMemoryFile).toHaveBeenCalledWith(
|
|
||||||
".fusion/memory/MEMORY.md",
|
|
||||||
"# Updated memory\n- Reusable learning",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compacts the selected memory file in the editor", async () => {
|
|
||||||
const addToast = vi.fn();
|
|
||||||
renderModal({ addToast });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
const compactButton = await screen.findByRole("button", { name: "Compact Selected File" });
|
|
||||||
await userEvent.click(compactButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockCompactMemory).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
||||||
expect(editor.value).toContain("Compacted Memory");
|
|
||||||
expect(addToast).toHaveBeenCalledWith("Memory file compacted", "success");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty memory content from API", async () => {
|
|
||||||
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/DREAMS.md", content: "" });
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
||||||
expect(editor.value).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches between memory files in the editor", async () => {
|
|
||||||
mockFetchMemoryFile.mockImplementation((path: string) =>
|
|
||||||
Promise.resolve({
|
|
||||||
path,
|
|
||||||
content: path.endsWith("DREAMS.md") ? "# Dreams\n\n- Pattern" : "# Memory\n\n- Durable",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Memory"));
|
|
||||||
|
|
||||||
const select = await screen.findByLabelText("Memory File");
|
|
||||||
await userEvent.selectOptions(select, ".fusion/memory/DREAMS.md");
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchMemoryFile).toHaveBeenCalledWith(".fusion/memory/DREAMS.md", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const editor = await screen.findByLabelText("Editor for .fusion/memory/DREAMS.md") as HTMLTextAreaElement;
|
|
||||||
expect(editor.value).toContain("Dreams");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Merge section", () => {
|
|
||||||
it("shows push-after-merge toggle and keeps Push Remote hidden by default", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
||||||
|
|
||||||
const pushAfterMergeToggle = screen.getByRole("checkbox", {
|
|
||||||
name: /push to remote after merge/i,
|
|
||||||
});
|
|
||||||
expect(pushAfterMergeToggle).not.toBeChecked();
|
|
||||||
expect(screen.queryByLabelText("Push Remote")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows Push Remote input when push-after-merge is enabled", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
||||||
await userEvent.click(
|
|
||||||
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText("Push Remote")).toBeInTheDocument();
|
|
||||||
expect(screen.getByPlaceholderText("origin")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes pushAfterMerge and pushRemote in the save payload", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchSettings).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(screen.getAllByText("Merge")[0]);
|
|
||||||
await userEvent.click(
|
|
||||||
screen.getByRole("checkbox", { name: /push to remote after merge/i }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pushRemoteInput = screen.getByLabelText("Push Remote");
|
|
||||||
await userEvent.clear(pushRemoteInput);
|
|
||||||
await userEvent.type(pushRemoteInput, "upstream main");
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
|
||||||
expect(payload.pushAfterMerge).toBe(true);
|
|
||||||
expect(payload.pushRemote).toBe("upstream main");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Experimental Features section", () => {
|
|
||||||
const openExperimentalFeaturesSection = async () => {
|
|
||||||
const sectionLabel = await screen.findByText("Experimental Features");
|
|
||||||
await userEvent.click(sectionLabel);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("renders the Experimental Features section in the sidebar", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
expect(await screen.findByText("Experimental Features")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows known experimental features (Insights, Roadmaps) even when no custom features are configured", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
// Known features should always be shown
|
|
||||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows feature flags when experimentalFeatures is set", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: { "my-feature": true, "another-feature": false },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
expect(screen.getByText("my-feature")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("another-feature")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("feature flags are unchecked when value is false", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: { "my-feature": false },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
||||||
expect(checkbox.checked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("feature flags are checked when value is true", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: { "my-feature": true },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
||||||
expect(checkbox.checked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggling a feature flag updates the form state", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: { "my-feature": false },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
||||||
expect(checkbox.checked).toBe(false);
|
|
||||||
|
|
||||||
// Toggle it
|
|
||||||
await userEvent.click(checkbox);
|
|
||||||
expect(checkbox.checked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saving with toggled feature flag includes experimentalFeatures in payload", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: { "my-feature": false },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
// Toggle the feature
|
|
||||||
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
|
|
||||||
await userEvent.click(checkbox);
|
|
||||||
|
|
||||||
// Save
|
|
||||||
await userEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
|
||||||
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows project scope banner in Experimental Features section", async () => {
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
// Should show project scope indicator
|
|
||||||
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles undefined experimentalFeatures (falls back to empty) but still shows known features", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
// Known features should always be shown regardless of settings
|
|
||||||
expect(screen.getByText("Insights")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Roadmaps")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves experimentalFeatures with multiple toggled flags", async () => {
|
|
||||||
mockFetchSettings.mockResolvedValue({
|
|
||||||
...defaultSettings,
|
|
||||||
experimentalFeatures: { "feature-a": true, "feature-b": false },
|
|
||||||
});
|
|
||||||
|
|
||||||
renderModal();
|
|
||||||
|
|
||||||
await openExperimentalFeaturesSection();
|
|
||||||
|
|
||||||
// Toggle feature-b to true
|
|
||||||
const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement;
|
|
||||||
await userEvent.click(checkboxB);
|
|
||||||
|
|
||||||
// Save
|
|
||||||
await userEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
|
||||||
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
import { TaskCard } from "./TaskCard";
|
|
||||||
import type { Task } from "@fusion/core";
|
|
||||||
|
|
||||||
// Mock lucide-react to avoid SVG rendering issues in test env
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
Link: () => null,
|
|
||||||
Clock: () => null,
|
|
||||||
Pencil: () => null,
|
|
||||||
Layers: () => null,
|
|
||||||
ChevronDown: () => null,
|
|
||||||
Folder: () => null,
|
|
||||||
GitPullRequest: () => null,
|
|
||||||
CircleDot: () => null,
|
|
||||||
Target: () => null,
|
|
||||||
Bot: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the api module
|
|
||||||
vi.mock("../api", () => ({
|
|
||||||
fetchTaskDetail: vi.fn(),
|
|
||||||
uploadAttachment: vi.fn(),
|
|
||||||
fetchMission: vi.fn(),
|
|
||||||
fetchAgent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { uploadAttachment, fetchMission, fetchAgent } from "../api";
|
|
||||||
|
|
||||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
|
||||||
return {
|
|
||||||
id: "FN-001",
|
|
||||||
title: "Test task",
|
|
||||||
column: "in-progress",
|
|
||||||
status: undefined as any,
|
|
||||||
steps: [],
|
|
||||||
dependencies: [],
|
|
||||||
description: "",
|
|
||||||
...overrides,
|
|
||||||
} as Task;
|
|
||||||
}
|
|
||||||
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
describe("TaskCard", () => {
|
|
||||||
it("renders the card ID text", () => {
|
|
||||||
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
|
||||||
expect(screen.getByText("FN-001")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the status badge when task.status is set", () => {
|
|
||||||
render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ status: "executing" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("executing")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the status badge after the card ID in DOM order", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ status: "executing" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const cardId = container.querySelector(".card-id")!;
|
|
||||||
const badge = container.querySelector(".card-status-badge")!;
|
|
||||||
expect(cardId).toBeDefined();
|
|
||||||
expect(badge).toBeDefined();
|
|
||||||
// Badge should be the next sibling of card-id
|
|
||||||
expect(cardId.nextElementSibling).toBe(badge);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render a status badge when task.status is falsy", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask({ status: undefined as any })} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".card-status-badge")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders unified progress counts for task steps + workflow checks", () => {
|
|
||||||
render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({
|
|
||||||
steps: [
|
|
||||||
{ name: "Step 0", status: "done" },
|
|
||||||
{ name: "Step 1", status: "pending" },
|
|
||||||
],
|
|
||||||
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
|
|
||||||
workflowStepResults: [
|
|
||||||
{
|
|
||||||
workflowStepId: "WS-001",
|
|
||||||
workflowStepName: "Browser Verification",
|
|
||||||
status: "passed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
workflowStepId: "WS-002",
|
|
||||||
workflowStepName: "Frontend UX Design",
|
|
||||||
status: "failed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("2/5")).toBeDefined();
|
|
||||||
expect(screen.getByText("5 steps")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses singular step label when unified progress total is one", () => {
|
|
||||||
render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({
|
|
||||||
steps: [{ name: "Step 0", status: "done" }],
|
|
||||||
})}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("1 step")).toBeDefined();
|
|
||||||
expect(screen.queryByText("1 steps")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders workflow checks after normal steps with mapped statuses and phase badges", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({
|
|
||||||
steps: [
|
|
||||||
{ name: "Step 0", status: "done" },
|
|
||||||
{ name: "Step 1", status: "pending" },
|
|
||||||
],
|
|
||||||
enabledWorkflowSteps: ["WS-001", "WS-002", "WS-003"],
|
|
||||||
workflowStepResults: [
|
|
||||||
{
|
|
||||||
workflowStepId: "WS-001",
|
|
||||||
workflowStepName: "Browser Verification",
|
|
||||||
status: "passed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
workflowStepId: "WS-002",
|
|
||||||
workflowStepName: "Frontend UX Design",
|
|
||||||
status: "failed",
|
|
||||||
phase: "post-merge",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})}
|
|
||||||
workflowStepNameLookup={new Map([["WS-003", "Accessibility Audit"]])}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
|
||||||
expect(stepNames).toEqual([
|
|
||||||
"Step 0",
|
|
||||||
"Step 1",
|
|
||||||
"Browser Verification",
|
|
||||||
"Frontend UX Design",
|
|
||||||
"Accessibility Audit",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const dots = container.querySelectorAll(".card-step-dot");
|
|
||||||
expect(dots[2]?.className).toContain("card-step-dot--done");
|
|
||||||
expect(dots[3]?.className).toContain("card-step-dot--failed");
|
|
||||||
expect(dots[4]?.className).toContain("card-step-dot--pending");
|
|
||||||
|
|
||||||
const workflowBadgeElements = container.querySelectorAll(".card-step-workflow-badge");
|
|
||||||
const workflowBadges = Array.from(workflowBadgeElements).map((el) => el.textContent);
|
|
||||||
expect(workflowBadges).toEqual(["workflow", "workflow", "workflow"]);
|
|
||||||
|
|
||||||
expect(workflowBadgeElements[0]?.className).toContain("card-step-workflow-badge--pre-merge");
|
|
||||||
expect(workflowBadgeElements[1]?.className).toContain("card-step-workflow-badge--post-merge");
|
|
||||||
expect(workflowBadgeElements[2]?.className).toContain("card-step-workflow-badge--pre-merge");
|
|
||||||
|
|
||||||
workflowBadgeElements.forEach((badge) => {
|
|
||||||
expect(badge.getAttribute("title")).toBe("Workflow check");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to workflow result name, then raw ID when lookup names are unavailable", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({
|
|
||||||
enabledWorkflowSteps: ["WS-002", "WS-003"],
|
|
||||||
workflowStepResults: [
|
|
||||||
{
|
|
||||||
workflowStepId: "WS-002",
|
|
||||||
workflowStepName: "Fallback from result",
|
|
||||||
status: "passed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})}
|
|
||||||
workflowStepNameLookup={new Map([["WS-002", " "]])}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent);
|
|
||||||
expect(stepNames).toEqual(["Fallback from result", "WS-003"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows drop indicator on file dragover and removes on dragleave", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
const card = container.querySelector(".card")!;
|
|
||||||
|
|
||||||
// Simulate file dragover
|
|
||||||
fireEvent.dragOver(card, {
|
|
||||||
dataTransfer: { types: ["Files"], dropEffect: "none" },
|
|
||||||
});
|
|
||||||
expect(card.classList.contains("file-drop-target")).toBe(true);
|
|
||||||
|
|
||||||
// Simulate dragleave
|
|
||||||
fireEvent.dragLeave(card, {
|
|
||||||
dataTransfer: { types: ["Files"] },
|
|
||||||
});
|
|
||||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not show drop indicator for non-file drag", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
const card = container.querySelector(".card")!;
|
|
||||||
|
|
||||||
// Simulate card dragover (not files)
|
|
||||||
fireEvent.dragOver(card, {
|
|
||||||
dataTransfer: { types: ["text/plain"], dropEffect: "none" },
|
|
||||||
});
|
|
||||||
expect(card.classList.contains("file-drop-target")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls uploadAttachment on file drop", async () => {
|
|
||||||
const mockUpload = vi.mocked(uploadAttachment);
|
|
||||||
mockUpload.mockResolvedValue({
|
|
||||||
filename: "abc-test.png",
|
|
||||||
originalName: "test.png",
|
|
||||||
mimeType: "image/png",
|
|
||||||
size: 1024,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
const addToast = vi.fn();
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
|
||||||
);
|
|
||||||
const card = container.querySelector(".card")!;
|
|
||||||
|
|
||||||
const file = new File(["content"], "test.png", { type: "image/png" });
|
|
||||||
fireEvent.drop(card, {
|
|
||||||
dataTransfer: { types: ["Files"], files: [file] },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockUpload).toHaveBeenCalledWith("FN-001", file, undefined);
|
|
||||||
expect(addToast).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Attached test.png"),
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows error toast when upload fails", async () => {
|
|
||||||
const mockUpload = vi.mocked(uploadAttachment);
|
|
||||||
mockUpload.mockRejectedValue(new Error("Upload failed"));
|
|
||||||
const addToast = vi.fn();
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
|
|
||||||
);
|
|
||||||
const card = container.querySelector(".card")!;
|
|
||||||
|
|
||||||
const file = new File(["content"], "bad.png", { type: "image/png" });
|
|
||||||
fireEvent.drop(card, {
|
|
||||||
dataTransfer: { types: ["Files"], files: [file] },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(addToast).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("Failed to attach bad.png"),
|
|
||||||
"error",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Size badge positioning regression tests (KB-197)
|
|
||||||
it("renders size badge for sized tasks", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".card-size-badge")).not.toBeNull();
|
|
||||||
expect(screen.getByText("S")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render size badge when task has no size", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask({ size: undefined })} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector(".card-size-badge")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders all three size values with correct CSS classes", () => {
|
|
||||||
const sizes: Array<"S" | "M" | "L"> = ["S", "M", "L"];
|
|
||||||
const expectedClasses = ["size-s", "size-m", "size-l"];
|
|
||||||
|
|
||||||
sizes.forEach((size, index) => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask({ size })} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
const badge = container.querySelector(".card-size-badge");
|
|
||||||
expect(badge).not.toBeNull();
|
|
||||||
expect(badge?.classList.contains(expectedClasses[index])).toBe(true);
|
|
||||||
// Clean up for next iteration
|
|
||||||
container.remove();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places size badge inside card-header-actions container", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask({ size: "M" })} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
const actionsContainer = container.querySelector(".card-header-actions");
|
|
||||||
const sizeBadge = container.querySelector(".card-size-badge");
|
|
||||||
|
|
||||||
expect(actionsContainer).not.toBeNull();
|
|
||||||
expect(sizeBadge).not.toBeNull();
|
|
||||||
expect(actionsContainer?.contains(sizeBadge)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("places card-header-actions after card-id in DOM order", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
|
|
||||||
);
|
|
||||||
const cardId = container.querySelector(".card-id")!;
|
|
||||||
const actionsContainer = container.querySelector(".card-header-actions")!;
|
|
||||||
|
|
||||||
expect(cardId).not.toBeNull();
|
|
||||||
expect(actionsContainer).not.toBeNull();
|
|
||||||
// The actions container should come after card-id
|
|
||||||
expect(
|
|
||||||
cardId.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
||||||
).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders edit button inside card-header-actions for editable columns", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ column: "todo", size: "S" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
onUpdateTask={async () => makeTask()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const actionsContainer = container.querySelector(".card-header-actions");
|
|
||||||
const editBtn = container.querySelector(".card-edit-btn");
|
|
||||||
|
|
||||||
expect(actionsContainer).not.toBeNull();
|
|
||||||
expect(editBtn).not.toBeNull();
|
|
||||||
expect(actionsContainer?.contains(editBtn)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders archive button inside card-header-actions for done column", () => {
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ column: "done", size: "L" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
onArchiveTask={async () => makeTask()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const actionsContainer = container.querySelector(".card-header-actions");
|
|
||||||
const archiveBtn = container.querySelector(".card-archive-btn");
|
|
||||||
|
|
||||||
expect(actionsContainer).not.toBeNull();
|
|
||||||
expect(archiveBtn).not.toBeNull();
|
|
||||||
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TaskCard mission badge", () => {
|
|
||||||
// Access the internal cache reset helper
|
|
||||||
let clearCache: () => void;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const mod = await import("./TaskCard");
|
|
||||||
clearCache = (mod as any).__test_clearMissionTitleCache;
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache?.();
|
|
||||||
vi.mocked(fetchMission).mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("displays mission title instead of missionId", async () => {
|
|
||||||
vi.mocked(fetchMission).mockResolvedValue({
|
|
||||||
id: "M-ABC123",
|
|
||||||
title: "Database Optimization",
|
|
||||||
status: "active",
|
|
||||||
interviewState: "completed",
|
|
||||||
milestones: [],
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ missionId: "M-ABC123" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const badge = container.querySelector(".card-mission-badge");
|
|
||||||
expect(badge).not.toBeNull();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..."
|
|
||||||
expect(badge?.textContent).toContain("Database ...");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("abbreviates long mission titles with ellipsis", async () => {
|
|
||||||
vi.mocked(fetchMission).mockResolvedValue({
|
|
||||||
id: "M-LONG1",
|
|
||||||
title: "This Is A Very Long Mission Title That Exceeds Twenty Characters",
|
|
||||||
status: "active",
|
|
||||||
interviewState: "completed",
|
|
||||||
milestones: [],
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ missionId: "M-LONG1" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const badge = container.querySelector(".card-mission-badge");
|
|
||||||
expect(badge).not.toBeNull();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..."
|
|
||||||
expect(badge?.textContent).toContain("This Is A...");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to missionId on fetch error", async () => {
|
|
||||||
vi.mocked(fetchMission).mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ missionId: "M-ERR99" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const badge = container.querySelector(".card-mission-badge");
|
|
||||||
expect(badge).not.toBeNull();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(badge?.textContent).toContain("M-ERR99");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows mission title in title attribute", async () => {
|
|
||||||
vi.mocked(fetchMission).mockResolvedValue({
|
|
||||||
id: "M-TITLE",
|
|
||||||
title: "Refactor Auth",
|
|
||||||
status: "active",
|
|
||||||
interviewState: "completed",
|
|
||||||
milestones: [],
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ missionId: "M-TITLE" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const badge = container.querySelector(".card-mission-badge");
|
|
||||||
expect(badge).not.toBeNull();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows short mission title without abbreviation", async () => {
|
|
||||||
vi.mocked(fetchMission).mockResolvedValue({
|
|
||||||
id: "M-SHORT",
|
|
||||||
title: "Auth Fix",
|
|
||||||
status: "active",
|
|
||||||
interviewState: "completed",
|
|
||||||
milestones: [],
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { container } = render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ missionId: "M-SHORT" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const badge = container.querySelector(".card-mission-badge");
|
|
||||||
expect(badge).not.toBeNull();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// "Auth Fix" is 8 chars, well under 20 — no abbreviation needed
|
|
||||||
expect(badge?.textContent).toContain("Auth Fix");
|
|
||||||
expect(badge?.textContent).not.toContain("...");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TaskCard agent badge", () => {
|
|
||||||
let clearAgentCache: () => void;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const mod = await import("./TaskCard");
|
|
||||||
clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearAgentCache?.();
|
|
||||||
vi.mocked(fetchAgent).mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders agent badge when task has assignedAgentId", async () => {
|
|
||||||
vi.mocked(fetchAgent).mockResolvedValue({
|
|
||||||
id: "agent-001",
|
|
||||||
name: "Task Robot",
|
|
||||||
role: "executor",
|
|
||||||
state: "active",
|
|
||||||
metadata: {},
|
|
||||||
heartbeatHistory: [],
|
|
||||||
completedRuns: [],
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask({ assignedAgentId: "agent-001" })}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTitle("Assigned to Task Robot")).toBeDefined();
|
|
||||||
expect(screen.getByText("Task Robot")).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not render agent badge when assignedAgentId is undefined", () => {
|
|
||||||
render(
|
|
||||||
<TaskCard
|
|
||||||
task={makeTask()}
|
|
||||||
onOpenDetail={noop}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
import { AgentReflectionsTab } from "../AgentReflectionsTab";
|
||||||
import {
|
import {
|
||||||
addAgentRating,
|
addAgentRating,
|
||||||
deleteAgentRating,
|
deleteAgentRating,
|
||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
fetchAgentRatingSummary,
|
fetchAgentRatingSummary,
|
||||||
fetchAgentReflections,
|
fetchAgentReflections,
|
||||||
triggerAgentReflection,
|
triggerAgentReflection,
|
||||||
} from "../api";
|
} from "../../api";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
addAgentRating: vi.fn(),
|
addAgentRating: vi.fn(),
|
||||||
deleteAgentRating: vi.fn(),
|
deleteAgentRating: vi.fn(),
|
||||||
fetchAgentPerformance: vi.fn(),
|
fetchAgentPerformance: vi.fn(),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { DashboardLoader } from "./DashboardLoader";
|
import { DashboardLoader } from "../DashboardLoader";
|
||||||
|
|
||||||
function getStep(label: string): HTMLElement {
|
function getStep(label: string): HTMLElement {
|
||||||
const step = screen.getByText(label).closest("li");
|
const step = screen.getByText(label).closest("li");
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { FileBrowserModal } from "./FileBrowserModal";
|
import { FileBrowserModal } from "../FileBrowserModal";
|
||||||
import * as workspaceBrowserHook from "../hooks/useWorkspaceFileBrowser";
|
import * as workspaceBrowserHook from "../../hooks/useWorkspaceFileBrowser";
|
||||||
import * as workspaceEditorHook from "../hooks/useWorkspaceFileEditor";
|
import * as workspaceEditorHook from "../../hooks/useWorkspaceFileEditor";
|
||||||
import * as workspacesHook from "../hooks/useWorkspaces";
|
import * as workspacesHook from "../../hooks/useWorkspaces";
|
||||||
|
|
||||||
vi.mock("../hooks/useWorkspaceFileBrowser");
|
vi.mock("../../hooks/useWorkspaceFileBrowser");
|
||||||
vi.mock("../hooks/useWorkspaceFileEditor");
|
vi.mock("../../hooks/useWorkspaceFileEditor");
|
||||||
vi.mock("../hooks/useWorkspaces");
|
vi.mock("../../hooks/useWorkspaces");
|
||||||
|
|
||||||
const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser);
|
const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser);
|
||||||
const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor);
|
const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor);
|
||||||
@@ -222,10 +222,8 @@ describe("FileBrowserModal", () => {
|
|||||||
it("long file path is truncated on mobile", async () => {
|
it("long file path is truncated on mobile", async () => {
|
||||||
// Read CSS file directly to verify the overflow/ellipsis rules
|
// Read CSS file directly to verify the overflow/ellipsis rules
|
||||||
// (JSDOM doesn't apply stylesheets, so computed style checks won't work)
|
// (JSDOM doesn't apply stylesheets, so computed style checks won't work)
|
||||||
const { readFileSync } = await import("fs");
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||||
const { resolve } = await import("path");
|
const cssContent = loadAllAppCss();
|
||||||
const cssPath = resolve(__dirname, "../styles.css");
|
|
||||||
const cssContent = readFileSync(cssPath, "utf-8");
|
|
||||||
|
|
||||||
// Extract mobile media query blocks
|
// Extract mobile media query blocks
|
||||||
function extractMobileMediaBlocks(content: string): string {
|
function extractMobileMediaBlocks(content: string): string {
|
||||||
@@ -532,10 +530,8 @@ describe("FileBrowserModal", () => {
|
|||||||
|
|
||||||
describe("modal height constraint regression", () => {
|
describe("modal height constraint regression", () => {
|
||||||
it("max-height uses calc() to stay within viewport padding", async () => {
|
it("max-height uses calc() to stay within viewport padding", async () => {
|
||||||
const fs = await import("fs");
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||||
const path = await import("path");
|
const css = loadAllAppCss();
|
||||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
|
||||||
const css = fs.readFileSync(cssPath, "utf-8");
|
|
||||||
|
|
||||||
// Extract the first .file-browser-modal block (desktop base styles)
|
// Extract the first .file-browser-modal block (desktop base styles)
|
||||||
// Match from ".file-browser-modal {" to its closing "}"
|
// Match from ".file-browser-modal {" to its closing "}"
|
||||||
@@ -553,10 +549,8 @@ describe("FileBrowserModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("height and max-height together do not exceed viewport on desktop", async () => {
|
it("height and max-height together do not exceed viewport on desktop", async () => {
|
||||||
const fs = await import("fs");
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||||
const path = await import("path");
|
const css = loadAllAppCss();
|
||||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
|
||||||
const css = fs.readFileSync(cssPath, "utf-8");
|
|
||||||
|
|
||||||
const blockMatch = css.match(
|
const blockMatch = css.match(
|
||||||
/\.file-browser-modal\s*\{([^}]*)\}/,
|
/\.file-browser-modal\s*\{([^}]*)\}/,
|
||||||
@@ -581,10 +575,8 @@ describe("FileBrowserModal", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("mobile styles use 100dvh for full-screen behavior", async () => {
|
it("mobile styles use 100dvh for full-screen behavior", async () => {
|
||||||
const fs = await import("fs");
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||||
const path = await import("path");
|
const css = loadAllAppCss();
|
||||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
|
||||||
const css = fs.readFileSync(cssPath, "utf-8");
|
|
||||||
|
|
||||||
// Extract mobile media query blocks (similar to existing pattern)
|
// Extract mobile media query blocks (similar to existing pattern)
|
||||||
function extractMobileMediaBlocks(content: string): string {
|
function extractMobileMediaBlocks(content: string): string {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { FileEditor } from "./FileEditor";
|
import { FileEditor } from "../FileEditor";
|
||||||
|
|
||||||
describe("FileEditor", () => {
|
describe("FileEditor", () => {
|
||||||
it("renders textarea with correct class names", () => {
|
it("renders textarea with correct class names", () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
|
import { MilestoneSliceInterviewModal } from "../MilestoneSliceInterviewModal";
|
||||||
|
|
||||||
const mockStartMilestoneInterview = vi.fn();
|
const mockStartMilestoneInterview = vi.fn();
|
||||||
const mockStartSliceInterview = vi.fn();
|
const mockStartSliceInterview = vi.fn();
|
||||||
@@ -18,7 +18,7 @@ const mockForceAcquireSessionLock = vi.fn();
|
|||||||
const mockFetchAiSession = vi.fn();
|
const mockFetchAiSession = vi.fn();
|
||||||
const mockParseConversationHistory = vi.fn();
|
const mockParseConversationHistory = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args),
|
startMilestoneInterview: (...args: any[]) => mockStartMilestoneInterview(...args),
|
||||||
startSliceInterview: (...args: any[]) => mockStartSliceInterview(...args),
|
startSliceInterview: (...args: any[]) => mockStartSliceInterview(...args),
|
||||||
respondToMilestoneInterview: (...args: any[]) => mockRespondToMilestoneInterview(...args),
|
respondToMilestoneInterview: (...args: any[]) => mockRespondToMilestoneInterview(...args),
|
||||||
@@ -36,7 +36,7 @@ vi.mock("../api", () => ({
|
|||||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/useSessionLock", () => ({
|
vi.mock("../../hooks/useSessionLock", () => ({
|
||||||
useSessionLock: vi.fn(() => ({
|
useSessionLock: vi.fn(() => ({
|
||||||
isLockedByOther: false,
|
isLockedByOther: false,
|
||||||
takeControl: vi.fn(),
|
takeControl: vi.fn(),
|
||||||
@@ -44,7 +44,7 @@ vi.mock("../hooks/useSessionLock", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/useAiSessionSync", () => ({
|
vi.mock("../../hooks/useAiSessionSync", () => ({
|
||||||
useAiSessionSync: vi.fn(() => ({
|
useAiSessionSync: vi.fn(() => ({
|
||||||
activeTabMap: new Map(),
|
activeTabMap: new Map(),
|
||||||
broadcastUpdate: vi.fn(),
|
broadcastUpdate: vi.fn(),
|
||||||
@@ -55,7 +55,7 @@ vi.mock("../hooks/useAiSessionSync", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../utils/getSessionTabId", () => ({
|
vi.mock("../../utils/getSessionTabId", () => ({
|
||||||
getSessionTabId: vi.fn(() => "test-tab-id"),
|
getSessionTabId: vi.fn(() => "test-tab-id"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import * as api from "../api";
|
import * as api from "../../api";
|
||||||
import { PlanningModeModal } from "./PlanningModeModal";
|
import { PlanningModeModal } from "../PlanningModeModal";
|
||||||
import { TaskDetailModal } from "./TaskDetailModal";
|
import { TaskDetailModal } from "../TaskDetailModal";
|
||||||
import { useSessionLock } from "../hooks/useSessionLock";
|
import { useSessionLock } from "../../hooks/useSessionLock";
|
||||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
import { getSessionTabId } from "../../utils/getSessionTabId";
|
||||||
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
|
import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core";
|
||||||
|
|
||||||
// Mock the API functions
|
// Mock the API functions
|
||||||
@@ -34,7 +34,7 @@ const mockApprovePlan = vi.fn();
|
|||||||
const mockRejectPlan = vi.fn();
|
const mockRejectPlan = vi.fn();
|
||||||
const mockRefineTask = vi.fn();
|
const mockRefineTask = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
startPlanning: (...args: any[]) => mockStartPlanning(...args),
|
startPlanning: (...args: any[]) => mockStartPlanning(...args),
|
||||||
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||||
@@ -444,10 +444,8 @@ describe("PlanningModeModal", () => {
|
|||||||
const modal = container.querySelector(".planning-modal");
|
const modal = container.querySelector(".planning-modal");
|
||||||
expect(modal).toBeTruthy();
|
expect(modal).toBeTruthy();
|
||||||
|
|
||||||
const fs = await import("fs");
|
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||||
const path = await import("path");
|
const css = loadAllAppCss();
|
||||||
const cssPath = path.resolve(__dirname, "../styles.css");
|
|
||||||
const css = fs.readFileSync(cssPath, "utf-8");
|
|
||||||
|
|
||||||
const blockMatch = css.match(
|
const blockMatch = css.match(
|
||||||
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
|
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type { AiSessionSummary } from "../api";
|
import type { AiSessionSummary } from "../../api";
|
||||||
import { SessionNotificationBanner, dismissedIds } from "./SessionNotificationBanner";
|
import { SessionNotificationBanner, dismissedIds } from "../SessionNotificationBanner";
|
||||||
|
|
||||||
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
|
function buildSession(overrides: Partial<AiSessionSummary>): AiSessionSummary {
|
||||||
return {
|
return {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { SubtaskBreakdownModal } from "./SubtaskBreakdownModal";
|
import { SubtaskBreakdownModal } from "../SubtaskBreakdownModal";
|
||||||
|
|
||||||
const mockStartSubtaskBreakdown = vi.fn();
|
const mockStartSubtaskBreakdown = vi.fn();
|
||||||
const mockRetrySubtaskSession = vi.fn();
|
const mockRetrySubtaskSession = vi.fn();
|
||||||
@@ -13,7 +13,7 @@ const mockAcquireSessionLock = vi.fn();
|
|||||||
const mockReleaseSessionLock = vi.fn();
|
const mockReleaseSessionLock = vi.fn();
|
||||||
const mockForceAcquireSessionLock = vi.fn();
|
const mockForceAcquireSessionLock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
startSubtaskBreakdown: (...args: any[]) => mockStartSubtaskBreakdown(...args),
|
||||||
retrySubtaskSession: (...args: any[]) => mockRetrySubtaskSession(...args),
|
retrySubtaskSession: (...args: any[]) => mockRetrySubtaskSession(...args),
|
||||||
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
connectSubtaskStream: (...args: any[]) => mockConnectSubtaskStream(...args),
|
||||||
@@ -26,7 +26,7 @@ vi.mock("../api", () => ({
|
|||||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../hooks/modalPersistence", () => ({
|
vi.mock("../../hooks/modalPersistence", () => ({
|
||||||
saveSubtaskDescription: vi.fn(),
|
saveSubtaskDescription: vi.fn(),
|
||||||
getSubtaskDescription: vi.fn(() => ""),
|
getSubtaskDescription: vi.fn(() => ""),
|
||||||
clearSubtaskDescription: vi.fn(),
|
clearSubtaskDescription: vi.fn(),
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { UsageIndicator } from "./UsageIndicator";
|
import { UsageIndicator } from "../UsageIndicator";
|
||||||
import * as useUsageDataModule from "../hooks/useUsageData";
|
import * as useUsageDataModule from "../../hooks/useUsageData";
|
||||||
import type { ProviderUsage } from "../api";
|
import type { ProviderUsage } from "../../api";
|
||||||
import { scopedKey } from "../utils/projectStorage";
|
import { scopedKey } from "../../utils/projectStorage";
|
||||||
|
|
||||||
// Mock the useUsageData hook
|
// Mock the useUsageData hook
|
||||||
vi.mock("../hooks/useUsageData", () => ({
|
vi.mock("../../hooks/useUsageData", () => ({
|
||||||
useUsageData: vi.fn(),
|
useUsageData: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
||||||
import { fetchWorkflowSteps } from "../api";
|
import { fetchWorkflowSteps } from "../../api";
|
||||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchWorkflowSteps: vi.fn(),
|
fetchWorkflowSteps: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -738,7 +738,7 @@ describe("WorkflowResultsTab", () => {
|
|||||||
// Read the component source file
|
// Read the component source file
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const componentPath = path.join(__dirname, "WorkflowResultsTab.tsx");
|
const componentPath = path.join(__dirname, "..", "WorkflowResultsTab.tsx");
|
||||||
const componentSource = fs.readFileSync(componentPath, "utf-8");
|
const componentSource = fs.readFileSync(componentPath, "utf-8");
|
||||||
|
|
||||||
// These hardcoded color patterns should NOT appear in the component
|
// These hardcoded color patterns should NOT appear in the component
|
||||||
@@ -758,7 +758,7 @@ describe("WorkflowResultsTab", () => {
|
|||||||
it("prevents reintroduction of getStatusColor function with hardcoded colors", () => {
|
it("prevents reintroduction of getStatusColor function with hardcoded colors", () => {
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const componentPath = path.join(__dirname, "WorkflowResultsTab.tsx");
|
const componentPath = path.join(__dirname, "..", "WorkflowResultsTab.tsx");
|
||||||
const componentSource = fs.readFileSync(componentPath, "utf-8");
|
const componentSource = fs.readFileSync(componentPath, "utf-8");
|
||||||
|
|
||||||
// The getStatusColor function should not exist (removed to use CSS classes)
|
// The getStatusColor function should not exist (removed to use CSS classes)
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||||
import { useCurrentProject } from "./useCurrentProject";
|
import { useCurrentProject } from "../useCurrentProject";
|
||||||
import type { ProjectInfo } from "../api";
|
import type { ProjectInfo } from "../../api";
|
||||||
|
|
||||||
// Mock the API functions
|
// Mock the API functions
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchGlobalSettings: vi.fn(),
|
fetchGlobalSettings: vi.fn(),
|
||||||
updateGlobalSettings: vi.fn(),
|
updateGlobalSettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { fetchGlobalSettings, updateGlobalSettings } from "../api";
|
import { fetchGlobalSettings, updateGlobalSettings } from "../../api";
|
||||||
|
|
||||||
describe("useCurrentProject", () => {
|
describe("useCurrentProject", () => {
|
||||||
const mockProjects: ProjectInfo[] = [
|
const mockProjects: ProjectInfo[] = [
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act } from "@testing-library/react";
|
||||||
import { useExecutorStats } from "./useExecutorStats";
|
import { useExecutorStats } from "../useExecutorStats";
|
||||||
import * as apiModule from "../api";
|
import * as apiModule from "../../api";
|
||||||
import type { Task } from "@fusion/core";
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
// Mock the API module
|
// Mock the API module
|
||||||
vi.mock("../api", async () => {
|
vi.mock("../../api", async () => {
|
||||||
const actual = await vi.importActual("../api");
|
const actual = await vi.importActual("../../api");
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
fetchExecutorStats: vi.fn(),
|
fetchExecutorStats: vi.fn(),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act } from "@testing-library/react";
|
||||||
import { useFlashOnIncrease } from "./useFlashOnIncrease";
|
import { useFlashOnIncrease } from "../useFlashOnIncrease";
|
||||||
|
|
||||||
describe("useFlashOnIncrease", () => {
|
describe("useFlashOnIncrease", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { useTerminal } from "./useTerminal";
|
import { useTerminal } from "../useTerminal";
|
||||||
|
|
||||||
class MockWebSocket {
|
class MockWebSocket {
|
||||||
static CONNECTING = 0;
|
static CONNECTING = 0;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, renderHook, act, screen } from "@testing-library/react";
|
import { render, renderHook, act, screen } from "@testing-library/react";
|
||||||
import { ToastProvider, useToast } from "./useToast";
|
import { ToastProvider, useToast } from "../useToast";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
||||||
import { useActivityLog } from "./useActivityLog";
|
|
||||||
import * as apiModule from "../api";
|
|
||||||
import type { ActivityFeedEntry } from "../api";
|
|
||||||
|
|
||||||
// Mock the API module
|
|
||||||
vi.mock("../api", () => ({
|
|
||||||
fetchActivityFeed: vi.fn(),
|
|
||||||
fetchActivityLog: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
|
||||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
|
||||||
|
|
||||||
/** Create ActivityFeedEntry[] entries (unified feed format) */
|
|
||||||
function createFeedEntries(
|
|
||||||
count: number,
|
|
||||||
projectId = "proj_123",
|
|
||||||
projectName = "Test Project",
|
|
||||||
): ActivityFeedEntry[] {
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: `feed_entry_${i}`,
|
|
||||||
timestamp: new Date(Date.now() - i * 60000).toISOString(),
|
|
||||||
type: "task:created" as const,
|
|
||||||
projectId,
|
|
||||||
projectName,
|
|
||||||
taskId: "FN-001",
|
|
||||||
taskTitle: "Test Task",
|
|
||||||
details: "Task created",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useActivityLog", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
||||||
// Default: both mocks return empty arrays
|
|
||||||
mockFetchActivityFeed.mockResolvedValue([]);
|
|
||||||
mockFetchActivityLog.mockResolvedValue([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Single-project mode (default) ─────────────────────────────────
|
|
||||||
|
|
||||||
it("initializes with empty entries and loads on mount", async () => {
|
|
||||||
mockFetchActivityLog.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog());
|
|
||||||
|
|
||||||
expect(result.current.loading).toBe(true);
|
|
||||||
expect(result.current.entries).toEqual([]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.loading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.entries).toEqual([]);
|
|
||||||
// Should use per-project log, not unified feed
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
|
||||||
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetches entries from per-project log in single-project mode", async () => {
|
|
||||||
const mockEntries = createFeedEntries(1);
|
|
||||||
mockFetchActivityLog.mockResolvedValue(
|
|
||||||
mockEntries.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
timestamp: e.timestamp,
|
|
||||||
type: e.type,
|
|
||||||
taskId: e.taskId,
|
|
||||||
taskTitle: e.taskTitle,
|
|
||||||
details: e.details,
|
|
||||||
metadata: e.metadata,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.entries).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hook converts ActivityLogEntry to ActivityFeedEntry with empty project fields
|
|
||||||
expect(result.current.entries[0].type).toBe("task:created");
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
|
||||||
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters by type via per-project log", async () => {
|
|
||||||
mockFetchActivityLog.mockResolvedValue([]);
|
|
||||||
|
|
||||||
renderHook(() => useActivityLog({ type: "task:created" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ type: "task:created" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects custom limit via per-project log", async () => {
|
|
||||||
mockFetchActivityLog.mockResolvedValue([]);
|
|
||||||
|
|
||||||
renderHook(() => useActivityLog({ limit: 100 }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ limit: 100 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not auto-refresh when disabled", async () => {
|
|
||||||
mockFetchActivityLog.mockResolvedValue([]);
|
|
||||||
|
|
||||||
renderHook(() => useActivityLog({ autoRefresh: false }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Advance time — should not trigger another fetch
|
|
||||||
vi.useRealTimers();
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refresh function manually refreshes data", async () => {
|
|
||||||
mockFetchActivityLog.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.loading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clear removes all entries", async () => {
|
|
||||||
const mockEntries = createFeedEntries(1);
|
|
||||||
mockFetchActivityLog.mockResolvedValue(
|
|
||||||
mockEntries.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
timestamp: e.timestamp,
|
|
||||||
type: e.type,
|
|
||||||
taskId: e.taskId,
|
|
||||||
taskTitle: e.taskTitle,
|
|
||||||
details: e.details,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.entries).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.entries).toEqual([]);
|
|
||||||
expect(result.current.hasMore).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles errors gracefully", async () => {
|
|
||||||
mockFetchActivityLog.mockRejectedValue(new Error("Server error"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.loading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.error).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets hasMore when entries equal limit", async () => {
|
|
||||||
const mockEntries = createFeedEntries(50);
|
|
||||||
mockFetchActivityLog.mockResolvedValue(
|
|
||||||
mockEntries.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
timestamp: e.timestamp,
|
|
||||||
type: e.type,
|
|
||||||
taskId: e.taskId,
|
|
||||||
taskTitle: e.taskTitle,
|
|
||||||
details: e.details,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.entries).toHaveLength(50);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.hasMore).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets hasMore to false when fewer entries than limit", async () => {
|
|
||||||
const mockEntries = createFeedEntries(30);
|
|
||||||
mockFetchActivityLog.mockResolvedValue(
|
|
||||||
mockEntries.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
timestamp: e.timestamp,
|
|
||||||
type: e.type,
|
|
||||||
taskId: e.taskId,
|
|
||||||
taskTitle: e.taskTitle,
|
|
||||||
details: e.details,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.entries).toHaveLength(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.hasMore).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Multi-project mode (useCentralFeed) ───────────────────────────
|
|
||||||
|
|
||||||
it("fetches from unified feed when useCentralFeed is true", async () => {
|
|
||||||
const mockEntries = createFeedEntries(2, "proj_multi", "Multi Project");
|
|
||||||
mockFetchActivityFeed.mockResolvedValue(mockEntries);
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useActivityLog({ useCentralFeed: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.entries).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.entries[0].projectName).toBe("Multi Project");
|
|
||||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
|
||||||
expect(mockFetchActivityLog).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes projectId to unified feed when useCentralFeed is true", async () => {
|
|
||||||
mockFetchActivityFeed.mockResolvedValue([]);
|
|
||||||
|
|
||||||
renderHook(() =>
|
|
||||||
useActivityLog({ projectId: "proj_456", useCentralFeed: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ projectId: "proj_456" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes type filter to unified feed when useCentralFeed is true", async () => {
|
|
||||||
mockFetchActivityFeed.mockResolvedValue([]);
|
|
||||||
|
|
||||||
renderHook(() =>
|
|
||||||
useActivityLog({ type: "task:failed", useCentralFeed: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ type: "task:failed" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
fetchProjects,
|
|
||||||
registerProject,
|
|
||||||
unregisterProject,
|
|
||||||
fetchProject,
|
|
||||||
updateProject,
|
|
||||||
detectProjects,
|
|
||||||
fetchProjectHealth,
|
|
||||||
fetchActivityFeed,
|
|
||||||
pauseProject,
|
|
||||||
resumeProject,
|
|
||||||
fetchFirstRunStatus,
|
|
||||||
fetchGlobalConcurrency,
|
|
||||||
fetchProjectTasks,
|
|
||||||
fetchProjectConfig,
|
|
||||||
type ProjectInfo,
|
|
||||||
type ProjectHealth,
|
|
||||||
type ActivityFeedEntry,
|
|
||||||
type FirstRunStatus,
|
|
||||||
type GlobalConcurrencyState,
|
|
||||||
type DetectedProject,
|
|
||||||
} from "../api";
|
|
||||||
|
|
||||||
function mockFetchResponse(
|
|
||||||
ok: boolean,
|
|
||||||
body: unknown,
|
|
||||||
status = ok ? 200 : 500,
|
|
||||||
contentType = "application/json"
|
|
||||||
) {
|
|
||||||
const bodyText = JSON.stringify(body);
|
|
||||||
return Promise.resolve({
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
statusText: ok ? "OK" : "Error",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? contentType : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(body),
|
|
||||||
text: () => Promise.resolve(bodyText),
|
|
||||||
} as unknown as Response);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Project Management API", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjects", () => {
|
|
||||||
it("returns empty array when no projects", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
const result = await fetchProjects();
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns projects list when available", async () => {
|
|
||||||
const mockProjects: ProjectInfo[] = [
|
|
||||||
{
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProjects));
|
|
||||||
|
|
||||||
const result = await fetchProjects();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].id).toBe("proj_123");
|
|
||||||
expect(result[0].name).toBe("Test Project");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("registerProject", () => {
|
|
||||||
it("registers a new project with valid input", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_new",
|
|
||||||
name: "New Project",
|
|
||||||
path: "/absolute/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await registerProject({
|
|
||||||
name: "New Project",
|
|
||||||
path: "/absolute/path",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.id).toBe("proj_new");
|
|
||||||
expect(result.name).toBe("New Project");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
body: expect.any(String),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unregisterProject", () => {
|
|
||||||
it("unregisters a project", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
|
||||||
|
|
||||||
await unregisterProject("proj_test123");
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_test123",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjectHealth", () => {
|
|
||||||
it("returns health metrics for a project", async () => {
|
|
||||||
const mockHealth: ProjectHealth = {
|
|
||||||
projectId: "proj_test123",
|
|
||||||
status: "active",
|
|
||||||
activeTaskCount: 5,
|
|
||||||
inFlightAgentCount: 2,
|
|
||||||
totalTasksCompleted: 10,
|
|
||||||
totalTasksFailed: 1,
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockHealth));
|
|
||||||
|
|
||||||
const result = await fetchProjectHealth("proj_test123");
|
|
||||||
|
|
||||||
expect(result.projectId).toBe("proj_test123");
|
|
||||||
expect(result.activeTaskCount).toBe(5);
|
|
||||||
expect(result.totalTasksCompleted).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchActivityFeed", () => {
|
|
||||||
it("returns activity feed entries", async () => {
|
|
||||||
const mockEntries: ActivityFeedEntry[] = [
|
|
||||||
{
|
|
||||||
id: "entry_1",
|
|
||||||
timestamp: "2026-01-01T00:00:00.000Z",
|
|
||||||
type: "task:created",
|
|
||||||
projectId: "proj_123",
|
|
||||||
projectName: "Test Project",
|
|
||||||
taskId: "FN-001",
|
|
||||||
taskTitle: "Test Task",
|
|
||||||
details: "Task created",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
|
||||||
|
|
||||||
const result = await fetchActivityFeed();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].type).toBe("task:created");
|
|
||||||
expect(result[0].projectName).toBe("Test Project");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports limit parameter", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
await fetchActivityFeed({ limit: 10 });
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("limit=10"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports projectId filter", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
await fetchActivityFeed({ projectId: "proj_123" });
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("projectId=proj_123"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchFirstRunStatus", () => {
|
|
||||||
it("returns first run status", async () => {
|
|
||||||
const mockStatus: FirstRunStatus = {
|
|
||||||
hasProjects: false,
|
|
||||||
singleProjectPath: null,
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
|
||||||
|
|
||||||
const result = await fetchFirstRunStatus();
|
|
||||||
|
|
||||||
expect(result.hasProjects).toBe(false);
|
|
||||||
expect(result.singleProjectPath).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns single project path when only one project", async () => {
|
|
||||||
const mockStatus: FirstRunStatus = {
|
|
||||||
hasProjects: true,
|
|
||||||
singleProjectPath: "/projects/my-project",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
|
|
||||||
|
|
||||||
const result = await fetchFirstRunStatus();
|
|
||||||
|
|
||||||
expect(result.hasProjects).toBe(true);
|
|
||||||
expect(result.singleProjectPath).toBe("/projects/my-project");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchGlobalConcurrency", () => {
|
|
||||||
it("returns global concurrency state", async () => {
|
|
||||||
const mockState: GlobalConcurrencyState = {
|
|
||||||
globalMaxConcurrent: 4,
|
|
||||||
currentlyActive: 2,
|
|
||||||
queuedCount: 0,
|
|
||||||
projectsActive: { "proj_123": 2 },
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockState));
|
|
||||||
|
|
||||||
const result = await fetchGlobalConcurrency();
|
|
||||||
|
|
||||||
expect(result.globalMaxConcurrent).toBe(4);
|
|
||||||
expect(result.currentlyActive).toBe(2);
|
|
||||||
expect(result.projectsActive["proj_123"]).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pauseProject", () => {
|
|
||||||
it("pauses a project", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "paused",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await pauseProject("proj_123");
|
|
||||||
|
|
||||||
expect(result.status).toBe("paused");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123/pause",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resumeProject", () => {
|
|
||||||
it("resumes a paused project", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await resumeProject("proj_123");
|
|
||||||
|
|
||||||
expect(result.status).toBe("active");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123/resume",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjectTasks", () => {
|
|
||||||
it("fetches tasks for a specific project", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
await fetchProjectTasks("proj_123");
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("projectId=proj_123"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports pagination", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
|
||||||
|
|
||||||
await fetchProjectTasks("proj_123", 10, 20);
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("limit=10"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("offset=20"),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProjectConfig", () => {
|
|
||||||
it("fetches project config", async () => {
|
|
||||||
const mockConfig = { maxConcurrent: 4, rootDir: "/projects/test" };
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockConfig));
|
|
||||||
|
|
||||||
const result = await fetchProjectConfig("proj_123");
|
|
||||||
|
|
||||||
expect(result.maxConcurrent).toBe(4);
|
|
||||||
expect(result.rootDir).toBe("/projects/test");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetchProject (single)", () => {
|
|
||||||
it("fetches a specific project by ID", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Specific Project",
|
|
||||||
path: "/specific/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "child-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await fetchProject("proj_123");
|
|
||||||
|
|
||||||
expect(result.id).toBe("proj_123");
|
|
||||||
expect(result.name).toBe("Specific Project");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updateProject", () => {
|
|
||||||
it("updates project with valid data", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Updated Name",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "in-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await updateProject("proj_123", { name: "Updated Name" });
|
|
||||||
|
|
||||||
expect(result.name).toBe("Updated Name");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/proj_123",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "PATCH",
|
|
||||||
body: expect.any(String),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates project isolationMode", async () => {
|
|
||||||
const mockProject: ProjectInfo = {
|
|
||||||
id: "proj_123",
|
|
||||||
name: "Test Project",
|
|
||||||
path: "/test/path",
|
|
||||||
status: "active",
|
|
||||||
isolationMode: "child-process",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockProject));
|
|
||||||
|
|
||||||
const result = await updateProject("proj_123", { isolationMode: "child-process" });
|
|
||||||
|
|
||||||
expect(result.isolationMode).toBe("child-process");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("detectProjects", () => {
|
|
||||||
it("auto-detects projects in a base path", async () => {
|
|
||||||
const mockDetected = {
|
|
||||||
projects: [
|
|
||||||
{ path: "/home/user/project1", suggestedName: "project1", existing: false },
|
|
||||||
{ path: "/home/user/project2", suggestedName: "project2", existing: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockDetected));
|
|
||||||
|
|
||||||
const result = await detectProjects("/home/user");
|
|
||||||
|
|
||||||
expect(result.projects).toHaveLength(2);
|
|
||||||
expect(result.projects[0].path).toBe("/home/user/project1");
|
|
||||||
expect(result.projects[0].suggestedName).toBe("project1");
|
|
||||||
expect(result.projects[1].existing).toBe(true);
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/detect",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ basePath: "/home/user" }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses home directory when basePath not provided", async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { projects: [] }));
|
|
||||||
|
|
||||||
await detectProjects();
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/projects/detect",
|
|
||||||
expect.objectContaining({
|
|
||||||
body: JSON.stringify({ basePath: undefined }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user