- Add node API typings and client methods plus a polling useNodes hook with visibility-triggered refresh - Introduce NodesView with NodeCard, AddNodeModal, and NodeDetailModal to register, inspect, update, health-check, and remove nodes - Wire the Nodes surface into the app shell/header and add project node assignment UI with project card node badges - Add unit coverage for useNodes, NodesView, NodeCard, Header, and ProjectCard behavior, plus node-specific dashboard styling
140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { NodesView } from "../NodesView";
|
|
import type { NodeInfo, ProjectInfo } from "../../api";
|
|
import { useNodes } from "../../hooks/useNodes";
|
|
import { useProjects } from "../../hooks/useProjects";
|
|
|
|
vi.mock("../../hooks/useNodes", () => ({
|
|
useNodes: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../hooks/useProjects", () => ({
|
|
useProjects: vi.fn(),
|
|
}));
|
|
|
|
const mockUseNodes = vi.mocked(useNodes);
|
|
const mockUseProjects = vi.mocked(useProjects);
|
|
|
|
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|
return {
|
|
id: "node-1",
|
|
name: "Primary Node",
|
|
type: "local",
|
|
status: "online",
|
|
maxConcurrent: 2,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function makeUseNodesResult(overrides: Partial<ReturnType<typeof useNodes>> = {}): ReturnType<typeof useNodes> {
|
|
return {
|
|
nodes: [],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
register: vi.fn().mockResolvedValue(makeNode()),
|
|
update: vi.fn().mockResolvedValue(makeNode()),
|
|
unregister: vi.fn().mockResolvedValue(undefined),
|
|
healthCheck: vi.fn().mockResolvedValue(undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockUseProjects.mockReturnValue({
|
|
projects: [],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
register: vi.fn(),
|
|
update: vi.fn(),
|
|
unregister: vi.fn(),
|
|
});
|
|
});
|
|
|
|
describe("NodesView", () => {
|
|
it("renders node cards and stats", () => {
|
|
mockUseProjects.mockReturnValue({
|
|
projects: [makeProject({ nodeId: "node-1" }), makeProject({ id: "proj-2", nodeId: "node-2" })],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
register: vi.fn(),
|
|
update: vi.fn(),
|
|
unregister: vi.fn(),
|
|
});
|
|
|
|
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
|
nodes: [
|
|
makeNode({ id: "node-1", name: "Alpha", status: "online", type: "local" }),
|
|
makeNode({ id: "node-2", name: "Beta", status: "offline", type: "remote", url: "https://beta.node" }),
|
|
],
|
|
}));
|
|
|
|
render(<NodesView addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Alpha")).toBeDefined();
|
|
expect(screen.getByText("Beta")).toBeDefined();
|
|
expect(screen.getByText("2 registered")).toBeDefined();
|
|
expect(screen.getByTestId("nodes-stat-total").textContent).toContain("2");
|
|
expect(screen.getByTestId("nodes-stat-online").textContent).toContain("1");
|
|
expect(screen.getByTestId("nodes-stat-offline").textContent).toContain("1");
|
|
expect(screen.getByTestId("nodes-stat-remote").textContent).toContain("1");
|
|
});
|
|
|
|
it("renders empty state when there are no nodes", () => {
|
|
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
|
|
|
|
render(<NodesView addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("No nodes are registered yet.")).toBeDefined();
|
|
expect(screen.getByText("Add First Node")).toBeDefined();
|
|
});
|
|
|
|
it("opens Add Node modal when Add Node button is clicked", () => {
|
|
mockUseNodes.mockReturnValue(makeUseNodesResult({ nodes: [] }));
|
|
|
|
render(<NodesView addToast={vi.fn()} />);
|
|
|
|
fireEvent.click(screen.getByText("Add Node"));
|
|
expect(screen.getByRole("dialog", { name: "Add Node" })).toBeDefined();
|
|
});
|
|
|
|
it("opens Node Detail modal when a node card is clicked", () => {
|
|
mockUseProjects.mockReturnValue({
|
|
projects: [makeProject({ nodeId: "node-1" })],
|
|
loading: false,
|
|
error: null,
|
|
refresh: vi.fn().mockResolvedValue(undefined),
|
|
register: vi.fn(),
|
|
update: vi.fn(),
|
|
unregister: vi.fn(),
|
|
});
|
|
|
|
mockUseNodes.mockReturnValue(makeUseNodesResult({
|
|
nodes: [makeNode({ id: "node-1", name: "Alpha Node" })],
|
|
}));
|
|
|
|
render(<NodesView addToast={vi.fn()} />);
|
|
|
|
fireEvent.click(screen.getByText("Alpha Node"));
|
|
expect(screen.getByRole("dialog", { name: "Node details for Alpha Node" })).toBeDefined();
|
|
});
|
|
});
|