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
- 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
144 lines
5.7 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|