From 0caafb85bc83ff143718f1fa444962c9ac40a9a9 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-UIUX Date: Tue, 12 May 2026 03:45:48 +0000 Subject: [PATCH] test(canvas): ActivityTab + DetailsTab + DropTargetBadge (65 cases) (#647) Co-authored-by: Molecule AI Core-UIUX Co-committed-by: Molecule AI Core-UIUX --- .../src/components/canvas/DropTargetBadge.tsx | 2 + .../canvas/__tests__/DropTargetBadge.test.tsx | 253 +++++++++ .../tabs/__tests__/ActivityTab.test.tsx | 535 ++++++++++++++++++ .../tabs/__tests__/DetailsTab.test.tsx | 459 +++++++++++++++ 4 files changed, 1249 insertions(+) create mode 100644 canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx create mode 100644 canvas/src/components/tabs/__tests__/ActivityTab.test.tsx create mode 100644 canvas/src/components/tabs/__tests__/DetailsTab.test.tsx diff --git a/canvas/src/components/canvas/DropTargetBadge.tsx b/canvas/src/components/canvas/DropTargetBadge.tsx index 13c0f7d4..48e5d8de 100644 --- a/canvas/src/components/canvas/DropTargetBadge.tsx +++ b/canvas/src/components/canvas/DropTargetBadge.tsx @@ -63,6 +63,7 @@ export function DropTargetBadge() { <> {ghostVisible && (
)}
diff --git a/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx b/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx new file mode 100644 index 00000000..da2a13b6 --- /dev/null +++ b/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx @@ -0,0 +1,253 @@ +// @vitest-environment jsdom +/** + * Tests for DropTargetBadge — floating drag affordance rendered over the + * ReactFlow canvas while a workspace node is being dragged onto a parent. + * + * Covers: + * - Renders nothing when dragOverNodeId is null + * - Renders nothing when target node not found in store + * - Renders nothing when getInternalNode returns null + * - Renders ghost slot + badge when valid target is found + * - Ghost hidden when slot falls outside parent bounds + * - Badge text includes the target workspace name + * - Badge positioned via screen-space coordinates from flowToScreenPosition + */ +import React from "react"; +import { render, screen, cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DropTargetBadge } from "../DropTargetBadge"; + +// ─── Mutable store state — hoisted so vi.mock factory closures capture the ref ─ + +let _storeState: { + dragOverNodeId: string | null; + nodes: Array<{ + id: string; + data: Record; + parentId: string | null; + measured?: { width: number; height: number }; + }>; +} = { + dragOverNodeId: null, + nodes: [], +}; + +const _subscribers = new Set<() => void>(); +function _notifySubscribers() { + for (const fn of _subscribers) fn(); +} + +const _mockUseCanvasStore = vi.hoisted(() => { + const impl = (selector: (s: typeof _storeState) => unknown) => selector(_storeState); + return impl; +}); + +// Module-level mutable impl — setFlowMock() swaps it out per test. +let _flowImpl: (arg: { x: number; y: number }) => { x: number; y: number } = + ({ x, y }) => ({ x: x * 2, y: y * 2 }); + +let _flowToScreenPosition = vi.hoisted(() => + vi.fn((arg: { x: number; y: number }) => _flowImpl(arg)), +); + +let _getInternalNode = vi.hoisted(() => + vi.fn<(id: string) => { + internals: { positionAbsolute: { x: number; y: number } }; + measured?: { width: number; height: number }; + } | null>(() => null), +); + +const _mockUseReactFlow = vi.hoisted(() => + vi.fn(() => ({ + getInternalNode: _getInternalNode, + flowToScreenPosition: _flowToScreenPosition, + })), +); + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: _mockUseCanvasStore, +})); + +vi.mock("@xyflow/react", () => ({ + useReactFlow: _mockUseReactFlow, +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function setStore(state: Partial) { + _storeState = { ..._storeState, ...state }; + _notifySubscribers(); +} + +// Helper to set per-test flowToScreenPosition mock — replaces _flowImpl. +function setFlowMock(impl: (arg: { x: number; y: number }) => { x: number; y: number }) { + _flowImpl = impl; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("DropTargetBadge — renders nothing when not dragging", () => { + afterEach(() => { + cleanup(); + _storeState = { dragOverNodeId: null, nodes: [] }; + _getInternalNode.mockReset().mockReturnValue(null); + _flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 }); + }); + + it("returns null when dragOverNodeId is null", () => { + setStore({ dragOverNodeId: null }); + render(); + expect(document.body.textContent).toBe(""); + }); + + it("returns null when target node not found in store nodes array", () => { + setStore({ dragOverNodeId: "ws-target", nodes: [] }); + render(); + expect(document.body.textContent).toBe(""); + }); +}); + +describe("DropTargetBadge — renders nothing when getInternalNode is null", () => { + afterEach(() => { + cleanup(); + _storeState = { dragOverNodeId: null, nodes: [] }; + _getInternalNode.mockReset().mockReturnValue(null); + _flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 }); + }); + + it("returns null when getInternalNode returns null (node not in RF viewport)", () => { + _getInternalNode.mockReturnValue(null); + setStore({ + dragOverNodeId: "ws-target", + nodes: [{ id: "ws-target", data: { name: "Target WS" }, parentId: null }], + }); + render(); + expect(document.body.textContent).toBe(""); + }); +}); + +describe("DropTargetBadge — renders ghost slot + badge for valid drag target", () => { + afterEach(() => { + cleanup(); + _storeState = { dragOverNodeId: null, nodes: [] }; + _getInternalNode.mockReset().mockReturnValue(null); + _flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 }); + }); + + it("renders the drop badge with target name", () => { + _getInternalNode.mockReturnValue({ + internals: { positionAbsolute: { x: 100, y: 200 } }, + measured: { width: 220, height: 120 }, + }); + _flowToScreenPosition + .mockReturnValueOnce({ x: 500, y: 400 }) // slotTL + .mockReturnValueOnce({ x: 900, y: 600 }) // slotBR + .mockReturnValueOnce({ x: 700, y: 200 }); // badge + + setStore({ + dragOverNodeId: "ws-target", + nodes: [ + { id: "ws-target", data: { name: "SEO Workspace" }, parentId: null, measured: { width: 220, height: 120 } }, + ], + }); + render(); + expect(screen.getByText(/Drop into: SEO Workspace/)).toBeTruthy(); + }); + + it("renders the ghost slot div via data-testid", () => { + // measured.height must be large enough that parentBR.y > slotTL.y=330 so + // ghostVisible = (slotTL.y < parentBR.y) is true. + // parentBR.y = abs.y + measured.height = 200 + h > 330 → h > 130 + _getInternalNode.mockReturnValue({ + internals: { positionAbsolute: { x: 100, y: 200 } }, + measured: { width: 220, height: 500 }, + }); + // Component calls flowToScreenPosition 5 times (confirmed via debug): + // 1) badge {x:210, y:200} -> {x:420, y:400} (badge center) + // 2) slotTL {x:116, y:330} -> {x:232, y:660} (slot origin) + // 3) slotBR {x:356, y:460} -> {x:712, y:920} (ghost uses this) + // 4) parentTL {x:100, y:200} -> {x:200, y:400} (parent origin) + // 5) parentBR {x:320, y:320} -> {x:640, y:640} (parent corner) + setFlowMock(({ x, y }: { x: number; y: number }) => { + if (x === 210 && y === 200) return { x: 420, y: 400 }; + if (x === 116 && y === 330) return { x: 232, y: 660 }; + if (x === 356 && y === 460) return { x: 712, y: 920 }; + if (x === 100 && y === 200) return { x: 200, y: 400 }; + // 5th call: parentBR = abs + {w:220, h:500} = {320, 700} + if (x === 320 && y === 700) return { x: 640, y: 1400 }; + return { x: x * 2, y: y * 2 }; + }); + + setStore({ + dragOverNodeId: "ws-target", + nodes: [ + { id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } }, + ], + }); + render(); + expect(screen.getByTestId("ghost-slot")).toBeTruthy(); + // Ghost uses slotBR from 3rd call: slotBR - slotTL = (712-232, 920-660) + expect(screen.getByTestId("ghost-slot").style.left).toBe("232px"); + expect(screen.getByTestId("ghost-slot").style.top).toBe("660px"); + expect(screen.getByTestId("ghost-slot").style.width).toBe("480px"); + expect(screen.getByTestId("ghost-slot").style.height).toBe("260px"); + }); + + it("ghost is hidden when slot falls entirely outside parent bounds", () => { + _getInternalNode.mockReturnValue({ + internals: { positionAbsolute: { x: 100, y: 200 } }, + measured: { width: 220, height: 120 }, + }); + // Set slotBR (3rd call) to be inside parent to hide ghost. + // slotBR.x ≤ parentTL.x makes slotBR.x - slotTL.x < 0 → ghostVisible = false. + setFlowMock(({ x, y }: { x: number; y: number }) => { + if (x === 210 && y === 200) return { x: 420, y: 400 }; // badge (1st call) + if (x === 116 && y === 330) return { x: 232, y: 660 }; // slotTL (2nd call) + if (x === 356 && y === 460) return { x: 150, y: 460 }; // slotBR (3rd): slotBR.x=150 < parentTL.x=200 → hidden + if (x === 100 && y === 200) return { x: 200, y: 400 }; // parentTL (4th call) + if (x === 320 && y === 320) return { x: 640, y: 640 }; // parentBR (5th call) + return { x: x * 2, y: y * 2 }; + }); + + setStore({ + dragOverNodeId: "ws-target", + nodes: [ + { id: "ws-target", data: { name: "Tiny" }, parentId: null, measured: { width: 220, height: 120 } }, + ], + }); + render(); + // Badge should still render, ghost should not + expect(screen.getByText(/Drop into: Tiny/)).toBeTruthy(); + expect(screen.queryByTestId("ghost-slot")).toBeNull(); + }); + + it("badge is absolutely positioned with left and top from flowToScreenPosition", () => { + _getInternalNode.mockReturnValue({ + internals: { positionAbsolute: { x: 100, y: 200 } }, + measured: { width: 220, height: 120 }, + }); + setFlowMock(({ x, y }: { x: number; y: number }) => { + if (x === 210 && y === 200) return { x: 420, y: 400 }; + if (x === 116 && y === 330) return { x: 232, y: 660 }; + if (x === 356 && y === 460) return { x: 712, y: 920 }; + if (x === 100 && y === 200) return { x: 200, y: 400 }; + if (x === 320 && y === 320) return { x: 640, y: 640 }; + return { x: x * 2, y: y * 2 }; + }); + + setStore({ + dragOverNodeId: "ws-target", + nodes: [ + { id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 120 } }, + ], + }); + render(); + expect(screen.getByTestId("drop-badge")).toBeTruthy(); + // Badge uses 1st call: {x:210,y:200} -> {x:420,y:400}, badge.y = 400-6 = 394 + expect(screen.getByTestId("drop-badge").style.left).toBe("420px"); + expect(screen.getByTestId("drop-badge").style.top).toBe("394px"); + expect(screen.getByText(/Drop into: Target/)).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/ActivityTab.test.tsx b/canvas/src/components/tabs/__tests__/ActivityTab.test.tsx new file mode 100644 index 00000000..da5c637f --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ActivityTab.test.tsx @@ -0,0 +1,535 @@ +// @vitest-environment jsdom +/** + * Tests for ActivityTab — activity ledger with live updates, filtering, + * expand/collapse, and A2A error hint rendering. + * + * Covers: + * - Loading state + * - Error state (network failure) + * - Empty state (no activities) + * - Activity list rendering (single + multiple) + * - Filter bar: 7 filters, active filter highlighted + * - Each filter updates the rendered list + * - Auto-refresh toggle (Live / Paused) + * - Refresh button calls API + * - Full Trace button opens ConversationTraceModal + * - Duration display in activity rows + * - Expand/collapse row details + * - A2A rows show source → target name flow + * - Error rows styled differently + * - Error detail shown when expanded + * - getSkills exported function (standalone unit) + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ActivityTab } from "../ActivityTab"; +import type { ActivityEntry } from "@/types/activity"; + +const mockApiGet = vi.fn(); + +const mockUseSocketEvent = vi.fn(); +const mockUseWorkspaceName = vi.fn<(id: string | null) => string>((_id: string | null) => "Test Workspace"); +const mockConversationTraceModal = vi.fn(() => null); +const mockConversationTraceModalRender = vi.fn( + ({ open }: { open: boolean }) => (open ?
Trace
: null), +); + +vi.mock("@/hooks/useSocketEvent", () => ({ + useSocketEvent: (...args: unknown[]) => mockUseSocketEvent(...args), +})); + +vi.mock("@/hooks/useWorkspaceName", () => ({ + useWorkspaceName: () => mockUseWorkspaceName, +})); + +vi.mock("@/components/ConversationTraceModal", () => ({ + ConversationTraceModal: (props: { open: boolean; onClose: () => void; workspaceId: string }) => + props.open ?
Trace
: null, +})); + +vi.mock("@/lib/api", () => ({ + api: { get: (...args: unknown[]) => mockApiGet(...args) }, +})); + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +function activity(overrides: Partial = {}): ActivityEntry { + return { + id: "act-1", + workspace_id: "ws-1", + activity_type: "agent_log", + source_id: null, + target_id: null, + method: null, + summary: null, + request_body: null, + response_body: null, + duration_ms: null, + status: "ok", + error_detail: null, + created_at: new Date(Date.now() - 60_000).toISOString(), + ...overrides, + }; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +async function flush() { + await act(async () => { await Promise.resolve(); }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +describe("ActivityTab — loading / error / empty", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("shows loading state initially", () => { + mockApiGet.mockImplementation(() => new Promise(() => {})); + render(); + expect(screen.getByText("Loading activity...")).toBeTruthy(); + }); + + it("shows error banner when API fails", async () => { + mockApiGet.mockRejectedValue(new Error("network failure")); + render(); + await flush(); + expect(screen.getByText(/network failure/i)).toBeTruthy(); + }); + + it("shows empty state when no activities", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + expect(screen.getByText("No activity recorded yet")).toBeTruthy(); + }); +}); + +describe("ActivityTab — list rendering", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders a single activity row", async () => { + mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]); + render(); + await flush(); + expect(screen.getByText("LOG")).toBeTruthy(); + }); + + it("renders multiple activity rows", async () => { + mockApiGet.mockResolvedValue([ + activity({ id: "a1", activity_type: "agent_log" }), + activity({ id: "a2", activity_type: "task_update" }), + ]); + render(); + await flush(); + expect(screen.getByText("LOG")).toBeTruthy(); + expect(screen.getByText("TASK")).toBeTruthy(); + }); + + it("shows duration when duration_ms is present", async () => { + mockApiGet.mockResolvedValue([ + activity({ id: "a1", duration_ms: 1234, activity_type: "agent_log" }), + ]); + render(); + await flush(); + expect(screen.getByText("1234ms")).toBeTruthy(); + }); + + it("shows summary text when present", async () => { + mockApiGet.mockResolvedValue([ + activity({ id: "a1", summary: "Delegated task to SEO Agent", activity_type: "a2a_send" }), + ]); + render(); + await flush(); + expect(screen.getByText(/Delegated task to SEO Agent/)).toBeTruthy(); + }); +}); + +describe("ActivityTab — filter bar", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders all 7 filter buttons", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + expect(screen.getByRole("button", { name: /all/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /a2a in/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /a2a out/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /tasks/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /skill promo/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /logs/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /errors/i })).toBeTruthy(); + }); + + it("active filter has aria-pressed=true", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + const allBtn = screen.getByRole("button", { name: /all/i }); + expect(allBtn.getAttribute("aria-pressed")).toBe("true"); + }); + + it("clicking a filter updates aria-pressed and re-fetches", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + const errorsBtn = screen.getByRole("button", { name: /errors/i }); + await act(async () => { errorsBtn.click(); }); + await flush(); + expect(errorsBtn.getAttribute("aria-pressed")).toBe("true"); + // API was called with ?type=error + expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity?type=error"); + }); + + it("clicking All removes the type query param", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + // First click a specific filter + const errorsBtn = screen.getByRole("button", { name: /errors/i }); + await act(async () => { errorsBtn.click(); }); + await flush(); + // Then click All + const allBtn = screen.getByRole("button", { name: /all/i }); + await act(async () => { allBtn.click(); }); + await flush(); + expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity"); + }); +}); + +describe("ActivityTab — auto-refresh toggle", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders Live by default", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + expect(screen.getByText("⟳ Live")).toBeTruthy(); + }); + + it("clicking Live toggles to Paused", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + const liveBtn = screen.getByText("⟳ Live"); + await act(async () => { liveBtn.click(); }); + await flush(); + expect(screen.getByText("⟳ Paused")).toBeTruthy(); + }); + + it("clicking Paused toggles back to Live", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + const liveBtn = screen.getByText("⟳ Live"); + await act(async () => { liveBtn.click(); }); + await flush(); + const pausedBtn = screen.getByText("⟳ Paused"); + await act(async () => { pausedBtn.click(); }); + await flush(); + expect(screen.getByText("⟳ Live")).toBeTruthy(); + }); +}); + +describe("ActivityTab — refresh button", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("Refresh calls the API", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + const refreshBtn = screen.getByRole("button", { name: /refresh/i }); + await act(async () => { refreshBtn.click(); }); + await flush(); + // loadActivities called again (second call) + expect(mockApiGet.mock.calls.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("ActivityTab — Full Trace button", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("Full Trace button opens the trace modal", async () => { + mockApiGet.mockResolvedValue([]); + render(); + await flush(); + const traceBtn = screen.getByRole("button", { name: /full trace/i }); + await act(async () => { traceBtn.click(); }); + await flush(); + expect(screen.getByTestId("trace-modal")).toBeTruthy(); + }); +}); + +describe("ActivityTab — row expand / collapse", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("row is collapsed by default (shows ▶)", async () => { + mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]); + render(); + await flush(); + expect(screen.getByText("▶")).toBeTruthy(); + }); + + it("clicking a row expands it (shows ▼)", async () => { + mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]); + render(); + await flush(); + const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement; + await act(async () => { rowBtn.click(); }); + await flush(); + expect(screen.getByText("▼")).toBeTruthy(); + }); + + it("clicking expanded row collapses it", async () => { + mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]); + render(); + await flush(); + const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement; + await act(async () => { rowBtn.click(); }); // expand + await flush(); + await act(async () => { rowBtn.click(); }); // collapse + await flush(); + expect(screen.getByText("▶")).toBeTruthy(); + }); +}); + +describe("ActivityTab — A2A rows with source/target", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + mockUseWorkspaceName.mockImplementation((id: string | null) => { + if (id === "ws-agent-1") return "Alice Agent"; + if (id === "ws-agent-2") return "Bob Agent"; + return "Unknown"; + }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("shows source → target for a2a_receive rows", async () => { + mockApiGet.mockResolvedValue([ + activity({ + id: "a1", + activity_type: "a2a_receive", + source_id: "ws-agent-1", + target_id: "ws-agent-2", + method: "message/send", + }), + ]); + render(); + await flush(); + expect(screen.getByText("Alice Agent")).toBeTruthy(); + expect(screen.getByText("→")).toBeTruthy(); + expect(screen.getByText("Bob Agent")).toBeTruthy(); + }); + + it("shows A2A OUT badge for a2a_send rows", async () => { + mockApiGet.mockResolvedValue([ + activity({ + id: "a1", + activity_type: "a2a_send", + source_id: "ws-agent-1", + target_id: "ws-agent-2", + }), + ]); + render(); + await flush(); + expect(screen.getByText("A2A OUT")).toBeTruthy(); + }); +}); + +describe("ActivityTab — error rows", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("error status row renders with ERROR badge", async () => { + mockApiGet.mockResolvedValue([ + activity({ id: "a1", activity_type: "error", status: "error" }), + ]); + render(); + await flush(); + expect(screen.getByText("ERROR")).toBeTruthy(); + }); + + it("error detail is shown when row is expanded", async () => { + mockApiGet.mockResolvedValue([ + activity({ + id: "a1", + activity_type: "error", + status: "error", + error_detail: "Connection refused", + duration_ms: null, + }), + ]); + render(); + await flush(); + const rowBtn = screen.getByText("ERROR").closest("button") as HTMLButtonElement; + await act(async () => { rowBtn.click(); }); + await flush(); + // Text appears twice: collapsed-row preview + expanded detail section + expect(screen.getAllByText("Connection refused")).toHaveLength(2); + }); +}); + +describe("ActivityTab — type badge rendering", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders correct badge text for each type", async () => { + const types: ActivityEntry["activity_type"][] = [ + "a2a_receive", "a2a_send", "task_update", "skill_promotion", "agent_log", "error", + ]; + const entries = types.map((t, i) => + activity({ id: `a${i}`, activity_type: t }), + ); + mockApiGet.mockResolvedValue(entries); + render(); + await flush(); + expect(screen.getByText("A2A IN")).toBeTruthy(); + expect(screen.getByText("A2A OUT")).toBeTruthy(); + expect(screen.getByText("TASK")).toBeTruthy(); + expect(screen.getByText("PROMO")).toBeTruthy(); + expect(screen.getByText("LOG")).toBeTruthy(); + expect(screen.getByText("ERROR")).toBeTruthy(); + }); +}); + +describe("ActivityTab — count display", () => { + beforeEach(() => { + mockApiGet.mockReset(); + mockUseSocketEvent.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("shows count with 'activities' label when filter=all", async () => { + mockApiGet.mockResolvedValue([ + activity({ id: "a1" }), + activity({ id: "a2" }), + ]); + render(); + await flush(); + expect(screen.getByText(/2 activities/)).toBeTruthy(); + }); + + it("shows count with filter label when non-all filter selected", async () => { + mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "error" })]); + render(); + await flush(); + const errorsBtn = screen.getByRole("button", { name: /errors/i }); + await act(async () => { errorsBtn.click(); }); + await flush(); + expect(screen.getByText(/1 error entries/)).toBeTruthy(); + }); +}); + +describe("getSkills — unit", () => { + it("returns empty array for null card", async () => { + const { getSkills } = await import("../DetailsTab"); + expect(getSkills(null)).toEqual([]); + }); + + it("returns empty array when skills is not an array", async () => { + const { getSkills } = await import("../DetailsTab"); + expect(getSkills({ name: "test" } as Record)).toEqual([]); + }); + + it("extracts skill ids and descriptions", async () => { + const { getSkills } = await import("../DetailsTab"); + const card = { + skills: [ + { id: "web-search", description: "Search the web" }, + { name: "code-interpreter" }, + { id: "analytics" }, + ], + }; + const result = getSkills(card as Record); + expect(result).toEqual([ + { id: "web-search", description: "Search the web" }, + { id: "code-interpreter" }, + { id: "analytics" }, + ]); + }); + + it("filters out skills with no id or name", async () => { + const { getSkills } = await import("../DetailsTab"); + const card = { skills: [{ description: "no id" }, { id: "valid" }] }; + expect(getSkills(card as Record)).toEqual([{ id: "valid" }]); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/DetailsTab.test.tsx b/canvas/src/components/tabs/__tests__/DetailsTab.test.tsx new file mode 100644 index 00000000..7b3bb053 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/DetailsTab.test.tsx @@ -0,0 +1,459 @@ +// @vitest-environment jsdom +/** + * Tests for DetailsTab — workspace detail panel with editable fields, + * delete/restart workflows, peers list, error display, and section + * composition. + * + * Covers: + * - View mode: all rows rendered (name, role, tier, status, URL, etc.) + * - Edit mode: name/role/tier fields become editable + * - Save workflow: calls PATCH and updates store + * - Cancel: reverts fields to original data + * - Delete: two-step confirm (confirm button shows alertdialog) + * - Delete confirm: calls DELETE and removes node from store + * - Restart button: calls POST /restart for failed/degraded/offline + * - Error section: shown for failed/degraded with lastSampleError + * - Skills section: rendered when agentCard has skills + * - Peers section: loads and displays peer list + * - Peers section: empty state when offline + * - ConsoleModal: opens/closes via button click + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DetailsTab } from "../DetailsTab"; +import type { WorkspaceNodeData } from "@/store/canvas"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), + patch: vi.fn(), + del: vi.fn(), + post: vi.fn(), +})); + +const mockUpdateNodeData = vi.hoisted(() => vi.fn()); +const mockRemoveSubtree = vi.hoisted(() => vi.fn()); +const mockSelectNode = vi.hoisted(() => vi.fn()); + +const mockUseCanvasStore = vi.hoisted(() => { + const fn = (selector: (s: { + updateNodeData: typeof mockUpdateNodeData; + removeSubtree: typeof mockRemoveSubtree; + selectNode: typeof mockSelectNode; + }) => unknown) => + selector({ + updateNodeData: mockUpdateNodeData, + removeSubtree: mockRemoveSubtree, + selectNode: mockSelectNode, + }); + return fn; +}); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: mockUseCanvasStore, +})); + +vi.mock("@/lib/api", () => ({ + api: mockApi, +})); + +vi.mock("@/components/BudgetSection", () => ({ + BudgetSection: () =>
BudgetSection
, +})); + +vi.mock("@/components/WorkspaceUsage", () => ({ + WorkspaceUsage: () =>
WorkspaceUsage
, +})); + +vi.mock("@/components/ConsoleModal", () => ({ + ConsoleModal: ({ open, onClose }: { open: boolean; onClose: () => void; workspaceId: string; workspaceName: string }) => + open ? ( +
+ +
+ ) : null, +})); + +// ─── Fixtures ─────────────────────────────────────────────────────────────── + +const baseData: WorkspaceNodeData = { + name: "Test Workspace", + status: "online", + tier: 2, + url: "https://test.molecules.ai", + parentId: null, + activeTasks: 0, + agentCard: null, +} as WorkspaceNodeData; + +function data(overrides: Partial = {}): WorkspaceNodeData { + return { ...baseData, ...overrides } as WorkspaceNodeData; +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +async function flush() { + await act(async () => { await Promise.resolve(); }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +describe("DetailsTab — view mode", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockUpdateNodeData.mockReset(); + mockRemoveSubtree.mockReset(); + mockSelectNode.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders name, role, tier, status, URL, parent rows", () => { + render(); + expect(screen.getByText("Test Workspace")).toBeTruthy(); + expect(screen.getByText("SEO Specialist")).toBeTruthy(); + expect(screen.getByText("T2")).toBeTruthy(); + expect(screen.getByText("online")).toBeTruthy(); + expect(screen.getByText("https://example.com")).toBeTruthy(); + expect(screen.getByText("root")).toBeTruthy(); + }); + + it("renders Edit button", () => { + render(); + expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy(); + }); + + it("renders BudgetSection and WorkspaceUsage", () => { + render(); + expect(screen.getByTestId("budget-section")).toBeTruthy(); + expect(screen.getByTestId("workspace-usage")).toBeTruthy(); + }); + + it("renders Restart button for failed status", () => { + render(); + expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy(); + }); + + it("renders Restart button for offline status", () => { + render(); + expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy(); + }); + + it("renders Restart button for degraded status", () => { + render(); + expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy(); + }); + + it("does not render Restart for online status", () => { + render(); + expect(screen.queryByRole("button", { name: /restart|retry/i })).toBeNull(); + }); + + it("renders error section for failed status with lastSampleError", () => { + render( + , + ); + expect(screen.getByTestId("details-error-log")).toBeTruthy(); + expect(screen.getByText(/ModuleNotFoundError/)).toBeTruthy(); + }); + + it("renders error rate for degraded status", () => { + render(); + expect(screen.getByText(/15%/)).toBeTruthy(); + }); + + it("renders Delete Workspace button in Danger Zone", () => { + render(); + expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy(); + }); +}); + +describe("DetailsTab — edit mode", () => { + beforeEach(() => { + mockApi.patch.mockReset(); + mockUpdateNodeData.mockReset(); + mockApi.get.mockResolvedValue([]); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("clicking Edit shows form fields", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /edit/i })); + expect(screen.getByLabelText(/name/i)).toBeTruthy(); + expect(screen.getByLabelText(/role/i)).toBeTruthy(); + expect(screen.getByLabelText(/tier/i)).toBeTruthy(); + }); + + it("Edit form pre-fills current values", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /edit/i })); + expect((screen.getByLabelText(/name/i) as HTMLInputElement).value).toBe("My WS"); + expect((screen.getByLabelText(/role/i) as HTMLInputElement).value).toBe("Coder"); + }); + + it("Save calls PATCH and exits edit mode", async () => { + mockApi.patch.mockResolvedValue({}); + render(); + fireEvent.click(screen.getByRole("button", { name: /edit/i })); + await flush(); + const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "Renamed WS" } }); + await flush(); + // Use scoped search: BudgetSection also has a Save button + const saveBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Save" && !b.getAttribute("data-testid"), + ) as HTMLButtonElement; + fireEvent.click(saveBtn); + await flush(); + expect(mockApi.patch).toHaveBeenCalledWith( + "/workspaces/ws-1", + expect.objectContaining({ name: "Renamed WS" }), + ); + expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", expect.objectContaining({ name: "Renamed WS" })); + // Edit fields should no longer be visible + expect(screen.queryByLabelText(/name/i)).toBeNull(); + }); + + it("Cancel reverts to view mode without saving", async () => { + mockApi.patch.mockResolvedValue({}); + render(); + fireEvent.click(screen.getByRole("button", { name: /edit/i })); + await flush(); + const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: "Changed" } }); + await flush(); + const cancelBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Cancel" && !b.getAttribute("data-testid"), + ) as HTMLButtonElement; + fireEvent.click(cancelBtn); + await flush(); + expect(mockApi.patch).not.toHaveBeenCalled(); + expect(screen.getByText("Original")).toBeTruthy(); + expect(screen.queryByLabelText(/name/i)).toBeNull(); + }); + + it("Save shows error banner on failure", async () => { + mockApi.patch.mockRejectedValue(new Error("Server error")); + render(); + fireEvent.click(screen.getByRole("button", { name: /edit/i })); + await flush(); + const saveBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Save" && !b.getAttribute("data-testid"), + ) as HTMLButtonElement; + fireEvent.click(saveBtn); + await flush(); + expect(screen.getByText(/server error/i)).toBeTruthy(); + }); +}); + +describe("DetailsTab — delete workflow", () => { + beforeEach(() => { + mockApi.del.mockReset(); + mockRemoveSubtree.mockReset(); + mockSelectNode.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("clicking Delete shows confirm dialog", async () => { + render(); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /delete workspace/i })); + await flush(); + expect(screen.getByRole("alertdialog")).toBeTruthy(); + expect(screen.getByText(/confirm deletion/i)).toBeTruthy(); + }); + + it("confirming delete calls DELETE and removes node from store", async () => { + mockApi.del.mockResolvedValue(undefined); + render(); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /delete workspace/i })); + await flush(); + // Radix ConfirmDialog uses dispatchEvent with bubbling click + const confirmBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Confirm Delete", + ) as HTMLButtonElement; + fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true })); + await flush(); + expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true"); + expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1"); + expect(mockSelectNode).toHaveBeenCalledWith(null); + }); + + it("cancelling delete returns to view mode", async () => { + mockApi.del.mockResolvedValue(undefined); + render(); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /delete workspace/i })); + await flush(); + const cancelBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === "Cancel", + ) as HTMLButtonElement; + fireEvent(cancelBtn, new MouseEvent("click", { bubbles: true })); + await flush(); + expect(screen.queryByRole("alertdialog")).toBeNull(); + expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy(); + }); +}); + +describe("DetailsTab — restart workflow", () => { + beforeEach(() => { + mockApi.post.mockReset(); + mockUpdateNodeData.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("Restart button calls POST /restart and sets status to provisioning", async () => { + mockApi.post.mockResolvedValue(undefined); + render(); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + await flush(); + expect(mockApi.post).toHaveBeenCalledWith("/workspaces/ws-1/restart", {}); + expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", { status: "provisioning" }); + }); + + it("Restart shows error on failure", async () => { + mockApi.post.mockRejectedValue(new Error("Restart failed")); + render(); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /restart/i })); + await flush(); + expect(screen.getByText(/restart failed/i)).toBeTruthy(); + }); +}); + +describe("DetailsTab — peers section", () => { + beforeEach(() => { + mockApi.get.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("loads peers from API", async () => { + mockApi.get.mockResolvedValue([ + { id: "p1", name: "Alice Agent", role: "seo", status: "online", tier: 2 }, + { id: "p2", name: "Bob Agent", role: null, status: "offline", tier: 3 }, + ]); + render(); + await flush(); + expect(screen.getByText("Alice Agent")).toBeTruthy(); + expect(screen.getByText("Bob Agent")).toBeTruthy(); + }); + + it("shows 'No reachable peers' when list is empty", async () => { + mockApi.get.mockResolvedValue([]); + render(); + await flush(); + expect(screen.getByText("No reachable peers")).toBeTruthy(); + }); + + it("shows offline message when workspace is not online", async () => { + mockApi.get.mockResolvedValue([]); + render(); + await flush(); + expect(screen.getByText(/only discoverable while the workspace is online/i)).toBeTruthy(); + }); + + it("clicking peer name selects that node", async () => { + mockApi.get.mockResolvedValue([{ id: "p1", name: "Alice Agent", role: null, status: "online", tier: 2 }]); + render(); + await flush(); + fireEvent.click(screen.getByText("Alice Agent")); + await flush(); + expect(mockSelectNode).toHaveBeenCalledWith("p1"); + }); +}); + +describe("DetailsTab — skills section", () => { + beforeEach(() => { + mockApi.get.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("renders skills from agentCard", () => { + render( + , + ); + expect(screen.getByText("web-search")).toBeTruthy(); + expect(screen.getByText("Search the web")).toBeTruthy(); + expect(screen.getByText("code-interpreter")).toBeTruthy(); + }); + + it("does not render Skills section when agentCard is null", () => { + render(); + expect(screen.queryByText("Skills")).toBeNull(); + }); +}); + +describe("DetailsTab — ConsoleModal", () => { + beforeEach(() => { + mockApi.get.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("View console output button opens ConsoleModal", async () => { + render( + , + ); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /view console output/i })); + await flush(); + expect(screen.getByTestId("console-modal")).toBeTruthy(); + }); + + it("Close button closes ConsoleModal", async () => { + render( + , + ); + await flush(); + fireEvent.click(screen.getByRole("button", { name: /view console output/i })); + await flush(); + expect(screen.getByTestId("console-modal")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /close console/i })); + await flush(); + expect(screen.queryByTestId("console-modal")).toBeNull(); + }); +});