dorfteich/apps/web/e2e/a11y.spec.ts
Claude Opus 5 f938ee9880 #301: stop /settings scrolling horizontally at 320px
WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px,
which is also what 400% zoom on a 1280px screen produces. The layout
skeleton was already hardened for this in #165; the overflow came from
content inside the sections.

- The sessions table cannot shrink below its min-content width — four
  columns, one of them the full user-agent string. It now scrolls inside
  its own container rather than pushing the page. The container is
  focusable with a role and a name, because a scroll area that only a
  mouse can reach trades one barrier for another.
- `.settings-checkbox` rows may wrap. The accent swatches have a fixed
  size and cannot shrink, so an unwrappable row set a floor for the whole
  page width.

Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the
criterion is not derivable from the DOM — so this is a separate check,
and it names the overflowing elements when it trips instead of only
reporting that something overflows.
2026-08-01 12:31:30 +02:00

159 lines
6.7 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 offenders: string[] = [];
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) {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
offenders.push(
`${el.tagName.toLowerCase()}${cls} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`,
);
}
}
return { scrollWidth: doc.scrollWidth, clientWidth: limit, offenders: offenders.slice(0, 12) };
});
expect(
{ overflowBy: report.scrollWidth - report.clientWidth, offenders: report.offenders },
`${label}: horizontaler Überlauf bei 320 px`,
).toEqual({ overflowBy: 0, offenders: [] });
}
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();
});
});