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'); }); }); });