126 lines
4.7 KiB
TypeScript
126 lines
4.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();
|
||
|
|
await expect(page.getByRole('dialog').first()).toBeVisible();
|
||
|
|
await auditPage(page, 'room settings');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|