Files
cinny/e2e/a11y.spec.ts
T
Lotus CIandClaude Opus 5.5 885ec7c82e
CI / Build & Quality Checks (pull_request) Successful in 1m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m18s
fix(a11y): axe findings: dialog names, aria-pressed, labels, avatar alt, placeholder contrast (#278)
- Modal500 takes a label: Room/Space Settings ("<name> settings") and
  Settings dialogs have accessible names.
- aria-selected on plain buttons (inbox filter chips, date/time picker
  chips, verification/usage/notification menu items) -> aria-pressed; the
  Permissions power chips open a menu -> aria-haspopup + aria-expanded.
- Labels tied to the room pack name input and the Export From/To dates.
- UserAvatar defaults to alt="" (decorative next to a name / in a
  labelled button) unless a caller passes one.
- Composer placeholder: Slate's inline opacity 0.333 overrode the #222
  AA-contrast class (2.66:1); it is dropped so the class (0.75) applies.
- e2e/a11y.spec.ts also audits Room Settings → Permissions, Emojis &
  Stickers and Export, checks the dialog name, and audits the inbox.

Fixes #278

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:35:59 -04:00

144 lines
5.7 KiB
TypeScript

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();
// [Gitea #278] The dialog has an accessible name.
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
await auditPage(page, 'room settings');
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
// export date inputs were only on these pages.
// One page at a time, in order.
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
// eslint-disable-next-line no-await-in-loop
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(1000);
// eslint-disable-next-line no-await-in-loop
await auditPage(page, `room settings: ${name}`);
}
});
test('inbox invites', async ({ page }) => {
await page.goto('/inbox/invites/');
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
await auditPage(page, 'inbox invites');
});
});
});