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(); }