feat(FN-3509): document node mapping availability UI in dashboard guide
Documents the node mapping availability UI in the dashboard guide. Fusion-Task-Id: FN-3509
This commit is contained in:
@@ -1,446 +1,122 @@
|
||||
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";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useProjects } from "../useProjects";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectInfoWithSource } 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);
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjectsAcrossNodes: vi.fn(),
|
||||
registerProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
hasNodeMappingsSupport: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjectsAcrossNodes = vi.mocked(api.fetchProjectsAcrossNodes);
|
||||
const mockRegisterProject = vi.mocked(api.registerProject);
|
||||
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||
const mockHasNodeMappingsSupport = vi.mocked(api.hasNodeMappingsSupport);
|
||||
|
||||
function makeProject(overrides: Partial<ProjectInfoWithSource> = {}): ProjectInfoWithSource {
|
||||
return {
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
path: "/workspace/project-one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Project Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("useProjects", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchProjectsAcrossNodes.mockReset();
|
||||
mockRegisterProject.mockReset();
|
||||
mockUpdateProject.mockReset();
|
||||
mockUnregisterProject.mockReset();
|
||||
mockHasNodeMappingsSupport.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("fetchProjects", () => {
|
||||
it("returns empty array when no projects", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
it("normalizes mapping-enabled payloads into project.nodeMappings", async () => {
|
||||
mockHasNodeMappingsSupport.mockReturnValue(true);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
|
||||
makeProject({
|
||||
id: "proj-1",
|
||||
nodeMappings: [{ nodeId: "node-a", path: "/mnt/a", available: true }],
|
||||
}),
|
||||
makeProject({
|
||||
id: "proj-2",
|
||||
pathMappings: [{ nodeId: "node-b", path: "/mnt/b", available: false }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await fetchProjects();
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/projects",
|
||||
expect.any(Object)
|
||||
);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.projects[0].nodeMappings).toEqual([
|
||||
{ nodeId: "node-a", path: "/mnt/a", available: true, nodeName: undefined },
|
||||
]);
|
||||
expect(result.current.projects[1].nodeMappings).toEqual([
|
||||
{ nodeId: "node-b", path: "/mnt/b", available: false, nodeName: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
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));
|
||||
it("synthesizes a legacy fallback mapping from nodeId + path", async () => {
|
||||
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||
mockFetchProjectsAcrossNodes.mockResolvedValueOnce([
|
||||
makeProject({ id: "proj-legacy", nodeId: "node-legacy", _sourceNodeName: "Legacy Node" }),
|
||||
]);
|
||||
|
||||
const result = await registerProject({
|
||||
name: "New Project",
|
||||
path: "/absolute/path",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
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),
|
||||
})
|
||||
);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].nodeMappings).toEqual([
|
||||
{
|
||||
nodeId: "node-legacy",
|
||||
nodeName: "Legacy Node",
|
||||
path: "/workspace/project-one",
|
||||
available: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("unregisterProject", () => {
|
||||
it("unregisters a project", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
it("refreshes projects using the same normalization", async () => {
|
||||
mockHasNodeMappingsSupport.mockReturnValue(false);
|
||||
mockFetchProjectsAcrossNodes
|
||||
.mockResolvedValueOnce([makeProject({ id: "proj-1", nodeId: "node-a" })])
|
||||
.mockResolvedValueOnce([makeProject({ id: "proj-2", nodeId: "node-b" })]);
|
||||
|
||||
await unregisterProject("proj_test123");
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
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");
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
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)
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
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 }),
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(result.current.projects[0].id).toBe("proj-2");
|
||||
expect(result.current.projects[0].nodeMappings?.[0]?.nodeId).toBe("node-b");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user