- Add onboarding project-mapping API contracts and dashboard UI wiring in AddNodeModal/NodesView - Persist node-to-project mappings with rollback-safe failure handling and expanded hook/API test coverage - Document node onboarding mapping behavior in architecture and multi-project docs - Add tsup bundle asset gates for openclaw bridge and droid runtime, with changesets and bundle output tests Fusion-Task-Id: FN-3505
322 lines
9.9 KiB
TypeScript
322 lines
9.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { useNodes } from "../useNodes";
|
|
import * as api from "../../api";
|
|
import * as nodeApi from "../../api-node";
|
|
import type { NodeInfo, NodeOnboardingInput } from "../../api";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchNodes: vi.fn(),
|
|
registerNode: vi.fn(),
|
|
updateNode: vi.fn(),
|
|
unregisterNode: vi.fn(),
|
|
checkNodeHealth: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../api-node", () => ({
|
|
persistNodeProjectPathMappings: vi.fn(),
|
|
}));
|
|
|
|
const mockFetchNodes = vi.mocked(api.fetchNodes);
|
|
const mockRegisterNode = vi.mocked(api.registerNode);
|
|
const mockUpdateNode = vi.mocked(api.updateNode);
|
|
const mockUnregisterNode = vi.mocked(api.unregisterNode);
|
|
const mockCheckNodeHealth = vi.mocked(api.checkNodeHealth);
|
|
const mockPersistNodeProjectPathMappings = vi.mocked(nodeApi.persistNodeProjectPathMappings);
|
|
|
|
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
|
return {
|
|
id: "node_local",
|
|
name: "Local Node",
|
|
type: "local",
|
|
status: "online",
|
|
capabilities: ["executor"],
|
|
maxConcurrent: 2,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function flushPromises(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe("useNodes", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
mockFetchNodes.mockReset();
|
|
mockRegisterNode.mockReset();
|
|
mockUpdateNode.mockReset();
|
|
mockUnregisterNode.mockReset();
|
|
mockCheckNodeHealth.mockReset();
|
|
mockPersistNodeProjectPathMappings.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("fetches nodes on mount", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.nodes).toHaveLength(1);
|
|
expect(result.current.nodes[0].name).toBe("Local Node");
|
|
});
|
|
|
|
it("handles fetch error gracefully", async () => {
|
|
mockFetchNodes.mockRejectedValueOnce(new Error("boom"));
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.error).toBe("boom");
|
|
});
|
|
|
|
it("register creates node and persists selected path mappings", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
const nodeInput: NodeOnboardingInput = {
|
|
name: "Remote Node",
|
|
type: "remote",
|
|
url: "https://node.test",
|
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
|
|
};
|
|
const createdNode = makeNode({
|
|
id: "node_remote",
|
|
name: "Remote Node",
|
|
type: "remote",
|
|
url: "https://node.test",
|
|
status: "connecting",
|
|
});
|
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
|
mockPersistNodeProjectPathMappings.mockResolvedValueOnce([]);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.register(nodeInput);
|
|
});
|
|
|
|
expect(mockRegisterNode).toHaveBeenCalledWith({
|
|
name: "Remote Node",
|
|
type: "remote",
|
|
url: "https://node.test",
|
|
});
|
|
expect(mockPersistNodeProjectPathMappings).toHaveBeenCalledWith("node_remote", nodeInput.projectMappings);
|
|
expect(mockFetchNodes).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("register rolls back node when mapping write fails", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
|
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
|
mockPersistNodeProjectPathMappings.mockRejectedValueOnce(new Error("mapping failed"));
|
|
mockUnregisterNode.mockResolvedValueOnce(undefined);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
await expect(result.current.register({
|
|
name: "Remote Node",
|
|
type: "remote",
|
|
url: "https://node.test",
|
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
|
|
})).rejects.toThrow("mapping failed");
|
|
|
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_remote");
|
|
expect(mockFetchNodes).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("register appends cleanup failure message when rollback also fails", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
|
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
|
mockPersistNodeProjectPathMappings.mockRejectedValueOnce(new Error("mapping failed"));
|
|
mockUnregisterNode.mockRejectedValueOnce(new Error("cleanup failed"));
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
await expect(result.current.register({
|
|
name: "Remote Node",
|
|
type: "remote",
|
|
url: "https://node.test",
|
|
projectMappings: [{ projectId: "proj-1", path: "/mnt/proj-1" }],
|
|
})).rejects.toThrow("mapping failed. Cleanup also failed: cleanup failed");
|
|
|
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_remote");
|
|
});
|
|
|
|
it("does not rollback when no mappings are selected even if post-success refresh fails", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error("refresh failed"));
|
|
const createdNode = makeNode({ id: "node_remote", type: "remote", status: "connecting" });
|
|
mockRegisterNode.mockResolvedValueOnce(createdNode);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
await act(async () => {
|
|
await expect(result.current.register({
|
|
name: "Remote Node",
|
|
type: "remote",
|
|
url: "https://node.test",
|
|
projectMappings: [],
|
|
})).resolves.toEqual(createdNode);
|
|
});
|
|
|
|
expect(mockPersistNodeProjectPathMappings).not.toHaveBeenCalled();
|
|
expect(mockUnregisterNode).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("update modifies node optimistically", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
|
const updatedNode = makeNode({ name: "Renamed Node", maxConcurrent: 4 });
|
|
mockUpdateNode.mockResolvedValueOnce(updatedNode);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.update("node_local", { name: "Renamed Node", maxConcurrent: 4 });
|
|
});
|
|
|
|
expect(mockUpdateNode).toHaveBeenCalledWith("node_local", { name: "Renamed Node", maxConcurrent: 4 });
|
|
expect(result.current.nodes[0].name).toBe("Renamed Node");
|
|
});
|
|
|
|
it("unregister removes node optimistically", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([makeNode()]);
|
|
mockUnregisterNode.mockResolvedValueOnce(undefined);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(result.current.nodes).toHaveLength(1);
|
|
|
|
await act(async () => {
|
|
await result.current.unregister("node_local");
|
|
});
|
|
|
|
expect(mockUnregisterNode).toHaveBeenCalledWith("node_local");
|
|
expect(result.current.nodes).toHaveLength(0);
|
|
});
|
|
|
|
it("healthCheck updates node status in local state", async () => {
|
|
mockFetchNodes.mockResolvedValueOnce([makeNode({ status: "offline" })]);
|
|
mockCheckNodeHealth.mockResolvedValueOnce({
|
|
nodeId: "node_local",
|
|
status: "online",
|
|
checkedAt: "2026-01-03T00:00:00.000Z",
|
|
});
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(result.current.nodes[0].status).toBe("offline");
|
|
|
|
await act(async () => {
|
|
await result.current.healthCheck("node_local");
|
|
});
|
|
|
|
expect(mockCheckNodeHealth).toHaveBeenCalledWith("node_local");
|
|
expect(result.current.nodes[0].status).toBe("online");
|
|
expect(result.current.nodes[0].updatedAt).toBe("2026-01-03T00:00:00.000Z");
|
|
});
|
|
|
|
it("refresh manually refetches nodes", async () => {
|
|
mockFetchNodes
|
|
.mockResolvedValueOnce([makeNode({ name: "Before Refresh" })])
|
|
.mockResolvedValueOnce([makeNode({ name: "After Refresh" })]);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
expect(result.current.nodes[0].name).toBe("Before Refresh");
|
|
|
|
await act(async () => {
|
|
await result.current.refresh();
|
|
});
|
|
|
|
expect(result.current.nodes[0].name).toBe("After Refresh");
|
|
});
|
|
|
|
it("refetches when visibility changes back to visible", async () => {
|
|
const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
|
mockFetchNodes
|
|
.mockResolvedValueOnce([makeNode({ name: "Initial" })])
|
|
.mockResolvedValueOnce([makeNode({ name: "Visible Again" })]);
|
|
|
|
const { result } = renderHook(() => useNodes());
|
|
|
|
await act(async () => {
|
|
await flushPromises();
|
|
});
|
|
|
|
Object.defineProperty(document, "visibilityState", {
|
|
value: "hidden",
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
await act(async () => {
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
Object.defineProperty(document, "visibilityState", {
|
|
value: "visible",
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1100);
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current.nodes[0].name).toBe("Visible Again");
|
|
|
|
if (originalVisibilityState) {
|
|
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
|
}
|
|
});
|
|
});
|