feat(mobile): FilterChips + AgentCard WCAG 2.1 AA accessibility
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 10s
sop-checklist / all-items-acked (pull_request) [soft-fail tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: 7
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
security-review / approved (pull_request) Failing after 11s
qa-review / approved (pull_request) Failing after 12s
sop-checklist-gate / gate (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Failing after 20s
Harness Replays / Harness Replays (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas (Next.js) (pull_request) Successful in 4m12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6m52s
audit-force-merge / audit (pull_request) Has been skipped
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 10s
sop-checklist / all-items-acked (pull_request) [soft-fail tier:low] acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: 7
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
security-review / approved (pull_request) Failing after 11s
qa-review / approved (pull_request) Failing after 12s
sop-checklist-gate / gate (pull_request) Successful in 12s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 18s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Failing after 20s
Harness Replays / Harness Replays (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 6s
CI / Platform (Go) (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Canvas (Next.js) (pull_request) Successful in 4m12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6m52s
audit-force-merge / audit (pull_request) Has been skipped
FilterChips: - Add role=toolbar + aria-label="Filter agents" on container - Add role=radio + aria-checked on each button - Add aria-hidden on count spans - FilterChips.test.tsx: 9 cases AgentCard: - Add aria-label composing name, status, tier, remote flag - AgentCard.test.tsx: 8 cases 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
778294fd88
commit
8aa2a96d23
115
canvas/src/components/mobile/__tests__/AgentCard.test.tsx
Normal file
115
canvas/src/components/mobile/__tests__/AgentCard.test.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* AgentCard — mobile agent row card.
|
||||||
|
*
|
||||||
|
* Per WCAG 2.1 AA:
|
||||||
|
* - Rendered as <button> with aria-label composing accessible name
|
||||||
|
* - aria-label includes: name, status, tier, remote flag
|
||||||
|
*
|
||||||
|
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { AgentCard, type MobileAgent } from "../components";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
const onlineAgent: MobileAgent = {
|
||||||
|
id: "ws-1",
|
||||||
|
name: "My Agent",
|
||||||
|
tag: "claude-code",
|
||||||
|
tier: "T2",
|
||||||
|
status: "online",
|
||||||
|
remote: false,
|
||||||
|
runtime: "claude-code",
|
||||||
|
skills: 3,
|
||||||
|
calls: 12,
|
||||||
|
desc: "Handles customer support",
|
||||||
|
parentId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const remoteFailedAgent: MobileAgent = {
|
||||||
|
id: "ws-2",
|
||||||
|
name: "Remote Worker",
|
||||||
|
tag: "external",
|
||||||
|
tier: "T4",
|
||||||
|
status: "failed",
|
||||||
|
remote: true,
|
||||||
|
runtime: "external",
|
||||||
|
skills: 5,
|
||||||
|
calls: 0,
|
||||||
|
desc: "",
|
||||||
|
parentId: "ws-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("AgentCard — render", () => {
|
||||||
|
it("renders as a button", () => {
|
||||||
|
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||||
|
expect(document.querySelector("button")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("button has aria-label with name, status, tier", () => {
|
||||||
|
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||||
|
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||||
|
const label = btn.getAttribute("aria-label") ?? "";
|
||||||
|
expect(label).toContain("My Agent");
|
||||||
|
expect(label).toContain("online");
|
||||||
|
expect(label).toContain("T2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aria-label includes remote for remote agents", () => {
|
||||||
|
render(<AgentCard agent={remoteFailedAgent} dark={false} onClick={vi.fn()} />);
|
||||||
|
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||||
|
const label = btn.getAttribute("aria-label") ?? "";
|
||||||
|
expect(label).toContain("Remote Worker");
|
||||||
|
expect(label).toContain("failed");
|
||||||
|
expect(label).toContain("T4");
|
||||||
|
expect(label).toContain("remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aria-label omits remote for non-remote agents", () => {
|
||||||
|
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||||
|
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||||
|
const label = btn.getAttribute("aria-label") ?? "";
|
||||||
|
expect(label).not.toContain("remote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders agent name text inside the button", () => {
|
||||||
|
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} />);
|
||||||
|
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||||
|
expect(btn.textContent).toContain("My Agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compact prop reduces padding", () => {
|
||||||
|
render(<AgentCard agent={onlineAgent} dark={false} onClick={vi.fn()} compact={true} />);
|
||||||
|
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||||
|
const style = btn.getAttribute("style") ?? "";
|
||||||
|
// compact uses "12px 14px" padding vs "14px 16px" default
|
||||||
|
expect(style).toContain("padding");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Interaction ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("AgentCard — interaction", () => {
|
||||||
|
it("calls onClick when button is clicked", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<AgentCard agent={onlineAgent} dark={false} onClick={onClick} />);
|
||||||
|
const btn = document.querySelector("button") as HTMLButtonElement;
|
||||||
|
btn.click();
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without onClick (optional prop)", () => {
|
||||||
|
// Should not throw
|
||||||
|
expect(() => render(<AgentCard agent={onlineAgent} dark={false} />)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
118
canvas/src/components/mobile/__tests__/FilterChips.test.tsx
Normal file
118
canvas/src/components/mobile/__tests__/FilterChips.test.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
/**
|
||||||
|
* FilterChips — mobile agent filter toolbar.
|
||||||
|
*
|
||||||
|
* Per WCAG 2.1 AA / ARIA radio group pattern:
|
||||||
|
* - Container has role="toolbar" + aria-label
|
||||||
|
* - Each button has role="radio" + aria-checked
|
||||||
|
* - Icon spans have aria-hidden="true"
|
||||||
|
* - Only one radio can be checked at a time (single-select filter)
|
||||||
|
*
|
||||||
|
* NOTE: No @testing-library/jest-dom — use DOM APIs.
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { FilterChips, type AgentFilter } from "../components";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultCounts = { all: 12, online: 8, issue: 2, paused: 2 };
|
||||||
|
|
||||||
|
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("FilterChips — render", () => {
|
||||||
|
it("renders 4 filter buttons", () => {
|
||||||
|
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
const buttons = document.querySelectorAll('[role="radio"]');
|
||||||
|
expect(buttons.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("container has role=toolbar and aria-label", () => {
|
||||||
|
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
const toolbar = document.querySelector('[role="toolbar"]');
|
||||||
|
expect(toolbar).toBeTruthy();
|
||||||
|
expect(toolbar?.getAttribute("aria-label")).toBe("Filter agents");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each button has role=radio", () => {
|
||||||
|
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
const buttons = document.querySelectorAll('[role="radio"]');
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
expect(btn.getAttribute("role")).toBe("radio");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("active filter has aria-checked=true, others false", () => {
|
||||||
|
render(<FilterChips value="issue" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
const buttons = document.querySelectorAll('[role="radio"]');
|
||||||
|
buttons.forEach((btn) => {
|
||||||
|
const label = btn.textContent ?? "";
|
||||||
|
if (label.startsWith("Issues")) {
|
||||||
|
expect(btn.getAttribute("aria-checked")).toBe("true");
|
||||||
|
} else {
|
||||||
|
expect(btn.getAttribute("aria-checked")).toBe("false");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("count spans have aria-hidden=true", () => {
|
||||||
|
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
const hidden = document.querySelectorAll('[aria-hidden="true"]');
|
||||||
|
// Each chip has one count span marked aria-hidden
|
||||||
|
expect(hidden.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Interaction ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("FilterChips — interaction", () => {
|
||||||
|
it("calls onChange with correct filter id when clicked", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FilterChips value="all" onChange={onChange} dark={false} counts={defaultCounts} />);
|
||||||
|
const buttons = document.querySelectorAll('[role="radio"]');
|
||||||
|
const onlineBtn = Array.from(buttons).find((b) => b.textContent?.startsWith("Online")) as Element;
|
||||||
|
fireEvent.click(onlineBtn);
|
||||||
|
expect(onChange).toHaveBeenCalledWith("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange when the already-active filter is clicked (component does not guard)", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<FilterChips value="all" onChange={onChange} dark={false} counts={defaultCounts} />);
|
||||||
|
const buttons = document.querySelectorAll('[role="radio"]');
|
||||||
|
const allBtn = Array.from(buttons).find((b) => b.textContent?.startsWith("All")) as Element;
|
||||||
|
fireEvent.click(allBtn);
|
||||||
|
// Component calls onChange even for the already-active filter;
|
||||||
|
// the guard belongs at the consumer level (MobileHome) if needed.
|
||||||
|
expect(onChange).toHaveBeenCalledWith("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updating value prop changes aria-checked", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />,
|
||||||
|
);
|
||||||
|
const allBtn = document.querySelector('[id="filter-all"]') as Element;
|
||||||
|
expect(allBtn.getAttribute("aria-checked")).toBe("true");
|
||||||
|
|
||||||
|
rerender(<FilterChips value="paused" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
expect(allBtn.getAttribute("aria-checked")).toBe("false");
|
||||||
|
const pausedBtn = document.querySelector('[id="filter-paused"]') as Element;
|
||||||
|
expect(pausedBtn.getAttribute("aria-checked")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all filter labels are present", () => {
|
||||||
|
render(<FilterChips value="all" onChange={vi.fn()} dark={false} counts={defaultCounts} />);
|
||||||
|
const texts = Array.from(document.querySelectorAll('[role="radio"]')).map((b) =>
|
||||||
|
b.textContent?.trim(),
|
||||||
|
);
|
||||||
|
expect(texts.some((t) => t?.startsWith("All"))).toBe(true);
|
||||||
|
expect(texts.some((t) => t?.startsWith("Online"))).toBe(true);
|
||||||
|
expect(texts.some((t) => t?.startsWith("Issues"))).toBe(true);
|
||||||
|
expect(texts.some((t) => t?.startsWith("Paused"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -287,6 +287,7 @@ export function AgentCard({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
@ -420,6 +421,9 @@ export function FilterChips({
|
|||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Filter agents"
|
||||||
|
aria-activedescendant={value ? `filter-${value}` : undefined}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
@ -433,7 +437,10 @@ export function FilterChips({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={o.id}
|
key={o.id}
|
||||||
|
id={`filter-${o.id}`}
|
||||||
|
role="radio"
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-checked={on}
|
||||||
onClick={() => onChange(o.id)}
|
onClick={() => onChange(o.id)}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
@ -453,6 +460,7 @@ export function FilterChips({
|
|||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
<span
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user