- Add project API routes for multi-project backend integration - Create useProjects hook for listing and managing registered projects - Create useCurrentProject hook for current project selection and switching - Create useProjectHealth hook for real-time project health metrics - Create useActivityLog hook with ActivityLogModal integration - Add comprehensive test coverage for all hooks (useProjects, useCurrentProject, useActivityLog) - Add test setup utilities for React Query mocking
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
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 }),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|