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:
Fusion
2026-05-08 18:40:00 -07:00
committed by gsxdsm
parent 6ab07c27d0
commit eed16abacb
13 changed files with 464 additions and 730 deletions

View File

@@ -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");
});
});