Loading...
)}
{!loading && templates.length === 0 && (
-
+
No templates found in
workspace-configs-templates/
)}
diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx
new file mode 100644
index 00000000..f0bca774
--- /dev/null
+++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.a11y.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ get: vi.fn().mockResolvedValue([]),
+ post: vi.fn().mockResolvedValue({}),
+ },
+}));
+
+// Import component AFTER mocks
+import { CreateWorkspaceButton } from "../CreateWorkspaceDialog";
+
+async function openDialog() {
+ render(
);
+ const trigger = screen
+ .getAllByRole("button")
+ .find((b) => b.textContent?.includes("New Workspace"));
+ expect(trigger).toBeTruthy();
+ fireEvent.click(trigger!);
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog")).toBeTruthy()
+ );
+}
+
+describe("CreateWorkspaceDialog — accessibility", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("dialog is absent before the trigger is clicked", () => {
+ render(
);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("clicking the trigger renders a role=dialog", async () => {
+ await openDialog();
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ });
+
+ it("dialog has aria-labelledby pointing to the 'Create Workspace' title", async () => {
+ await openDialog();
+ const dialog = screen.getByRole("dialog");
+ const labelledBy = dialog.getAttribute("aria-labelledby");
+ expect(labelledBy).toBeTruthy();
+ const titleEl = document.getElementById(labelledBy!);
+ expect(titleEl?.textContent?.trim()).toBe("Create Workspace");
+ });
+
+ it("dialog has data-state='open' when visible (Radix modal state)", async () => {
+ await openDialog();
+ const dialog = screen.getByRole("dialog");
+ expect(dialog.getAttribute("data-state")).toBe("open");
+ });
+
+ it("Cancel button closes the dialog", async () => {
+ await openDialog();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ });
+
+ it("empty-name submit renders a role=alert error message", async () => {
+ await openDialog();
+ // Click Create without filling in Name
+ fireEvent.click(screen.getByRole("button", { name: "Create" }));
+ await waitFor(() =>
+ expect(screen.getByRole("alert")).toBeTruthy()
+ );
+ expect(screen.getByRole("alert").textContent).toContain("required");
+ });
+
+ it("tier buttons have role=radio and aria-checked reflects selection", async () => {
+ await openDialog();
+ const radios = screen.getAllByRole("radio");
+ expect(radios.length).toBe(3);
+ // T1 is default selection
+ const t1 = radios.find((r) => r.textContent?.includes("T1"));
+ const t2 = radios.find((r) => r.textContent?.includes("T2"));
+ expect(t1?.getAttribute("aria-checked")).toBe("true");
+ expect(t2?.getAttribute("aria-checked")).toBe("false");
+ // Click T2 and verify aria-checked flips
+ fireEvent.click(t2!);
+ await waitFor(() =>
+ expect(t2?.getAttribute("aria-checked")).toBe("true")
+ );
+ });
+});
diff --git a/canvas/src/components/__tests__/SidePanel.tabs.test.tsx b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx
new file mode 100644
index 00000000..a244f90b
--- /dev/null
+++ b/canvas/src/components/__tests__/SidePanel.tabs.test.tsx
@@ -0,0 +1,162 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── Mock all tab content components to null ──────────────────────────────────
+vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
+vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
+vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
+vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
+vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
+vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
+vi.mock("../tabs/MemoryTab", () => ({ MemoryTab: () => null }));
+vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
+vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
+vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
+vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
+vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
+
+// ── Mock StatusDot and Tooltip ───────────────────────────────────────────────
+vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
+vi.mock("../Tooltip", () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
+
+// ── Mock canvas store ────────────────────────────────────────────────────────
+const mockSetPanelTab = vi.fn();
+
+const mockStoreState = {
+ selectedNodeId: "ws-1",
+ panelTab: "chat",
+ setPanelTab: mockSetPanelTab,
+ selectNode: vi.fn(),
+ nodes: [
+ {
+ id: "ws-1",
+ data: {
+ name: "Test WS",
+ status: "online",
+ tier: 1,
+ role: "Engineer",
+ parentId: null,
+ needsRestart: false,
+ currentTask: null,
+ agentCard: null,
+ },
+ },
+ ],
+};
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: Object.assign(
+ vi.fn((selector: (s: typeof mockStoreState) => unknown) =>
+ selector(mockStoreState)
+ ),
+ { getState: () => mockStoreState }
+ ),
+ summarizeWorkspaceCapabilities: () => ({ runtime: "claude-code", skillCount: 0 }),
+}));
+
+// ── Import component under test AFTER all mocks ──────────────────────────────
+import { SidePanel } from "../SidePanel";
+
+const TABS = [
+ "chat", "activity", "details", "skills", "terminal",
+ "config", "schedule", "channels", "files", "memory", "traces", "events",
+];
+
+describe("SidePanel — ARIA tablist pattern", () => {
+ it("renders a tablist with aria-label='Workspace panel tabs'", () => {
+ render(
);
+ const tablist = screen.getByRole("tablist");
+ expect(tablist).toBeTruthy();
+ expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
+ });
+
+ it("renders exactly 12 tab buttons", () => {
+ render(
);
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs.length).toBe(12);
+ });
+
+ it("active tab (chat) has aria-selected='true'", () => {
+ render(
);
+ const chatTab = screen.getAllByRole("tab").find(
+ (t) => t.id === "tab-chat"
+ );
+ expect(chatTab?.getAttribute("aria-selected")).toBe("true");
+ });
+
+ it("all other 11 tabs have aria-selected='false'", () => {
+ render(
);
+ const tabs = screen.getAllByRole("tab");
+ const inactive = tabs.filter((t) => t.id !== "tab-chat");
+ expect(inactive.length).toBe(11);
+ for (const tab of inactive) {
+ expect(tab.getAttribute("aria-selected")).toBe("false");
+ }
+ });
+
+ it("active tab has tabIndex=0 and all others have tabIndex=-1 (roving tabIndex)", () => {
+ render(
);
+ const tabs = screen.getAllByRole("tab");
+ const zeros = tabs.filter((t) => t.getAttribute("tabindex") === "0");
+ const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
+ expect(zeros.length).toBe(1);
+ expect(zeros[0].id).toBe("tab-chat");
+ expect(minusOnes.length).toBe(11);
+ });
+
+ it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
+ render(
);
+ const chatTab = document.getElementById("tab-chat");
+ expect(chatTab).toBeTruthy();
+ expect(chatTab?.getAttribute("aria-controls")).toBe("panel-chat");
+ });
+
+ it("renders a role=tabpanel for the active tab", () => {
+ render(
);
+ const tabpanel = screen.getByRole("tabpanel");
+ expect(tabpanel).toBeTruthy();
+ });
+
+ it("tabpanel has id='panel-chat' and aria-labelledby='tab-chat'", () => {
+ render(
);
+ const panel = document.getElementById("panel-chat");
+ expect(panel).toBeTruthy();
+ expect(panel?.getAttribute("aria-labelledby")).toBe("tab-chat");
+ });
+
+ it("ArrowRight calls setPanelTab with 'activity' (next tab after chat)", () => {
+ render(
);
+ const tablist = screen.getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "ArrowRight" });
+ expect(mockSetPanelTab).toHaveBeenCalledWith("activity");
+ });
+
+ it("ArrowLeft from 'chat' (first) wraps to 'events' (last)", () => {
+ render(
);
+ const tablist = screen.getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "ArrowLeft" });
+ expect(mockSetPanelTab).toHaveBeenCalledWith("events");
+ });
+
+ it("Home key calls setPanelTab with 'chat' (first tab)", () => {
+ render(
);
+ const tablist = screen.getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "Home" });
+ expect(mockSetPanelTab).toHaveBeenCalledWith("chat");
+ });
+
+ it("End key calls setPanelTab with 'events' (last tab)", () => {
+ render(
);
+ const tablist = screen.getByRole("tablist");
+ fireEvent.keyDown(tablist, { key: "End" });
+ expect(mockSetPanelTab).toHaveBeenCalledWith("events");
+ });
+});