Der Knopf "nach oben" ist ein Anker, kein Skript: er springt an den Seitenanfang, eingeblendet ueber eine Scroll-Zeitachse. Abschaltbar und vollstaendig einstellbar - Seite, Form, Groesse, Farben, Schwelle, Beschriftung. Bilder lassen sich vergroessern, je Bild waehlbar (nichts / vergroessern / Adresse), Galerien mit einem Schalter. Grundlage ist ein gewoehnlicher Verweis auf die grosse Fassung: ohne JavaScript oeffnet er das Bild, mit JavaScript einen dialog-Betrachter mit Blaettern, Escape und Pfeiltasten. Das Skript laedt nur, wo es etwas zu tun hat. Die Rechtszeile wird ein Baustein und steht damit im Fussbereich statt als heller Streifen darunter. Die eingebaute Leiste erscheint nur noch, wenn die Seite den Baustein nicht enthaelt. Dabei gefunden: zwei Stylesheet-Regeln griffen seit drei Veroeffentlichungen nicht mehr. Beim Entfernen alter Regeln waren Selektorreste stehen geblieben, und CSS nimmt dann die naechste Regel mit - betroffen waren der Einwilligungsdialog und die Rechtszeile. Ein Test prueft jetzt jede CSS-Datei auf abgebrochene Selektoren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
13 KiB
JavaScript
203 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 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);
|
|
// 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();
|
|
}
|