dorfteich/apps/web/e2e/a11y.spec.ts
Claude Opus 5 9fce824a8e #301: make the reflow guard report the ancestor chain
The previous run came back with an empty offender list and an unchanged
417px overflow: the filter treated everything under a scroll container as
innocent, including the container that was itself too wide. A scroller
only absolves its children when the scroller fits.

It now reports the chain from body down to the widest offender with each
box's width, so the first element wider than the viewport is visible
instead of inferred.
2026-08-01 12:31:30 +02:00

199 lines
8.2 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 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: HTMLElement): string => {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
return `${el.tagName.toLowerCase()}${cls}`;
};
/**
* Content inside a scroll container may exceed the viewport — that is the
* remedy, not the defect. But only if the CONTAINER itself fits: a
* scroller that is wider than the viewport still pushes the page, and its
* children are then symptoms, not causes.
*/
const containedByFittingScroller = (el: HTMLElement): boolean => {
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
const overflowX = getComputedStyle(node).overflowX;
if (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'hidden') {
return node.getBoundingClientRect().right <= limit + 1;
}
}
return false;
};
const offenders: string[] = [];
let widest: HTMLElement | null = null;
for (const el of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
const rect = el.getBoundingClientRect();
// 1 px Toleranz gegen Subpixel-Rundung.
if (rect.width > 0 && rect.right > limit + 1 && !containedByFittingScroller(el)) {
offenders.push(
`${describe(el)} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`,
);
if (!widest || rect.width > widest.getBoundingClientRect().width) widest = el;
}
}
// Where does the inflation start? The chain from body down to the widest
// offender, with each box's width — the first entry wider than the
// viewport is the element that actually needs constraining.
const chain: string[] = [];
for (let node: HTMLElement | null = widest; node && node !== doc; node = node.parentElement) {
chain.unshift(`${describe(node)} w=${Math.round(node.getBoundingClientRect().width)}`);
}
return {
scrollWidth: doc.scrollWidth,
clientWidth: limit,
offenders: offenders.slice(0, 20),
chain,
};
});
expect(
{
overflowBy: report.scrollWidth - report.clientWidth,
offenders: report.offenders,
chain: report.chain,
},
`${label}: horizontaler Überlauf bei 320 px`,
).toEqual({ overflowBy: 0, offenders: [], chain: [] });
}
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();
});
});