Aus dem Plan in docs/UPDATE.md ist Code geworden: cms:update, cms:health, der Bildschirm "System" und der Wartungszustand. Die Vorprüfung ist die halbe Arbeit. Sie fragt nach proc_open, Git, sauberer Arbeitskopie, Herkunft, Schreibrechten und Platz - und danach, ob Datenbank und Medienordner wirklich von Git ausgenommen sind. Das ist der Punkt, an dem ein Update Inhalte zerstören könnte, also wird er geprüft und nicht angenommen. Jede Prüfung sagt, was zu tun ist, nicht bloß "fehlgeschlagen". Der Rückweg ist Teil des Wegs: scheitert ein Schritt, wird auf den vorherigen Commit zurückgesetzt. Die Datenbank nur dann zurückgeschrieben, wenn die Migration schon begonnen hatte - sonst verlöre der Rückweg Inhalte, die während des Updates entstanden sind. Antwortet der alte Stand im Rauchtest, geht die Website wieder online; antwortet er nicht, bleibt der Wartungszustand, weil eine kaputte Seite schlechter ist als eine ehrliche Meldung. Der Rauchtest läuft als eigener Prozess, denn nur ein frischer Prozess führt den neuen Code aus. Ein Test im laufenden Prozess hätte den alten geprüft und "alles in Ordnung" gemeldet. Kein symfony/process: der Runner sind dreißig Zeilen über proc_open mit dem Befehl als Array - kein Shell, nichts zu maskieren, und "proc_open ist abgeschaltet" wird die erste Antwort statt der Überraschung im dritten Schritt. 285 PHP-Tests / 1244 Assertions. Sechs davon fahren den Updater gegen echte Git-Repositorys in einem Temp-Verzeichnis, mit Platzhalterskripten für die zwei Befehle - inklusive des Fehlschlags, nach dem Code und Datenbank zurückgesetzt sein müssen. Nicht gegen dieses Projekt: ein Test, der die Arbeitskopie auscheckt, in der er läuft, ist ein Weg, Arbeit zu verlieren. Offen bleibt die Sicherung einer MySQL-Datenbank per mysqldump - dafür fehlt die Installation; der Updater sagt das im Schritt "Sicherung". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
32 KiB
JavaScript
475 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'],
|
|
['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: '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();
|
|
}
|