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.
159 lines
6.7 KiB
TypeScript
159 lines
6.7 KiB
TypeScript
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();
|
||
});
|
||
});
|