forked from molecule-ai/molecule-core
fix(canvas): QA blockers — ChatTab aria-controls, AuthGate test, CommunicationOverlay status icons
BLOCKER 1 (ChatTab.tsx): Replace ternary rendering with always-in-DOM panels
using `hidden` attribute so `aria-controls` targets always exist (WCAG 4.1.2).
Add `id` to tab buttons for `aria-labelledby` back-reference. Non-blocking:
change `key={i}` → `key={line + i}` on activity log items.
BLOCKER 2 (AuthGate.test.tsx): Create test file asserting the loading state
renders a `.bg-zinc-950.fixed.inset-0` overlay with `aria-hidden="true"` —
covers the zinc-950 flash-prevention overlay added in the prior commit.
BLOCKER 3 (CommunicationOverlay.tsx): Add `aria-hidden="true"` to the status
icon span so decorative glyphs (✓ ✕ ⏱) are not announced by screen readers.
Tests: 490/490 passing. Build: clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c936b451a9
commit
c33b59a93a
@ -150,7 +150,7 @@ export function CommunicationOverlay() {
|
||||
<span className="text-zinc-300 truncate">{c.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={statusColor}>{statusIcon}</span>
|
||||
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
|
||||
<span className="text-zinc-400">{age}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
117
canvas/src/components/__tests__/AuthGate.test.tsx
Normal file
117
canvas/src/components/__tests__/AuthGate.test.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Mocks (defined before dynamic import of component) ───────────────────────
|
||||
let mockFetchSession: ReturnType<typeof vi.fn>;
|
||||
let mockRedirectToLogin: ReturnType<typeof vi.fn>;
|
||||
let mockGetTenantSlug: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetchSession = vi.fn();
|
||||
mockRedirectToLogin = vi.fn();
|
||||
mockGetTenantSlug = vi.fn(() => null); // default: non-SaaS (pass-through)
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
fetchSession: (...args: unknown[]) => mockFetchSession(...args),
|
||||
redirectToLogin: (...args: unknown[]) => mockRedirectToLogin(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tenant", () => ({
|
||||
getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { AuthGate } from "../AuthGate";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AuthGate — loading state", () => {
|
||||
it("renders a blank overlay while session fetch is in-flight (prevents flash of unauth'd content)", () => {
|
||||
// getTenantSlug returns a slug so the session fetch is triggered
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
// fetchSession never resolves — keeps the component in loading state
|
||||
mockFetchSession.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { container } = render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
|
||||
const overlay = container.querySelector(".bg-zinc-950.fixed.inset-0");
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not render children while in loading state", () => {
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
mockFetchSession.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { queryByTestId } = render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
|
||||
expect(queryByTestId("child")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate — non-SaaS / pass-through mode", () => {
|
||||
it("renders children immediately when there is no tenant slug", async () => {
|
||||
mockGetTenantSlug.mockReturnValue(null);
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
});
|
||||
|
||||
expect(result!.getByTestId("child")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate — authenticated state", () => {
|
||||
it("renders children after a successful session fetch", async () => {
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
mockFetchSession.mockResolvedValue({ userId: "u1", email: "a@b.com" });
|
||||
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
});
|
||||
|
||||
expect(result!.getByTestId("child")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate — anonymous / redirect state", () => {
|
||||
it("calls redirectToLogin when session fetch returns null", async () => {
|
||||
mockGetTenantSlug.mockReturnValue("acme");
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<AuthGate>
|
||||
<div data-testid="child">Protected content</div>
|
||||
</AuthGate>
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockRedirectToLogin).toHaveBeenCalledWith("sign-in");
|
||||
});
|
||||
});
|
||||
@ -109,6 +109,7 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
}}
|
||||
>
|
||||
<button
|
||||
id="chat-tab-my-chat"
|
||||
role="tab"
|
||||
aria-selected={subTab === "my-chat"}
|
||||
aria-controls="chat-panel-my-chat"
|
||||
@ -123,6 +124,7 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
My Chat
|
||||
</button>
|
||||
<button
|
||||
id="chat-tab-agent-comms"
|
||||
role="tab"
|
||||
aria-selected={subTab === "agent-comms"}
|
||||
aria-controls="chat-panel-agent-comms"
|
||||
@ -137,17 +139,14 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
Agent Comms
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{subTab === "my-chat" ? (
|
||||
<div id="chat-panel-my-chat" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
</div>
|
||||
) : (
|
||||
<div id="chat-panel-agent-comms" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
</div>
|
||||
)}
|
||||
{/* Content — both panels are always in the DOM so aria-controls targets exist.
|
||||
The inactive panel is hidden via the HTML `hidden` attribute (removed from
|
||||
display and accessibility tree, but present in the DOM for WCAG 4.1.2). */}
|
||||
<div id="chat-panel-my-chat" role="tabpanel" aria-labelledby="chat-tab-my-chat" hidden={subTab !== "my-chat"} className="flex-1 overflow-hidden flex flex-col">
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
</div>
|
||||
<div id="chat-panel-agent-comms" role="tabpanel" aria-labelledby="chat-tab-agent-comms" hidden={subTab !== "agent-comms"} className="flex-1 overflow-hidden flex flex-col">
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -398,7 +397,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div className="mt-1.5 text-[9px] text-zinc-500 space-y-0.5">
|
||||
<div className="text-zinc-400">Processing with {runtimeDisplayName(data.runtime)}...</div>
|
||||
{activityLog.map((line, i) => (
|
||||
<div key={i} className="pl-2 border-l border-zinc-700">◇ {line}</div>
|
||||
<div key={line + i} className="pl-2 border-l border-zinc-700">◇ {line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user