Eine Website ohne Beitraege redet nirgends von Beitraegen: Archiv und Feed antworten mit 404, der Feedverweis faellt aus dem Kopf, und der Hinweiskasten der Beitragsliste bleibt im Editor statt oeffentlich zu erscheinen. Das gilt auch je Kategorie, sobald deren letzter Beitrag weg ist. Dabei gefunden: die Liste fragte je Beitrag nach Bild und Begriffen - jetzt je eine Abfrage fuer die ganze Liste (22 -> 16 Abfragen auf der Startseite des Nachbaus). Unterstrichene Links gibt es nur noch im Fliesstext, dort mit einem Schalter im Designbereich; Karten, Menue, Kontaktzeilen und der Ansehen-Streifen haben keinen mehr. Die aktuelle Seite im Menue wird ueber das Schriftgewicht kenntlich - die Akzentfarbe fiel im dunklen Fussmenue durch die Kontrastpruefung. Neu: das Symbol der Website laesst sich festlegen. Aus einem Bild der Mediathek entstehen beim Speichern quadratische PNG-Dateien in 32 und 180 px; schlaegt das fehl, wird die Einstellung nicht gespeichert. Die Browserpruefung laeuft jetzt gegen jede Installation statt nur gegen die Demo: geprueft wird, was in der Sitemap steht, und das Ladebudget trennt Bilder vom Beiwerk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
11 KiB
JavaScript
173 lines
11 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 });
|
|
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 mit Autolauf,
|
|
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
|
|
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
|
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
|
|
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 <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 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');
|
|
// 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);
|
|
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();
|
|
}
|