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 { 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'); // Einladungs-Abschnitt (issue #332) gerendert — sonst liefe der Scan // auch grün, wenn die Sektion gar nicht erscheint. await page.locator('.invitations').waitFor(); await expectClean(page, `/settings (${scheme})`); // Lizenzseite im selben Kontext (issue #304: sie trägt seit den // eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst // KEIN eigener Test — jeder zusätzliche Login im Pack bringt die // CI zwei Packs später ans Rate-Limit (Lehre aus #301). await page.goto('/fonts'); await page.waitForLoadState('networkidle'); await expectClean(page, `/fonts (${scheme})`); // Teich-Einstellungen im selben Kontext (fixture-user besitzt den // Fixture-Teich): dort sitzt seit issue #305 das Archiv-Angebot in der // Löschzone. Wieder KEIN eigener Test — zusätzliche Logins kippen die // CI zwei Packs später am Rate-Limit (Lehre aus #301). await page.goto('/p/content-fixtures/settings'); await page.waitForLoadState('networkidle'); await page.locator('.pond-archive__download').waitFor(); await expectClean(page, `Teich-Einstellungen (${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(); // Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung // liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert. await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor(); // Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist // erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl. await page.locator('.branding .crop-field input[type="file"]').first().waitFor(); await expectClean(page, `/admin (${scheme})`); // Anlage-Dialog (issue #331) im selben Kontext öffnen und mitscannen — // wieder KEIN eigener Test (Rate-Limit-Lehre aus #301). await page.locator('.user-manager__create').click(); await page.locator('.create-user-dialog').waitFor(); await expectClean(page, `/admin Anlage-Dialog (${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 { 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(); }); });