Arbeitspaket M sagte "Abfragen je Seite zaehlen. Messen, dann zusammenfassen." Messen braucht ein Messgeraet, also gibt es jetzt eines: ein Zaehler als Doctrine-Middleware, dessen Zahl ausserhalb der Produktion im Kopf X-Kant-Queries steht. Keine Profiler-Maschinerie, eine Zahl. Die erste Messung: die Startseite brauchte 18 Abfragen. Nicht wegen einer komplizierten Seite, sondern weil SiteSettings::load() und Theme::load() je Aufrufer dieselbe Zeile erneut holten - Renderer, strukturierte Daten, Einwilligungsschalter, Vorlagen. Beide merken sich ihren Stand jetzt fuer die Dauer einer Anfrage: 18 auf 8. Dabei ist mir die klassische Falle des Merkens selbst passiert, und ein bestehender Test hat sie sofort gezeigt: ich leerte den Merker am Anfang des Speicherns. Das Speichern liest den Stand aber noch einmal und fuellte ihn damit erneut - der Rest der Anfrage bekam den alten Stand. Geleert wird jetzt nach dem Schreiben. Das Ladebudget wird gemessen statt behauptet: die Browserpruefung liest je Seite HTML-Groesse, CSS-Groesse, Anzahl Anfragen, Anzahl Skripte und Abfragezahl und prueft Obergrenzen. Startseite: 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, 0 Skripte. Die Null ist die wichtigste Zahl - sie haelt als Test fest, was bisher nur Absicht war. Das erste Bild einer Seite ist nicht mehr lazy. Es ist meistens das groesste Ding ueber der Falz, und es zu verzoegern verzoegert genau das Pixel, auf das jemand wartet. Der Renderer bestimmt es in Renderreihenfolge, Kopfbereich zuerst. Eine feste Obergrenze ist kein Blaettern: der Fassungsverlauf hoerte bei dreissig auf, die Einsendungen bei fuenfzig, die Weiterleitungen zeigten bis zu zweitausend auf einmal. Das Erste sieht aus wie "es gibt dreissig" und ist das Einzige, was eine Historie nicht tun darf. Alle drei blaettern jetzt, mit derselben Rechnung und demselben Pager wie Beitragsarchiv und Mediathek. 347 PHP-Tests / 1422 Assertions unter PHP 8.2.12 und 8.4.22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
7.6 KiB
JavaScript
133 lines
7.6 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();
|
|
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();
|
|
}
|