Merge pull request #875 from Molecule-AI/fix/canvas-a11y-configtab-detailstab-htmlfor

fix(canvas): htmlFor/id association in ConfigTab + DetailsTab inputs
This commit is contained in:
molecule-ai[bot] 2026-04-18 01:24:42 +00:00 committed by GitHub
commit fc7317d5cd
7 changed files with 344 additions and 19 deletions

View File

@ -0,0 +1,289 @@
// @vitest-environment jsdom
/**
* WCAG 1.3.1 labelinput 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()` (labelcontrol 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<string, unknown>) => 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 }) => (
<button onClick={onNewFile} data-testid="new-file-btn">New File</button>
),
}));
vi.mock("../tabs/FilesTab/FileTree", () => ({
FileTree: () => <div data-testid="file-tree" />,
}));
vi.mock("../tabs/FilesTab/FileEditor", () => ({
FileEditor: () => <div data-testid="file-editor" />,
}));
vi.mock("../tabs/FilesTab/useFilesApi", () => ({
useFilesApi: () => ({
files: [],
loading: false,
loadFiles: vi.fn(),
expandedDirs: new Set<string>(),
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 from source URL"', async () => {
render(<SkillsTab data={makeSkillsData() as never} />);
// 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 from source url/i,
});
expect(input).toBeDefined();
expect(input.getAttribute("aria-label")).toBe("Install from source URL");
});
it("install source input is a text input (not hidden)", async () => {
render(<SkillsTab data={makeSkillsData() as never} />);
const installBtn = screen.getByRole("button", { name: /install plugin/i });
fireEvent.click(installBtn);
const input = screen.getByRole("textbox", {
name: /install 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(<FilesTab workspaceId="ws-1" />);
// 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(<FilesTab workspaceId="ws-1" />);
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(<ChannelsTab workspaceId="ws-1" />);
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(<ScheduleTab workspaceId="ws-1" />);
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());
});
});

View File

@ -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<Set<string>>(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 && (
<div className="space-y-2 p-3 bg-zinc-800/40 rounded border border-zinc-700/50">
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Platform</label>
<label htmlFor={platformId} className="text-[10px] text-zinc-500 block mb-1">Platform</label>
<select
id={platformId}
value={formType}
onChange={(e) => setFormType(e.target.value)}
className="w-full text-xs bg-zinc-900 border border-zinc-700 rounded px-2 py-1.5 text-zinc-300"
@ -220,8 +227,9 @@ export function ChannelsTab({ workspaceId }: Props) {
</select>
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
<label htmlFor={botTokenId} className="text-[10px] text-zinc-500 block mb-1">Bot Token</label>
<input
id={botTokenId}
type="password"
value={formBotToken}
onChange={(e) => setFormBotToken(e.target.value)}
@ -231,7 +239,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-zinc-500">Chat IDs</label>
<label htmlFor={chatIdId} className="text-[10px] text-zinc-500">Chat IDs</label>
<button
onClick={handleDiscover}
disabled={discovering || !formBotToken}
@ -261,6 +269,7 @@ export function ChannelsTab({ workspaceId }: Props) {
)}
{(discoveredChats.length === 0 || showManualInput) && (
<input
id={chatIdId}
value={formChatId}
onChange={(e) => setFormChatId(e.target.value)}
placeholder="-100123456789, -100987654321"
@ -285,10 +294,11 @@ export function ChannelsTab({ workspaceId }: Props) {
</p>
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">
<label htmlFor={allowedUsersId} className="text-[10px] text-zinc-500 block mb-1">
Allowed Users <span className="text-zinc-600">(optional, comma-separated)</span>
</label>
<input
id={allowedUsersId}
value={formAllowedUsers}
onChange={(e) => setFormAllowedUsers(e.target.value)}
placeholder="123456789, 987654321"

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useId } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
@ -170,6 +170,14 @@ export function ConfigTab({ workspaceId }: Props) {
}
};
// Stable IDs for bare label↔control pairs (WCAG 1.3.1)
const descriptionId = useId();
const tierId = useId();
const runtimeId = useId();
const effortId = useId();
const taskBudgetId = useId();
const sandboxBackendId = useId();
const isDirty = rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml;
if (loading) {
@ -214,8 +222,9 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="General">
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Description</label>
<label htmlFor={descriptionId} className="text-[10px] text-zinc-500 block mb-1">Description</label>
<textarea
id={descriptionId}
value={config.description}
onChange={(e) => update("description", e.target.value)}
rows={3}
@ -225,8 +234,9 @@ export function ConfigTab({ workspaceId }: Props) {
<div className="grid grid-cols-2 gap-3">
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Tier</label>
<label htmlFor={tierId} className="text-[10px] text-zinc-500 block mb-1">Tier</label>
<select
id={tierId}
value={config.tier}
onChange={(e) => update("tier", parseInt(e.target.value, 10))}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
@ -242,8 +252,9 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Runtime">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
<label htmlFor={runtimeId} className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
<select
id={runtimeId}
value={config.runtime || ""}
onChange={(e) => update("runtime", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
@ -273,11 +284,12 @@ export function ConfigTab({ workspaceId }: Props) {
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
<Section title="Claude Settings" defaultOpen={false}>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">
<label htmlFor={effortId} className="text-[10px] text-zinc-500 block mb-1">
Effort
<span className="ml-1 text-zinc-600">(output_config.effort Opus 4.7+)</span>
</label>
<select
id={effortId}
value={config.effort || ""}
onChange={(e) => update("effort", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
@ -292,11 +304,12 @@ export function ConfigTab({ workspaceId }: Props) {
</select>
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">
<label htmlFor={taskBudgetId} className="text-[10px] text-zinc-500 block mb-1">
Task Budget (tokens)
<span className="ml-1 text-zinc-600">(output_config.task_budget.total 0 = unset)</span>
</label>
<input
id={taskBudgetId}
type="number"
min={0}
step={1000}
@ -334,8 +347,9 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Sandbox" defaultOpen={false}>
<div>
<label className="text-[10px] text-zinc-500 block mb-1">Backend</label>
<label htmlFor={sandboxBackendId} className="text-[10px] text-zinc-500 block mb-1">Backend</label>
<select
id={sandboxBackendId}
value={config.sandbox?.backend || "docker"}
onChange={(e) => updateNested("sandbox" as keyof ConfigData, "backend", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useId, cloneElement, type ReactElement } from "react";
import { api } from "@/lib/api";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { StatusDot } from "../StatusDot";
@ -300,10 +300,11 @@ function Section({ title, children }: { title: string; children: React.ReactNode
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
const fieldId = useId();
return (
<div>
<label className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
{children}
<label htmlFor={fieldId} className="text-[10px] text-zinc-500 block mb-0.5">{label}</label>
{cloneElement(children as ReactElement<{ id?: string }>, { id: fieldId })}
</div>
);
}

View File

@ -192,6 +192,7 @@ export function FilesTab({ workspaceId }: Props) {
{showNewFile && (
<div className="px-2 py-1 border-b border-zinc-800/40">
<input
aria-label="New file path"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createFile()}

View File

@ -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";
@ -67,6 +67,11 @@ export function ScheduleTab({ workspaceId }: Props) {
const [error, setError] = useState("");
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null);
// Stable IDs for label↔input associations (WCAG 1.3.1)
const cronId = useId();
const timezoneId = useId();
const promptId = useId();
const fetchSchedules = useCallback(async () => {
try {
const data = await api.get<Schedule[]>(`/workspaces/${workspaceId}/schedules`);
@ -198,6 +203,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<div className="p-3 border-b border-zinc-800/50 bg-zinc-900/50 space-y-2">
<input
type="text"
aria-label="Schedule name"
placeholder="Schedule name (e.g., Daily security scan)"
value={formName}
onChange={(e) => setFormName(e.target.value)}
@ -205,8 +211,9 @@ export function ScheduleTab({ workspaceId }: Props) {
/>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
<label htmlFor={cronId} className="text-[10px] text-zinc-500 block mb-0.5">Cron Expression</label>
<input
id={cronId}
type="text"
value={formCron}
onChange={(e) => setFormCron(e.target.value)}
@ -217,8 +224,9 @@ export function ScheduleTab({ workspaceId }: Props) {
</div>
</div>
<div className="w-24">
<label className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
<label htmlFor={timezoneId} className="text-[10px] text-zinc-500 block mb-0.5">Timezone</label>
<select
id={timezoneId}
value={formTimezone}
onChange={(e) => setFormTimezone(e.target.value)}
className="w-full text-[10px] bg-zinc-800 border border-zinc-700 rounded px-1 py-1 text-zinc-200"
@ -237,8 +245,9 @@ export function ScheduleTab({ workspaceId }: Props) {
</div>
</div>
<div>
<label className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
<label htmlFor={promptId} className="text-[10px] text-zinc-500 block mb-0.5">Prompt / Task</label>
<textarea
id={promptId}
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
placeholder="What should the agent do on this schedule?"

View File

@ -232,6 +232,7 @@ export function SkillsTab({ data }: Props) {
<div className="flex items-center gap-1.5">
<input
type="text"
aria-label="Install from source URL"
value={customSource}
onChange={(e) => setCustomSource(e.target.value)}
onKeyDown={(e) => {