Files
KantSites/scripts/editor-check.mjs
T
kantarciandClaude Opus 5 0e2334d30f
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.5.1: der Werkzeugkasten kennt den Zusammenhang
Umuts Einwand: Zeile und Spalte standen oben in der Auswahl, als waeren sie
Seitenanfaenge. Sie gehoeren in den Inhalt - erst eine Sektion, dann eine Zeile
darin. Das war nicht nur eine Anzeigefrage: eine Zeile nahm bisher jedes Kind
an, eine Ueberschrift konnte neben den Spalten landen, und eine Spalte durfte
ueberall stehen. Die Struktur, die der Editor in seinem eigenen Hinweistext
beschreibt, galt fuer die Schaltflaechen nicht.

Uebernommen ist Flatsomes Regelmodell (Elements.php): ALLOW - eine Zeile nimmt
nur Spalten; REQUIRE - eine Spalte lebt nur in einer Zeile. Dazu ein Zusatz,
den Flatsome nicht hat: ROOT_NEEDS_CONTAINER - eine Zeile ist kein
Seitenanfang. In jedem Behaelter bleibt sie erlaubt, auch in einem Tab.

Daraus die Bedienung:

- Werkzeugkasten nach Aufbau gruppiert statt flacher Liste aus 31 Typen.
- Was nicht hingehoert, ist gesperrt und sagt beim Ueberfahren warum. Eine
  Regel, die man erklaert bekommt, lernt man; eine Schaltflaeche, die nur
  nicht mehr reagiert, drueckt man weiter.
- Was moeglich ist, sagt wohin es kommt.
- Die Zeile wird nach Spaltenzahl angeboten und bringt ihre Spalten mit; der
  separate Zeilenbereich ist entfallen.
- insertionTarget beantwortet "wohin kaeme das?", ohne etwas einzufuegen.
  Knopf, Einfuegepunkt, Ziehen und die Sperrlogik nutzen dieselbe Funktion -
  vorher entschied insertBlock allein, und insertRow hatte eine zweite Kopie
  derselben Platzierungslogik.

Ein Element, das der gewaehlte Behaelter nicht nimmt, wird nicht gesperrt,
sondern steigt auf, bis ein Elternteil es annimmt.

Nebenbei behoben: postnav fehlte seit Schema v18 in labels und icons, der
Strukturbaum zeigte dafuer "Unbekannt" und ein Fragezeichen.

Geprueft: npm test 27/27, phpunit 446/446, npm run test:editor vollstaendig
mit neuem Abschnitt und ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 07:13:25 +02:00

600 lines
40 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: 'Entwurf ·' }).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 });
// --- Die Vorschau als Arbeitsflaeche -------------------------------------------------------
// Rahmen, Werkzeugleiste am Element, Einfuegepunkte und Ziehen aus dem Werkzeugkasten.
const inPreview = selector => page.evaluate(
sel => document.querySelector('#preview').contentDocument?.querySelector(sel)?.id ?? null, selector);
await frame.locator('h1').click();
await page.locator('.ov-select').waitFor({ state: 'visible' });
assert.equal(await page.locator('.ov-toolbar-name').innerText(), 'Überschrift');
await page.getByRole('toolbar', { name: 'Aktionen für das gewählte Element' }).waitFor();
// Die Leiste benennt das Element, auf das sie wirkt - sonst haette sie denselben Namen wie
// die gleichnamige Schaltflaeche in der Seitenleiste.
await page.getByRole('button', { name: 'Überschrift duplizieren', exact: true }).waitFor();
await page.getByRole('button', { name: 'Element über Überschrift auswählen', exact: true }).click();
assert.notEqual(await page.locator('#properties-title').innerText(), 'Überschrift');
// Einfuegepunkt: das naechste Element landet genau dort und nicht am Ende.
await frame.locator('h1').click();
const headingId = await inPreview('h1');
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
await page.locator('#insert-hint').waitFor({ state: 'visible' });
await page.locator('#library button[data-add="text"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
assert.equal(await page.locator('#insert-hint').isVisible(), false);
await page.waitForFunction(id => {
const next = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.nextElementSibling;
return Boolean(next?.classList.contains('block-text'));
}, headingId);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
// Ziehen aus dem Werkzeugkasten auf die Seite.
await frame.locator('h1').waitFor();
const beforeButtons = await page.locator('#structure [data-block-id]').count();
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
// Oben abgelegt heisst davor, nicht dahinter.
await page.waitForFunction(id => {
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
return Boolean(previous?.classList.contains('button'));
}, headingId);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
// Eine Zeile kommt nicht aus dem Nichts, und eine Spalte gehoert in eine Zeile.
const toolboxState = type => page.evaluate(selector => {
const button = document.querySelector(selector);
return button ? { disabled: button.disabled, title: button.title } : null;
}, `#library button[data-add="${type}"]:not([data-row])`);
const rowState = () => page.evaluate(() => {
const button = document.querySelector('#library button[data-row="2"]');
return { disabled: button.disabled, title: button.title };
});
await page.locator('#select-page').click();
assert.equal((await toolboxState('column')).disabled, true, 'Spalte ohne Zeile gesperrt');
assert.match((await toolboxState('column')).title, /gehört in Zeile/);
assert.equal((await rowState()).disabled, true, 'Zeile ohne Sektion gesperrt');
assert.match((await rowState()).title, /gehört in eine Sektion/);
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// einem anderen Dokument.
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
// Und in der Zeile wird die Spalte moeglich.
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
await page.locator('#library button[data-row="3"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
// Differenz.
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
checks.push({ name: 'the toolbox follows the structure', passed: true });
await page.screenshot({ path: 'reports/editor/toolbox.png' });
console.log('Context aware toolbox passed.');
// Zurueck in den Zustand, den dieser Abschnitt vorgefunden hat: die folgenden Pruefungen
// setzen eine bestehende Auswahl voraus.
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).click();
await page.locator('#duplicate:not([disabled])').waitFor();
console.log('Canvas overlay, insert points and drag and drop passed.');
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
// Farben da: schwarze Knöpfe statt roter.
const beobachtung = page.evaluate(async () => {
const rahmen = document.getElementById('preview');
const verlauf = [];
let ungestylt = 0;
// Zweierlei wird festgehalten: dass der Rahmen ueberhaupt verdeckt wird, waehrend das neue
// Dokument entsteht - und ob er je sichtbar war, ohne seine Gestaltung zu haben.
new MutationObserver(() => verlauf.push(rahmen.style.visibility || 'sichtbar'))
.observe(rahmen, { attributes: true, attributeFilter: ['style'] });
const bis = performance.now() + 5000;
while (performance.now() < bis) {
const doc = rahmen.contentDocument;
if ('visible' === getComputedStyle(rahmen).visibility
&& doc?.getElementById('kant-styles')
&& 0 === (doc.adoptedStyleSheets ?? []).length) {
ungestylt++;
}
await new Promise(r => setTimeout(r, 30));
}
return { verlauf, ungestylt };
});
await page.click('#duplicate');
const { verlauf, ungestylt } = await beobachtung;
assert.equal(ungestylt, 0, 'Die Vorschau war sichtbar, bevor ihre Gestaltung angewandt war.');
// Der Rahmen muss verdeckt worden und danach wieder sichtbar geworden sein. Ohne diese Prüfung
// hinge der Nachweis am Zufall: auf einer kleinen Seite ist das Fenster zu kurz, um es zu
// treffen - auf der nachgebauten Startseite von kant-it.de war es 1440 ms lang.
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
await page.click('#undo');
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
checks.push({ name: 'preview never shows unstyled content', 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'],
['statistics', '/admin/statistik'],
['system and update', '/admin/system'],
['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: 'Entwurf ·' }).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();
}