diff --git a/canvas/src/components/ApprovalBanner.tsx b/canvas/src/components/ApprovalBanner.tsx index 152f7997..fd2e148b 100644 --- a/canvas/src/components/ApprovalBanner.tsx +++ b/canvas/src/components/ApprovalBanner.tsx @@ -54,11 +54,14 @@ export function ApprovalBanner() { {approvals.map((approval) => (
- +
{approval.workspace_name} needs approval
diff --git a/canvas/src/components/BundleDropZone.tsx b/canvas/src/components/BundleDropZone.tsx index febfdc08..b17d8ac8 100644 --- a/canvas/src/components/BundleDropZone.tsx +++ b/canvas/src/components/BundleDropZone.tsx @@ -1,42 +1,24 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; import { api } from "@/lib/api"; export function BundleDropZone() { const [isDragging, setIsDragging] = useState(false); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ status: string; name?: string } | null>(null); + const fileInputRef = useRef(null); - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer.types.includes("Files")) { - setIsDragging(true); - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - const file = Array.from(e.dataTransfer.files).find( - (f) => f.name.endsWith(".bundle.json") - ); - - if (!file) { + /** + * Core file processor — shared between drag-drop and keyboard file-picker + * so both code paths have identical import behaviour (WCAG 2.1.1). + */ + const processFile = useCallback(async (file: File) => { + if (!file.name.endsWith(".bundle.json")) { setResult({ status: "error", name: "Only .bundle.json files are accepted" }); setTimeout(() => setResult(null), 3000); return; } - setImporting(true); try { const text = await file.text(); @@ -58,8 +40,55 @@ export function BundleDropZone() { } }, []); + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.types.includes("Files")) { + setIsDragging(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const file = Array.from(e.dataTransfer.files).find( + (f) => f.name.endsWith(".bundle.json") + ); + if (!file) { + setResult({ status: "error", name: "Only .bundle.json files are accepted" }); + setTimeout(() => setResult(null), 3000); + return; + } + await processFile(file); + }, [processFile]); + + const handleFileInput = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ""; // reset so the same file can be re-selected + await processFile(file); + }, [processFile]); + return ( <> + {/* Hidden file input — keyboard / assistive-tech alternative to drag-drop (WCAG 2.1.1) */} + + {/* Invisible drop zone covering the canvas */}
+ {/* Keyboard-accessible import button — visible on focus or hover so + keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */} + + {/* Visual overlay when dragging */} {isDragging && (
-
📦
+
Drop Bundle to Import
.bundle.json files only
@@ -95,9 +135,11 @@ export function BundleDropZone() {
)} - {/* Result toast */} + {/* Result toast — role="status" announces import outcome to screen readers */} {result && (
>(undefined); useEffect(() => { const handler = (e: Event) => { const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail; - // Small delay so ReactFlow has time to lay out the node + // Small delay so ReactFlow has time to measure the newly rendered node clearTimeout(panTimerRef.current); panTimerRef.current = setTimeout(() => { - const node = useCanvasStore.getState().nodes.find((n) => n.id === nodeId); - if (node) { - setCenter(node.position.x + 130, node.position.y + 60, { zoom: 1, duration: 500 }); - } + fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 }); }, 100); }; window.addEventListener("molecule:pan-to-node", handler); @@ -150,7 +149,7 @@ function CanvasInner() { window.removeEventListener("molecule:pan-to-node", handler); clearTimeout(panTimerRef.current); }; - }, [setCenter]); + }, [fitView]); useEffect(() => { const handler = (e: Event) => { const { nodeId } = (e as CustomEvent).detail; diff --git a/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx new file mode 100644 index 00000000..6e175ab4 --- /dev/null +++ b/canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +/** + * Tests that Canvas.tsx responds to the "molecule:pan-to-node" custom event + * (fired by canvas-events.ts on WORKSPACE_PROVISIONING for new nodes) by + * calling fitView({ nodes: [{ id }] }) instead of setCenter with a forced + * zoom=1 (which was jarring when the user was zoomed out — issue #426). + */ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, act, cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ── Shared fitView spy — must be set up before vi.mock hoisting ────────────── +const mockFitView = vi.fn(); +const mockFitBounds = vi.fn(); + +vi.mock("@xyflow/react", () => { + const ReactFlow = ({ + children, + "aria-label": ariaLabel, + }: { + children?: React.ReactNode; + "aria-label"?: string; + }) => ( +
+ {children} +
+ ); + return { + __esModule: true, + default: ReactFlow, + ReactFlow, + ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ), + Background: () => null, + Controls: () => null, + MiniMap: () => null, + BackgroundVariant: { Dots: "dots" }, + useReactFlow: () => ({ + fitView: mockFitView, + fitBounds: mockFitBounds, + setViewport: vi.fn(), + getIntersectingNodes: vi.fn(() => []), + setCenter: vi.fn(), + }), + applyNodeChanges: vi.fn((_: unknown, nodes: unknown) => nodes), + useStore: vi.fn(() => ({ width: 800, height: 600 })), + }; +}); + +// ── Canvas store mock ───────────────────────────────────────────────────────── +const mockStoreState = { + nodes: [{ id: "ws-1", position: { x: 100, y: 100 }, data: { name: "WS1" } }], + edges: [], + selectedNodeId: null, + panelTab: "chat", + dragOverNodeId: null, + contextMenu: null, + viewport: { x: 0, y: 0, zoom: 1 }, + searchOpen: false, + onNodesChange: vi.fn(), + savePosition: vi.fn(), + saveViewport: vi.fn(), + selectNode: vi.fn(), + openContextMenu: vi.fn(), + closeContextMenu: vi.fn(), + setDragOverNode: vi.fn(), + nestNode: vi.fn(), + isDescendant: vi.fn(() => false), + setSearchOpen: vi.fn(), +}; + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn((selector: (s: typeof mockStoreState) => unknown) => + selector(mockStoreState) + ), + { getState: () => mockStoreState } + ), +})); + +vi.mock("@/store/socket", () => ({ + connectSocket: vi.fn(), + disconnectSocket: vi.fn(), +})); + +// ── Stub child components ───────────────────────────────────────────────────── +vi.mock("../Toolbar", () => ({ Toolbar: () => null })); +vi.mock("../SidePanel", () => ({ SidePanel: () => null })); +vi.mock("../EmptyState", () => ({ EmptyState: () => null })); +vi.mock("../ContextMenu", () => ({ ContextMenu: () => null })); +vi.mock("../SearchDialog", () => ({ SearchDialog: () => null })); +vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null })); +vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null })); +vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null })); +vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null })); +vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null })); +vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null })); +vi.mock("../settings", () => ({ + SettingsPanel: () => null, + DeleteConfirmDialog: () => null, +})); +vi.mock("../Toaster", () => ({ Toaster: () => null })); +vi.mock("../WorkspaceNode", () => ({ WorkspaceNode: () => null })); + +import { Canvas } from "../Canvas"; + +// ───────────────────────────────────────────────────────────────────────────── + +describe("Canvas — molecule:pan-to-node event handler", () => { + beforeEach(() => { + mockFitView.mockClear(); + mockFitBounds.mockClear(); + }); + + it("calls fitView with the provisioned nodeId after a 100ms debounce", async () => { + vi.useFakeTimers(); + render(); + + // Simulate the custom event fired by canvas-events.ts on WORKSPACE_PROVISIONING + act(() => { + window.dispatchEvent( + new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-1" } }) + ); + }); + + // fitView should NOT be called yet (100ms debounce) + expect(mockFitView).not.toHaveBeenCalled(); + + // Advance past the 100ms delay + await act(async () => { + vi.advanceTimersByTime(150); + }); + + expect(mockFitView).toHaveBeenCalledOnce(); + const [options] = mockFitView.mock.calls[0]; + expect(options.nodes).toEqual([{ id: "ws-1" }]); + expect(options.duration).toBe(400); + expect(typeof options.padding).toBe("number"); + + vi.useRealTimers(); + }); + + it("debounces rapid successive events — only the last nodeId is fitted", async () => { + vi.useFakeTimers(); + render(); + + act(() => { + window.dispatchEvent( + new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-first" } }) + ); + window.dispatchEvent( + new CustomEvent("molecule:pan-to-node", { detail: { nodeId: "ws-last" } }) + ); + }); + + await act(async () => { + vi.advanceTimersByTime(150); + }); + + // Only one fitView call — the debounce clears the first timer + expect(mockFitView).toHaveBeenCalledOnce(); + expect(mockFitView.mock.calls[0][0].nodes).toEqual([{ id: "ws-last" }]); + + vi.useRealTimers(); + }); +}); diff --git a/canvas/src/components/__tests__/aria-time-sensitive.test.tsx b/canvas/src/components/__tests__/aria-time-sensitive.test.tsx new file mode 100644 index 00000000..d7bf8cc9 --- /dev/null +++ b/canvas/src/components/__tests__/aria-time-sensitive.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +/** + * WCAG 2 audit — time-sensitive component ARIA fixes: + * Fix 1: ApprovalBanner — role="alert" aria-live="assertive" + aria-hidden on ⚠ icon + * Fix 2: TerminalTab — role="status" on connection bar, role="alert" on error + * Fix 3: BundleDropZone — keyboard file-picker (hidden + accessible button) + * + role="status" on result toast + */ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Fix 1 — ApprovalBanner +// ──────────────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn().mockResolvedValue([]), + post: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock("../Toaster", () => ({ showToast: vi.fn() })); + +import { api } from "@/lib/api"; +import { ApprovalBanner } from "../ApprovalBanner"; + +// Stub a minimal approval so the banner renders +const mockApproval = { + id: "a1", + workspace_id: "ws-1", + workspace_name: "PM Agent", + action: "Run deployment script", + reason: "Routine release", + status: "pending", + created_at: new Date().toISOString(), +}; + +describe("ApprovalBanner — ARIA time-sensitive (Fix 1)", () => { + beforeEach(() => { + vi.mocked(api.get).mockResolvedValue([mockApproval]); + }); + + it("renders role='alert' with aria-live='assertive' on each approval card", async () => { + const { findByRole } = render(); + const alert = await findByRole("alert"); + expect(alert.getAttribute("aria-live")).toBe("assertive"); + expect(alert.getAttribute("aria-atomic")).toBe("true"); + }); + + it("⚠ icon span has aria-hidden='true'", async () => { + render(); + // Wait for data + await screen.findByRole("alert"); + // The ⚠ span should be aria-hidden + const hiddenSpans = document.querySelectorAll('[aria-hidden="true"]'); + const warningSpan = Array.from(hiddenSpans).find((el) => + el.textContent?.includes("⚠") + ); + expect(warningSpan).not.toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Fix 2 — TerminalTab +// ──────────────────────────────────────────────────────────────────────────── + +// Mock xterm — not installed in jsdom, just need component to render +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + loadAddon = vi.fn(); + open = vi.fn(); + dispose = vi.fn(); + onData = vi.fn(() => ({ dispose: vi.fn() })); + onResize = vi.fn(() => ({ dispose: vi.fn() })); + writeln = vi.fn(); + write = vi.fn(); + clear = vi.fn(); + options = {}; + }, +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit = vi.fn(); + activate = vi.fn(); + dispose = vi.fn(); + }, +})); +vi.mock("@xterm/addon-web-links", () => ({ + WebLinksAddon: class { activate = vi.fn(); dispose = vi.fn(); }, +})); + +import { TerminalTab } from "../tabs/TerminalTab"; + +describe("TerminalTab — ARIA live regions (Fix 2)", () => { + it("status bar wrapper has role='status' and aria-live='polite'", () => { + render(); + const statusBar = document.querySelector('[role="status"]'); + expect(statusBar).not.toBeNull(); + expect(statusBar?.getAttribute("aria-live")).toBe("polite"); + }); + + it("status bar text changes reflect connection state (content test)", () => { + render(); + // Default state while attempting to connect will show some status text + const statusBar = document.querySelector('[role="status"]'); + expect(statusBar?.textContent?.length).toBeGreaterThan(0); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Fix 3 — BundleDropZone +// ──────────────────────────────────────────────────────────────────────────── + +import { BundleDropZone } from "../BundleDropZone"; + +describe("BundleDropZone — keyboard accessibility (Fix 3)", () => { + it("renders a hidden file input with accept='.bundle.json' and an accessible label", () => { + render(); + const input = document.getElementById("bundle-file-input") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input?.type).toBe("file"); + expect(input?.accept).toBe(".bundle.json"); + expect(input?.getAttribute("aria-label")).toBeTruthy(); + // Must be visually hidden but still reachable by AT + expect(input?.className).toContain("sr-only"); + }); + + it("renders a keyboard-accessible import button that is tabbable", () => { + render(); + // The button may be sr-only but must exist in the DOM and be focusable + const btn = screen.getByRole("button", { name: /import bundle/i }); + expect(btn).not.toBeNull(); + }); + + it("result toast renders with role='status' and aria-live='polite'", async () => { + vi.mocked(api.post).mockResolvedValue({ name: "my-bundle", status: "ok" }); + + render(); + + const input = document.getElementById("bundle-file-input") as HTMLInputElement; + + const file = new File(['{"workspaces":[]}'], "test.bundle.json", { + type: "application/json", + }); + + // Simulate file selection via the hidden input + Object.defineProperty(input, "files", { value: [file], configurable: true }); + await fireEvent.change(input); + + // Toast should appear with role=status + const toast = await screen.findByRole("status"); + expect(toast).not.toBeNull(); + expect(toast.getAttribute("aria-live")).toBe("polite"); + }); +}); diff --git a/canvas/src/components/tabs/TerminalTab.tsx b/canvas/src/components/tabs/TerminalTab.tsx index 6278d377..371a5638 100644 --- a/canvas/src/components/tabs/TerminalTab.tsx +++ b/canvas/src/components/tabs/TerminalTab.tsx @@ -121,8 +121,8 @@ export function TerminalTab({ workspaceId }: Props) { return (
- {/* Status bar */} -
+ {/* Status bar — role="status" so connection state changes are announced politely */} +
- {/* Error message */} + {/* Error message — role="alert" announces immediately via assertive live region */} {errorMsg && ( -
+
{errorMsg}
)} diff --git a/canvas/src/store/__tests__/canvas-events-pan.test.ts b/canvas/src/store/__tests__/canvas-events-pan.test.ts new file mode 100644 index 00000000..cca7c945 --- /dev/null +++ b/canvas/src/store/__tests__/canvas-events-pan.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +/** + * Tests the molecule:pan-to-node CustomEvent dispatch from canvas-events.ts. + * Runs in jsdom because window.dispatchEvent is required. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { handleCanvasEvent, resetProvisioningSequence } from "../canvas-events"; +import type { WSMessage } from "../socket"; +import type { WorkspaceNodeData } from "../canvas"; +import type { Node, Edge } from "@xyflow/react"; + +// ── Helpers (copied from canvas-events.test.ts) ────────────────────────────── + +function makeNode( + id: string, + overrides: Partial = {} +): Node { + return { + id, + type: "workspaceNode", + position: { x: 0, y: 0 }, + data: { + name: `Node-${id}`, + status: "online", + tier: 1, + agentCard: null, + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "http://localhost:9000", + parentId: null, + currentTask: "", + needsRestart: false, + runtime: "", + ...overrides, + }, + }; +} + +function makeMsg( + overrides: Partial & { event: string; workspace_id: string } +): WSMessage { + return { timestamp: new Date().toISOString(), payload: {}, ...overrides }; +} + +function makeStore( + nodes: Node[] = [], + edges: Edge[] = [] +) { + const state = { nodes, edges, selectedNodeId: null, agentMessages: {} }; + const get = () => state; + const set = vi.fn((partial: Record) => { Object.assign(state, partial); }); + return { state, get, set }; +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe("canvas-events – molecule:pan-to-node dispatch", () => { + beforeEach(() => { + resetProvisioningSequence(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("dispatches molecule:pan-to-node with the new nodeId for a NEW provision", () => { + const { get, set } = makeStore([]); + const dispatched: Event[] = []; + const spy = vi.spyOn(window, "dispatchEvent").mockImplementation((e) => { + dispatched.push(e); + return true; + }); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_PROVISIONING", workspace_id: "ws-new", payload: {} }), + get, + set + ); + + expect(dispatched).toHaveLength(1); + expect(dispatched[0].type).toBe("molecule:pan-to-node"); + expect((dispatched[0] as CustomEvent).detail?.nodeId).toBe("ws-new"); + }); + + it("does NOT dispatch molecule:pan-to-node when restarting an existing node", () => { + const { get, set } = makeStore([makeNode("ws-existing")]); + const dispatched: Event[] = []; + const spy = vi.spyOn(window, "dispatchEvent").mockImplementation((e) => { + dispatched.push(e); + return true; + }); + + handleCanvasEvent( + makeMsg({ event: "WORKSPACE_PROVISIONING", workspace_id: "ws-existing", payload: {} }), + get, + set + ); + + expect(dispatched).toHaveLength(0); + }); +});