diff --git a/frontend/src/__tests__/a11y.test.tsx b/frontend/src/__tests__/a11y.test.tsx new file mode 100644 index 00000000..5338f51f --- /dev/null +++ b/frontend/src/__tests__/a11y.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import axe, { type AxeResults } from "axe-core"; + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => ({ + wallets: [], + status: "disconnected", + selectedWalletId: null, + errorMessage: null, + connect: vi.fn(), + clearError: vi.fn(), + isConnected: vi.fn().mockResolvedValue({ isConnected: false }), + }), +})); + +vi.mock("@stellar/freighter-api", () => ({ + isConnected: () => Promise.resolve({ isConnected: false }), +})); + +// #1198 — Automated accessibility suite. +// +// Renders the primary interactive surfaces in happy-dom and asserts that axe +// reports zero critical/serious violations. `color-contrast` and any rule that +// depends on real browser layout (canvas-based color computation, native +// widget rendering) is disabled here because happy-dom cannot reproduce +// computed styles/canvas; the visual contrast audit is enforced separately via +// the dark-mode token changes and a browser-based Playwright run. + +import { IncomingStreamCard } from "@/components/streams/IncomingStreamCard"; +import { StreamDetailsModal } from "@/components/dashboard/StreamDetailsModal"; +import { TopUpModal } from "@/components/stream-creation/TopUpModal"; +import { WalletModal } from "@/components/wallet/WalletModal"; +import type { IncomingStreamRecord } from "@/lib/api/streams"; +import type { Stream } from "@/lib/dashboard"; + +const AXE_RULES = (() => { + const disabled: { + [key: string]: { enabled: false }; + } = { + "color-contrast": { enabled: false }, + }; + return disabled; +})(); + +async function assertNoCriticalOrSerious(container: HTMLElement) { + const results: AxeResults = await axe.run(container, { + rules: AXE_RULES, + resultTypes: ["violations"], + }); + const failures = results.violations.filter((v) => + v.impact === "critical" || v.impact === "serious" + ); + expect( + failures.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`), + ).toEqual([]); +} + +const streamRecord: IncomingStreamRecord = { + id: "stream-1", + streamId: 1, + sender: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + senderDisplay: "alice*stellar", + token: "USDC", + tokenAddress: "CAS3FLKZ2N6YUFY66TKSXJQVOTLNOB4IIBW7YHDWQ7M5AGPB2QRUUAAA", + ratePerSecond: 0.5, + deposited: 1000, + withdrawn: 0, + startTime: Math.floor(Date.now() / 1000) - 3600, + lastUpdateTime: Math.floor(Date.now() / 1000), + isActive: true, + isPaused: false, + pausedAt: null, + totalPausedDuration: 0, + status: "Active", +}; + +const mockStream: Stream = { + id: "stream-1", + recipient: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + amount: 1000, + token: "USDC", + status: "Active", + deposited: 1000, + withdrawn: 250, + date: "2026-08-30", + ratePerSecond: 0.5, + lastUpdateTime: Math.floor(Date.now() / 1000), + isActive: true, +}; + +afterEach(() => cleanup()); + +describe("Accessibility (axe-core) — critical & serious violations", () => { + it("IncomingStreamCard is accessible", async () => { + const { container } = render( + {}} + />, + ); + await assertNoCriticalOrSerious(container); + }); + + it("StreamDetailsModal is accessible with focusable content", async () => { + const { container } = render( + {}} + onCancelClick={() => {}} + onTopUpClick={() => {}} + />, + ); + + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(container.querySelector('[aria-modal="true"]')).not.toBeNull(); + + await assertNoCriticalOrSerious(container); + }); + + it("TopUpModal is accessible", async () => { + const { container } = render( + Promise.resolve()} + onClose={() => {}} + />, + ); + await assertNoCriticalOrSerious(container); + }); + + it("WalletModal is accessible", async () => { + const { container } = render( {}} />); + await assertNoCriticalOrSerious(container); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/live-value.test.tsx b/frontend/src/__tests__/live-value.test.tsx new file mode 100644 index 00000000..2b3c5428 --- /dev/null +++ b/frontend/src/__tests__/live-value.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act } from "@testing-library/react"; +import { LiveValue } from "@/components/ui/LiveValue"; + +afterEach(() => cleanup()); + +describe("LiveValue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders a polite atomic live region with the current value", () => { + const { container } = render(); + const region = container.querySelector('span[aria-live="polite"]'); + expect(region).not.toBeNull(); + expect(region?.getAttribute("aria-atomic")).toBe("true"); + expect(region?.textContent).toBe("Claimable amount 12.5 USDC"); + }); + + it("throttles announcements (at most one per cadence, not one per frame)", () => { + const { container, rerender } = render(); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1"); + + // Rapid value updates before the cadence elapses are not announced. + rerender(); + rerender(); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1"); + + // After the cadence, the latest value is announced. + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("2.0001"); + + // An unchanged value does not re-announce. + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("2.0001"); + }); + + it("announces the settled value within one cadence after streaming stops", () => { + const { container, rerender } = render(); + + rerender(); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1.0005"); + }); + + it("provides no live region until a value is announced", () => { + const { container } = render(); + expect(container.querySelector('[aria-live]')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d42f8839..3b1d35f9 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,6 +1,23 @@ @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); +/* + * #1198 — Visible keyboard focus indicator (WCAG 2.1 AA 2.4.7). + * Every interactive element must show a clear focus ring when reached via + * keyboard; component-level custom focus states complement rather than + * replace this baseline. + */ +:focus-visible { + outline: 2px solid var(--accent-secondary); + outline-offset: 2px; + border-radius: 2px; +} + +/* Neutralize the default outline only when a richer ring is provided. */ +:focus-visible:has(.focus-ring) { + outline: none; +} + :root { --background: #020617; --foreground: #f8fafc; diff --git a/frontend/src/app/streams/[id]/stream-details-content.tsx b/frontend/src/app/streams/[id]/stream-details-content.tsx index 63879898..b9f5b4fb 100644 --- a/frontend/src/app/streams/[id]/stream-details-content.tsx +++ b/frontend/src/app/streams/[id]/stream-details-content.tsx @@ -6,6 +6,7 @@ import { getApiBaseUrl } from "@/lib/api/_shared"; import { logger } from "@/lib/logger"; import { ArrowLeft, Pause, Play, X, Plus, Download, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/Button"; +import { LiveValue } from "@/components/ui/LiveValue"; import toast from "react-hot-toast"; import { useWallet } from "@/context/wallet-context"; import { useStreamEvents } from "@/hooks/useStreamEvents"; @@ -731,6 +732,7 @@ function StatCard({ {value} {live && }

+ {live && } ); } diff --git a/frontend/src/components/dashboard/StreamDetailsModal.tsx b/frontend/src/components/dashboard/StreamDetailsModal.tsx index 999891c2..a09c04a4 100644 --- a/frontend/src/components/dashboard/StreamDetailsModal.tsx +++ b/frontend/src/components/dashboard/StreamDetailsModal.tsx @@ -63,7 +63,8 @@ export const StreamDetailsModal: React.FC = ({ {stream.recipient}