Files
KantSites/scripts/browser-check.mjs
T
kantarciandClaude Opus 5 c8a8fa7460 Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:35 +02:00

211 lines
13 KiB
JavaScript

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 <base> 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>([^<]+)<\/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();
}