diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index fcbba2b4d..cb1c09cc8 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -57,6 +57,8 @@ Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They bo | **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` | | **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script | +**Accessibility gate** (Gitea #222): `e2e/a11y.spec.ts` runs `@axe-core/playwright` (WCAG 2.x A/AA tags) over the login page, room timeline + composer, message options menu, thread panel, user settings and room settings, and fails on any **critical/serious** finding except `color-contrast` (reported in the log, not gated — generated avatar colours and portal false positives). It also keeps accessibility-tree snapshots (`e2e/a11y.spec.ts-snapshots/*.aria.yml`) of the composer, message menu, thread panel and settings nav, so a lost name/role/live-region shows as a diff; update them deliberately with `npx playwright test e2e/a11y --update-snapshots` and keep dynamic bits as regexes. A real NVDA/VoiceOver pass is still manual. + **Browsers** (Gitea #221): everything runs under Chromium; the tests tagged `@webkit` (boot, login + send/receive, thread panel, lightbox) also run under Playwright's WebKit as desktop Safari, and those tagged `@ios` under the `iPhone 14` descriptor — the closest CI gets to Safari/iOS. It catches WebKit-only breakage (CSS, `dvh`, IndexedDB, media decode) but does not emulate the on-screen keyboard or Home-Screen install; a real iPhone pass (#166/#199) stays manual. Locally: `npx playwright install --with-deps webkit` once, then `npx playwright test --project=webkit --project=iphone`. WebKit words handled fetch failures as page errors (`TypeError: Load failed`, `due to access control checks`), so the allowlist in `e2e/helpers.ts` applies to page errors too. **CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs): diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts new file mode 100644 index 000000000..1c233f715 --- /dev/null +++ b/e2e/a11y.spec.ts @@ -0,0 +1,125 @@ +import { test, expect, Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { + createRoom, + hsReachable, + loginUI, + openRoom, + ensureUser, + sendText, + uniq, + TestUser, +} from './localHs'; + +// [Gitea #222] Machine-checkable accessibility on every push. Two parts: +// 1. axe-core over the main surfaces, failing on critical/serious findings. +// Colour-contrast is reported but not gated: several hits are generated +// avatar colours and portal false positives (see the issue for the list). +// 2. Accessibility-tree snapshots of the composer, message menu, thread +// panel and settings nav, so a lost name/role/live-region shows up as a +// diff. Update deliberately with `npx playwright test e2e/a11y --update-snapshots`. +// A real screen-reader pass still needs a human. + +const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']; + +async function auditPage(page: Page, label: string) { + const results = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + const describe = (v: (typeof results.violations)[number]) => + `[${v.impact}] ${v.id}: ${v.help}\n${v.nodes + .slice(0, 5) + .map((n) => ` ${n.html.replace(/\s+/g, ' ').slice(0, 140)}`) + .join('\n')}`; + const contrast = results.violations.filter((v) => v.id === 'color-contrast'); + if (contrast.length) { + // eslint-disable-next-line no-console + console.log(`axe ${label}: contrast (not gated)\n${contrast.map(describe).join('\n')}`); + } + const gated = results.violations.filter( + (v) => v.id !== 'color-contrast' && (v.impact === 'critical' || v.impact === 'serious'), + ); + expect(gated.map(describe), `axe ${label}: critical/serious findings`).toEqual([]); +} + +test.describe('accessibility', () => { + test('login page passes axe @webkit', async ({ page }) => { + await page.goto('/'); + await expect(page.getByLabel('Username or email')).toBeVisible(); + await auditPage(page, 'login'); + }); + + test.describe('signed in', () => { + let alice: TestUser; + let room: string; + + test.beforeAll(async () => { + test.skip(!(await hsReachable()), 'no local homeserver'); + alice = await ensureUser(uniq('e2e_a11y_')); + room = await createRoom(alice, 'A11y Room'); + const root = await sendText(alice, room, 'thread root for a11y'); + await sendText(alice, room, 'a reply', { + 'm.relates_to': { + rel_type: 'm.thread', + event_id: root, + is_falling_back: true, + 'm.in_reply_to': { event_id: root }, + }, + }); + }); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 850 }); + await loginUI(page, alice); + await openRoom(page, room); + await expect(page.getByText('thread root for a11y')).toBeVisible(); + }); + + test('room timeline + composer', async ({ page }) => { + await auditPage(page, 'room'); + await expect(page.locator('[data-slate-editor]').first()).toMatchAriaSnapshot({ + name: 'composer-editor.aria.yml', + }); + await expect(page.getByRole('button', { name: 'Send message' })).toMatchAriaSnapshot({ + name: 'composer-send.aria.yml', + }); + }); + + test('message options menu', async ({ page }) => { + const msg = page.locator('[data-message-item]', { hasText: 'thread root for a11y' }); + await msg.hover(); + await msg.getByRole('button', { name: 'More options' }).click(); + const menu = page.locator('[data-message-menu]').first(); + await expect(menu).toBeVisible(); + await auditPage(page, 'message menu'); + await expect(menu).toMatchAriaSnapshot({ name: 'message-menu.aria.yml' }); + }); + + test('thread panel', async ({ page }) => { + await page + .locator('[data-message-item]', { hasText: 'thread root for a11y' }) + .getByText(/1 reply/) + .click(); + await expect(page.locator('[data-slate-editor]')).toHaveCount(2); + await auditPage(page, 'thread panel'); + await expect(page.getByRole('complementary').first()).toMatchAriaSnapshot({ + name: 'thread-panel.aria.yml', + }); + }); + + test('user settings', async ({ page }) => { + await page.getByRole('button', { name: 'User Settings' }).click(); + const dialog = page.getByRole('dialog').first(); + await expect(dialog).toBeVisible(); + await auditPage(page, 'settings'); + await expect(dialog.getByRole('navigation').first()).toMatchAriaSnapshot({ + name: 'settings-nav.aria.yml', + }); + }); + + test('room settings', async ({ page }) => { + await page.getByRole('button', { name: 'More Options' }).first().click(); + await page.getByText('Room Settings', { exact: true }).click(); + await expect(page.getByRole('dialog').first()).toBeVisible(); + await auditPage(page, 'room settings'); + }); + }); +}); diff --git a/e2e/a11y.spec.ts-snapshots/composer-editor.aria.yml b/e2e/a11y.spec.ts-snapshots/composer-editor.aria.yml new file mode 100644 index 000000000..11e4e2474 --- /dev/null +++ b/e2e/a11y.spec.ts-snapshots/composer-editor.aria.yml @@ -0,0 +1,2 @@ +- textbox "Send a message...": + - paragraph: Send a message... diff --git a/e2e/a11y.spec.ts-snapshots/composer-send.aria.yml b/e2e/a11y.spec.ts-snapshots/composer-send.aria.yml new file mode 100644 index 000000000..17cdd2730 --- /dev/null +++ b/e2e/a11y.spec.ts-snapshots/composer-send.aria.yml @@ -0,0 +1,2 @@ +- button "Send message": + - img diff --git a/e2e/a11y.spec.ts-snapshots/message-menu.aria.yml b/e2e/a11y.spec.ts-snapshots/message-menu.aria.yml new file mode 100644 index 000000000..f3095edb6 --- /dev/null +++ b/e2e/a11y.spec.ts-snapshots/message-menu.aria.yml @@ -0,0 +1,39 @@ +- button "Add Reaction": + - text: '' + - img +- button "Reply": + - text: '' + - img +- button "Forward": + - text: '' + - img +- button "Bookmark Message": + - text: '' + - img +- button "Remind Me": + - text: '' + - img +- button "Edit Message": + - text: '' + - img +- button "Read Receipts": + - text: '' + - img +- button "Copy Text": + - text: '' + - img +- button "Translate": + - text: '' + - img +- button "Copy Link": + - text: '' + - img +- button "Copy Lotus Link": + - text: '' + - img +- button "Pin Message": + - text: '' + - img +- button "Delete": + - text: '' + - img diff --git a/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml b/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml new file mode 100644 index 000000000..e5085f090 --- /dev/null +++ b/e2e/a11y.spec.ts-snapshots/settings-nav.aria.yml @@ -0,0 +1,22 @@ +- navigation "Settings sections": + - button "General" [pressed]: + - img + - paragraph: General + - button "Account": + - img + - paragraph: Account + - button "Notifications": + - img + - paragraph: Notifications + - button "Devices": + - img + - paragraph: Devices + - button "Emojis & Stickers": + - img + - paragraph: Emojis & Stickers + - button "Developer Tools": + - img + - paragraph: Developer Tools + - button "About": + - img + - paragraph: About diff --git a/e2e/a11y.spec.ts-snapshots/thread-panel.aria.yml b/e2e/a11y.spec.ts-snapshots/thread-panel.aria.yml new file mode 100644 index 000000000..5de65bd7b --- /dev/null +++ b/e2e/a11y.spec.ts-snapshots/thread-panel.aria.yml @@ -0,0 +1,31 @@ +- complementary "Thread": + - paragraph: Thread + - paragraph: A11y Room + - button "Thread notifications": + - img + - button "Close thread": + - img + - log "Thread timeline": + - article: + - button /e2e_a11y_\w+, open profile/: + - img + - button /e2e_a11y_\w+/ + - time: /\d+:\d+ (AM|PM)/ + - text: thread root for a11y + - paragraph: 1 reply + - article: + - button /e2e_a11y_\w+, open profile/: + - img + - button /e2e_a11y_\w+/ + - time: /\d+:\d+ (AM|PM)/ + - text: a reply + - button "More actions": + - img + - textbox "Send a message...": + - paragraph: Send a message... + - button "Insert sticker": + - img + - button "Insert emoji": + - img + - button "Send message": + - img diff --git a/e2e/helpers.ts b/e2e/helpers.ts index c18fc270e..e3d251c69 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -19,7 +19,7 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [ // (CI) or a self-signed dev server (local calls stack). /ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i, // WebKit's spellings of the same discovery failures (#221). - /Unacceptable TLS certificate|Could not connect to the server|TypeError: Load failed/i, + /Unacceptable TLS certificate|Could not connect to|Connection refused|TypeError: Load failed/i, // Also a fetch cut short by our own navigation (e.g. the crypto wasm while // the test moves from /home to a room) — WebKit words that the same way. /due to access control checks/i, diff --git a/package-lock.json b/package-lock.json index 52ea15ba3..b603d9a42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "workbox-precaching": "7.4.1" }, "devDependencies": { + "@axe-core/playwright": "4.13.0", "@lotusguild/element-call-embedded": "0.25.0-lotus.9", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", @@ -172,6 +173,19 @@ "@babel/runtime": "^7.0.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -4847,10 +4861,11 @@ } }, "node_modules/axe-core": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz", - "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==", + "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": { "node": ">=4" } diff --git a/package.json b/package.json index 25c6c48d0..2516a083a 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "workbox-precaching": "7.4.1" }, "devDependencies": { + "@axe-core/playwright": "4.13.0", "@lotusguild/element-call-embedded": "0.25.0-lotus.9", "@playwright/test": "1.63.0", "@rollup/plugin-inject": "5.0.5", diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index b74008d5b..7281a421d 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) { (({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => ( - - {children} - -)); +>( + ( + { + as: AsNavItem = 'div', + className, + highlight, + variant, + radii, + children, + 'aria-selected': selected, + ...props + }, + ref, + ) => ( + // [Gitea #222] Callers pass `aria-selected`, but that attribute is only + // valid on option/tab/row roles; on a plain div axe flags it as critical. + // Keep the prop for callers and styling, expose the state as aria-current. + + {children} + + ), +); export const NavLink = forwardRef>( ({ className, ...props }, ref) => ( diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 0accbd7af..c9869e91a 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -69,7 +69,7 @@ const NavItemBase = style({ [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, }, - '&[aria-selected=true]': { + '&[data-selected=true]': { backgroundColor: ContainerActive, }, [`&:has(.${NavLink}:focus-visible)`]: { diff --git a/src/app/features/common-settings/general/RoomVoiceLimit.tsx b/src/app/features/common-settings/general/RoomVoiceLimit.tsx index 55ca87e75..9210f126d 100644 --- a/src/app/features/common-settings/general/RoomVoiceLimit.tsx +++ b/src/app/features/common-settings/general/RoomVoiceLimit.tsx @@ -65,6 +65,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) { + {canSendReaction && ( -
+
+