diff --git a/canvas/src/components/__tests__/ConsoleModal.test.tsx b/canvas/src/components/__tests__/ConsoleModal.test.tsx index fb44eadc..6e816f1d 100644 --- a/canvas/src/components/__tests__/ConsoleModal.test.tsx +++ b/canvas/src/components/__tests__/ConsoleModal.test.tsx @@ -71,3 +71,54 @@ describe("ConsoleModal", () => { expect(onClose).toHaveBeenCalled(); }); }); + +// ── WCAG 2.1 dialog accessibility ───────────────────────────────────────────── + +describe("ConsoleModal — WCAG 2.1 dialog accessibility", () => { + it("renders role=dialog when open", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + render( {}} />); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy()); + }); + + it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + render( {}} />); + const dialog = await waitFor(() => screen.getByRole("dialog")); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("dialog has aria-labelledby pointing to the title", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + render( {}} />); + const dialog = await waitFor(() => screen.getByRole("dialog")); + const labelledBy = dialog.getAttribute("aria-labelledby"); + expect(labelledBy).toBeTruthy(); + const titleEl = document.getElementById(labelledBy!); + expect(titleEl?.textContent?.trim()).toBe("EC2 console output"); + }); + + it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + render( {}} />); + const backdrop = document.querySelector('[aria-hidden="true"]'); + expect(backdrop).toBeTruthy(); + expect(backdrop?.className).toContain("bg-black"); + }); + + it("error div has role=alert (WCAG 4.1.3)", async () => { + mockGet.mockRejectedValueOnce(new Error("GET /workspaces/ws-1/console: 404 Not Found")); + render( {}} />); + const alert = await waitFor(() => screen.getByRole("alert")); + expect(alert).toBeTruthy(); + expect(alert.textContent).toMatch(/No EC2 instance found/i); + }); + + it("Close button has accessible name via aria-label", async () => { + mockGet.mockResolvedValueOnce({ output: "" }); + render( {}} />); + // Two close buttons: X icon (aria-label="Close") and text "Close" button + const closeBtns = await waitFor(() => screen.getAllByRole("button", { name: /close/i })); + expect(closeBtns.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/canvas/src/components/__tests__/DeleteCascadeConfirmDialog.test.tsx b/canvas/src/components/__tests__/DeleteCascadeConfirmDialog.test.tsx new file mode 100644 index 00000000..9f20a104 --- /dev/null +++ b/canvas/src/components/__tests__/DeleteCascadeConfirmDialog.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +/** + * DeleteCascadeConfirmDialog — WCAG 2.1 dialog accessibility + interaction tests + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +afterEach(cleanup); + +import { DeleteCascadeConfirmDialog } from "../DeleteCascadeConfirmDialog"; + +const defaultProps = { + name: "Test Workspace", + children: [ + { id: "ws-child-1", name: "Child Workspace 1" }, + { id: "ws-child-2", name: "Child Workspace 2" }, + ], + checked: false, + onCheckedChange: vi.fn(), + onConfirm: vi.fn(), + onCancel: vi.fn(), +}; + +function renderDialog(props = {}) { + return render(); +} + +describe("DeleteCascadeConfirmDialog — basic rendering", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the dialog with correct title", () => { + renderDialog(); + expect(screen.getByText("Delete Workspace and Children")).toBeTruthy(); + }); + + it("renders child workspace names in the list", () => { + renderDialog(); + expect(screen.getByText("Child Workspace 1")).toBeTruthy(); + expect(screen.getByText("Child Workspace 2")).toBeTruthy(); + }); + + it("Delete All button is disabled when checkbox is unchecked", () => { + renderDialog({ checked: false }); + const deleteBtn = screen.getByRole("button", { name: "Delete All" }); + // disabled={!checked}={!false}={true} → button has disabled attribute + expect(deleteBtn.getAttribute("disabled") !== null).toBe(true); + }); + + it("Delete All button is enabled when checkbox is checked", () => { + renderDialog({ checked: true }); + const deleteBtn = screen.getByRole("button", { name: "Delete All" }); + expect(deleteBtn.getAttribute("disabled")).toBeFalsy(); + }); + + it("checking the checkbox calls onCheckedChange", () => { + renderDialog(); + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + expect(defaultProps.onCheckedChange).toHaveBeenCalledWith(true); + }); + + it("Cancel button calls onCancel", () => { + renderDialog(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it("Delete All button calls onConfirm when enabled", () => { + renderDialog({ checked: true }); + fireEvent.click(screen.getByRole("button", { name: "Delete All" })); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); +}); + +describe("DeleteCascadeConfirmDialog — WCAG 2.1 dialog accessibility", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders role=dialog", () => { + renderDialog(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("dialog has aria-modal='true' (WCAG 2.1 SC 1.3.2)", () => { + renderDialog(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + }); + + it("dialog has aria-labelledby pointing to the title", () => { + renderDialog(); + const dialog = screen.getByRole("dialog"); + const labelledBy = dialog.getAttribute("aria-labelledby"); + expect(labelledBy).toBeTruthy(); + const titleEl = document.getElementById(labelledBy!); + expect(titleEl?.textContent?.trim()).toBe("Delete Workspace and Children"); + }); + + it("backdrop div has aria-hidden='true' so screen readers skip it (WCAG 4.1.2)", () => { + renderDialog(); + const backdrop = document.querySelector('[aria-hidden="true"]'); + expect(backdrop).toBeTruthy(); + expect(backdrop?.className).toContain("bg-black"); + }); + + it("warning SVG icon has aria-hidden='true' (decorative)", () => { + renderDialog(); + const dialog = screen.getByRole("dialog"); + const svgIcons = dialog.querySelectorAll("svg"); + // The warning triangle SVG should have aria-hidden + const warningSvg = svgIcons[0]; + expect(warningSvg?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("all interactive buttons have accessible names", () => { + renderDialog(); + const buttons = screen.getAllByRole("button"); + for (const btn of buttons) { + const name = btn.textContent?.trim(); + expect(name?.length).toBeGreaterThan(0); + } + }); + + it("checkbox is labelled by the cascade warning text", () => { + renderDialog(); + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeTruthy(); + // The label wrapping the checkbox provides the accessible name + expect( + screen.getByText(/I understand this will permanently delete/i), + ).toBeTruthy(); + }); +}); + +describe("DeleteCascadeConfirmDialog — keyboard interaction", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Escape key calls onCancel", () => { + renderDialog(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(defaultProps.onCancel).toHaveBeenCalledTimes(1); + }); + + it("Enter key on checkbox does NOT confirm when unchecked", () => { + renderDialog({ checked: false }); + const checkbox = screen.getByRole("checkbox"); + checkbox.focus(); + fireEvent.keyDown(checkbox, { key: "Enter" }); + // onConfirm should NOT be called because checkbox is unchecked + expect(defaultProps.onConfirm).not.toHaveBeenCalled(); + }); + + it("Enter key on checkbox confirms when checked", () => { + renderDialog({ checked: true }); + const checkbox = screen.getByRole("checkbox"); + checkbox.focus(); + fireEvent.keyDown(checkbox, { key: "Enter" }); + expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1); + }); +});