forked from molecule-ai/molecule-core
Merge pull request #470 from Molecule-AI/fix/aria-time-sensitive-components
fix(a11y): WCAG ARIA fixes for time-sensitive components
This commit is contained in:
commit
d10067697e
@ -54,11 +54,14 @@ export function ApprovalBanner() {
|
||||
{approvals.map((approval) => (
|
||||
<div
|
||||
key={approval.id}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
className="bg-amber-950/90 backdrop-blur-md border border-amber-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 max-w-md animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-amber-300 text-lg">⚠</span>
|
||||
<span className="text-amber-300 text-lg" aria-hidden="true">⚠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
|
||||
|
||||
@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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) */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="bundle-file-input"
|
||||
type="file"
|
||||
accept=".bundle.json"
|
||||
className="sr-only"
|
||||
onChange={handleFileInput}
|
||||
aria-label="Import bundle file"
|
||||
/>
|
||||
|
||||
{/* Invisible drop zone covering the canvas */}
|
||||
<div
|
||||
className="fixed inset-0 z-10 pointer-events-none"
|
||||
@ -76,11 +105,22 @@ export function BundleDropZone() {
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
|
||||
{/* 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) */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Import bundle file"
|
||||
aria-controls="bundle-file-input"
|
||||
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors"
|
||||
>
|
||||
📦 Import bundle
|
||||
</button>
|
||||
|
||||
{/* Visual overlay when dragging */}
|
||||
{isDragging && (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
|
||||
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||
<div className="text-3xl mb-2">📦</div>
|
||||
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
|
||||
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
|
||||
</div>
|
||||
@ -95,9 +135,11 @@ export function BundleDropZone() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result toast */}
|
||||
{/* Result toast — role="status" announces import outcome to screen readers */}
|
||||
{result && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl px-5 py-3 shadow-2xl text-sm ${
|
||||
result.status === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/50 text-emerald-200"
|
||||
|
||||
@ -129,20 +129,19 @@ function CanvasInner() {
|
||||
}, [selectNode]);
|
||||
|
||||
// Team zoom-in: double-click a team node to zoom to its children
|
||||
const { fitBounds, setCenter } = useReactFlow();
|
||||
const { fitBounds, fitView } = useReactFlow();
|
||||
|
||||
// Pan to newly deployed workspace
|
||||
// Pan to newly deployed workspace.
|
||||
// Uses fitView({ nodes }) so the viewport adapts to any current zoom level
|
||||
// instead of forcing zoom=1 (which was jarring when the user was zoomed out).
|
||||
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(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;
|
||||
|
||||
171
canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
Normal file
171
canvas/src/components/__tests__/Canvas.pan-to-node.test.tsx
Normal file
@ -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;
|
||||
}) => (
|
||||
<div role="application" data-testid="react-flow" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
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(<Canvas />);
|
||||
|
||||
// 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(<Canvas />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
161
canvas/src/components/__tests__/aria-time-sensitive.test.tsx
Normal file
161
canvas/src/components/__tests__/aria-time-sensitive.test.tsx
Normal file
@ -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 <input> + 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(<ApprovalBanner />);
|
||||
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(<ApprovalBanner />);
|
||||
// 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(<TerminalTab workspaceId="ws-1" />);
|
||||
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(<TerminalTab workspaceId="ws-1" />);
|
||||
// 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(<BundleDropZone />);
|
||||
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(<BundleDropZone />);
|
||||
// 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(<BundleDropZone />);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@ -121,8 +121,8 @@ export function TerminalTab({ workspaceId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
|
||||
{/* Status bar — role="status" so connection state changes are announced politely */}
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
status === "connected" ? "bg-green-500" :
|
||||
@ -145,9 +145,9 @@ export function TerminalTab({ workspaceId }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{/* Error message — role="alert" announces immediately via assertive live region */}
|
||||
{errorMsg && (
|
||||
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
104
canvas/src/store/__tests__/canvas-events-pan.test.ts
Normal file
104
canvas/src/store/__tests__/canvas-events-pan.test.ts
Normal file
@ -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<WorkspaceNodeData> = {}
|
||||
): Node<WorkspaceNodeData> {
|
||||
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<WSMessage> & { event: string; workspace_id: string }
|
||||
): WSMessage {
|
||||
return { timestamp: new Date().toISOString(), payload: {}, ...overrides };
|
||||
}
|
||||
|
||||
function makeStore(
|
||||
nodes: Node<WorkspaceNodeData>[] = [],
|
||||
edges: Edge[] = []
|
||||
) {
|
||||
const state = { nodes, edges, selectedNodeId: null, agentMessages: {} };
|
||||
const get = () => state;
|
||||
const set = vi.fn((partial: Record<string, unknown>) => { 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user