Files
KantSites/scripts/editor-check.mjs
T
kantarciandClaude Opus 5 ee446f1ea1 Einwilligung, die tatsächlich zurückhält, und Pflichtseiten
Ein Hinweis, der nur fragt und sonst nichts ändert, ist Theater. Deshalb zwei
Hälften, und die zweite ist die wichtigere.

Die Frage: serverseitig gerendert, per Formular beantwortet, ohne JavaScript -
die öffentlichen Seiten laufen mit script-src 'none', und ein Banner mit
Skript wäre das eine, was diese Regel bricht. "Alle akzeptieren" und "Nur
notwendige" sind dieselbe Art Knopf, gleich groß, nebeneinander. Die
Entscheidung steht in einem eigenen Cookie: Fassung der Frage, gewährte
Gruppen, Zeitpunkt. Keine Kennung, kein Serverabgleich, ein halbes Jahr. Kein
CSRF-Token, absichtlich: die Anfrage ändert nichts auf dem Server, und ein
Token bräuchte für jeden Besucher eine Sitzung - genau das, was ein
Einwilligungshinweis vermeiden soll. Geprüft wird stattdessen das
Rücksprungziel.

Das Zurückhalten: der neue Block "Externer Inhalt" (Dokumentschema v14) lädt
ohne Einwilligung nichts - kein iframe, keine Adresse im Markup, frame-src
'none'. Der Platzhalter nennt den Anbieter; sein Knopf gewährt genau diese
Gruppe und nimmt die vorher gewährten mit. Erst danach kommt der Host in die
Sicherheitsrichtlinie, und nur dieser. Im Editor bleibt es immer ein
Platzhalter.

Datenschutz und Impressum sind keine Einstellung mehr: fehlen sie, warnt die
Verwaltung und legt sie auf Knopfdruck als Gerüst an, mit NOCH AUSZUFÜLLEN an
jeder Pflichtangabe. Den Text schreibt das CMS nicht - das ist eine rechtliche
Aussage über eine Firma. Verlinkt werden beide aus dem Template, nicht aus dem
Fußbereich: ein Pflichtlink, den man löschen kann, ist keiner.

Dazu der Fehler, den Umut im Screenshot gesehen hat: die Wortmarke stand als
Buchstabenkolonne in einem 60 Pixel breiten Kasten. .site-header war noch ein
Flexcontainer aus der Zeit des handgeschriebenen Markups und quetschte die
Sektion zusammen, und design.css band jede Designklasse an #main - wo Kopf-
und Fußbereich seit ADR 026 nicht mehr liegen. Beides behoben.

Neu im Ausbauplan: Statistik (serverseitige, wiedererkennungsfreie Zählung,
die deshalb keine Einwilligung braucht), Inhalte und Ladeverhalten prüfen
(Blättern überall, minimal laden, Ladebudget), Installer (Schritt für Schritt
im Browser, Migration inklusive).

245 PHP-Tests / 976 Assertions unter PHP 8.2.12 und 8.4.22. Der Browsertest
fand zwei Fehler von mir: grauer Text auf dunklem Fußbereich mit 2,24:1, und
das fixierte Banner verdeckte den Einwilligungsknopf am Seitenende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 19:27:41 +02:00

473 lines
32 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';
import { spawn, execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore' });
const fixture = JSON.parse(execFileSync(php, ['tests/browser-fixture.php'], { encoding: 'utf8' }));
const probe = net.createServer();
await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port;
await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], {
windowsHide: true,
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
stdio: 'ignore',
});
server.on('error', error => { throw error; });
let browser;
let activePage;
await mkdir('reports/editor', { recursive: true });
const checks = [];
try {
for (let i = 0; i < 60; i++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (i === 59) throw new Error('Isolated PHP server did not start.');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1100 }, acceptDownloads: true });
context.setDefaultTimeout(15000);
const page = await context.newPage();
activePage = page;
const errors = [];
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
page.on('dialog', dialog => dialog.accept());
const axe = async name => {
console.log('Accessibility check:', name);
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical
// published renderer separately; retain the sandbox in the real editor.
const result = await new AxeBuilder({ page }).exclude('#preview').withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
checks.push({ name, violations: 0 });
};
await page.goto(origin + '/admin');
assert.equal(new URL(page.url()).pathname, '/admin/login');
await axe('login');
await page.screenshot({ path: 'reports/editor/login.png', fullPage: true });
await page.getByLabel('Benutzername').fill(fixture.username);
await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin');
console.log('Authenticated in isolated test installation.');
await axe('page list');
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
await page.locator('a[href="/admin/pages/test-home"]').first().click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
console.log('Editor data loaded.');
console.log('Preview status:', await page.locator('#preview-status').innerText());
const frame = page.frameLocator('#preview');
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
await frame.locator('h1').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
assert.equal(await page.locator('#publish').isDisabled(), true);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await frame.getByRole('heading', { name: /Deine Inhalte/, level: 1 }).waitFor();
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
console.log('Preview edits and history passed.');
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
assert.equal(await page.locator('#structure .tree-item').count(), 3);
await page.getByLabel('Element suchen', { exact: true }).fill('Meine bearbeitete');
assert.equal(await page.locator('#structure .tree-item').count(), 2);
await page.locator('#structure [data-block-id="headline"]').focus();
await page.keyboard.press('Enter');
await page.getByLabel('Element suchen', { exact: true }).fill('');
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
const frameValue = async (selector, property, expected) => {
await page.waitForFunction(({ selector, property, expected }) => {
const doc = document.querySelector('#preview').contentDocument;
const element = doc?.querySelector(selector);
return element && doc.defaultView.getComputedStyle(element)[property] === expected;
}, { selector, property, expected });
};
// Free values: a number plus a unit, not a fixed list. Filling and blurring fires the change.
const setLength = async (id, value, unit = 'px') => {
await page.locator(`#${id}`).fill(String(value));
await page.locator(`#${id}`).blur();
if (unit !== 'px') await page.locator(`#${id}`).locator('xpath=following-sibling::select').selectOption(unit);
};
const openGroup = async name => {
const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: name }) });
if (!(await group.first().evaluate(element => element.open))) await group.first().locator('summary').click();
};
await setLength('style-fontSize', 72);
await frameValue('#headline', 'fontSize', '72px');
assert.equal(await page.locator('#preview').evaluate(element => element.contentWindow.innerWidth), 1240);
// Tablet and mobile inherit until they get an own value, and a reset makes them inherit again.
await page.getByRole('button', { name: 'Tablet', exact: true }).click();
await frameValue('#headline', 'fontSize', '72px');
await setLength('style-fontSize', 52);
await frameValue('#headline', 'fontSize', '52px');
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await frameValue('#headline', 'fontSize', '52px');
await setLength('style-fontSize', 36);
await frameValue('#headline', 'fontSize', '36px');
await page.getByRole('button', { name: 'Mobil zurücksetzen', exact: true }).click();
await frameValue('#headline', 'fontSize', '52px');
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await frameValue('#headline', 'fontSize', '36px');
// A single value can be reset on its own, without touching the rest of the device.
await page.getByLabel('Ausrichtung', { exact: true }).selectOption('center');
await frameValue('#headline', 'textAlign', 'center');
await page.getByRole('button', { name: 'Ausrichtung zurücksetzen', exact: true }).click();
await frameValue('#headline', 'textAlign', 'start');
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await frameValue('#headline', 'fontSize', '72px');
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link');
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
const contrast = page.locator('.contrast-note');
await contrast.waitFor();
assert.match(await contrast.textContent(), /Kontrast \d/, 'The panel reports a contrast ratio.');
// Per side spacing on the section, and a hover rule that also covers keyboard focus.
await page.locator('#structure [data-block-id="hero"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
await setLength('style-padding-top', 96);
await frameValue('#hero', 'paddingTop', '96px');
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await setLength('style-padding-top', 32);
await frameValue('#hero', 'paddingTop', '32px');
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
// The background group is collapsed by default, and the readable field next to the swatch is
// what a person types into.
await openGroup('Hintergrund');
const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]');
await hoverColor.fill('#112233');
await hoverColor.blur();
// The preview carries its stylesheet as template text, applied by the editor as a constructed
// stylesheet; the hover rule has to arrive through that path, not as a style element.
await page.waitForFunction(() => {
const doc = document.querySelector('#preview').contentDocument;
const template = doc?.getElementById('kant-styles')?.content.textContent ?? '';
const adopted = [...(doc?.adoptedStyleSheets ?? [])].some(sheet => [...sheet.cssRules].some(rule => rule.selectorText?.includes('#hero:hover')));
return template.includes('#hero:hover,#hero:focus-within') && adopted;
});
checks.push({ name: 'free styles: units, inheritance, single reset, palette colour, per-side spacing, hover rule', passed: true });
await page.locator('[data-preset="columns"]').click();
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
const presetRow = `#${presetSection} .block-row`;
await frame.locator(presetRow).waitFor();
// The preset carries its own responsive tokens: one column on mobile, two from tablet up.
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
const columnCount = async expected => {
await page.waitForFunction(({ selector, expected }) => {
const doc = document.querySelector('#preview').contentDocument;
const element = doc?.querySelector(selector);
return element && doc.defaultView.getComputedStyle(element).gridTemplateColumns.split(' ').length === expected;
}, { selector: presetRow, expected });
};
await columnCount(1);
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await columnCount(2);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await frame.locator(presetRow).waitFor({ state: 'detached' });
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
await columnCount(2);
await page.locator('#structure [data-block-id="headline"]').click();
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
checks.push({ name: 'keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset', passed: true });
console.log('Responsive design, tree tools and presets passed.');
await page.locator('#library [data-add="gallery"]').click();
const galleryId = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
await page.locator('#media-upload').setInputFiles(fixture.images);
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
const mediaItems = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
const mediaResponse = await context.request.get(origin + mediaItems[0].variants[0].src);
assert.equal(mediaResponse.status(), 200, `Media delivery: ${(await mediaResponse.text()).slice(0, 160)}`);
for (const [index, item] of mediaItems.entries()) {
await page.locator(`[data-media-id="${item.id}"]`).focus();
await page.keyboard.press('Space');
assert.equal(await page.locator(`[data-media-id="${item.id}"]`).evaluate(button => button === document.activeElement), true);
await page.getByLabel('Bildtitel', { exact: true }).fill(index === 0 ? 'Bergblick' : 'Küstenblick');
await page.getByLabel('Alternativtext des Mediums', { exact: true }).fill(index === 0 ? 'Grüne Berglandschaft mit warmer Abendsonne' : 'Küstenlandschaft mit Hügeln und Abendsonne');
await page.getByRole('button', { name: 'Bildangaben speichern', exact: true }).click();
await page.getByRole('button', { name: 'Bildangaben speichern', exact: true }).waitFor({ state: 'visible' });
await page.waitForFunction(() => !document.querySelector('#media-save').disabled);
}
await axe('media picker');
await page.screenshot({ path: 'reports/editor/media-library.png', fullPage: true });
await page.getByRole('button', { name: 'Auswahl übernehmen (2)', exact: true }).click();
await frame.locator(`#${galleryId} img`).first().waitFor();
assert.equal(await frame.locator(`#${galleryId} img`).count(), 2);
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
const imageId = await frame.locator(`#${galleryId} figure`).first().getAttribute('id');
await page.locator(`#structure [data-block-id="${imageId}"]`).click();
await page.getByLabel('Bildunterschrift', { exact: true }).fill('Ein Ausblick zum Durchatmen.');
await frame.getByText('Ein Ausblick zum Durchatmen.', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Nach unten verschieben', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator(`#structure [data-block-id="${galleryId}"]`).click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
await openGroup('Layout');
await page.locator('#style-gridColumns').fill('2');
await page.locator('#style-gridColumns').blur();
const columnsOfGallery = async expected => {
await page.waitForFunction(({ id, expected }) => {
const doc = document.querySelector('#preview').contentDocument;
const element = doc?.querySelector(`#${id}`);
return element && doc.defaultView.getComputedStyle(element).gridTemplateColumns.split(' ').length === expected;
}, { id: galleryId, expected });
};
await columnsOfGallery(2);
// Motion presets are keyframes, which the free system cannot express, so they stay available.
await openGroup('Bewegung und Effekte');
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
await page.getByLabel('Einblendung beim Laden', { exact: true }).selectOption('rise');
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await openGroup('Layout');
await page.locator('#style-gridColumns').fill('1');
await page.locator('#style-gridColumns').blur();
await columnsOfGallery(1);
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await columnsOfGallery(2);
await page.emulateMedia({ reducedMotion: 'reduce' });
await frameValue(`#${galleryId}`, 'animationName', 'none');
await frameValue(`#${galleryId}`, 'transform', 'none');
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.locator('#open-media-library').click();
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
await page.getByRole('button', { name: 'Archivieren', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '1 Bilder' }).waitFor();
assert.equal((await context.request.get(origin + mediaItems[0].variants[0].src)).status(), 200);
await page.getByLabel('Archivierte Bilder anzeigen', { exact: true }).check();
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
await page.getByRole('button', { name: 'Wiederherstellen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
await page.locator('#structure [data-block-id="headline"]').click();
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
checks.push({ name: 'image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects', passed: true });
console.log('Media library, gallery and effects passed.');
await page.locator('#library [data-add="text"]').click();
await page.getByLabel('Text', { exact: true }).fill('Dieser Absatz wurde im Editor eingefügt.');
await page.getByRole('button', { name: 'Duplizieren', exact: true }).click();
await page.getByLabel('Text', { exact: true }).fill('Das ist eine unabhängige Kopie.');
await page.getByRole('button', { name: 'Nach oben verschieben', exact: true }).click();
await page.getByRole('button', { name: 'Löschen', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.locator(`#structure [data-toggle="${presetSection}"]`).click();
await page.locator(`#structure [data-toggle="${galleryId}"]`).click();
await page.getByRole('button', { name: 'Text: Dieser Absatz wurde im Editor eingefügt.', exact: true }).dragTo(page.locator('#structure [data-block-id="principles"]'));
await page.waitForFunction(() => document.querySelector('#structure [data-block-id="principles"]')?.parentElement.textContent.includes('Dieser Absatz wurde im Editor eingefügt.'));
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.route('**/admin/api/pages/test-home/draft', route => route.abort(), { times: 1 });
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
await page.locator('#message').filter({ hasText: 'Verbindung ist unterbrochen' }).waitFor();
assert.equal(await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).isEnabled(), true);
checks.push({ name: 'network failure retains edits and allows retry', passed: true });
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
const stored = await (await context.request.get(origin + '/admin/api/pages/test-home')).json();
console.log('Draft stored.');
assert.equal((await (await context.request.get(origin)).text()).includes('Meine bearbeitete Website'), false);
await page.reload();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
const reloaded = await (await context.request.get(origin + '/admin/api/pages/test-home')).json();
assert.deepEqual(reloaded.document, stored.document);
await page.getByRole('button', { name: 'Veröffentlichen', exact: true }).click();
await page.getByRole('button', { name: 'Abbrechen', exact: true }).first().focus();
await page.keyboard.press('Escape');
assert.equal(await page.locator('#publish-dialog').isVisible(), false);
assert.equal(await page.locator('#publish').evaluate(element => element === document.activeElement), true);
await page.getByRole('button', { name: 'Veröffentlichen', exact: true }).click();
await page.getByRole('button', { name: 'Jetzt veröffentlichen', exact: true }).click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
assert.equal((await (await context.request.get(origin)).text()).includes('Meine bearbeitete Website'), true);
const publicPage = await context.newPage();
await publicPage.goto(origin);
assert.equal(await publicPage.locator(`#${galleryId} img`).count(), 2);
await publicPage.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
await publicPage.waitForFunction(id => document.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
assert.equal(await publicPage.locator('#headline').evaluate(element => getComputedStyle(element).fontSize), '72px');
await publicPage.setViewportSize({ width: 768, height: 1000 });
assert.equal(await publicPage.locator('#headline').evaluate(element => getComputedStyle(element).fontSize), '52px');
await publicPage.setViewportSize({ width: 390, height: 844 });
assert.equal(await publicPage.locator('#headline').evaluate(element => getComputedStyle(element).fontSize), '36px');
assert.equal(await publicPage.locator('#hero').evaluate(element => getComputedStyle(element).paddingTop), '32px');
assert.equal(await publicPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await publicPage.screenshot({ path: 'reports/editor/designed-public-mobile.png', fullPage: true });
const contentAudit = await new AxeBuilder({ page: publicPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
assert.deepEqual(contentAudit.violations.map(v => v.id), [], JSON.stringify(contentAudit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => ({ target: n.target, summary: n.failureSummary?.split('\n')[1] })) })), null, 1));
checks.push({ name: 'edited public renderer accessibility', violations: 0 });
await publicPage.close();
checks.push({ name: 'add, duplicate, move, delete, undo, exact reload, separate publication, dialog focus', passed: true });
console.log('Draft reload and publication passed.');
// A second server save must never be silently overwritten by the open editor.
const latest = await (await context.request.get(origin + '/admin/api/pages/test-home')).json();
const external = structuredClone(latest.document); external.title = 'Parallel gespeicherter Titel';
const externalSave = await context.request.post(origin + '/admin/api/pages/test-home/draft', { headers: { 'X-CSRF-Token': latest.csrfToken }, data: { document: external, expectedVersion: latest.version } });
assert.equal(externalSave.status(), 200);
await page.locator('#select-page').click();
await page.getByLabel('Seitentitel', { exact: true }).fill('Meine lokalen Änderungen');
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
await page.locator('#message').filter({ hasText: 'neuerer Stand' }).waitFor();
console.log('Conflict displayed.');
assert.equal(await page.getByLabel('Seitentitel', { exact: true }).inputValue(), 'Meine lokalen Änderungen');
const download = page.waitForEvent('download');
console.log('Testing conflict export.');
await page.getByRole('button', { name: 'Änderungen als JSON sichern' }).click();
assert.equal((await download).suggestedFilename(), 'kant-entwurf.json');
await page.getByRole('button', { name: 'Serverstand laden', exact: true }).click();
await page.getByRole('button', { name: 'Serverstand übernehmen', exact: true }).click();
await page.getByLabel('Seitentitel', { exact: true }).filter({ visible: true }).waitFor();
await page.waitForFunction(() => document.querySelector('#property-title')?.value === 'Parallel gespeicherter Titel');
checks.push({ name: 'conflict retains local edits, JSON export, explicit server reload', passed: true });
// Every management screen, not just the page list: they carry tables, disclosures and now the
// media grid, and none of that was ever checked automatically before.
for (const [name, path] of [
['user administration', '/admin/benutzer'],
['own account', '/admin/konto'],
['media library', '/admin/media'],
['menus', '/admin/menus'],
['forms', '/admin/forms'],
['areas', '/admin/partials'],
['design', '/admin/theme'],
['settings', '/admin/settings'],
]) {
await page.goto(origin + path);
assert.equal(new URL(page.url()).pathname, path, path);
await axe(name);
}
// The media screen renders its images itself, so the library has to be visible without the dialog.
await page.goto(origin + '/admin/media');
assert.equal(await page.locator('.library-tile').count(), 2);
// The drop area is an enhancement of a real file input: choosing files has to reach the input
// and be named back to the user. Nothing is submitted here.
await page.locator('#files').setInputFiles(fixture.images);
await page.locator('[data-chosen]').filter({ hasText: '2 Datei(en) gewählt' }).waitFor();
await page.screenshot({ path: 'reports/editor/media-screen.png', fullPage: true });
checks.push({ name: 'management screens reachable, media library rendered as a page', passed: true });
// The consent question, switched on the way an administrator would switch it on, and then
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
await page.goto(origin + '/admin/settings');
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
await page.locator('.notice.ok').waitFor();
// A page with an embed, added through the API so the check stays about consent, not the editor.
const forEmbed = await (await context.request.get(origin + '/admin/api/pages/test-projektstand')).json();
const withEmbed = structuredClone(forEmbed.document);
withEmbed.schemaVersion = 14;
withEmbed.blocks.push({
id: 'karte', type: 'embed',
props: { group: 'external', title: 'Anfahrt', provider: 'OpenStreetMap', url: 'https://karten.example/einbetten', hint: '', height: 320 },
});
const embedSave = await context.request.post(origin + '/admin/api/pages/test-projektstand/draft', { headers: { 'X-CSRF-Token': forEmbed.csrfToken }, data: { document: withEmbed, expectedVersion: forEmbed.version } });
assert.equal(embedSave.status(), 200, await embedSave.text());
// The version and the revision after a save are whatever the server says, not an assumption.
const afterSave = await (await context.request.get(origin + '/admin/api/pages/test-projektstand')).json();
const published = await context.request.post(origin + '/admin/api/pages/test-projektstand/publish', { headers: { 'X-CSRF-Token': afterSave.csrfToken }, data: { revisionId: afterSave.revisionId, expectedVersion: afterSave.version } });
assert.equal(published.status(), 200, await published.text());
// A separate context: a visitor without the administration's cookies.
const visitor = await browser.newContext({ viewport: { width: 1100, height: 900 } });
const visitorPage = await visitor.newPage();
await visitorPage.goto(origin + '/projektstand');
await visitorPage.locator('.consent-bar').waitFor();
assert.equal(await visitorPage.locator('iframe').count(), 0, 'Nothing may load before consent.');
assert.equal((await visitorPage.content()).includes('karten.example/einbetten'), false, 'The address must not even be in the markup.');
const blocked = await new AxeBuilder({ page: visitorPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
assert.deepEqual(blocked.violations.map(v => v.id), [], 'consent banner accessibility');
await visitorPage.screenshot({ path: 'reports/editor/consent-banner.png', fullPage: false });
// Consenting through the embed's own button grants exactly that group, and then it loads.
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
// And the way back: the consent page can refuse again, which blocks the embed once more.
await visitorPage.goto(origin + '/einwilligung');
await new AxeBuilder({ page: visitorPage }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()
.then(result => assert.deepEqual(result.violations.map(v => v.id), [], 'consent page accessibility'));
await visitorPage.getByRole('button', { name: 'Nur notwendige', exact: true }).click();
await visitorPage.goto(origin + '/projektstand');
assert.equal(await visitorPage.locator('iframe').count(), 0, 'A refusal must stick.');
await visitor.close();
checks.push({ name: 'consent banner, blocked embed, granting per embed and revoking', passed: true });
console.log('Consent checks passed.');
// Back to the way the rest of the run expects it: no question, so the other screens stay as they were.
await page.goto(origin + '/admin/settings');
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).uncheck();
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
await page.locator('.notice.ok').waitFor();
// Back into the editor. The draft differs from the published state by now, so wait for the
// structure rather than for a particular save status.
await page.goto(origin + '/admin/pages/test-home');
await page.locator('#structure [data-block-id="headline"]').waitFor();
await page.locator('#structure [data-block-id="headline"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
await axe('desktop editor');
await page.screenshot({ path: 'reports/editor/editor-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
assert.equal(await page.locator('#preview').evaluate(element => Math.round(element.getBoundingClientRect().width)), 390);
await page.screenshot({ path: 'reports/editor/editor-mobile-preview.png', fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await axe('mobile editor');
await page.screenshot({ path: 'reports/editor/editor-phone.png', fullPage: true });
await page.locator('#open-media-library').click();
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await axe('mobile media library');
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
assert.deepEqual(errors, []);
await writeFile('reports/editor/result.json', JSON.stringify({ checks, publicDataUntouched: true }, null, 2));
console.log('Editor end-to-end checks passed; isolated database, production data unchanged.');
if (process.argv.includes('--audit')) {
console.log('Auditing the published v3 document with responsive design, gallery images and effects.');
await browser.close(); browser = null;
await new Promise((resolve, reject) => {
const audit = spawn(process.execPath, ['scripts/audit.mjs'], { windowsHide: true, stdio: 'inherit', env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/lighthouse-editor' } });
audit.on('error', reject);
audit.on('exit', code => code === 0 ? resolve() : reject(new Error(`Designed-page audit failed (${code}).`)));
});
}
} catch (error) {
console.error('Check failed:', error.message);
await activePage?.screenshot({ path: 'reports/editor/failure.png', fullPage: true, timeout: 5000 }).catch(() => {});
throw error;
} finally {
await browser?.close();
server.kill();
}