Abgleich mit dem Flatsome UX Builder (docs/FLATSOME-ABGLEICH.md): die Gestaltung ist Flatsome voraus, die Luecken liegen beim Elementkatalog und bei der Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen. Erste Haelfte der Bedienung: - Werkzeugleiste am Element in der Vorschau. Sie nennt das Element, auf das sie wirkt - die Seitenleiste hat dieselben fuenf Aktionen, und zwei gleichnamige Bedienelemente auf einem Bildschirm sind fuer eine Sprachausgabe nicht unterscheidbar. Beide Wege rufen dieselben Funktionen auf. - Auswahl- und Ueberfahrrahmen mit Beschriftung. - Einfuegepunkte an Ober- und Unterkante: das naechste Element landet dort statt am Ende. - Ziehen aus dem Werkzeugkasten in die Vorschau, mit Ablegemarke zwischen den Elementen und Rahmen um einen aufnehmenden Behaelter. Der Strukturbaum zieht in dieselbe Flaeche. Die Ebene liegt ueber dem Rahmen, nicht darin: die Vorschau wird bei jeder Aenderung ersetzt, eingezeichnete Bedienelemente waeren weg und stuenden in der Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Leiste und Pluszeichen werden gegenskaliert und bleiben in der Mobilvorschau fingergross. Im Modell neu: insertBlockAt, moveBlockTo, dropList. Dabei behoben: die Ebene war aria-hidden und enthielt fokussierbare Schaltflaechen; canContain zerbrach an einem fehlenden Block beim Ziehen aus dem Werkzeugkasten; beim Verschieben ans Listenende wurde der Index begrenzt, bevor die Entnahme verrechnet war. Geprueft: npm test 22/22, phpunit 434/434, npm run test:editor vollstaendig mit neuem Abschnitt und ohne axe-Verstoss. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
560 lines
37 KiB
JavaScript
560 lines
37 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' });
|
|
// 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();
|
|
}
|