dorfteich/apps/web/e2e/a11y.spec.ts
Claude Opus 5 2422f3a28f #301: sort the reflow report so the culprit cannot be buried
CI still reports 737 while the local stack is now clean, and the box list
was capped at 15 entries — all of them nav links clipped by their own
scroller. Whatever pushes the page in CI sits past that cap.

The list is now sorted by reach, marks each entry as either clipped by a
fitting scroller or actually pushing the page, and shows 40.
2026-08-01 12:31:30 +02:00

199 lines
8.5 KiB
TypeScript
Raw Permalink 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 AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* A11y-Smoke-Pack (issue #171): axe-core-Scan der Kern-Oberflächen gegen
* WCAG 2.1 A/AA. Regressionsschutz für das Audit vom 21.07.2026 (Bericht im
* Workspace, Befunde A11Y-001…024) — die Screens hier waren nach den Fixes
* der Issues #162#170 verletzungsfrei; jede neue Verletzung bricht den
* Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier
* bewusst NICHT, nur normative WCAG-Kriterien.
*
* Seit issue #180 läuft jeder Scan in BEIDEN Farbschemata: emulateMedia
* setzt prefers-color-scheme, theme-init.js löst den Default „System“ zum
* konkreten data-theme auf, axe misst dann die echten Dark-Token-Farben.
*/
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
const SCHEMES = ['light', 'dark'] as const;
/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */
const ALLOWED_RULES: string[] = [];
async function expectClean(page: Page, label: string): Promise<void> {
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
const violations = results.violations.filter((v) => !ALLOWED_RULES.includes(v.id));
expect(
violations.map((v) => ({
rule: v.id,
impact: v.impact,
help: v.help,
nodes: v.nodes.slice(0, 5).map((n) => n.target),
})),
`axe-Verletzungen auf ${label}`,
).toEqual([]);
}
for (const scheme of SCHEMES) {
test.describe(`${scheme} scheme`, () => {
test(`login page passes the axe WCAG A/AA scan (${scheme})`, async ({ page }) => {
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/login');
await page.waitForLoadState('networkidle');
await expectClean(page, `/login (${scheme})`);
});
test(`reading and editing a page passes the axe WCAG A/AA scan (${scheme})`, async ({
browser,
}) => {
const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/p/content-fixtures/every-element');
await page.waitForLoadState('networkidle');
await expectClean(page, `Lesemodus every-element (${scheme})`);
await page.locator('.editor-page__mode-toggle').click();
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
await page.waitForTimeout(500);
await expectClean(page, `Editor every-element (${scheme})`);
await context.close();
});
test(`a classified page shows the marking top+bottom and passes axe (${scheme})`, async ({
browser,
}) => {
const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/p/content-fixtures/classified-note');
await page.waitForLoadState('networkidle');
// Kennzeichnung oben UND unten (issue #206, ADR 0022) — fester
// Wortlaut, nicht lokalisiert.
const banners = page.locator('.classification-banner');
await expect(banners).toHaveCount(2);
await expect(banners.first()).toContainText('VS NUR FÜR DEN DIENSTGEBRAUCH');
await expect(banners.last()).toContainText('VS NUR FÜR DEN DIENSTGEBRAUCH');
await expectClean(page, `Eingestufte Seite classified-note (${scheme})`);
await context.close();
});
test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/settings');
await page.waitForLoadState('networkidle');
await expectClean(page, `/settings (${scheme})`);
await context.close();
});
test(`admin area passes the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-admin');
const page = await context.newPage();
await page.emulateMedia({ colorScheme: scheme });
await page.goto('/admin');
await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
await context.close();
});
});
}
/**
* Reflow (WCAG 2.1 SC 1.4.10, issue #301): bei 320 px CSS-Breite — was 400 %
* Zoom auf 1280 px entspricht — darf die Seite nicht seitenweit horizontal
* scrollen. axe prüft das NICHT, das Kriterium ist nicht maschinell aus dem
* DOM ableitbar; deshalb ein eigener Zaun.
*
* Schlägt er an, nennt er die überstehenden Elemente. Ohne diese Diagnose
* weiß man nur DASS es überläuft und muss im Browser bisektieren.
*/
const NARROW = { width: 320, height: 800 };
async function expectNoHorizontalScroll(page: Page, label: string): Promise<void> {
const report = await page.evaluate(() => {
const doc = document.documentElement;
const limit = doc.clientWidth;
const describe = (el: Element): string => {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
return `${el.tagName.toLowerCase()}${cls}`;
};
// Every element whose own content is wider than its box. One of these is
// the source; the ones that scroll it away on purpose are marked.
const overflowing: string[] = [];
for (const el of Array.from(document.querySelectorAll('*'))) {
if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
const overflowX = getComputedStyle(el).overflowX;
overflowing.push(
`${describe(el)} client=${el.clientWidth} scroll=${el.scrollWidth} overflow-x=${overflowX}`,
);
}
}
/** Content inside a scroll container may exceed the viewport — that is
* the remedy. But only when the CONTAINER fits: a scroller that is
* itself too wide still pushes the page. */
const insideFittingScroller = (el: Element): boolean => {
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
const ox = getComputedStyle(node).overflowX;
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') {
return node.getBoundingClientRect().right <= limit + 1;
}
}
return false;
};
// Widest reach first, so a long tail of clipped children cannot bury the
// one box that actually pushes the page.
const past = Array.from(document.querySelectorAll('body *'))
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
.filter(({ rect }) => rect.width > 0 && rect.right > limit + 1)
.sort((a, b) => b.rect.right - a.rect.right)
.map(
({ el, rect }) =>
`${describe(el)} right=${Math.round(rect.right)} w=${Math.round(rect.width)}` +
`${insideFittingScroller(el) ? ' [in fitting scroller]' : ' <-- pushes page'}`,
);
return {
overflowBy: doc.scrollWidth - limit,
viewport: `html client=${limit} scroll=${doc.scrollWidth} | body client=${document.body.clientWidth} scroll=${document.body.scrollWidth} rect=${Math.round(document.body.getBoundingClientRect().width)}`,
overflowing: overflowing.slice(0, 15),
past: past.slice(0, 40),
};
});
const diagnosis = [
`${label}: horizontaler Überlauf bei 320 px`,
report.viewport,
`eigener Inhaltsüberlauf: ${JSON.stringify(report.overflowing, null, 1)}`,
`Boxen über dem Rand: ${JSON.stringify(report.past, null, 1)}`,
].join('\n');
expect({ overflowBy: report.overflowBy }, diagnosis).toEqual({ overflowBy: 0 });
}
test.describe('reflow at 320px', () => {
test('user settings do not scroll horizontally at 320px', async ({ browser }) => {
const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage();
await page.setViewportSize(NARROW);
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Die Sitzungstabelle rendert asynchron und ist der breiteste Inhalt —
// ohne sie misst der Zaun eine halb aufgebaute Seite.
await page.locator('.table tbody tr').first().waitFor();
await expectNoHorizontalScroll(page, '/settings');
await context.close();
});
});