diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 9b8851bc..a603b553 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { type ActivityEntry } from "@/types/activity"; @@ -46,7 +47,7 @@ function extractMessageText(body: Record | null): string { return ""; } -export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { +export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const nodes = useCanvasStore((s) => s.nodes); @@ -83,205 +84,215 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { }); }, [open, nodes]); - if (!open) return null; - const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; return ( -
- {/* Backdrop */} -
+ { if (!o) onClose(); }}> + + {/* Overlay replaces the old manual backdrop div */} + - {/* Modal */} -
- {/* Header */} -
-
-

- Conversation Trace -

-

- {entries.length} events across all workspaces -

-
- -
- - {/* Timeline */} -
- {loading && ( -
- Loading trace from all workspaces... + {/* Content wraps the entire centred modal panel */} + + {/* Modal panel */} +
+ {/* Header */} +
+
+ + Conversation Trace + +

+ {entries.length} events across all workspaces +

+
+ + +
- )} - {!loading && entries.length === 0 && ( -
- No activity found -
- )} + {/* Timeline */} +
+ {loading && ( +
+ Loading trace from all workspaces... +
+ )} -
- {entries.map((entry) => { - const time = new Date(entry.created_at).toLocaleTimeString(); - const wsName = resolveName(entry.workspace_id); - const sourceName = resolveName(entry.source_id); - const targetName = resolveName(entry.target_id); - const requestText = extractMessageText(entry.request_body); - const responseText = extractMessageText(entry.response_body); - const isError = entry.status === "error"; - const isSend = entry.activity_type === "a2a_send"; - const isReceive = entry.activity_type === "a2a_receive"; + {!loading && entries.length === 0 && ( +
+ No activity found +
+ )} - return ( -
- {/* Event header */} -
- {/* Timeline dot + line */} -
-
-
-
+
+ {entries.map((entry) => { + const time = new Date(entry.created_at).toLocaleTimeString(); + const wsName = resolveName(entry.workspace_id); + const sourceName = resolveName(entry.source_id); + const targetName = resolveName(entry.target_id); + const requestText = extractMessageText(entry.request_body); + const responseText = extractMessageText(entry.response_body); + const isError = entry.status === "error"; + const isSend = entry.activity_type === "a2a_send"; + const isReceive = entry.activity_type === "a2a_receive"; - {/* Content */} -
-
- - {time} - - - {isSend - ? "SEND" - : isReceive - ? "RECEIVE" - : entry.activity_type.toUpperCase()} - - {entry.duration_ms != null && entry.duration_ms > 0 && ( - - {entry.duration_ms > 1000 - ? `${Math.round(entry.duration_ms / 1000)}s` - : `${entry.duration_ms}ms`} - - )} -
+ return ( +
+ {/* Event header */} +
+ {/* Timeline dot + line */} +
+
+
+
- {/* Flow */} - {isA2A(entry) && ( -
- {isSend ? ( - - - {sourceName || wsName} - - - - {targetName} - + {/* Content */} +
+
+ + {time} - ) : ( - - - {targetName || wsName} + + {isSend + ? "SEND" + : isReceive + ? "RECEIVE" + : entry.activity_type.toUpperCase()} + + {entry.duration_ms != null && entry.duration_ms > 0 && ( + + {entry.duration_ms > 1000 + ? `${Math.round(entry.duration_ms / 1000)}s` + : `${entry.duration_ms}ms`} - {sourceName && ( - <> - - {" "}← {" "} - + )} +
+ + {/* Flow */} + {isA2A(entry) && ( +
+ {isSend ? ( + - {sourceName} + {sourceName || wsName} - + + + {targetName} + + + ) : ( + + + {targetName || wsName} + + {sourceName && ( + <> + + {" "}← {" "} + + + {sourceName} + + + )} + )} - +
+ )} + + {/* Summary */} + {entry.summary && !isA2A(entry) && ( +
+ {wsName}:{" "} + {entry.summary} +
+ )} + + {/* Error */} + {isError && entry.error_detail && ( +
+ {entry.error_detail.slice(0, 200)} +
+ )} + + {/* Message content — show request and/or response */} + {requestText && ( +
+
+ {isSend ? "Task" : "Request"} +
+
+ {requestText.slice(0, 2000)} + {requestText.length > 2000 && ( + ...({requestText.length} chars) + )} +
+
+ )} + {responseText && ( +
+
Response
+
+ {responseText.slice(0, 2000)} + {responseText.length > 2000 && ( + ...({responseText.length} chars) + )} +
+
)}
- )} - - {/* Summary */} - {entry.summary && !isA2A(entry) && ( -
- {wsName}:{" "} - {entry.summary} -
- )} - - {/* Error */} - {isError && entry.error_detail && ( -
- {entry.error_detail.slice(0, 200)} -
- )} - - {/* Message content — show request and/or response */} - {requestText && ( -
-
- {isSend ? "Task" : "Request"} -
-
- {requestText.slice(0, 2000)} - {requestText.length > 2000 && ( - ...({requestText.length} chars) - )} -
-
- )} - {responseText && ( -
-
Response
-
- {responseText.slice(0, 2000)} - {responseText.length > 2000 && ( - ...({responseText.length} chars) - )} -
-
- )} +
-
-
- ); - })} -
-
+ ); + })} +
+
- {/* Footer */} -
- -
-
-
+ {/* Footer */} +
+ + + +
+
+ + + ); } diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index 52cab350..3b793495 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -153,7 +153,7 @@ export function EmptyState() {
{error && ( -
+
{error}
)} diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index bb9e7516..eac67c65 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -33,6 +33,19 @@ interface Props { // ── Helpers ─────────────────────────────────────────────────────────────────── +/** + * Sanitise a memory key for use in an HTML id attribute. + * HTML IDs must not contain whitespace; many non-alphanumeric characters also + * cause selector or ARIA failures. Replace every non-alphanumeric character + * with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones. + */ +function sanitizeId(key: string): string { + return key + .replace(/[^a-zA-Z0-9]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); if (diff < 60_000) return `${Math.floor(diff / 1000)}s`; @@ -414,6 +427,7 @@ function MemoryEntryRow({ onCancelEdit, onDelete, }: MemoryEntryRowProps) { + const bodyId = `mem-body-${sanitizeId(entry.key)}`; return (
{/* Header row — click to expand/collapse */} @@ -421,6 +435,7 @@ function MemoryEntryRow({ className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors" onClick={onToggle} aria-expanded={isExpanded} + aria-controls={bodyId} > {entry.key} @@ -455,7 +470,12 @@ function MemoryEntryRow({ {/* Expanded body */} {isExpanded && ( -
+
{entry.expires_at && (

Expires: {new Date(entry.expires_at).toLocaleString()} diff --git a/canvas/src/components/OnboardingWizard.tsx b/canvas/src/components/OnboardingWizard.tsx index a0d18bca..1bd68d6b 100644 --- a/canvas/src/components/OnboardingWizard.tsx +++ b/canvas/src/components/OnboardingWizard.tsx @@ -120,8 +120,20 @@ export function OnboardingWizard() { const currentStepIdx = STEPS.findIndex((s) => s.id === step); const currentStep = STEPS[currentStepIdx]; + // Screen-reader labels for each step (announced on step transitions) + const stepLabels: Record = { + welcome: "Onboarding step 1 of 4: Welcome", + "api-key": "Onboarding step 2 of 4: Configure your workspace", + "send-message": "Onboarding step 3 of 4: Send your first message", + done: "Onboarding complete", + }; + return ( -

+
{/* Progress bar */}
+ {/* Polite live region — announces step transitions to screen readers */} +
+ {stepLabels[step] ?? currentStep.title} +
+
{/* Step indicator */}
diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx index 21983eb8..c8b6456e 100644 --- a/canvas/src/components/SidePanel.tsx +++ b/canvas/src/components/SidePanel.tsx @@ -23,6 +23,7 @@ import { summarizeWorkspaceCapabilities } from "@/store/canvas"; const SIDEPANEL_WIDTH_KEY = "molecule:sidepanel-width"; const SIDEPANEL_DEFAULT_WIDTH = 480; const SIDEPANEL_MIN_WIDTH = 320; +const SIDEPANEL_MAX_WIDTH = 800; const TABS: { id: PanelTab; label: string; icon: string }[] = [ { id: "chat", label: "Chat", icon: "◈" }, @@ -72,6 +73,29 @@ export function SidePanel() { document.body.style.userSelect = "none"; }, [width]); + const onResizeKeyDown = useCallback((e: React.KeyboardEvent) => { + const STEP = 16; + let newWidth: number | null = null; + if (e.key === "ArrowLeft") { + e.preventDefault(); + newWidth = Math.min(width + STEP, SIDEPANEL_MAX_WIDTH); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + newWidth = Math.max(width - STEP, SIDEPANEL_MIN_WIDTH); + } else if (e.key === "Home") { + e.preventDefault(); + newWidth = SIDEPANEL_MIN_WIDTH; + } else if (e.key === "End") { + e.preventDefault(); + newWidth = SIDEPANEL_MAX_WIDTH; + } + if (newWidth !== null) { + setWidth(newWidth); + widthRef.current = newWidth; + localStorage.setItem(SIDEPANEL_WIDTH_KEY, String(newWidth)); + } + }, [width]); + useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!dragging.current) return; @@ -111,8 +135,16 @@ export function SidePanel() { > {/* Resize handle */}
{/* Header */}
diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index a4273a05..63684204 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -157,6 +157,7 @@ export function Toolbar() { disabled={stopping} className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50" title={`Stop all running tasks (${counts.activeTasks} active)`} + aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`} > @@ -174,6 +175,7 @@ export function Toolbar() { disabled={restartingAll} className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50" title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`} + aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`} > @@ -315,9 +317,9 @@ export function Toolbar() { function StatusPill({ color, count, label }: { color: string; count: number; label: string }) { return ( -
-
- {count} +
+ ); } @@ -325,24 +327,24 @@ function StatusPill({ color, count, label }: { color: string; count: number; lab function WsStatusPill({ status }: { status: "connected" | "connecting" | "disconnected" }) { if (status === "connected") { return ( -
-
- Live +
+ ); } if (status === "connecting") { return ( -
-
- Reconnecting +
+ ); } return ( -
-
- Offline +
+ ); } diff --git a/canvas/src/components/WorkspaceNode.tsx b/canvas/src/components/WorkspaceNode.tsx index 8b1fd5fc..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 ( - + @@ -344,9 +344,6 @@ function TeamMemberChip({ return (
{ e.stopPropagation(); @@ -357,13 +354,6 @@ function TeamMemberChip({ e.stopPropagation(); useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data }); }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - onSelect(node.id); - } - }} > {/* Status gradient bar */}
@@ -387,14 +377,15 @@ function TeamMemberChip({ {tierCfg.label}
diff --git a/canvas/src/components/__tests__/AuthGate.test.tsx b/canvas/src/components/__tests__/AuthGate.test.tsx index 7f581769..656a7701 100644 --- a/canvas/src/components/__tests__/AuthGate.test.tsx +++ b/canvas/src/components/__tests__/AuthGate.test.tsx @@ -8,23 +8,29 @@ afterEach(() => { }); // ── Mocks (defined before dynamic import of component) ─────────────────────── -let mockFetchSession: ReturnType; -let mockRedirectToLogin: ReturnType; -let mockGetTenantSlug: ReturnType; +// Use a function type so TypeScript accepts the mock as callable in vi.mock factories. +// ReturnType resolves to Mock in newer Vitest +// type defs, which TS no longer considers directly callable. Casting to a plain +// function type avoids the TS2348 error while keeping full mock API (mockReturnValue etc.). +let mockFetchSession: ((...args: unknown[]) => unknown) & ReturnType; +let mockRedirectToLogin: ((...args: unknown[]) => unknown) & ReturnType; +let mockGetTenantSlug: ((...args: unknown[]) => unknown) & ReturnType; beforeEach(() => { - mockFetchSession = vi.fn(); - mockRedirectToLogin = vi.fn(); - mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through) + mockFetchSession = vi.fn() as typeof mockFetchSession; + mockRedirectToLogin = vi.fn() as typeof mockRedirectToLogin; + mockGetTenantSlug = vi.fn(() => null) as typeof mockGetTenantSlug; }); vi.mock("@/lib/auth", () => ({ - fetchSession: (...args: unknown[]) => mockFetchSession(...args), - redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args), + // Cast required: vi.fn() returns Mock which TypeScript + // won't call directly inside a factory closure (TS2348). Cast to Function resolves it. + fetchSession: (...args: unknown[]) => (mockFetchSession as unknown as (...a: unknown[]) => unknown)(...args), + redirectToLogin: (...args: unknown[]) => (mockRedirectToLogin as unknown as (...a: unknown[]) => unknown)(...args), })); vi.mock("@/lib/tenant", () => ({ - getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args), + getTenantSlug: (...args: unknown[]) => (mockGetTenantSlug as unknown as (...a: unknown[]) => unknown)(...args), })); // Import after mocks are set up diff --git a/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx b/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx new file mode 100644 index 00000000..7983b2fe --- /dev/null +++ b/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +/** + * WCAG 2.1 / Issue M — ConversationTraceModal accessibility + * + * Migrated from custom
to Radix Dialog, which provides: + * - role="dialog" + aria-modal="true" automatically (WCAG 4.1.2) + * - aria-labelledby pointing to Dialog.Title (WCAG 1.3.1) + * - Focus trap (WCAG 2.1.2 / 2.4.3) + * - Escape key closes the dialog (WCAG 2.1.1) + * - ✕ close button has aria-label="Close conversation trace" + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Mocks must be declared before importing the component ──────────────────── + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: (selector: (s: { nodes: unknown[] }) => unknown) => + selector({ nodes: [] }), +})); + +vi.mock("@/hooks/useWorkspaceName", () => ({ + useWorkspaceName: () => () => "Test WS", +})); + +import { ConversationTraceModal } from "../ConversationTraceModal"; + +// Helper: renders the modal in open state with a spy for onClose +function renderOpen() { + const onClose = vi.fn(); + render( + + ); + return { onClose }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Presence / absence +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — dialog presence (Issue M)", () => { + it("dialog is absent when open=false", () => { + render( + + ); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("dialog is present when open=true", () => { + renderOpen(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// ARIA attributes provided by Radix Dialog +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — ARIA attributes (Issue M)", () => { + it("dialog element is accessible via role='dialog' with a non-empty accessible name", () => { + renderOpen(); + // Radix Dialog.Content renders role="dialog" with aria-labelledby pointing + // to Dialog.Title. Verify the role is present and the name is non-empty + // (testing-library computes the accessible name from aria-labelledby). + const dialog = screen.getByRole("dialog", { name: /conversation trace/i }); + expect(dialog).toBeTruthy(); + }); + + it("dialog has aria-labelledby pointing to 'Conversation Trace' title", () => { + renderOpen(); + const dialog = screen.getByRole("dialog"); + const labelledBy = dialog.getAttribute("aria-labelledby"); + expect(labelledBy).toBeTruthy(); + const titleEl = document.getElementById(labelledBy!); + expect(titleEl?.textContent?.trim()).toBe("Conversation Trace"); + }); + + it("dialog has data-state='open' (Radix state attribute)", () => { + renderOpen(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("data-state")).toBe("open"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Close button accessible name +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — close button (Issue M)", () => { + it("✕ close button has aria-label='Close conversation trace'", () => { + renderOpen(); + const closeBtn = screen.getByRole("button", { + name: /close conversation trace/i, + }); + expect(closeBtn).toBeTruthy(); + }); + + it("clicking ✕ button calls onClose", async () => { + const { onClose } = renderOpen(); + const closeBtn = screen.getByRole("button", { + name: /close conversation trace/i, + }); + fireEvent.click(closeBtn); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("footer 'Close' button also closes the dialog", async () => { + const { onClose } = renderOpen(); + const closeBtn = screen.getByRole("button", { name: /^Close$/i }); + fireEvent.click(closeBtn); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Escape key closes the dialog (WCAG 2.1.1 — Keyboard) +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — Escape key (Issue M)", () => { + it("Escape key triggers onClose via Radix onOpenChange", async () => { + const { onClose } = renderOpen(); + // Radix Dialog automatically closes on Escape and fires onOpenChange(false) + // which our handler converts to onClose(). Dispatch on the document so + // Radix's own keydown listener picks it up. + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Empty state +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — loading state (Issue M)", () => { + it("shows loading indicator when dialog opens and fetch is in progress", () => { + renderOpen(); + // After render + effects (flushed by act inside render), loading=true + // because useEffect fired setLoading(true). The loading text should + // be visible at this synchronous point. + expect(screen.getByText(/loading trace from all workspaces/i)).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx b/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx index 432954aa..f1c5b150 100644 --- a/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx +++ b/canvas/src/components/__tests__/ProvisioningTimeout.test.tsx @@ -28,6 +28,7 @@ function makeWS(overrides: Partial & { id: string }): WorkspaceDa y: 0, collapsed: false, runtime: "", + budget_limit: null, ...overrides, }; } 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..058b7cfc --- /dev/null +++ b/canvas/src/components/__tests__/WorkspaceNode.eject.test.tsx @@ -0,0 +1,188 @@ +// @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, + } as WorkspaceNodeData; +} + +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"); + }); +}); diff --git a/canvas/src/components/__tests__/tabs.a11y.test.tsx b/canvas/src/components/__tests__/tabs.a11y.test.tsx new file mode 100644 index 00000000..712555e0 --- /dev/null +++ b/canvas/src/components/__tests__/tabs.a11y.test.tsx @@ -0,0 +1,289 @@ +// @vitest-environment jsdom +/** + * WCAG 1.3.1 — label↔input association tests for SkillsTab, FilesTab, + * ChannelsTab, and ScheduleTab. + * + * Each test verifies that every form control has an accessible name either via: + * - `aria-label` (bare inputs without a visible label element) + * - `htmlFor` + matching `id` wired through `useId()` (label↔control pairs) + * + * `getByLabelText` is the definitive assertion for the htmlFor/id pattern — + * if it resolves, the association is valid per the AT accessibility tree. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +// ── Global mocks (hoisted before imports) ──────────────────────────────────── + +const mockApiGet = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (...args: unknown[]) => mockApiGet(...args), + post: vi.fn().mockResolvedValue({}), + put: vi.fn().mockResolvedValue({}), + del: vi.fn().mockResolvedValue({}), + patch: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: vi.fn((selector: (s: Record) => unknown) => + selector({ setPanelTab: vi.fn() }) + ), + summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })), +})); + +vi.mock("../Toaster", () => ({ showToast: vi.fn() })); + +// FilesTab sub-module stubs — stub them so we control the onNewFile callback +vi.mock("../tabs/FilesTab/FilesToolbar", () => ({ + FilesToolbar: ({ onNewFile }: { onNewFile: () => void }) => ( + + ), +})); +vi.mock("../tabs/FilesTab/FileTree", () => ({ + FileTree: () =>
, +})); +vi.mock("../tabs/FilesTab/FileEditor", () => ({ + FileEditor: () =>
, +})); +vi.mock("../tabs/FilesTab/useFilesApi", () => ({ + useFilesApi: () => ({ + files: [], + loading: false, + loadFiles: vi.fn(), + expandedDirs: new Set(), + loadingDir: null, + toggleDir: vi.fn(), + readFile: vi.fn().mockResolvedValue({ content: "" }), + writeFile: vi.fn().mockResolvedValue({}), + deleteFile: vi.fn().mockResolvedValue({}), + downloadAllFiles: vi.fn(), + uploadFiles: vi.fn(), + deleteAllFiles: vi.fn(), + }), +})); +vi.mock("../tabs/FilesTab/tree", () => ({ + buildTree: vi.fn(() => []), +})); + +vi.mock("../ConfirmDialog", () => ({ + ConfirmDialog: () => null, +})); + +// ── Static imports (after mocks) ───────────────────────────────────────────── + +import { SkillsTab } from "../tabs/SkillsTab"; +import { FilesTab } from "../tabs/FilesTab"; +import { ChannelsTab } from "../tabs/ChannelsTab"; +import { ScheduleTab } from "../tabs/ScheduleTab"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeSkillsData() { + return { + id: "ws-1", + name: "Test WS", + status: "online", + tier: 1, + agentCard: null, + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "http://localhost:9000", + parentId: null, + currentTask: "", + runtime: "langgraph", + needsRestart: false, + budgetLimit: null, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 1. SkillsTab — aria-label on the "Install from source" bare input +// ──────────────────────────────────────────────────────────────────────────── + +describe("SkillsTab — aria-label on bare source input (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockResolvedValue([]); + }); + + it('install source input has aria-label="Install from source URL"', async () => { + render(); + + // The source input is inside the registry section (showRegistry=false initially). + // Click the "+ Install Plugin" button to reveal it. + const installBtn = screen.getByRole("button", { name: /install plugin/i }); + fireEvent.click(installBtn); + + const input = screen.getByRole("textbox", { + name: /install from source url/i, + }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("Install from source URL"); + }); + + it("install source input is a text input (not hidden)", async () => { + render(); + + const installBtn = screen.getByRole("button", { name: /install plugin/i }); + fireEvent.click(installBtn); + + const input = screen.getByRole("textbox", { + name: /install from source url/i, + }); + expect(input.tagName.toLowerCase()).toBe("input"); + expect((input as HTMLInputElement).type).toBe("text"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 2. FilesTab — aria-label on the new file path bare input +// ──────────────────────────────────────────────────────────────────────────── + +describe("FilesTab — aria-label on new file path input (WCAG 1.3.1)", () => { + it('new file input has aria-label="New file path"', () => { + render(); + + // Trigger showNewFile via the FilesToolbar stub + const btn = screen.getByTestId("new-file-btn"); + fireEvent.click(btn); + + const input = screen.getByRole("textbox", { name: /new file path/i }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("New file path"); + }); + + it("new file input is not shown before clicking the new file button", () => { + render(); + + expect(screen.queryByRole("textbox", { name: /new file path/i })).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 3. ChannelsTab — htmlFor/id label associations via useId() +// ──────────────────────────────────────────────────────────────────────────── + +describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockImplementation((url: string) => { + if (url.includes("/channels/adapters")) { + return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]); + } + return Promise.resolve([]); + }); + }); + + async function renderAndOpenForm() { + render(); + await waitFor(() => screen.getByRole("button", { name: /\+ connect/i })); + fireEvent.click(screen.getByRole("button", { name: /\+ connect/i })); + } + + it("Platform label is associated with the select via htmlFor/id", async () => { + await renderAndOpenForm(); + const platformSelect = screen.getByLabelText("Platform"); + expect(platformSelect.tagName.toLowerCase()).toBe("select"); + }); + + it("Bot Token label is associated with the password input via htmlFor/id", async () => { + await renderAndOpenForm(); + const botTokenInput = screen.getByLabelText("Bot Token"); + expect(botTokenInput.tagName.toLowerCase()).toBe("input"); + expect((botTokenInput as HTMLInputElement).type).toBe("password"); + }); + + it("Chat IDs label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + const chatIdInput = screen.getByLabelText("Chat IDs"); + expect(chatIdInput.tagName.toLowerCase()).toBe("input"); + }); + + it("Allowed Users label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + // Label contains "(optional, comma-separated)" in a nested span — use regex + const allowedUsersInput = screen.getByLabelText(/allowed users/i); + expect(allowedUsersInput.tagName.toLowerCase()).toBe("input"); + }); + + it("all form control ids are unique and non-empty", async () => { + await renderAndOpenForm(); + + const platformSelect = screen.getByLabelText("Platform"); + const botTokenInput = screen.getByLabelText("Bot Token"); + const chatIdInput = screen.getByLabelText("Chat IDs"); + const allowedUsersInput = screen.getByLabelText(/allowed users/i); + + const ids = [ + platformSelect.id, + botTokenInput.id, + chatIdInput.id, + allowedUsersInput.id, + ]; + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(4); + ids.forEach((id) => expect(id).toBeTruthy()); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 4. ScheduleTab — aria-label on name + htmlFor/id associations via useId() +// ──────────────────────────────────────────────────────────────────────────── + +describe("ScheduleTab — aria-label + htmlFor/id label associations (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockResolvedValue([]); + }); + + async function renderAndOpenForm() { + render(); + await waitFor(() => screen.getByRole("button", { name: /\+ add schedule/i })); + fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i })); + } + + it('Schedule name input has aria-label="Schedule name"', async () => { + await renderAndOpenForm(); + const nameInput = screen.getByRole("textbox", { name: /^schedule name$/i }); + expect(nameInput.getAttribute("aria-label")).toBe("Schedule name"); + }); + + it("Cron Expression label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + const cronInput = screen.getByLabelText("Cron Expression"); + expect(cronInput.tagName.toLowerCase()).toBe("input"); + expect((cronInput as HTMLInputElement).type).toBe("text"); + }); + + it("Timezone label is associated with the select via htmlFor/id", async () => { + await renderAndOpenForm(); + const timezoneSelect = screen.getByLabelText("Timezone"); + expect(timezoneSelect.tagName.toLowerCase()).toBe("select"); + }); + + it("Prompt / Task label is associated with the textarea via htmlFor/id", async () => { + await renderAndOpenForm(); + const promptTextarea = screen.getByLabelText(/prompt \/ task/i); + expect(promptTextarea.tagName.toLowerCase()).toBe("textarea"); + }); + + it("all form control ids are unique and non-empty", async () => { + await renderAndOpenForm(); + + const cronInput = screen.getByLabelText("Cron Expression"); + const timezoneSelect = screen.getByLabelText("Timezone"); + const promptTextarea = screen.getByLabelText(/prompt \/ task/i); + + const ids = [cronInput.id, timezoneSelect.id, promptTextarea.id]; + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(3); + ids.forEach((id) => expect(id).toBeTruthy()); + }); +}); diff --git a/canvas/src/components/tabs/ActivityTab.tsx b/canvas/src/components/tabs/ActivityTab.tsx index 68d942a6..74f0d781 100644 --- a/canvas/src/components/tabs/ActivityTab.tsx +++ b/canvas/src/components/tabs/ActivityTab.tsx @@ -80,6 +80,7 @@ export function ActivityTab({ workspaceId }: Props) {
-