Files
portfolio-next/e2e/a11y.spec.ts
T
Lucas WintherandClaude Opus 5.5 cf53e4280a test(e2e): axe-core WCAG A/AA checks on every kind of page
Colour contrast is left out until the dim tokens are retuned (owner looks first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 11:12:17 +01:00

99 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { P } from './helpers.ts';
// Accessibility and motion checks on the built fixture site (see playwright.config.ts).
test.beforeEach(async ({ page }) => {
// The analytics script is the one allowed third-party request; keep tests offline.
await page.route('https://analytics.lucaswinther.info/**', (r) => r.abort());
});
// Every element's (and pseudo-element's) computed durations, minus those that are all zero.
async function movingElements(page: Page): Promise<string[]> {
return page.evaluate(() => {
const zero = (v: string) => v.split(',').every((d) => parseFloat(d) === 0);
const out: string[] = [];
for (const el of document.querySelectorAll('*')) {
for (const pseudo of [null, '::before', '::after']) {
const cs = getComputedStyle(el, pseudo);
if (!zero(cs.transitionDuration) || !zero(cs.animationDuration)) {
const id = el.id ? `#${el.id}` : '';
const cls = typeof el.className === 'string' && el.className ? `.${el.className.trim().split(/\s+/).join('.')}` : '';
out.push(`${el.tagName.toLowerCase()}${id}${cls}${pseudo ?? ''}: ${cs.transitionDuration} / ${cs.animationDuration}`);
}
}
}
return out;
});
}
test.describe('reduced motion', () => {
test.use({ reducedMotion: 'reduce' });
for (const path of ['/', '/work/', '/work/learning/', '/off-the-clock/']) {
test(`nothing moves on ${path}`, async ({ page }) => {
await page.goto(path);
expect(await movingElements(page)).toEqual([]);
});
}
});
// Presses Tab until every wanted selector has had focus (or the budget runs out); returns the misses.
async function tabUntil(page: Page, wanted: Record<string, string>, budget = 120): Promise<string[]> {
const missing = new Set(Object.keys(wanted));
for (let i = 0; i < budget && missing.size; i++) {
await page.keyboard.press('Tab');
for (const name of [...missing]) {
if (await page.evaluate((sel) => document.activeElement?.matches(sel) ?? false, wanted[name]!)) missing.delete(name);
}
}
return [...missing];
}
test('Tab reaches both gates and the preference switch', async ({ page }) => {
await page.goto('/');
expect(
await tabUntil(page, {
'work gate': 'a[data-enter="work"]',
'personal gate': 'a[data-enter="personal"]',
'preference switch': '.pref-switch button',
}),
).toEqual([]);
});
test('Tab reaches the project list on /work/', async ({ page }) => {
await page.goto('/work/');
expect(await tabUntil(page, { 'preference switch': '.pref-switch button', 'project list': '#all a.row' })).toEqual([]);
});
test('Tab reaches the wall cards on /off-the-clock/', async ({ page }) => {
await page.goto('/off-the-clock/');
expect(await tabUntil(page, { 'wall card': '[data-wall-card] a' })).toEqual([]);
});
test('every image has alt text', async ({ page }) => {
await page.goto('/off-the-clock/issues/');
const issue = await page.locator('a[href^="/off-the-clock/issues/2"]').first().getAttribute('href');
const paths = ['/', '/work/', '/work/learning/', '/off-the-clock/', '/everything/', '/simple/', '/off-the-clock/fixture-comic/', issue!];
for (const path of paths) {
await page.goto(path);
const missing = await page.$$eval('img:not([alt])', (imgs) => imgs.map((i) => i.getAttribute('src')));
expect(missing, path).toEqual([]);
}
});
// axe-core (WCAG 2.x A and AA rules) on every kind of page. Spec 009 asked for /work/ and /work/learning/;
// the others ride along so the whole site keeps the same bar. `color-contrast` is left out until the dim text
// tokens of all three spaces are retuned (a visual change the owner looks at first; see docs/TODO.md).
test.describe('axe', () => {
const pages = ['/', '/work/', '/work/learning/', `/work/${P.byHand2}/`, '/off-the-clock/', '/simple/', '/everything/'];
for (const path of pages) {
test(`no WCAG A/AA violations on ${path}`, async ({ page }) => {
await page.goto(path);
const { violations } = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).disableRules(['color-contrast']).analyze();
const summary = violations.map((v) => `${v.id} (${v.impact}): ${v.nodes.length}× ${v.nodes.slice(0, 3).map((n) => n.target.join(' ')).join(' | ')}`);
expect(summary).toEqual([]);
});
}
});