diff --git a/package-lock.json b/package-lock.json index 1bfbec5..70d29c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^4.2.1", + "axe-core": "^4.13.0", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", @@ -518,6 +519,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -535,6 +537,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -552,6 +555,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -569,6 +573,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -586,6 +591,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -603,6 +609,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -620,6 +627,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -637,6 +645,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -654,6 +663,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -671,6 +681,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -688,6 +699,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -705,6 +717,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -722,6 +735,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -739,6 +753,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -756,6 +771,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -773,6 +789,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -790,6 +807,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -807,6 +825,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -824,6 +843,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -841,6 +861,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -858,6 +879,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -875,6 +897,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -892,6 +915,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -909,6 +933,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -926,6 +951,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -943,6 +969,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1738,9 +1765,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1758,9 +1782,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1778,9 +1799,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1798,9 +1816,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1818,9 +1833,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1838,9 +1850,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2036,9 +2045,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2053,9 +2059,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2070,9 +2073,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2087,9 +2087,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2104,9 +2101,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2121,9 +2115,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2138,9 +2129,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2155,9 +2143,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2172,9 +2157,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2189,9 +2171,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2206,9 +2185,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2223,9 +2199,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2240,9 +2213,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4370,7 +4340,9 @@ } }, "node_modules/axe-core": { - "version": "4.12.1", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -5643,6 +5615,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8033,9 +8006,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8057,9 +8027,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8081,9 +8048,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8105,9 +8069,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index b0428a0..1181c7e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint": "eslint .", "preview": "vite preview", "storybook": "storybook dev -p 6006", - "test": "vitest run test/integration test/unit test/lighthouse-config.test.js", + "test": "vitest run test/integration test/unit test/components test/services test/lighthouse-config.test.js", "test:lighthouse": "vitest run test/lighthouse-config.test.js", "test:watch": "vitest" }, @@ -32,6 +32,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^4.2.1", + "axe-core": "^4.13.0", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", diff --git a/src/components/Button.jsx b/src/components/Button.jsx index 967a8c3..35141b7 100644 --- a/src/components/Button.jsx +++ b/src/components/Button.jsx @@ -1,3 +1,4 @@ +import { forwardRef } from 'react'; import { Link } from 'react-router-dom'; import './Button.css'; @@ -10,17 +11,23 @@ import './Button.css'; * @param {'button'|'submit'} [props.type] * @param {string} [props.ariaLabel] - accessible label for icon-only buttons * @param {string} [props.title] - native tooltip text + * @param {string} [props.ariaHasPopup] - ARIA popup type when the button + * opens a dialog or menu (e.g. "dialog") */ -export default function Button({ - children, - variant = 'primary', - disabled = false, - type = 'button', - onClick, - to, - ariaLabel, - title, -}) { +const Button = forwardRef(function Button( + { + children, + variant = 'primary', + disabled = false, + type = 'button', + onClick, + to, + ariaLabel, + title, + ariaHasPopup, + }, + ref, +) { const className = `btn btn-${variant}`; if (to) { @@ -47,8 +54,12 @@ export default function Button({ onClick={onClick} aria-label={ariaLabel} title={title} + aria-haspopup={ariaHasPopup} + ref={ref} > {children} ); -} +}); + +export default Button; diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx index 8159ad9..f565b59 100644 --- a/src/components/Modal.jsx +++ b/src/components/Modal.jsx @@ -1,19 +1,79 @@ -import { useRef } from 'react'; +import { useEffect, useId, useRef } from 'react'; import { useKeyPress } from '../hooks/useKeyPress.js'; import { useOnClickOutside } from '../hooks/useOnClickOutside.js'; import './Modal.css'; +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +function getFocusableElements(container) { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)); +} + /** * Accessible dialog rendered over an overlay. * Closes on Escape, overlay click, or the close button. + * + * Keyboard behaviour: + * - On open, focus moves to the dialog panel so screen readers announce it. + * - Tab / Shift+Tab cycle within the dialog (focus is trapped while open). + * - On close, focus returns to the element that was focused before opening. * @param {object} props * @param {boolean} props.open - whether the dialog is visible * @param {Function} props.onClose - called when the dialog should close - * @param {string} [props.title] - heading shown in the header + * @param {string} [props.title] - heading shown in the header; used as the + * dialog's accessible name * @param {React.ReactNode} props.children - dialog body content */ export default function Modal({ open, onClose, title, children }) { const panelRef = useRef(null); + const previouslyFocusedRef = useRef(null); + const titleId = useId(); + + // Move focus into the dialog while it is open, and restore focus to the + // invoker when it closes or unmounts. + useEffect(() => { + if (!open) return undefined; + + previouslyFocusedRef.current = document.activeElement; + panelRef.current?.focus(); + + return () => { + const previous = previouslyFocusedRef.current; + if (previous && typeof previous.focus === 'function') { + previous.focus(); + } + previouslyFocusedRef.current = null; + }; + }, [open]); + + function handleKeyDown(event) { + if (event.key !== 'Tab') return; + const panel = panelRef.current; + if (!panel) return; + + const focusables = getFocusableElements(panel); + if (focusables.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const activeElement = document.activeElement; + const currentIndex = focusables.indexOf(activeElement); + + if (event.shiftKey) { + // Backwards from the first stop (or an unfocused panel) wraps to the end. + if (currentIndex <= 0) { + event.preventDefault(); + focusables[focusables.length - 1].focus(); + } + } else if (currentIndex === -1 || currentIndex === focusables.length - 1) { + // Forwards past the last stop (or from the unfocused panel) wraps round. + event.preventDefault(); + focusables[0].focus(); + } + } useKeyPress('Escape', () => { if (open) onClose(); @@ -25,16 +85,22 @@ export default function Modal({ open, onClose, title, children }) { if (!open) return null; return ( -
+
- {title &&

{title}

} + {/* h2: dialogs sit under the page's h1 in the heading outline. */} + {title && ( +

+ {title} +

+ )} @@ -201,6 +250,80 @@ export default function SendMoney() { )}
+ + {phase === 'confirm' && pendingQuote && ( + +
+
+
To
+
{recipient}
+
+
+ +

+ {submitting ? 'Submitting your transfer…' : ''} +

+
+ + +
+
+ )} + + {phase === 'success' && submittedTransfer && ( + setPhase(null)} title="Transfer submitted"> +

+ Your transfer was submitted successfully. Track its progress under + Transfers. +

+
+
+
To
+
{submittedTransfer.recipient}
+
+
+
You send
+
+ {formatAmount( + submittedTransfer.sendAmount, + submittedTransfer.from, + locale, + )} +
+
+
+
Recipient gets
+
+ {formatAmount( + submittedTransfer.receiveAmount, + submittedTransfer.to, + locale, + )} +
+
+
+
Status
+
{submittedTransfer.status}
+
+
+
+ + +
+
+ )}
); } diff --git a/test/components/Modal.test.jsx b/test/components/Modal.test.jsx new file mode 100644 index 0000000..d509dd5 --- /dev/null +++ b/test/components/Modal.test.jsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import Modal from '../../src/components/Modal.jsx'; + +function DialogHarness({ onClose }) { + const [open, setOpen] = useState(false); + const close = onClose ?? (() => setOpen(false)); + return ( +
+ + +

Dialog body

+ + +
+
+ ); +} + +async function openDialog(user) { + await user.click(screen.getByRole('button', { name: /open dialog/i })); + return screen.findByRole('dialog'); +} + +describe('Modal accessibility and focus management', () => { + it('exposes a modal dialog labelled by its visible title', async () => { + const user = userEvent.setup(); + render(); + + const dialog = await openDialog(user); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + + // The accessible name comes from the visible heading via aria-labelledby. + const title = screen.getByRole('heading', { name: 'Example dialog' }); + expect(dialog).toHaveAttribute('aria-labelledby', title.getAttribute('id')); + }); + + it('moves focus into the dialog when it opens', async () => { + const user = userEvent.setup(); + render(); + + const dialog = await openDialog(user); + await waitFor(() => { + expect(dialog).toHaveFocus(); + }); + }); + + it('keeps Tab cycling inside the dialog (focus trap)', async () => { + const user = userEvent.setup(); + render(); + + const dialog = await openDialog(user); + const closeButton = screen.getByRole('button', { name: /close dialog/i }); + const firstAction = screen.getByRole('button', { name: /first action/i }); + const secondAction = screen.getByRole('button', { name: /second action/i }); + + // From the panel, Tab enters the dialog's focus cycle. + await user.tab(); + expect(document.activeElement).toBe(closeButton); + await user.tab(); + expect(document.activeElement).toBe(firstAction); + await user.tab(); + expect(document.activeElement).toBe(secondAction); + + // Past the last control, focus wraps back to the first. + await user.tab(); + expect(document.activeElement).not.toBe(secondAction); + expect( + [dialog, closeButton, firstAction].includes(document.activeElement), + ).toBe(true); + }); + + it('wraps Shift+Tab backwards from the first control to the last', async () => { + const user = userEvent.setup(); + render(); + + const dialog = await openDialog(user); + const closeButton = screen.getByRole('button', { name: /close dialog/i }); + const secondAction = screen.getByRole('button', { name: /second action/i }); + + await user.tab(); // panel -> close button (first stop) + expect(document.activeElement).toBe(closeButton); + + await user.tab({ shift: true }); + expect(document.activeElement).toBe(secondAction); + }); + + it('returns focus to the invoking element when Escape closes the dialog', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: /open dialog/i }); + await user.click(trigger); + await screen.findByRole('dialog'); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Regression: focus must not be lost to when the dialog closes. + expect(document.activeElement).toBe(trigger); + }); + + it('returns focus to the invoking element on programmatic close', async () => { + const user = userEvent.setup(); + render(); + + const trigger = screen.getByRole('button', { name: /open dialog/i }); + await user.click(trigger); + await screen.findByRole('dialog'); + + await user.click(screen.getByRole('button', { name: /close dialog/i })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(document.activeElement).toBe(trigger); + }); + + it('closes when the overlay outside the panel is clicked', async () => { + const user = userEvent.setup(); + render(); + + await openDialog(user); + // Clicks inside the body must not dismiss it… + await user.click(screen.getByText(/dialog body/i)); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // …but a click on the overlay does. The overlay fills the viewport, so + // click near its edge by targeting a point left of the centred panel. + const overlay = document.querySelector('.modal-overlay'); + overlay.getBoundingClientRect = () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + width: 1000, + height: 800, + right: 1000, + bottom: 800, + toJSON: () => {}, + }); + await user.click(overlay, undefined, { skipPointerEventsCheck: true }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/test/integration/send-flow-accessibility.test.jsx b/test/integration/send-flow-accessibility.test.jsx new file mode 100644 index 0000000..e8161bc --- /dev/null +++ b/test/integration/send-flow-accessibility.test.jsx @@ -0,0 +1,184 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it } from 'vitest'; +import App from '../../src/App.jsx'; + +/** + * Keyboard-only acceptance tests for the complete Send Money flow. + * No pointer events are used anywhere: every step is performed with + * focus(), typing and key presses only, mirroring how a keyboard or + * switch-device user operates the app. + */ +describe('Send Money flow without a pointer', () => { + beforeEach(() => { + window.history.pushState({}, '', '/send'); + localStorage.clear(); + }); + + async function fillFormWithKeyboard(user) { + const recipient = screen.getByLabelText(/recipient/i); + recipient.focus(); + await user.keyboard('amina@example.com'); + + // Tab moves to the amount field next in DOM/tab order. + await user.keyboard('{Tab}'); + expect(document.activeElement).toBe(screen.getByLabelText(/amount/i)); + await user.keyboard('15'); + // Default USD -> EUR pair differs, so currency edits aren't needed here. + } + + it('reviews, confirms and reaches the result using only the keyboard', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByRole('heading', { name: /send money/i }); + await fillFormWithKeyboard(user); + + // Enter anywhere in the form submits it, opening the review dialog. + await user.keyboard('{Enter}'); + const confirmDialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + timeout: 5000, + }); + + // Focus moves into the dialog so screen readers announce its title. + await waitFor(() => { + expect(confirmDialog).toHaveFocus(); + }); + + // The quote breakdown is part of the review content. + expect( + within(confirmDialog).getByText(/transfer summary/i), + ).toBeInTheDocument(); + expect(within(confirmDialog).getByText('$15.00')).toBeInTheDocument(); + + // Tab cycles within the dialog: close button -> Back -> Confirm. + await user.keyboard('{Tab}'); + expect(document.activeElement).toHaveAttribute( + 'aria-label', + 'Close dialog', + ); + await user.keyboard('{Tab}'); + expect(document.activeElement.textContent).toBe('Back'); + await user.keyboard('{Tab}'); + expect(document.activeElement.textContent).toBe('Confirm transfer'); + + // Activate confirmation with Enter and observe the announced progress. + await user.keyboard('{Enter}'); + expect( + await screen.findByText( + /submitting your transfer/i, + {}, + { timeout: 5000 }, + ), + ).toBeInTheDocument(); + + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + + // Success is announced via a polite live region inside the dialog. + const announcement = within(resultDialog).getByText( + /your transfer was submitted successfully/i, + ); + expect(announcement).toHaveAttribute('role', 'status'); + await waitFor(() => { + expect(resultDialog).toHaveFocus(); + }); + + // Result summary is readable without a pointer too. + expect( + within(resultDialog).getByText(/amina@example\.com/i), + ).toBeInTheDocument(); + + // Tab to "View transfers" and activate it to finish the journey. + await user.keyboard('{Tab}'); + expect(document.activeElement).toHaveAttribute( + 'aria-label', + 'Close dialog', + ); + await user.keyboard('{Tab}'); // Close + await user.keyboard('{Tab}'); // View transfers + expect(document.activeElement.textContent).toBe('View transfers'); + await user.keyboard('{Enter}'); + + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); + }); + + it('closes the review dialog with Escape and returns focus to the form', async () => { + const user = userEvent.setup(); + render(); + + await fillFormWithKeyboard(user); + await user.keyboard('{Enter}'); + + const confirmDialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await waitFor(() => { + expect(confirmDialog).toHaveFocus(); + }); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Regression: dismissing the dialog must hand focus back to the control + // that invoked it — here the amount field the user submitted from — + // never dropping it on . + const amountField = screen.getByLabelText(/amount/i); + expect(document.activeElement).toBe(amountField); + + // The flow remains operable afterwards. + await user.keyboard('{Enter}'); + expect( + await screen.findByRole('dialog', { name: /confirm your transfer/i }), + ).toBeInTheDocument(); + }); + + it('associates and announces validation errors on a keyboard-only submit', async () => { + const user = userEvent.setup(); + render(); + + const recipient = screen.getByLabelText(/recipient/i); + recipient.focus(); + await user.keyboard('{Enter}'); + + // Focus jumps to the first invalid field… + await waitFor(() => { + expect(recipient).toHaveFocus(); + }); + expect(recipient).toHaveAttribute('aria-invalid', 'true'); + expect(recipient).toHaveAttribute('aria-describedby', 'recipient-error'); + + // …and every aria-describedby target actually exists with alert semantics. + const describedBy = recipient.getAttribute('aria-describedby'); + const errorNode = document.getElementById(describedBy); + expect(errorNode).not.toBeNull(); + expect(errorNode).toHaveAttribute('role', 'alert'); + expect(errorNode.textContent).toMatch(/valid email or stellar address/i); + + const amountInput = screen.getByLabelText(/amount/i); + expect(amountInput).toHaveAttribute('aria-invalid', 'true'); + const amountError = document.getElementById( + amountInput.getAttribute('aria-describedby'), + ); + expect(amountError).not.toBeNull(); + expect(amountError.textContent).toMatch(/greater than zero/i); + + // An assertive summary announces the failure count. + const summary = screen.getByText( + /form submission failed with 2 validation errors/i, + ); + expect(summary).toHaveAttribute('aria-live', 'assertive'); + + // No dialog should have opened for an invalid submission. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/test/integration/send-flow-axe.test.jsx b/test/integration/send-flow-axe.test.jsx new file mode 100644 index 0000000..59d9594 --- /dev/null +++ b/test/integration/send-flow-axe.test.jsx @@ -0,0 +1,101 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +// axe-core attaches itself to the global window object when imported. +import 'axe-core'; + +const axe = window.axe; +import App from '../../src/App.jsx'; + +/** + * Automated accessibility audit of the Send Money flow using axe-core. + * Each state of the flow is scanned for WCAG violations: + * 1. the empty form + * 2. the form with visible validation errors + * 3. the quote confirmation dialog + * 4. the transfer-submitted result dialog + * + * Rules that require a real layout/paint pipeline (colour contrast from + * stylesheets, landmark regions) cannot be evaluated reliably in jsdom and + * are disabled; everything semantic is enforced. + */ +const JSDOM_LIMITED_RULES = { + 'color-contrast': { enabled: false }, + region: { enabled: false }, +}; + +async function expectNoAxeViolations(container) { + const results = await axe.run(container, { + rules: JSDOM_LIMITED_RULES, + resultTypes: ['violations'], + }); + const summary = results.violations + .map( + (v) => + `${v.id} (${v.help}): ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`, + ) + .join('\n'); + expect(summary).toBe(''); +} + +describe('Send Money automated accessibility audit', () => { + beforeEach(() => { + window.history.pushState({}, '', '/send'); + localStorage.clear(); + }); + + it('has no axe violations on the initial form', async () => { + const { container } = render(); + await screen.findByRole('heading', { name: /send money/i }); + + await expectNoAxeViolations(container); + }); + + it('has no axe violations with validation errors displayed', async () => { + const { container } = render(); + const user = userEvent.setup(); + + await screen.findByRole('button', { name: /review & send/i }); + await user.click(screen.getByRole('button', { name: /review & send/i })); + await screen.findByText(/enter a valid email or stellar address/i); + + await expectNoAxeViolations(container); + }); + + it('has no axe violations in the quote confirmation dialog', async () => { + const { container } = render(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.type(screen.getByLabelText(/amount/i), '15'); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await expectNoAxeViolations(dialog); + }); + + it('has no axe violations in the transfer-submitted result dialog', async () => { + render(); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.type(screen.getByLabelText(/amount/i), '15'); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await expectNoAxeViolations(resultDialog); + }, 20000); +}); diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx index 956eded..25f6a23 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -4,6 +4,7 @@ import { render, screen, waitFor, + within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -25,6 +26,36 @@ function createdTransfer(payload) { }; } +// Open the quote confirmation dialog and submit the transfer from it. +async function reviewAndConfirm(user) { + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + // Wallet connect + transfer creation are mocked with real delays. + return screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { + timeout: 5000, + }, + ); +} + +async function finishOnTransfersPage(user, resultDialog) { + await user.click( + within(resultDialog).getByRole('button', { name: /view transfers/i }), + ); + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); +} + describe('Send money form flows', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); @@ -47,6 +78,10 @@ describe('Send money form flows', () => { expect( screen.getByText(/enter an amount greater than zero/i), ).toBeInTheDocument(); + // Invalid submissions must not open the confirmation dialog. + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); }); it('submits a transfer and shows it on the transfers page', async () => { @@ -54,55 +89,65 @@ describe('Send money form flows', () => { render(); await fillValidForm(user); - await user.click(screen.getByRole('button', { name: /review & send/i })); + const resultDialog = await reviewAndConfirm(user); + await finishOnTransfersPage(user, resultDialog); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); await waitFor(() => { expect(screen.getByText('$15.00')).toBeInTheDocument(); }); expect(screen.getAllByText(/pending/i).length).toBeGreaterThan(0); }); - it('disables the submit button while wallet connection is pending', async () => { + it('disables the confirm button while wallet connection is pending', async () => { const createTransfer = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); render(); await fillValidForm(user); await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled(); expect(createTransfer).not.toHaveBeenCalled(); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, { timeout: 5000 }, ); + await finishOnTransfersPage(user, resultDialog); }); - it('creates one transfer after two rapid submit-button clicks', async () => { + it('creates one transfer after two rapid confirm-button clicks', async () => { const createTransfer = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); render(); await fillValidForm(user); - const submitButton = screen.getByRole('button', { name: /review & send/i }); + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + const confirmButton = within(dialog).getByRole('button', { + name: /confirm transfer/i, + }); act(() => { - submitButton.click(); - submitButton.click(); + confirmButton.click(); + confirmButton.click(); }); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, { timeout: 5000 }, ); expect(createTransfer).toHaveBeenCalledTimes(1); + await finishOnTransfersPage(user, resultDialog); }); it('creates one transfer after two synchronous native submit events', async () => { @@ -119,12 +164,22 @@ describe('Send money form flows', () => { fireEvent.submit(form); }); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, + // A repeated review request must not stack a second confirmation dialog. + const dialogs = screen.getAllByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(dialogs).toHaveLength(1); + + await user.click( + within(dialogs[0]).getByRole('button', { name: /confirm transfer/i }), + ); + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, { timeout: 5000 }, ); expect(createTransfer).toHaveBeenCalledTimes(1); + await finishOnTransfersPage(user, resultDialog); }); it('releases the submission lock after failure and permits a retry', async () => { @@ -137,6 +192,12 @@ describe('Send money form flows', () => { await fillValidForm(user); await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); expect( await screen.findByText(/could not submit the transfer/i), @@ -146,11 +207,20 @@ describe('Send money form flows', () => { await user.click(retryButton); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, + // Re-review, then confirm again through the dialog. + const retryDialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + await user.click( + within(retryDialog).getByRole('button', { name: /confirm transfer/i }), + ); + + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, { timeout: 5000 }, ); + await finishOnTransfersPage(user, resultDialog); expect(createTransfer).toHaveBeenCalledTimes(2); }); @@ -168,13 +238,8 @@ describe('Send money form flows', () => { ).toBeEnabled(); await fillValidForm(user); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + const resultDialog = await reviewAndConfirm(user); + await finishOnTransfersPage(user, resultDialog); expect(createTransfer).toHaveBeenCalledTimes(1); }); });