- 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>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
/**
|
|
* Tests for project.ts commands
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
const mockListProjects = vi.fn();
|
|
const mockRegisterProject = vi.fn();
|
|
const mockUpdateProject = vi.fn().mockResolvedValue({});
|
|
const mockUnregisterProject = vi.fn();
|
|
const mockGetProject = vi.fn();
|
|
const mockGetProjectByPath = vi.fn();
|
|
const mockGetProjectHealth = vi.fn();
|
|
const mockInit = vi.fn();
|
|
const mockClose = vi.fn();
|
|
const mockQuestion = vi.fn();
|
|
const mockRlClose = vi.fn();
|
|
const mockSetDefaultProject = vi.fn();
|
|
const mockDetectProjectFromCwd = vi.fn();
|
|
const mockFormatProjectLine = vi.fn();
|
|
const mockGetSettings = vi.fn();
|
|
const mockGlobalInit = vi.fn();
|
|
const mockTaskStoreInit = vi.fn();
|
|
const mockTaskStoreListTasks = vi.fn();
|
|
const mockEnsureMemoryFileWithBackend = vi.fn();
|
|
|
|
// Mock @fusion/core
|
|
vi.mock("@fusion/core", () => ({
|
|
CentralCore: vi.fn().mockImplementation(() => ({
|
|
init: mockInit.mockResolvedValue(undefined),
|
|
close: mockClose.mockResolvedValue(undefined),
|
|
listProjects: mockListProjects,
|
|
registerProject: mockRegisterProject,
|
|
updateProject: mockUpdateProject,
|
|
unregisterProject: mockUnregisterProject,
|
|
getProject: mockGetProject,
|
|
getProjectByPath: mockGetProjectByPath,
|
|
getProjectHealth: mockGetProjectHealth,
|
|
})),
|
|
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
|
|
init: mockGlobalInit.mockResolvedValue(undefined),
|
|
getSettings: mockGetSettings,
|
|
})),
|
|
TaskStore: vi.fn().mockImplementation(() => ({
|
|
init: mockTaskStoreInit,
|
|
listTasks: mockTaskStoreListTasks,
|
|
})),
|
|
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
|
|
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
|
COLUMN_LABELS: {
|
|
triage: "Triage",
|
|
todo: "To Do",
|
|
"in-progress": "In Progress",
|
|
"in-review": "In Review",
|
|
done: "Done",
|
|
archived: "Archived",
|
|
},
|
|
}));
|
|
|
|
vi.mock("node:readline/promises", () => ({
|
|
createInterface: vi.fn(() => ({
|
|
question: mockQuestion,
|
|
close: mockRlClose,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("../../project-context.js", () => ({
|
|
formatProjectLine: mockFormatProjectLine,
|
|
detectProjectFromCwd: mockDetectProjectFromCwd,
|
|
setDefaultProject: mockSetDefaultProject,
|
|
resolveProject: vi.fn(),
|
|
}));
|
|
|
|
describe("project commands", () => {
|
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
|
throw new Error(`process.exit:${code ?? 0}`);
|
|
});
|
|
mockGetSettings.mockResolvedValue({});
|
|
mockFormatProjectLine.mockImplementation((project, isDefault) => `${isDefault ? "* " : " "}${project.name}`);
|
|
mockQuestion.mockResolvedValue("y");
|
|
mockGetProjectHealth.mockResolvedValue(undefined);
|
|
mockTaskStoreInit.mockResolvedValue(undefined);
|
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleSpy.mockRestore();
|
|
consoleWarnSpy.mockRestore();
|
|
consoleErrorSpy.mockRestore();
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("exports all project command functions", async () => {
|
|
const project = await import("../project.js");
|
|
expect(typeof project.runProjectList).toBe("function");
|
|
expect(typeof project.runProjectAdd).toBe("function");
|
|
expect(typeof project.runProjectRemove).toBe("function");
|
|
expect(typeof project.runProjectShow).toBe("function");
|
|
expect(typeof project.runProjectInfo).toBe("function");
|
|
expect(typeof project.runProjectSetDefault).toBe("function");
|
|
expect(typeof project.runProjectDetect).toBe("function");
|
|
});
|
|
|
|
it("runProjectList prints registered projects and summary", async () => {
|
|
mockListProjects.mockResolvedValue([
|
|
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
|
{ id: "proj-2", name: "app-two", path: "/tmp/app-two", status: "paused", isolationMode: "child-process" },
|
|
]);
|
|
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
|
|
mockGetProject.mockImplementation(async (id: string) => (
|
|
id === "proj-1"
|
|
? { id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" }
|
|
: undefined
|
|
));
|
|
|
|
const { runProjectList } = await import("../project.js");
|
|
await runProjectList();
|
|
|
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 projects registered, 1 active"));
|
|
// Check that projects are displayed in output
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("app-one");
|
|
expect(output).toContain("app-two");
|
|
});
|
|
|
|
it("runProjectList with --json flag outputs JSON", async () => {
|
|
mockListProjects.mockResolvedValue([
|
|
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
|
]);
|
|
mockGetSettings.mockResolvedValue({});
|
|
|
|
const { runProjectList } = await import("../project.js");
|
|
await runProjectList({ json: true });
|
|
|
|
// Should output JSON
|
|
const jsonOutput = consoleSpy.mock.calls.map((call) => String(call[0])).join("");
|
|
expect(() => JSON.parse(jsonOutput)).not.toThrow();
|
|
const parsed = JSON.parse(jsonOutput);
|
|
expect(Array.isArray(parsed)).toBe(true);
|
|
expect(parsed[0].name).toBe("app-one");
|
|
});
|
|
|
|
it("runProjectAdd registers project and prints sanitized path output", async () => {
|
|
mockListProjects.mockResolvedValue([]);
|
|
mockRegisterProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", isolationMode: "in-process" });
|
|
|
|
const { runProjectAdd } = await import("../project.js");
|
|
await runProjectAdd("demo", ".", { force: true });
|
|
|
|
expect(mockRegisterProject).toHaveBeenCalled();
|
|
const lines = consoleSpy.mock.calls.map((call) => String(call[0]));
|
|
expect(lines.some((line) => line.includes("Registered project 'demo'"))).toBe(true);
|
|
expect(lines.some((line) => line.includes("Location:"))).toBe(true);
|
|
expect(lines.some((line) => line.includes("/tmp/demo"))).toBe(false);
|
|
});
|
|
|
|
it("runProjectRemove unregisters project after confirmation", async () => {
|
|
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
|
|
|
const { runProjectRemove } = await import("../project.js");
|
|
await runProjectRemove("proj-1", { force: false });
|
|
|
|
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
|
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered project 'demo'"));
|
|
});
|
|
|
|
it("runProjectRemove with --force skips confirmation", async () => {
|
|
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
|
|
|
const { runProjectRemove } = await import("../project.js");
|
|
await runProjectRemove("proj-1", { force: true });
|
|
|
|
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
|
|
// Question should not be called when force is true
|
|
expect(mockQuestion).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runProjectShow prints detailed project metadata without absolute path leakage", async () => {
|
|
mockGetProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/tmp/demo",
|
|
status: "active",
|
|
isolationMode: "child-process",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-02T00:00:00.000Z",
|
|
});
|
|
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
|
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
|
|
|
const { runProjectShow } = await import("../project.js");
|
|
await runProjectShow("proj-1");
|
|
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("Project: demo (default)");
|
|
expect(output).toContain("Isolation: child-process");
|
|
expect(output).toContain("Created:");
|
|
expect(output).not.toContain("/tmp/demo");
|
|
});
|
|
|
|
it("runProjectInfo is alias for runProjectShow", async () => {
|
|
mockGetProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/tmp/demo",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-02T00:00:00.000Z",
|
|
});
|
|
mockGetSettings.mockResolvedValue({});
|
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
|
|
|
const { runProjectInfo } = await import("../project.js");
|
|
await runProjectInfo("proj-1");
|
|
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("Project: demo");
|
|
});
|
|
|
|
it("runProjectSetDefault sets default project", async () => {
|
|
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
|
|
|
|
const { runProjectSetDefault } = await import("../project.js");
|
|
await runProjectSetDefault("proj-1");
|
|
|
|
expect(mockSetDefaultProject).toHaveBeenCalledWith("proj-1");
|
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Set 'demo' as default project"));
|
|
});
|
|
|
|
it("runProjectDetect prints detected project without absolute path leakage", async () => {
|
|
mockDetectProjectFromCwd.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo" });
|
|
|
|
const { runProjectDetect } = await import("../project.js");
|
|
await runProjectDetect();
|
|
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("Detected: demo");
|
|
expect(output).toContain("Location:");
|
|
expect(output).not.toContain("/tmp/demo");
|
|
});
|
|
|
|
it("runProjectList shows task counts for projects", async () => {
|
|
mockListProjects.mockResolvedValue([
|
|
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
|
|
]);
|
|
mockGetSettings.mockResolvedValue({});
|
|
|
|
// Mock task store to return some tasks - return 3 tasks
|
|
mockTaskStoreListTasks.mockResolvedValue([
|
|
{ id: "FN-001", column: "todo" },
|
|
{ id: "FN-002", column: "in-progress" },
|
|
{ id: "FN-003", column: "done" },
|
|
]);
|
|
|
|
const { runProjectList } = await import("../project.js");
|
|
await runProjectList();
|
|
|
|
// Verify TaskStore.listTasks was called
|
|
expect(mockTaskStoreListTasks).toHaveBeenCalled();
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("3"); // Total task count
|
|
});
|
|
|
|
it("runProjectShow shows task counts in output", async () => {
|
|
mockGetProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/tmp/demo",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-02T00:00:00.000Z",
|
|
});
|
|
mockGetSettings.mockResolvedValue({});
|
|
mockTaskStoreListTasks.mockResolvedValue([
|
|
{ id: "FN-001", column: "todo" },
|
|
{ id: "FN-002", column: "todo" },
|
|
{ id: "FN-003", column: "in-progress" },
|
|
]);
|
|
|
|
const { runProjectShow } = await import("../project.js");
|
|
await runProjectShow("proj-1");
|
|
|
|
// Verify TaskStore.listTasks was called
|
|
expect(mockTaskStoreListTasks).toHaveBeenCalled();
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("Total: 3");
|
|
expect(output).toContain("To Do: 2");
|
|
expect(output).toContain("In Progress: 1");
|
|
});
|
|
|
|
it("runProjectShow shows health info when available", async () => {
|
|
mockGetProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/tmp/demo",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-02T00:00:00.000Z",
|
|
});
|
|
mockGetSettings.mockResolvedValue({});
|
|
mockGetProjectHealth.mockResolvedValue({
|
|
projectId: "proj-1",
|
|
status: "active",
|
|
activeTaskCount: 2,
|
|
inFlightAgentCount: 1,
|
|
totalTasksCompleted: 10,
|
|
totalTasksFailed: 1,
|
|
lastActivityAt: new Date().toISOString(),
|
|
});
|
|
mockTaskStoreListTasks.mockResolvedValue([]);
|
|
|
|
const { runProjectShow } = await import("../project.js");
|
|
await runProjectShow("proj-1");
|
|
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("Health:");
|
|
expect(output).toContain("Active Tasks: 2");
|
|
expect(output).toContain("In-Flight Agents: 1");
|
|
expect(output).toContain("Completed: 10");
|
|
});
|
|
|
|
it("validation exits on missing required args for runProjectAdd", async () => {
|
|
const { runProjectAdd } = await import("../project.js");
|
|
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
|
|
});
|
|
|
|
it("validation exits on missing required args for runProjectRemove", async () => {
|
|
const { runProjectRemove } = await import("../project.js");
|
|
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
|
|
});
|
|
|
|
it("validation exits on missing required args for runProjectSetDefault", async () => {
|
|
const { runProjectSetDefault } = await import("../project.js");
|
|
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
|
|
});
|
|
|
|
describe("runProjectAdd memory bootstrap", () => {
|
|
// Use "." as path like the existing runProjectAdd test - it resolves to cwd which exists
|
|
const testPath = ".";
|
|
|
|
beforeEach(() => {
|
|
mockEnsureMemoryFileWithBackend.mockReset();
|
|
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
|
});
|
|
|
|
it("calls ensureMemoryFileWithBackend after project registration", async () => {
|
|
mockListProjects.mockResolvedValue([]);
|
|
mockRegisterProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/fake/demo",
|
|
isolationMode: "in-process",
|
|
});
|
|
|
|
const { runProjectAdd } = await import("../project.js");
|
|
await runProjectAdd("demo", testPath, { force: true });
|
|
|
|
expect(mockEnsureMemoryFileWithBackend).toHaveBeenCalled();
|
|
// Verify it was called with an absolute path
|
|
const callArg = mockEnsureMemoryFileWithBackend.mock.calls[0][0];
|
|
expect(callArg).toBe(process.cwd());
|
|
});
|
|
|
|
it("shows memory initialized message when memory files are created", async () => {
|
|
mockListProjects.mockResolvedValue([]);
|
|
mockRegisterProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/fake/demo",
|
|
isolationMode: "in-process",
|
|
});
|
|
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
|
|
|
const { runProjectAdd } = await import("../project.js");
|
|
await runProjectAdd("demo", testPath, { force: true });
|
|
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).toContain("Memory: initialized");
|
|
});
|
|
|
|
it("does not show memory message when memory files already exist", async () => {
|
|
mockListProjects.mockResolvedValue([]);
|
|
mockRegisterProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/fake/demo",
|
|
isolationMode: "in-process",
|
|
});
|
|
mockEnsureMemoryFileWithBackend.mockResolvedValue(false); // Files already exist
|
|
|
|
const { runProjectAdd } = await import("../project.js");
|
|
await runProjectAdd("demo", testPath, { force: true });
|
|
|
|
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(output).not.toContain("Memory: initialized");
|
|
});
|
|
|
|
it("does not block project registration when memory bootstrap fails", async () => {
|
|
mockListProjects.mockResolvedValue([]);
|
|
mockRegisterProject.mockResolvedValue({
|
|
id: "proj-1",
|
|
name: "demo",
|
|
path: "/fake/demo",
|
|
isolationMode: "in-process",
|
|
});
|
|
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
|
|
|
|
const { runProjectAdd } = await import("../project.js");
|
|
await runProjectAdd("demo", testPath, { force: true });
|
|
|
|
// Project should still be registered
|
|
expect(mockRegisterProject).toHaveBeenCalled();
|
|
expect(mockUpdateProject).toHaveBeenCalledWith("proj-1", { status: "active" });
|
|
|
|
// Should show warning about memory failure on console.warn
|
|
const warnOutput = consoleWarnSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
expect(warnOutput).toContain("Could not initialize project memory");
|
|
});
|
|
});
|
|
});
|