import { chromium } from 'playwright'; import AxeBuilder from '@axe-core/playwright'; import assert from 'node:assert/strict'; import { mkdir, writeFile } from 'node:fs/promises'; const origin = process.env.CMS_URL || 'http://127.0.0.1:8080'; const browser = await chromium.launch(); await mkdir('reports', { recursive: true }); const results = []; // Accessibility problems caused by editorial colour choices, reported but not fatal. const findings = []; try { // The machine readable routes have a file extension, which the PHP built-in server would answer // itself. Without bin/dev-router.php they never reach the front controller - exactly the gap // that let them stay broken locally while every kernel test passed. const context = await browser.newContext(); for (const [path, type] of [['/sitemap.xml', 'application/xml'], ['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) { const response = await context.request.get(origin + path); assert.equal(response.status(), 200, path); assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path); } results.push({ name: 'machine readable routes reachable', paths: 3 }); await context.close(); // A load budget, measured rather than assumed: what a page actually transfers, and how many // database queries it needed. Both are ceilings - a page that starts asking once per section // or shipping a megabyte of CSS fails here instead of only getting slower. { const budget = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const page = await budget.newPage(); const transferred = new Map(); page.on('response', async response => { const type = (response.headers()['content-type'] ?? '').split(';')[0]; const length = Number(response.headers()['content-length'] ?? 0); transferred.set(response.url(), { type, length }); }); for (const path of ['/', '/beitraege']) { transferred.clear(); const response = await page.goto(origin + path, { waitUntil: 'networkidle' }); const queries = Number(response.headers()['x-kant-queries'] ?? -1); const html = (await response.text()).length; const styles = [...transferred.values()].filter(entry => entry.type === 'text/css').reduce((sum, entry) => sum + entry.length, 0); const scripts = [...transferred.values()].filter(entry => entry.type.includes('javascript')).length; const requests = transferred.size; assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`); assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`); // A public page runs no script unless it carries tabs or its own; the budget keeps that honest. assert.equal(scripts, 0, `${path}: ${scripts} Skript(e) geladen`); assert.ok(requests <= 12, `${path}: ${requests} Anfragen, Obergrenze 12`); if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`); results.push({ name: `load budget ${path}`, html, styles, requests, queries }); console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen, ${queries} Abfragen, ${scripts} Skripte`); } await budget.close(); } for (const width of [1440, 390]) { const context = await browser.newContext({ viewport: { width, height: 1000 } }); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); for (const path of ['/', '/grundlagen', '/projektstand']) { const response = await page.goto(origin + path); assert.equal(response.status(), 200); assert.equal(await page.locator('h1').count(), 1); assert.equal(await page.locator('script').count(), 0); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow'); const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze(); // A contrast failure has two very different causes, and they need different answers. // Comes from the theme or a stylesheet: a defect of the system, and the run must fail - // this is what caught the link colour that overrode every dark section. // Comes from a colour an editor set on one block: an editorial finding. It is reported on // every run and written into the report, but it must not turn a code change red, because // nobody can fix it in the code. const styledIds = new Set(await page.evaluate(() => [...document.querySelectorAll('style')] .flatMap(node => [...node.textContent.matchAll(/#([a-zA-Z0-9_-]+)\s*[{:]/g)].map(match => match[1])))); const isEditorial = node => node.target.every(target => typeof target === 'string' && target.startsWith('#') && styledIds.has(target.slice(1))); const defects = []; for (const violation of axe.violations) { const editorial = 'color-contrast' === violation.id ? violation.nodes.filter(isEditorial) : []; const rest = violation.nodes.filter(node => !editorial.includes(node)); if (editorial.length > 0) { findings.push({ path, width, id: violation.id, cause: 'Inhalt: im Editor gesetzte Farbe', nodes: editorial.map(node => ({ target: node.target, summary: node.failureSummary?.split('\n')[1]?.trim() })), }); } if (rest.length > 0) defects.push({ id: violation.id, nodes: rest.map(node => node.target) }); } assert.deepEqual(defects, [], JSON.stringify(defects, null, 1)); results.push({ width, path, defects: defects.length, editorialFindings: findings.filter(f => f.path === path && f.width === width).length, checks: axe.passes.length }); await page.screenshot({ path: `reports/${width}-${path === '/' ? 'home' : path.slice(1)}.png`, fullPage: true }); } await page.goto(origin); await page.keyboard.press('Tab'); assert.equal(await page.locator(':focus').innerText(), 'Zum Inhalt springen'); await page.keyboard.press('Enter'); assert.equal(await page.locator(':focus').getAttribute('id'), 'main'); await page.getByRole('link', { name: 'Die Grundlage entdecken' }).click(); assert.equal(new URL(page.url()).pathname, '/grundlagen'); await page.getByRole('link', { name: 'Projektstand' }).click(); assert.equal(new URL(page.url()).pathname, '/projektstand'); assert.deepEqual(errors, []); await context.close(); } const noJs = await browser.newPage({ javaScriptEnabled: false }); await noJs.goto(origin); assert.match(await noJs.locator('h1').innerText(), /Deine Inhalte/); await noJs.getByRole('link', { name: 'Die Grundlage entdecken' }).click(); assert.equal(new URL(noJs.url()).pathname, '/grundlagen'); await writeFile('reports/browser.json', JSON.stringify({ origin, results, editorialFindings: findings, keyboard: 'passed', noJavaScript: 'passed' }, null, 2)); console.log('Passed: 6 page/viewport accessibility checks, keyboard navigation, no-JS navigation.'); if (findings.length > 0) { // Loud on every run: these are real accessibility problems, they are just fixed in the editor // rather than in the code, so they do not make a code change fail. console.log('\n' + findings.length + ' Barrierefreiheits-Befund(e) aus im Editor gesetzten Farben:'); for (const finding of findings) { console.log(' ' + finding.path + ' (' + finding.width + ' px) ' + finding.id + ': ' + finding.nodes.map(n => n.target.join(',') + ' - ' + n.summary).join(' | ')); } console.log(' Zu beheben im Editor, nicht im Code. Details auch in reports/browser.json.'); } } finally { await browser.close(); }