From 9e856a87f6ea38c99749b3e6a4a25bcedea96115 Mon Sep 17 00:00:00 2001 From: Martin Obe Date: Sat, 25 Jul 2026 15:19:03 +0100 Subject: [PATCH 1/3] Closes #656: Add automated accessibility (jest-axe) tests for the toast view --- src/components/toast/toast-provider.test.tsx | 205 +++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/src/components/toast/toast-provider.test.tsx b/src/components/toast/toast-provider.test.tsx index d4fc17ff..60097904 100644 --- a/src/components/toast/toast-provider.test.tsx +++ b/src/components/toast/toast-provider.test.tsx @@ -4,6 +4,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { StrictMode } from 'react'; import { PreferencesProvider } from '@/lib/preferences'; import { ToastProvider, useToast } from './toast-provider'; +import { axe } from 'jest-axe'; function ToastHarness() { const { showError, showSuccess } = useToast(); @@ -1374,3 +1375,207 @@ describe('toastDuration preference', () => { expect(screen.getAllByRole('status')).toHaveLength(4); }); }); + +// --------------------------------------------------------------------------- +// a11y: toast view — jest-axe assertions +// +// Covers the three key rendering states of the toast view: +// • empty — ToastProvider mounted, no toasts in the viewport +// • loaded — a success toast is visible (role="status", polite live region) +// • error — an error toast is visible (role="alert", assertive live region) +// +// Each test uses axe() directly so violations are surfaced as readable +// diff output rather than a generic length assertion. +// +// Timer discipline: fake timers are engaged so no toast auto-dismisses +// mid-assertion (which would make axe see a partially-removed DOM). +// --------------------------------------------------------------------------- + +/** + * Minimal harness: two trigger buttons, no side-effects. + * Both are rendered even in the "empty" state so the component tree + * is identical across all three states — only the trigger call differs. + */ +function A11yHarness() { + const { showSuccess, showError } = useToast(); + return ( +
+ + +
+ ); +} + +describe('a11y: toast view — jest-axe', () => { + // Real timers are required here: axe() uses window.setTimeout internally + // and will deadlock if fake timers are active (the Promise it creates + // will never settle). The toasts are created with a long duration so + // they will not auto-dismiss during the ~50 ms axe run. + + it('empty state (no toasts) has no axe violations', async () => { + const { container } = render( + + + , + ); + + // No toast triggered — viewport renders but is empty. + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it('loaded state (success toast visible) has no axe violations', async () => { + const { container } = render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /trigger success/i })); + + // Verify the toast is actually in the DOM before running axe. + expect(screen.getByRole('status')).toBeInTheDocument(); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it('error state (error toast visible) has no axe violations', async () => { + const { container } = render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /trigger error/i })); + + // Verify the toast is actually in the DOM before running axe. + expect(screen.getByRole('alert')).toBeInTheDocument(); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it('loaded state with an action button has no axe violations', async () => { + function ActionHarness() { + const { showSuccess } = useToast(); + return ( + + ); + } + + const { container } = render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /trigger action toast/i })); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Undo' })).toBeInTheDocument(); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it('error state with an action button has no axe violations', async () => { + function ActionErrorHarness() { + const { showError } = useToast(); + return ( + + ); + } + + const { container } = render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /trigger action error toast/i })); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + + it('max-visible cap (4 toasts) has no axe violations', async () => { + function FourToastHarness() { + const { showSuccess } = useToast(); + return ( + + ); + } + + const { container } = render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /add 4 toasts/i })); + + expect(screen.getAllByRole('status')).toHaveLength(4); + + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); From cdf841bf5bab49781c0a326825dc699f40b90f34 Mon Sep 17 00:00:00 2001 From: Martin Obe Date: Sat, 25 Jul 2026 15:36:48 +0100 Subject: [PATCH 2/3] Closes #648: Make the navigation controls fully keyboard-operable --- src/components/WalletConnectButton.tsx | 2 +- src/components/__tests__/Navbar.test.tsx | 83 ++++++- .../__tests__/WalletConnectButton.test.tsx | 235 ++++++++++++++++++ 3 files changed, 318 insertions(+), 2 deletions(-) diff --git a/src/components/WalletConnectButton.tsx b/src/components/WalletConnectButton.tsx index b94fbd8f..d1817d7e 100644 --- a/src/components/WalletConnectButton.tsx +++ b/src/components/WalletConnectButton.tsx @@ -54,7 +54,7 @@ export const WalletConnectButton = () => { Connection Error