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
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:
parent
4a86793116
commit
6e6d412b8a
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user