Eine Quelle entscheidet jetzt, welche Seite unter / antwortet: SiteSettings::homePath() und publicPath(). Das Literal home kommt im Anwendungscode nicht mehr vor - öffentliche Routen, Canonical, Sitemap, llms.txt, Menüauflösung, Beitragslisten und der Formularrücksprung benutzen dieselben zwei Methoden. Die gewählte Seite muss veröffentlicht und nicht im Papierkorb sein, ihre eigene Adresse leitet dauerhaft auf /, und als Wurzel ist sie gegen Umbenennen und Papierkorb gesperrt. Zweiter echter Fehler, den nur ein HTTP-Aufruf zeigt: /sitemap.xml, /robots.txt, /llms.txt und /feed.xml waren lokal nie erreichbar. Der eingebaute PHP-Server antwortet für fehlende Pfade mit Dateiendung selbst mit 404, ohne den Frontcontroller zu fragen; alle Kernel-Tests bestanden trotzdem, weil sie die Anwendung direkt aufrufen. bin/dev-router.php bildet die Rewrite-Regel des Hostings nach, Start- und CI-Befehl nutzen es, und die Browserprüfung ruft die vier Adressen jetzt über HTTP auf. Barrierefreiheitsbefunde werden nach Ursache getrennt: ein Kontrastfehler aus Theme oder Stylesheet bleibt ein Defekt und bricht den Lauf ab, einer aus einer im Editor gesetzten Blockfarbe wird bei jedem Lauf mit Seite, Farbe und Verhältnis genannt und in reports/browser.json geschrieben, färbt aber keinen Codewechsel rot. Geprüft: 162 PHP-Tests / 636 Assertions unter PHP 8.2.12 und 8.4.22, 15 JavaScript-Modelltests, vollständiger Editortest mit axe, wieder grüne öffentliche Browserprüfung und Lighthouse mit je drei Läufen mobil und Desktop, alle fünf Kategorien 100. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
5.6 KiB
JavaScript
100 lines
5.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();
|
|
|
|
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();
|
|
}
|