From eba6e3a3de1aed2f59a1824809476b46adfb9d5d Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Fri, 17 Apr 2026 23:01:43 +0000 Subject: [PATCH] fix(canvas): expand a11y htmlFor/aria-label to SkillsTab, FilesTab, ChannelsTab, ScheduleTab (issue #856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG 1.3.1 fixes for 4 remaining tabs identified in UIUX Cycle 4 audit: - SkillsTab: aria-label="Install plugin from source URL" on bare source input - FilesTab: aria-label="New file path" on bare new-file input - ChannelsTab: useId() + htmlFor/id pairs for Platform, Bot Token, Chat IDs, and Allowed Users label↔input associations (4 pairs) - ScheduleTab: aria-label="Schedule name" on bare name input; useId() + htmlFor/id pairs for Cron Expression, Timezone, and Prompt/Task label↔control associations (3 pairs) - DetailsTab: fix ReactElement<{ id?: string }> cast in Field component to resolve React 19 TypeScript overload error Adds 14 new WCAG tests in tabs.a11y.test.tsx covering all above fixes. No visual change. All 736 tests pass. Build clean. Closes #856 Co-Authored-By: Claude Sonnet 4.6 --- .../components/__tests__/tabs.a11y.test.tsx | 289 ++++++++++++++++++ canvas/src/components/tabs/ChannelsTab.tsx | 20 +- canvas/src/components/tabs/DetailsTab.tsx | 2 +- canvas/src/components/tabs/FilesTab.tsx | 1 + canvas/src/components/tabs/ScheduleTab.tsx | 17 +- canvas/src/components/tabs/SkillsTab.tsx | 1 + 6 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 canvas/src/components/__tests__/tabs.a11y.test.tsx 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..471924ba --- /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 plugin 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 plugin from source url/i, + }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("Install plugin 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 plugin 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/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index 78cb628f..7402214b 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useId } from "react"; import { api } from "@/lib/api"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -53,6 +53,12 @@ export function ChannelsTab({ workspaceId }: Props) { const [selectedChats, setSelectedChats] = useState>(new Set()); const [showManualInput, setShowManualInput] = useState(false); + // Stable IDs for label↔input associations (WCAG 1.3.1) + const platformId = useId(); + const botTokenId = useId(); + const chatIdId = useId(); + const allowedUsersId = useId(); + const load = useCallback(async () => { try { const [chRes, adRes] = await Promise.all([ @@ -208,8 +214,9 @@ export function ChannelsTab({ workspaceId }: Props) { {showForm && (
- +
- + setFormBotToken(e.target.value)} @@ -231,7 +239,7 @@ export function ChannelsTab({ workspaceId }: Props) {
- +
-