test(canvas/ChatTab): add sub-tab ARIA pattern coverage
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Failing after 0s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Failing after 0s
CI / Platform (Go) (pull_request) Failing after 0s
CI / Detect changes (pull_request) Failing after 0s
CI / Canvas (Next.js) (pull_request) Failing after 1s
CI / Shellcheck (E2E scripts) (pull_request) Failing after 0s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Failing after 0s
CI / all-required (pull_request) Failing after 0s
E2E API Smoke Test / detect-changes (pull_request) Failing after 0s
E2E Chat / detect-changes (pull_request) Failing after 0s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Failing after 0s
E2E Chat / E2E Chat (pull_request) Has been skipped
Harness Replays / detect-changes (pull_request) Failing after 0s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 0s
Harness Replays / Harness Replays (pull_request) Has been skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Failing after 0s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Failing after 0s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Failing after 1s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 0s
lint-required-no-paths / lint-required-no-paths (pull_request) Failing after 0s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 0s
publish-runtime-autobump / pr-validate (pull_request) Failing after 0s
Runtime PR-Built Compatibility / detect-changes (pull_request) Failing after 0s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 0s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Has been skipped
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 0s
gate-check-v3 / gate-check (pull_request) Failing after 0s
qa-review / approved (pull_request) Failing after 0s
security-review / approved (pull_request) Failing after 0s
sop-checklist / all-items-acked (pull_request) Failing after 0s
sop-tier-check / tier-check (pull_request) Failing after 0s

Covers ChatTab sub-tab bar (My Chat / Agent Comms):
- Roving tabIndex: active tab tabIndex=0, others tabIndex=-1
- aria-selected reflects active state per tab
- aria-controls connects tab to its panel
- ArrowRight/ArrowLeft keyboard navigation with wrapping
- Home/End documented as NOT handled (only ArrowLeft/ArrowRight)
- Click switches active tab and updates panel visibility
- Both panels in DOM (hidden class controls visibility)
- focus-visible:ring class for WCAG 2.4.7
- Offline/degraded/paused agent status renders without error

27 tests, all passing.
This commit is contained in:
Molecule AI · core-uiux 2026-05-16 06:48:58 +00:00
parent 4a86793116
commit 6e6d412b8a

View File

@ -0,0 +1,293 @@
// @vitest-environment jsdom
/**
* Tests for ChatTab sub-tab bar ARIA pattern (My Chat / Agent Comms).
*
* Pattern: match ChatTab.lazyHistory.test.tsx mock only the API layer
* (so /chat-history returns empty) and the canvas store. The component
* runs with real hooks; jsdom is patched for scrollIntoView + IntersectionObserver.
*
* Covers:
* - Roving tabIndex: active tab tabIndex=0, others tabIndex=-1
* - aria-selected reflects active state
* - aria-controls connects tab to its panel
* - ArrowRight/ArrowLeft keyboard navigation between tabs
* - Home/End snap to first/last tab
* - Click switches active tab
* - Default active tab is My Chat
* - Both panels in the DOM; one hidden, one visible
* - focus-visible ring class on tabs (WCAG 2.4.7)
* - Renders correctly with offline/degraded/paused agent status
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
// ── API mocks ─────────────────────────────────────────────────────────────────
const myChatHistoryCalls: string[] = [];
const { apiGet, apiPost } = vi.hoisted(() => ({
apiGet: vi.fn((path: string): Promise<unknown> => {
if (path.includes("/chat-history")) {
myChatHistoryCalls.push(path);
return Promise.resolve({ messages: [], reached_end: true });
}
if (path.includes("/activity")) {
return Promise.resolve({ messages: [] });
}
return Promise.resolve([]);
}),
apiPost: vi.fn(() => Promise.resolve({})),
}));
vi.mock("@/lib/api", () => ({
api: {
get: (path: string) => apiGet(path),
post: (path: string, body: unknown) => apiPost(path, body),
del: vi.fn(),
patch: vi.fn(),
put: vi.fn(),
},
}));
// ── Canvas store mock ─────────────────────────────────────────────────────────
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn((selector?: (s: unknown) => unknown) =>
selector ? selector({ agentMessages: {}, consumeAgentMessages: () => [] }) : {}
),
}));
// ── Import AFTER mocks ────────────────────────────────────────────────────────
import { ChatTab } from "../ChatTab";
const minimalData = {
status: "online" as const,
runtime: "claude-code",
currentTask: null,
} as unknown as Parameters<typeof ChatTab>[0]["data"];
const PROPS = { workspaceId: "ws-chat-test", data: minimalData };
beforeEach(() => {
myChatHistoryCalls.length = 0;
apiGet.mockClear();
apiPost.mockReset();
// jsdom does not implement scrollIntoView or IntersectionObserver natively.
Element.prototype.scrollIntoView = vi.fn();
(window as unknown as { IntersectionObserver: unknown }).IntersectionObserver = class {
constructor(_cb: IntersectionObserverCallback) {}
observe(_el: Element) {}
unobserve() {}
disconnect() {}
};
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("ChatTab — sub-tab ARIA structure", () => {
it("renders a tablist with role=tablist", () => {
render(<ChatTab {...PROPS} />);
expect(screen.getByRole("tablist")).toBeTruthy();
});
it("renders exactly 2 tab buttons", () => {
render(<ChatTab {...PROPS} />);
expect(screen.getAllByRole("tab")).toHaveLength(2);
});
it("My Chat tab has id='chat-tab-my-chat'", () => {
render(<ChatTab {...PROPS} />);
expect(document.getElementById("chat-tab-my-chat")).toBeTruthy();
});
it("Agent Comms tab has id='chat-tab-agent-comms'", () => {
render(<ChatTab {...PROPS} />);
expect(document.getElementById("chat-tab-agent-comms")).toBeTruthy();
});
it("My Chat tab is aria-selected=true by default", () => {
render(<ChatTab {...PROPS} />);
const myChat = document.getElementById("chat-tab-my-chat")!;
expect(myChat.getAttribute("aria-selected")).toBe("true");
});
it("Agent Comms tab is aria-selected=false by default", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
expect(agentComms.getAttribute("aria-selected")).toBe("false");
});
it("My Chat tab aria-controls='chat-panel-my-chat'", () => {
render(<ChatTab {...PROPS} />);
const myChat = document.getElementById("chat-tab-my-chat")!;
expect(myChat.getAttribute("aria-controls")).toBe("chat-panel-my-chat");
});
it("Agent Comms tab aria-controls='chat-panel-agent-comms'", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
expect(agentComms.getAttribute("aria-controls")).toBe("chat-panel-agent-comms");
});
it("My Chat tabpanel has id='chat-panel-my-chat' and aria-labelledby='chat-tab-my-chat'", () => {
render(<ChatTab {...PROPS} />);
const panel = document.getElementById("chat-panel-my-chat");
expect(panel).toBeTruthy();
expect(panel?.getAttribute("aria-labelledby")).toBe("chat-tab-my-chat");
});
it("Agent Comms tabpanel has id='chat-panel-agent-comms' and aria-labelledby='chat-tab-agent-comms'", () => {
render(<ChatTab {...PROPS} />);
const panel = document.getElementById("chat-panel-agent-comms");
expect(panel).toBeTruthy();
expect(panel?.getAttribute("aria-labelledby")).toBe("chat-tab-agent-comms");
});
});
describe("ChatTab — roving tabIndex", () => {
it("active tab (My Chat) has tabIndex=0; Agent Comms has tabIndex=-1", () => {
render(<ChatTab {...PROPS} />);
const myChat = document.getElementById("chat-tab-my-chat")!;
const agentComms = document.getElementById("chat-tab-agent-comms")!;
expect(myChat.getAttribute("tabindex")).toBe("0");
expect(agentComms.getAttribute("tabindex")).toBe("-1");
});
it("only one tab has tabIndex=0 at a time", () => {
render(<ChatTab {...PROPS} />);
const tabs = screen.getAllByRole("tab");
const zeros = tabs.filter((t) => t.getAttribute("tabindex") === "0");
expect(zeros).toHaveLength(1);
});
});
describe("ChatTab — tab switching via click", () => {
it("clicking 'Agent Comms' tab makes it active", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
fireEvent.click(agentComms);
expect(agentComms.getAttribute("aria-selected")).toBe("true");
expect(agentComms.getAttribute("tabindex")).toBe("0");
});
it("clicking 'Agent Comms' deactivates 'My Chat'", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
fireEvent.click(agentComms);
const myChat = document.getElementById("chat-tab-my-chat")!;
expect(myChat.getAttribute("aria-selected")).toBe("false");
expect(myChat.getAttribute("tabindex")).toBe("-1");
});
it("My Chat panel is visible by default (not hidden)", () => {
render(<ChatTab {...PROPS} />);
const panel = document.getElementById("chat-panel-my-chat");
expect(panel).toBeTruthy();
// Use classList to avoid matching 'overflow-hidden' substring
expect(panel?.classList.contains("hidden")).toBe(false);
});
it("Agent Comms panel is hidden by default", () => {
render(<ChatTab {...PROPS} />);
const panel = document.getElementById("chat-panel-agent-comms");
expect(panel).toBeTruthy();
expect(panel?.classList.contains("hidden")).toBe(true);
});
it("clicking 'Agent Comms' shows the Agent Comms panel (removes hidden)", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
fireEvent.click(agentComms);
const panel = document.getElementById("chat-panel-agent-comms");
expect(panel?.classList.contains("hidden")).toBe(false);
});
it("clicking 'Agent Comms' hides the My Chat panel", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
fireEvent.click(agentComms);
const panel = document.getElementById("chat-panel-my-chat");
expect(panel?.classList.contains("hidden")).toBe(true);
});
it("clicking back to My Chat restores its visibility", () => {
render(<ChatTab {...PROPS} />);
const agentComms = document.getElementById("chat-tab-agent-comms")!;
fireEvent.click(agentComms);
const myChat = document.getElementById("chat-tab-my-chat")!;
fireEvent.click(myChat);
const panel = document.getElementById("chat-panel-my-chat");
expect(panel?.classList.contains("hidden")).toBe(false);
});
});
describe("ChatTab — keyboard navigation in sub-tab bar (ArrowLeft/ArrowRight only — no Home/End)", () => {
it("ArrowRight from My Chat (first tab) moves to Agent Comms", () => {
render(<ChatTab {...PROPS} />);
const tablist = screen.getByRole("tablist");
act(() => { fireEvent.keyDown(tablist, { key: "ArrowRight" }); });
const agentComms = document.getElementById("chat-tab-agent-comms")!;
expect(agentComms.getAttribute("aria-selected")).toBe("true");
expect(agentComms.getAttribute("tabindex")).toBe("0");
});
it("ArrowLeft from Agent Comms wraps back to My Chat", () => {
render(<ChatTab {...PROPS} />);
fireEvent.click(document.getElementById("chat-tab-agent-comms")!);
const tablist = screen.getByRole("tablist");
act(() => { fireEvent.keyDown(tablist, { key: "ArrowLeft" }); });
const myChat = document.getElementById("chat-tab-my-chat")!;
expect(myChat.getAttribute("aria-selected")).toBe("true");
});
it("ArrowRight from Agent Comms wraps to My Chat (last→first)", () => {
render(<ChatTab {...PROPS} />);
fireEvent.click(document.getElementById("chat-tab-agent-comms")!);
const tablist = screen.getByRole("tablist");
act(() => { fireEvent.keyDown(tablist, { key: "ArrowRight" }); });
const myChat = document.getElementById("chat-tab-my-chat")!;
expect(myChat.getAttribute("aria-selected")).toBe("true");
});
it("Home/End keys are NOT handled by ChatTab sub-tab bar (only ArrowLeft/ArrowRight)", () => {
// Note: ChatTab only handles ArrowLeft/ArrowRight (no Home/End). This
// test documents that pressing Home does NOT change the active tab.
render(<ChatTab {...PROPS} />);
const tablist = screen.getByRole("tablist");
act(() => { fireEvent.keyDown(tablist, { key: "Home" }); });
// My Chat is still active (Home was not handled)
const myChat = document.getElementById("chat-tab-my-chat")!;
expect(myChat.getAttribute("aria-selected")).toBe("true");
});
});
describe("ChatTab — WCAG focus-visible (issue #1273)", () => {
it("tab buttons have focus-visible:ring class for WCAG 2.4.7", () => {
render(<ChatTab {...PROPS} />);
const tabs = screen.getAllByRole("tab");
for (const tab of tabs) {
expect(tab.className).toContain("focus-visible:ring");
}
});
});
describe("ChatTab — agent status rendering", () => {
it("renders without error when agent status is offline", () => {
render(<ChatTab {...PROPS} data={{ ...minimalData, status: "offline" as const }} />);
expect(screen.getAllByRole("tab")).toHaveLength(2);
});
it("renders without error when agent status is degraded", () => {
render(<ChatTab {...PROPS} data={{ ...minimalData, status: "degraded" as const }} />);
expect(screen.getAllByRole("tab")).toHaveLength(2);
});
it("renders without error when agent status is paused", () => {
render(<ChatTab {...PROPS} data={{ ...minimalData, status: "paused" as const }} />);
expect(screen.getAllByRole("tab")).toHaveLength(2);
});
});