diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx index ad469de6..9b40be44 100644 --- a/canvas/src/components/WorkspaceNode.tsx +++ b/canvas/src/components/WorkspaceNode.tsx @@ -29,9 +29,9 @@ function useHierarchyInfo(parentId: string) { } /** Eject/extract arrow icon — visually distinct from delete ✕ */ -function EjectIcon() { +function EjectIcon(props: React.SVGProps) { return ( - + @@ -377,14 +377,15 @@ function TeamMemberChip({ {tierCfg.label} diff --git a/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx b/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx new file mode 100644 index 00000000..6e8e50ac --- /dev/null +++ b/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +/** + * Tests for issue #854 — TeamMemberChip eject button: + * - aria-label must be dynamic: `Extract ${childName} from team` + * - title must be dynamic: `Extract ${childName} from team` + * - EjectIcon svg must carry aria-hidden="true" + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import type { Node } from "@xyflow/react"; +import type { WorkspaceNodeData } from "@/store/canvas"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ── Mock @xyflow/react ───────────────────────────────────────────────────────── +vi.mock("@xyflow/react", () => ({ + Handle: () => null, + Position: { Bottom: "bottom", Top: "top" }, + useReactFlow: vi.fn(), +})); + +// ── Mock Toaster ─────────────────────────────────────────────────────────────── +vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() })); + +// ── Mock Tooltip ─────────────────────────────────────────────────────────────── +vi.mock("@/components/Tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// ── Mock design tokens ───────────────────────────────────────────────────────── +vi.mock("@/lib/design-tokens", () => ({ + STATUS_CONFIG: { + online: { label: "Online", dot: "bg-emerald-400", bar: "from-emerald-500/10" }, + offline: { label: "Offline", dot: "bg-zinc-600", bar: "from-zinc-700/10" }, + provisioning: { label: "Provisioning", dot: "bg-sky-400", bar: "from-sky-500/10" }, + degraded: { label: "Degraded", dot: "bg-amber-400", bar: "from-amber-500/10" }, + failed: { label: "Failed", dot: "bg-red-400", bar: "from-red-500/10" }, + paused: { label: "Paused", dot: "bg-zinc-500", bar: "from-zinc-600/10" }, + }, + TIER_CONFIG: { + 1: { label: "T1", color: "text-zinc-400 bg-zinc-800" }, + 2: { label: "T2", color: "text-blue-400 bg-blue-900/40" }, + }, +})); + +// ── Canvas store mock state ──────────────────────────────────────────────────── +const PARENT_ID = "parent-ws"; +const CHILD_ID = "child-ws"; +const CHILD_NAME = "Child Workspace"; + +function makeNodeData(overrides: Partial = {}): WorkspaceNodeData { + return { + name: "Test WS", + role: "agent", + tier: 1, + status: "online", + agentCard: null, + url: "http://localhost:9000", + parentId: null, + activeTasks: 0, + lastErrorRate: 0, + lastSampleError: "", + uptimeSeconds: 60, + currentTask: "", + collapsed: false, + runtime: "", + needsRestart: false, + budgetLimit: null, + ...overrides, + }; +} + +const parentNodeData = makeNodeData({ name: "Parent WS", parentId: null }); +const childNodeData = makeNodeData({ name: CHILD_NAME, parentId: PARENT_ID }); + +const allNodes: Node[] = [ + { id: PARENT_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: parentNodeData }, + { id: CHILD_ID, type: "workspaceNode", position: { x: 0, y: 0 }, data: childNodeData, hidden: true }, +]; + +// Build a selector-compatible mock of useCanvasStore +const mockStoreState = { + nodes: allNodes, + edges: [], + selectedNodeId: null, + panelTab: "chat", + dragOverNodeId: null, + contextMenu: null, + searchOpen: false, + viewport: { x: 0, y: 0, zoom: 1 }, + selectNode: vi.fn(), + openContextMenu: vi.fn(), + nestNode: vi.fn(), + isDescendant: vi.fn(() => false), + restartWorkspace: vi.fn(), + setPanelTab: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState) + ), + { getState: () => mockStoreState } + ), +})); + +// ── Mock zustand/react/shallow ───────────────────────────────────────────────── +vi.mock("zustand/react/shallow", () => ({ + useShallow: (fn: (s: typeof mockStoreState) => unknown) => fn, +})); + +// ── Import component AFTER mocks ─────────────────────────────────────────────── +import { WorkspaceNode } from "../WorkspaceNode"; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +function renderParentNode() { + return render( + + ); +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("TeamMemberChip eject button — aria-label (issue #854)", () => { + it("eject button has a dynamic aria-label containing the child workspace name", () => { + const { container } = renderParentNode(); + const buttons = container.querySelectorAll("button"); + const ejectBtn = Array.from(buttons).find( + (b) => b.getAttribute("aria-label")?.includes("Extract") && b.getAttribute("aria-label")?.includes("from team") + ); + expect(ejectBtn).toBeTruthy(); + expect(ejectBtn?.getAttribute("aria-label")).toBe(`Extract ${CHILD_NAME} from team`); + }); +}); + +describe("TeamMemberChip eject button — title tooltip (issue #854)", () => { + it("eject button has a dynamic title tooltip containing the child workspace name", () => { + const { container } = renderParentNode(); + const buttons = container.querySelectorAll("button"); + const ejectBtn = Array.from(buttons).find( + (b) => b.getAttribute("title")?.includes("Extract") && b.getAttribute("title")?.includes("from team") + ); + expect(ejectBtn).toBeTruthy(); + expect(ejectBtn?.getAttribute("title")).toBe(`Extract ${CHILD_NAME} from team`); + }); + + it("aria-label and title are identical (both use child workspace name)", () => { + const { container } = renderParentNode(); + const buttons = container.querySelectorAll("button"); + const ejectBtn = Array.from(buttons).find( + (b) => b.getAttribute("aria-label")?.startsWith("Extract") + ); + expect(ejectBtn).toBeTruthy(); + expect(ejectBtn?.getAttribute("aria-label")).toBe(ejectBtn?.getAttribute("title")); + }); +}); + +describe("TeamMemberChip eject button — aria-hidden on EjectIcon (issue #854)", () => { + it("EjectIcon svg has aria-hidden='true' to prevent AT double-announcement", () => { + const { container } = renderParentNode(); + const buttons = container.querySelectorAll("button"); + const ejectBtn = Array.from(buttons).find( + (b) => b.getAttribute("aria-label")?.startsWith("Extract") + ); + expect(ejectBtn).toBeTruthy(); + const svg = ejectBtn?.querySelector("svg"); + expect(svg).toBeTruthy(); + expect(svg?.getAttribute("aria-hidden")).toBe("true"); + }); +});