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();
const sitemap = await context.request.get(origin + '/sitemap.xml');
assert.equal(sitemap.status(), 200, '/sitemap.xml');
assert.match(sitemap.headers()['content-type'] ?? '', /application\/xml/, '/sitemap.xml');
// Eine Website ohne Beiträge kennt weder Archiv noch Feed: dann ist 404 die richtige Antwort,
// und die Sitemap führt die Adresse gar nicht erst auf.
const sitemapText = await sitemap.text();
const hasPosts = sitemapText.includes('/beitraege');
for (const [path, type] of [['/robots.txt', 'text/plain'], ['/feed.xml', 'application/xml']]) {
const response = await context.request.get(origin + path);
const expected = '/feed.xml' === path && !hasPosts ? 404 : 200;
assert.equal(response.status(), expected, path);
// Auch die 404 muss aus der Anwendung kommen - der eingebaute PHP-Server beantwortet Dateien
// mit Endung sonst selbst, und genau das soll dieser Test aufdecken.
assert.ok('x-kant-queries' in response.headers(), `${path}: nicht von der Anwendung beantwortet`);
if (200 === expected) assert.match(response.headers()['content-type'] ?? '', new RegExp(type), path);
}
results.push({ name: 'machine readable routes reachable', paths: 3, hasPosts });
// Die Schutzkopfzeilen einer öffentlichen Seite. Sie fehlen lautlos, wenn jemand eine Antwort
// an der Stelle vorbeibaut, die sie setzt - deshalb werden sie gemessen und nicht angenommen.
{
const seite = await context.request.get(origin + '/');
const kopf = seite.headers();
const csp = kopf['content-security-policy'] ?? '';
assert.match(csp, /default-src 'self'/, 'Content-Security-Policy fehlt');
assert.match(csp, /object-src 'none'/, 'Plugins müssen verboten bleiben');
assert.match(csp, /frame-ancestors 'none'/, 'Die Seite darf nicht in einen fremden Rahmen');
assert.match(csp, /base-uri 'none'/, 'Ein eingeschleustes würde jeden Verweis umlenken');
assert.equal(kopf['x-content-type-options'], 'nosniff');
assert.match(kopf['referrer-policy'] ?? '', /strict-origin/);
assert.match(kopf['permissions-policy'] ?? '', /camera=\(\)/);
// Und was nicht dastehen darf: die Serverkennung verrät, wonach ein Angreifer suchen kann.
assert.ok(!('x-powered-by' in kopf) || !/php/i.test(kopf['x-powered-by']), 'X-Powered-By nennt die PHP-Fassung');
results.push({ name: 'security headers', csp: csp.length });
}
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 hasPosts ? ['/', '/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;
// Bilder gehören zum Inhalt: eine bebilderte Startseite lädt zu Recht mehr Dateien als eine
// Textseite. Gedeckelt wird deshalb das Beiwerk (Stile, Schriften, Skripte) und das
// übertragene Gewicht - nicht die Zahl der Bilder.
const beiwerk = [...transferred.values()].filter(entry => !entry.type.startsWith('image/')).length;
const gewicht = [...transferred.values()].reduce((sum, entry) => sum + entry.length, 0);
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider, Einblendungen,
// Formular mit Schritten, eigener Code), darf überhaupt eines kommen. Jede Fähigkeit
// bringt genau eine kleine Datei mit; drei davon sind die Grenze, und alle vom eigenen Ursprung.
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(erlaubt ? scripts <= 3 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 3 : 0}`);
const fremd = [...transferred.keys()].filter(url => (transferred.get(url).type.includes('javascript')) && !url.startsWith(origin + '/assets/'));
assert.deepEqual(fremd, [], `${path}: Skript von außerhalb /assets/`);
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
results.push({ name: `load budget ${path}`, html, styles, requests, beiwerk, gewicht, queries });
console.log(`Budget ${path}: HTML ${html} B, CSS ${styles} B, ${requests} Anfragen (${beiwerk} ohne Bilder), ${gewicht} B gesamt, ${queries} Abfragen, ${scripts} Skripte`);
}
await budget.close();
}
// Die Seiten, die geprüft werden, stehen in der Sitemap - vorher waren es drei fest
// geschriebene Adressen der Demo, und gegen eine andere Installation lief der Check ins Leere.
const seiten = ['/', ...[...sitemapText.matchAll(/([^<]+)<\/loc>/g)]
.map(treffer => new URL(treffer[1]).pathname)
.filter(pfad => '/' !== pfad && !pfad.startsWith('/beitraege') && !pfad.startsWith('/kategorie/') && !pfad.startsWith('/schlagwort/'))
.slice(0, 2)];
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 seiten) {
const response = await page.goto(origin + path);
assert.equal(response.status(), 200);
assert.equal(await page.locator('h1').count(), 1);
// Strukturierte Daten stehen zwar in einem script-Element, führen aber nichts aus. Gezählt
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
// Block halb durchsichtig oder verschoben. axe rechnet den Kontrast dann gegen die
// verwaschene Farbe, und ein von der Seite hereingleitender Block zählt als Überlauf -
// beides Befunde, die kein Besucher je sieht.
await page.evaluate(() => document.getAnimations().forEach(animation => animation.finish()));
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);
// Steht der Einwilligungshinweis offen, liegt er über dem Inhalt - ein Besucher entscheidet
// sich zuerst, und danach ist die Seite frei. Genau so geht dieser Test auch vor.
const hinweis = page.locator('.consent-bar button[value="none"]');
if (await hinweis.count()) {
await hinweis.click();
await page.waitForLoadState('domcontentloaded');
}
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');
// Weiter mit der Tastatur: der erste Verweis im Inhalt muss anklickbar sein und tatsächlich
// woandershin führen. Vorher standen hier die Linktexte der Demo - gegen jede andere
// Installation lief die Prüfung damit ins Leere statt etwas zu prüfen.
const ersterLink = page.locator('main a[href^="/"]').first();
const ziel = await ersterLink.getAttribute('href');
await ersterLink.click();
assert.equal(new URL(page.url()).pathname, ziel.split('#')[0] || '/');
assert.equal(await page.locator('h1').count(), 1, ziel + ': keine Überschrift');
assert.deepEqual(errors, []);
await context.close();
}
// Ohne JavaScript muss dieselbe Website benutzbar sein - eine öffentliche Seite lädt ohnehin
// keines, aber das soll gemessen und nicht geglaubt werden.
const noJs = await browser.newPage({ javaScriptEnabled: false });
await noJs.goto(origin);
const hinweisOhneJs = noJs.locator('.consent-bar button[value="none"]');
if (await hinweisOhneJs.count()) {
await hinweisOhneJs.click();
await noJs.waitForLoadState('domcontentloaded');
}
assert.equal(await noJs.locator('h1').count(), 1);
const ohneJs = noJs.locator('main a[href^="/"]').first();
const zielOhneJs = await ohneJs.getAttribute('href');
await ohneJs.click();
assert.equal(new URL(noJs.url()).pathname, zielOhneJs.split('#')[0] || '/');
assert.equal(await noJs.locator('h1').count(), 1);
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();
}