diff --git a/canvas/src/components/__tests__/WorkspaceNode.test.tsx b/canvas/src/components/__tests__/WorkspaceNode.test.tsx new file mode 100644 index 00000000..5ade0d14 --- /dev/null +++ b/canvas/src/components/__tests__/WorkspaceNode.test.tsx @@ -0,0 +1,634 @@ +// @vitest-environment jsdom +/** + * Tests for WorkspaceNode component. + * + * 51 test cases covering: + * - render: name, status badge, role chip, tier badge, runtime badge, skills + * - status states: online, offline, provisioning, paused, degraded, failed, + * not_configured — dot color, label, gradient bar + * - interactions: click, shift-click, double-click, context menu, keyboard + * - error/banner: needs-restart banner, restart action, current task + * - layout: hasChildren → larger card + "N sub" badge, collapsed state + * - sub-workspace: parentId → embedded chip rendered via TeamMemberChip + * - a11y: role=button, tabIndex=0, aria-label, aria-pressed + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceNode } from "../WorkspaceNode"; +import { useCanvasStore } from "@/store/canvas"; + +// ─── Mock Toaster ────────────────────────────────────────────────────────────── + +vi.mock("../Toaster", () => ({ + showToast: vi.fn(), +})); + +// ─── Mock API ──────────────────────────────────────────────────────────────── + +const apiPatch = vi.fn().mockResolvedValue(undefined as void); +vi.mock("@/lib/api", () => ({ + api: { + patch: apiPatch, + get: vi.fn(), + post: vi.fn(), + }, +})); + +// ─── Mock Tooltip ──────────────────────────────────────────────────────────── + +vi.mock("../Tooltip", () => ({ + Tooltip: ({ text, children }: { text: string; children: React.ReactNode }) => ( + + {children} + + ), +})); + +// ─── Mock useOrgDeployState ────────────────────────────────────────────────── + +const DEFAULT_DEPLOY = { + isActivelyProvisioning: false, + isDeployingRoot: false, + isLockedChild: false, + descendantProvisioningCount: 0, +}; +vi.mock("@/components/canvas/useOrgDeployState", () => ({ + useOrgDeployState: () => DEFAULT_DEPLOY, +})); + +// ─── Mock OrgCancelButton ─────────────────────────────────────────────────── + +vi.mock("@/components/canvas/OrgCancelButton", () => ({ + OrgCancelButton: () => , +})); + +// ─── Mock React Flow ───────────────────────────────────────────────────────── + +vi.mock("@xyflow/react", () => { + const NodeResizer = ({ + isVisible, + minWidth, + minHeight, + }: { + isVisible: boolean; + minWidth: number; + minHeight: number; + }) => + isVisible ? ( +
+ ) : null; + + const Handle = vi.fn().mockImplementation(({ + type, + position, + "aria-label": ariaLabel, + onKeyDown, + }: { + type: string; + position: string; + "aria-label"?: string; + onKeyDown?: React.KeyboardEvent; + }) => ( +
+ )); + + return { + __esModule: true, + NodeResizer, + Handle, + NodeProps: vi.fn(), + Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" }, + useReactFlow: () => ({}), + }; +}); + +// ─── Shared node data factory ───────────────────────────────────────────────── + +function makeNode(overrides: Partial<{ + name: string; + status: string; + tier: number; + role: string; + agentCard: Record | null; + activeTasks: number; + collapsed: boolean; + parentId: string | null; + currentTask: string; + runtime: string; + needsRestart: boolean; + lastSampleError: string; + lastErrorRate: number; + url: string; + budgetLimit: number | null; +}> = {}): Parameters[0] { + return { + id: "ws-1", + data: { + name: "Test Agent", + status: "online", + tier: 2, + agentCard: null, + activeTasks: 0, + collapsed: false, + role: "assistant", + lastErrorRate: 0, + lastSampleError: "", + url: "http://localhost:8080", + parentId: null, + currentTask: "", + runtime: "langgraph", + needsRestart: false, + budgetLimit: null, + ...overrides, + }, + } as Parameters[0]; +} + +/** Create a node with a specific id (for selection/identity tests). */ +function makeNodeWithId(id: string, overrides?: Parameters[0]): Parameters[0] { + const base = makeNode(overrides); + return { ...base, id }; +} + +// ─── Store mock ───────────────────────────────────────────────────────────── +// Use inline mock pattern (matching BatchActionBar) so Zustand's +// useSyncExternalStore reads from the closure rather than a captured +// module-level reference that may diverge from the actual store state. + +const mockSelectNode = vi.fn(); +const mockToggleNodeSelection = vi.fn(); +const mockOpenContextMenu = vi.fn(); +const mockNestNode = vi.fn().mockResolvedValue(undefined as void); +const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined as void); +const mockSetCollapsed = vi.fn(); +const mockSetSearchOpen = vi.fn(); + +// Mutable snapshot — updated before each render and returned by getState(). +const _storeSnap = { + selectedNodeId: null as string | null, + selectedNodeIds: new Set(), + contextMenu: null, + nodes: [] as Array<{ id: string; data: { parentId?: string | null } }>, + dragOverNodeId: null as string | null, + searchOpen: false, + selectNode: mockSelectNode, + toggleNodeSelection: mockToggleNodeSelection, + openContextMenu: mockOpenContextMenu, + nestNode: mockNestNode, + restartWorkspace: mockRestartWorkspace, + setCollapsed: mockSetCollapsed, + setSearchOpen: mockSetSearchOpen, +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((selector: (s: typeof _storeSnap) => unknown) => selector(_storeSnap)), + { getState: () => _storeSnap } + ), +})) as typeof vi.mock; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Returns the card div button (first button in DOM — before the handles). */ +function cardButton(): HTMLElement { + return screen.getAllByRole("button")[0]; +} + +function dispatchKey(key: string, opts: { + shift?: boolean; + ctrl?: boolean; + meta?: boolean; +} = {}) { + fireEvent.keyDown(cardButton(), { + key, + shiftKey: opts.shift ?? false, + ctrlKey: opts.ctrl ?? false, + metaKey: opts.meta ?? false, + }); +} + +function clickNode(shiftKey = false) { + fireEvent.click(cardButton(), { shiftKey }); +} + +// ─── Setup / Teardown ───────────────────────────────────────────────────────── + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + _storeSnap.selectedNodeId = null; + _storeSnap.selectedNodeIds.clear(); + _storeSnap.nodes = []; + _storeSnap.dragOverNodeId = null; + _storeSnap.contextMenu = null; + apiPatch.mockClear(); + mockSelectNode.mockClear(); + mockToggleNodeSelection.mockClear(); + mockOpenContextMenu.mockClear(); + mockNestNode.mockClear(); + mockRestartWorkspace.mockClear(); + mockSetCollapsed.mockClear(); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// RENDER — name, status, role, tier, runtime, skills +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — render", () => { + it("renders the workspace name", () => { + render(); + expect(screen.getByText("Alice")).toBeTruthy(); + }); + + it("renders the role chip when role is set", () => { + render(); + expect(screen.getByText("analyst")).toBeTruthy(); + }); + + it("does not render role chip when role is empty", () => { + render(); + // The div with line-clamp has no visible text + const chips = screen.queryAllByText(""); + expect(chips).toBeTruthy(); + }); + + it("renders the tier badge", () => { + render(); + expect(screen.getByText("T2")).toBeTruthy(); + }); + + it("renders unknown tier gracefully", () => { + render(); + expect(screen.getByText("T99")).toBeTruthy(); + }); + + it("renders runtime badge when runtime is set", () => { + render(); + expect(screen.getByText("langgraph")).toBeTruthy(); + }); + + it("renders REMOTE badge for external runtime", () => { + render(); + expect(screen.getByText("★ REMOTE")).toBeTruthy(); + }); + + it("does not render runtime badge when runtime is empty", () => { + render(); + // Should not find "langgraph" or any runtime text + expect(screen.queryByText("langgraph")).toBeNull(); + }); + + it("renders skills from agentCard", () => { + render(); + expect(screen.getByText("coding")).toBeTruthy(); + expect(screen.getByText("research")).toBeTruthy(); + }); + + it("renders skill overflow badge when > 4 skills", () => { + render(); + expect(screen.getByText("+1")).toBeTruthy(); + }); + + it("renders current task banner", () => { + render(); + expect(screen.getByText("Running research")).toBeTruthy(); + }); + + it("renders active tasks count", () => { + render(); + expect(screen.getByText("3 tasks")).toBeTruthy(); + }); + + it("renders singular task label for 1 active task", () => { + render(); + expect(screen.getByText("1 task")).toBeTruthy(); + }); + + it("does not render active tasks count when zero", () => { + render(); + const pulses = document.querySelectorAll(".motion-safe\\\\:animate-pulse"); + // No amber pulse dot for task count + expect(screen.queryByText("0 tasks")).toBeNull(); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// STATUS STATES — dot color, label, gradient bar +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — status states", () => { + it("online: shows green dot (label div is empty for online)", () => { + render(); + const dot = document.querySelector(".bg-emerald-400"); + expect(dot).toBeTruthy(); + // For online status, the label div renders as
(no text) — confirmed + // by component: {effectiveStatus !== "online" ?
{label}
:
} + expect(screen.queryByText("Online")).toBeNull(); + }); + + it("offline: shows gray dot and 'Offline' label", () => { + render(); + const dot = document.querySelector(".bg-zinc-500"); + expect(dot).toBeTruthy(); + expect(screen.getByText("Offline")).toBeTruthy(); + }); + + it("provisioning: shows pulsing blue dot and 'Starting' label", () => { + render(); + const dot = document.querySelector(".motion-safe\\:animate-pulse"); + expect(dot).toBeTruthy(); + expect(screen.getByText("Starting")).toBeTruthy(); + }); + + it("paused: shows indigo dot and 'Paused' label", () => { + render(); + const dot = document.querySelector(".bg-indigo-400"); + expect(dot).toBeTruthy(); + expect(screen.getByText("Paused")).toBeTruthy(); + }); + + it("degraded: shows amber dot and 'Degraded' label", () => { + render(); + const dot = document.querySelector(".bg-amber-400"); + expect(dot).toBeTruthy(); + expect(screen.getByText("Degraded")).toBeTruthy(); + }); + + it("degraded: shows last sample error preview", () => { + render(); + expect(screen.getByText("Rate limit exceeded")).toBeTruthy(); + }); + + it("failed: shows red dot and 'Failed' label", () => { + render(); + const dot = document.querySelector(".bg-red-400"); + expect(dot).toBeTruthy(); + expect(screen.getByText("Failed")).toBeTruthy(); + }); + + it("not_configured: shows amber dot and 'Not configured' label", () => { + render(); + expect(screen.getByText("Not configured")).toBeTruthy(); + }); + + it("not_configured: shows configuration error preview", () => { + render(); + expect(screen.getByText("OPENAI_API_KEY missing")).toBeTruthy(); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// INTERACTIONS — click, shift-click, double-click, context menu, keyboard +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — interactions", () => { + it("click calls selectNode with the node id", () => { + _storeSnap.selectedNodeId = null; + render(); + clickNode(); + expect(mockSelectNode).toHaveBeenCalledWith("ws-1"); + }); + + it("click on already-selected node deselects (null)", () => { + _storeSnap.selectedNodeId = "ws-1"; + render(); + clickNode(); + expect(mockSelectNode).toHaveBeenCalledWith(null); + }); + + it("shift-click calls toggleNodeSelection", () => { + render(); + clickNode(true); + expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-2"); + }); + + it("double-click on leaf node does not throw", () => { + _storeSnap.nodes = []; + render(); + expect(() => { + fireEvent.doubleClick(cardButton()); + }).not.toThrow(); + }); + + it("double-click on parent node emits zoom-to-team custom event", () => { + // Simulate a parent with children + _storeSnap.nodes = [ + { id: "ws-child", data: { parentId: "ws-parent" } }, + ]; + render(); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + fireEvent.doubleClick(cardButton()); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "molecule:zoom-to-team" }) + ); + }); + + it("right-click calls openContextMenu with node data", () => { + render(); + fireEvent.contextMenu(cardButton(), { clientX: 100, clientY: 200 }); + expect(mockOpenContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ nodeId: "ws-3" }) + ); + }); + + it("Enter key calls selectNode", () => { + render(); + dispatchKey("Enter"); + expect(mockSelectNode).toHaveBeenCalledWith("ws-kb"); + }); + + it("Space key calls selectNode", () => { + render(); + dispatchKey(" "); + expect(mockSelectNode).toHaveBeenCalledWith("ws-space"); + }); + + it("Shift+Enter calls toggleNodeSelection", () => { + render(); + dispatchKey("Enter", { shift: true }); + expect(mockToggleNodeSelection).toHaveBeenCalledWith("ws-shift"); + }); + + it("ContextMenu key opens context menu", () => { + render(); + dispatchKey("ContextMenu"); + expect(mockOpenContextMenu).toHaveBeenCalled(); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// ERROR / BANNER — needs-restart banner, restart action +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — needs-restart banner", () => { + it("renders restart banner when needsRestart is true and no currentTask", () => { + render(); + expect(screen.getByText("Restart to apply changes")).toBeTruthy(); + }); + + it("does not render restart banner when needsRestart is false", () => { + render(); + expect(screen.queryByText("Restart to apply changes")).toBeNull(); + }); + + it("does not render restart banner when currentTask is present", () => { + render(); + expect(screen.queryByText("Restart to apply changes")).toBeNull(); + }); + + it("clicking restart banner calls restartWorkspace", async () => { + const { useCanvasStore } = await import("@/store/canvas"); + const getState = (useCanvasStore as unknown as { getState: () => typeof _storeSnap }).getState; + getState().restartWorkspace = mockRestartWorkspace; + + render(); + const btn = screen.getByRole("button", { name: /restart to apply/i }); + await act(async () => { + fireEvent.click(btn); + }); + expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-restart"); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// LAYOUT — child chips, "N sub" badge, expand/collapse +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — layout", () => { + it("shows 'N sub' badge when node has children in store", () => { + _storeSnap.nodes = [ + { id: "ws-child-1", data: { parentId: "ws-parent" } }, + { id: "ws-child-2", data: { parentId: "ws-parent" } }, + ]; + render(); + expect(screen.getByText("2 sub")).toBeTruthy(); + }); + + it("shows '1 sub' badge for single child", () => { + _storeSnap.nodes = [ + { id: "ws-child", data: { parentId: "ws-parent" } }, + ]; + render(); + expect(screen.getByText("1 sub")).toBeTruthy(); + }); + + it("no 'sub' badge when node has no children", () => { + _storeSnap.nodes = []; + render(); + expect(screen.queryByText(/\d+ sub/)).toBeNull(); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// SELECTION STATE — visual highlights +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — selection highlights", () => { + it("applies selected class when selectedNodeId matches", () => { + _storeSnap.selectedNodeId = "ws-selected"; + render(); + const el = cardButton(); + // Selected node has border-accent + expect(el.className).toMatch(/border-accent/); + }); + + it("applies batch-selected class when in selectedNodeIds", () => { + _storeSnap.selectedNodeId = "ws-other"; + _storeSnap.selectedNodeIds.add("ws-batch"); + render(); + const el = cardButton(); + // Batch-selected has distinct visual treatment + expect(el.className).toMatch(/border-accent/); + }); + + it("applies drag-target class when dragOverNodeId matches", () => { + _storeSnap.dragOverNodeId = "ws-drag"; + render(); + const el = cardButton(); + expect(el.className).toMatch(/emerald/); + }); +}); + +// ════════════════════════════════════════════════════════════════════════════════ +// ACCESSIBILITY +// ════════════════════════════════════════════════════════════════════════════════ + +describe("WorkspaceNode — a11y", () => { + it("has role=button", () => { + render(); + // Card div has role=button (the handles also do — use cardButton helper) + expect(cardButton()).toBeTruthy(); + }); + + it("has tabIndex=0", () => { + render(); + expect(cardButton().getAttribute("tabIndex")).toBe("0"); + }); + + it("has aria-pressed reflecting selected state", () => { + _storeSnap.selectedNodeId = "ws-1"; + render(); + expect(cardButton().getAttribute("aria-pressed")).toBe("true"); + }); + + it("aria-pressed is false when not selected", () => { + _storeSnap.selectedNodeId = null; + render(); + expect(cardButton().getAttribute("aria-pressed")).toBe("false"); + }); + + it("aria-label includes name and status", () => { + render(); + const el = cardButton(); + expect(el.getAttribute("aria-label")).toMatch(/MyAgent/); + expect(el.getAttribute("aria-label")).toMatch(/online/); + }); + + it("aria-label includes configuration error for misconfigured workspace", () => { + render(); + const el = cardButton(); + expect(el.getAttribute("aria-label")).toMatch(/KEY_MISSING/); + }); + + it("top handle has aria-label for extract action", () => { + render(); + const handles = document.querySelectorAll('[role="button"][data-handle-type="target"]'); + expect(handles[0].getAttribute("aria-label")).toMatch(/Extract/); + }); + + it("bottom handle has aria-label for nest action", () => { + render(); + const handles = document.querySelectorAll('[role="button"][data-handle-type="source"]'); + expect(handles[0].getAttribute("aria-label")).toMatch(/Nest/); + }); +});