Files
portfolio-next/e2e/a11y.spec.ts
T
Lucas WintherandClaude Opus 5.5 974b3eb0df feat(work): CV-first work page, six proud projects, record and courses (spec 009)
- CV header with highlights and keeping-up summary replaces the intro and Now strip
- "Projects I'm proudest of": up to six featured panels; screenshots muted until hover
- "Also made" list; searching it also lists matching courses
- Record section: recognition & volunteering, courses by topic
- /work/learning/: searchable course list (plain script), in the sitemap
- Top bar: Projects, Record, Writing, CV; no Bluesky link in the work footer
- Highlights: only the placement takes the accent (owner, after the preview)

Owner looked at the rendered preview on 2026-11-12 before this commit.

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

82 lines
3.3 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
// 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([]);
}
});