A button inside a summary is an interactive element in an interactive element; screen readers announce it unreliably. The handle keeps its look and its mouse, touch and arrow key behaviour. Also corrects the anchor hints and waits for the menu error in the admin check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1262 lines
87 KiB
JavaScript
1262 lines
87 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());
|
|
// Jede Fehlermeldung der Konsole ins Protokoll: ein Editor, der bei „Wird geladen“ stehen
|
|
// bleibt, sagt sonst nichts darüber, warum.
|
|
if (message.type() === 'error') console.error('Console error:', message.text());
|
|
});
|
|
page.on('dialog', dialog => dialog.accept());
|
|
// Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als
|
|
// `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update
|
|
// fuer immer veraltet - genau so brach der Editor mit "does not provide an export named ...".
|
|
const skripte = [];
|
|
page.on('request', anfrage => {
|
|
const url = new URL(anfrage.url());
|
|
if (url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js')) skripte.push(url);
|
|
});
|
|
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 });
|
|
};
|
|
const tab = name => page.getByRole('tab', { name, exact: true }).click();
|
|
const treeSelect = async id => { await tab('Struktur'); await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`#structure [data-edit-block="${id}"]`).click(); };
|
|
const treeToggle = async id => { await tab('Struktur'); await page.locator(`#structure [data-toggle="${id}"]`).click(); };
|
|
const addFromLibrary = async selector => { await tab('Elemente'); await page.locator(`#library ${selector}`).click(); };
|
|
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.');
|
|
|
|
// --- Die Verwaltungsnavigation ------------------------------------------------------------
|
|
// Sie steckt in einem details-Element: auf dem Telefon ein Klappmenue, am Schreibtisch die
|
|
// stehende Spalte. Am Schreibtisch ist der Schalter ausgeblendet - und ein geschlossenes
|
|
// details versteckt seinen Inhalt von sich aus. Ohne eine Regel, die ihn zurueckholt, ist die
|
|
// Navigation dort nicht bloss zugeklappt, sondern unerreichbar. Genau so ist 1.5.4 ausgeliefert
|
|
// worden, und genau das faellt hier auf.
|
|
const navigation = page.getByRole('navigation', { name: 'Verwaltung' });
|
|
const schalter = page.locator('.shell-menu > summary');
|
|
assert.equal(await schalter.isVisible(), false, 'am Schreibtisch gibt es keinen Menueschalter');
|
|
assert.equal(await navigation.isVisible(), true, 'am Schreibtisch steht die Navigation da');
|
|
const eintrag = navigation.getByRole('link', { name: 'Seiten', exact: true });
|
|
const flaeche = await eintrag.boundingBox();
|
|
assert.ok(flaeche && flaeche.height > 0 && flaeche.width > 0, 'ihre Eintraege sind anklickbar');
|
|
|
|
// Auf dem Telefon dagegen: ein Schalter, zunaechst zu, ein Klick oeffnet.
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
assert.equal(await schalter.isVisible(), true, 'auf dem Telefon gibt es den Schalter');
|
|
assert.equal(await navigation.isVisible(), false, 'und die Liste ist zunaechst zu');
|
|
await schalter.click();
|
|
assert.equal(await navigation.isVisible(), true, 'ein Klick oeffnet sie');
|
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
|
checks.push({ name: 'the administration navigation is reachable on both sizes', passed: true });
|
|
console.log('Administration navigation passed.');
|
|
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.');
|
|
// Der Editor laedt fuenf Module ueber Importe, die selbst keine Fassung tragen; die
|
|
// Importkarte in base.html.twig haengt sie an. Ohne sie kaeme das alte Modul aus dem Cache.
|
|
assert.ok(skripte.some(url => url.pathname === '/assets/editor-model.js'), 'editor-model.js wird geladen');
|
|
const ohneFassung = [...new Set(skripte.filter(url => !url.searchParams.has('v')).map(url => url.pathname))];
|
|
assert.deepEqual(ohneFassung, [], `Skripte ohne Fassungskennung: ${ohneFassung.join(', ')}`);
|
|
checks.push({ name: 'every module is requested with a version key', passed: true });
|
|
console.log(`Fassungskennung an allen ${new Set(skripte.map(u => u.pathname)).size} Skriptdateien.`);
|
|
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();
|
|
// One sidebar, contextual fields and a root insertion from a deeply selected element.
|
|
await page.reload();
|
|
await frame.locator('h1').waitFor();
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
for (const width of [1920, 1100, 850, 390]) {
|
|
await page.setViewportSize({ width, height: 1100 });
|
|
await page.waitForTimeout(100);
|
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
|
|
for (const selector of ['.sidebar', '.sidebar-body', '.library-buttons']) {
|
|
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
|
|
}
|
|
}
|
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
|
await frame.locator('h1').click();
|
|
const sectionCount = await frame.locator('main > .block-section').count();
|
|
await addFromLibrary('[data-add="section"]');
|
|
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
|
await page.locator('#undo').click();
|
|
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
|
|
await page.locator('#redo').click();
|
|
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
|
|
await page.locator('#undo').click();
|
|
await frame.locator('h1').click();
|
|
await addFromLibrary('[data-add="image"]');
|
|
await page.locator('[data-panel="design"]').click();
|
|
assert.equal(await page.locator('#style-fontSize').count(), 0);
|
|
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await page.screenshot({ path: 'reports/editor/context-image.png', fullPage: true });
|
|
await page.getByRole('button', { name: 'Alle Eigenschaften anzeigen', exact: true }).click();
|
|
assert.equal(await page.locator('#style-fontSize').count(), 1);
|
|
await page.locator('#undo').click();
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await frame.locator('main .block-text').first().click();
|
|
await page.locator('[data-panel="design"]').click();
|
|
assert.equal(await page.locator('#style-objectFit').count(), 0);
|
|
assert.equal(await page.locator('#style-fontSize').isVisible(), true);
|
|
const textWidth = await frame.locator('main .block-text').first().evaluate(el => ({ width: el.getBoundingClientRect().width, parent: el.parentElement.clientWidth }));
|
|
assert.ok(Math.abs(textWidth.width - textWidth.parent) < 2, 'text uses its container width');
|
|
const sizeGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Größe$/ }) });
|
|
await sizeGroup.locator('summary').click();
|
|
await page.locator('#style-maxWidth').locator('xpath=following-sibling::select').selectOption('ch');
|
|
await page.locator('#style-maxWidth').fill('60');
|
|
await page.locator('#style-maxWidth').blur();
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
|
return Boolean(el && el.getBoundingClientRect().width < el.parentElement.clientWidth - 100);
|
|
});
|
|
await page.getByLabel('Maximale Breite Vorgabe', { exact: true }).selectOption('none');
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
|
|
return Boolean(el && getComputedStyle(el).maxWidth === 'none');
|
|
});
|
|
await page.locator('#undo').click();
|
|
await page.locator('#undo').click();
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await page.locator('.sidebar-body').evaluate(el => el.scrollTop = 0);
|
|
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
|
|
await page.locator('[data-panel="content"]').click();
|
|
checks.push({ name: 'one sidebar, root sections, contextual image/text properties and full text width', passed: true });
|
|
|
|
// A layout starts empty: it must be visible, reachable and editable before it has any text.
|
|
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await frame.locator('h1').click();
|
|
await addFromLibrary('[data-add="section"]');
|
|
await previewReady();
|
|
const emptySection = frame.locator('main > .editor-empty-container');
|
|
await emptySection.waitFor();
|
|
const newSectionId = await emptySection.getAttribute('id');
|
|
assert.ok((await emptySection.boundingBox()).height > 100, 'new empty section has a visible hit area');
|
|
await page.screenshot({ path: 'reports/editor/empty-section.png', fullPage: true });
|
|
await frame.getByRole('button', { name: '2 Spalten in dieser Sektion anlegen', exact: true }).press('Enter');
|
|
await previewReady();
|
|
const emptyColumns = frame.locator(`#${newSectionId} .block-column`);
|
|
assert.equal(await emptyColumns.count(), 2);
|
|
for (let index = 0; index < 2; index++) assert.ok((await emptyColumns.nth(index).boundingBox()).height > 80, 'empty column has a hit area');
|
|
const secondColumnId = await emptyColumns.nth(1).getAttribute('id');
|
|
const firstColumnId = await emptyColumns.first().getAttribute('id');
|
|
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).press('Enter');
|
|
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
|
|
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
|
|
await page.locator('#library-search').fill('Text');
|
|
await addFromLibrary('[data-add="text"]');
|
|
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
|
|
await previewReady();
|
|
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
|
|
await tab('Elemente');
|
|
await page.locator('#library-search').fill('');
|
|
const dragSource = page.locator('#library [data-add="button"]');
|
|
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
|
await dragSource.scrollIntoViewIfNeeded();
|
|
const sourceBox = await dragSource.boundingBox();
|
|
let targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
|
|
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 10, sourceBox.y + sourceBox.height / 2, { steps: 3 });
|
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 });
|
|
// Chromium dispatches dragenter on the first move into a target, dragover on the next.
|
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
|
|
await page.locator('.ov-drop-label').filter({ hasText: 'Spalte 1 von 2' }).waitFor();
|
|
await page.screenshot({ path: 'reports/editor/empty-layout-drag.png', fullPage: true });
|
|
const beforeDragScroll = await page.locator('#preview').evaluate(el => el.contentWindow.scrollY);
|
|
const frameBox = await page.locator('#preview').boundingBox();
|
|
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8, { steps: 5 });
|
|
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8);
|
|
await page.waitForFunction(top => document.querySelector('#preview').contentWindow.scrollY > top + 10, beforeDragScroll);
|
|
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
|
|
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
|
|
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
|
|
targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
|
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 6 });
|
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
|
|
await page.mouse.up();
|
|
await frame.locator(`#${firstColumnId} .button`).waitFor();
|
|
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 0, 'placeholders disappear after filling');
|
|
await page.locator('#undo').click(); await previewReady();
|
|
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'undo restores the empty target');
|
|
await page.locator('#redo').click(); await previewReady();
|
|
await frame.locator(`#${firstColumnId} .button`).waitFor();
|
|
await page.locator('#undo').click(); await previewReady();
|
|
await page.locator('#undo').click(); await previewReady();
|
|
await page.screenshot({ path: 'reports/editor/empty-layout-desktop.png', fullPage: true });
|
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
|
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).scrollIntoViewIfNeeded();
|
|
await page.screenshot({ path: 'reports/editor/empty-layout-mobile.png', fullPage: true });
|
|
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
|
await page.locator('#save').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
|
await page.reload(); await previewReady();
|
|
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 2, 'saved empty columns survive reload');
|
|
for (const device of ['Desktop', 'Mobil']) {
|
|
await page.getByRole('button', { name: device, exact: true }).click();
|
|
const destination = frame.locator(`#${secondColumnId}`);
|
|
await destination.scrollIntoViewIfNeeded();
|
|
const box = await destination.boundingBox();
|
|
await tab('Struktur');
|
|
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
|
|
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
|
|
});
|
|
await previewReady();
|
|
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), secondColumnId, `column order follows the ${device} layout`);
|
|
await page.locator('#undo').click(); await previewReady();
|
|
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
|
|
}
|
|
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
|
// Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The border
|
|
// between the two columns is a handle; dragging it to the right widens the first column.
|
|
await treeSelect(firstColumnId);
|
|
await frame.locator(`#${firstColumnId}`).scrollIntoViewIfNeeded();
|
|
const resizeHandle = page.locator('.ov-resize');
|
|
await resizeHandle.waitFor();
|
|
assert.equal(await resizeHandle.count(), 1, 'two columns have one border to drag');
|
|
const columnTracks = () => frame.locator(`#${newSectionId} .block-row`).first().evaluate(el => getComputedStyle(el).gridTemplateColumns.split(' ').map(Number.parseFloat));
|
|
const evenTracks = await columnTracks();
|
|
assert.ok(Math.abs(evenTracks[0] - evenTracks[1]) < 2, `columns start out equal: ${evenTracks}`);
|
|
const handleBox = await resizeHandle.boundingBox();
|
|
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(handleBox.x + handleBox.width / 2 + 120, handleBox.y + handleBox.height / 2, { steps: 8 });
|
|
await page.mouse.up();
|
|
await previewReady();
|
|
const widenedTracks = await columnTracks();
|
|
assert.ok(widenedTracks[0] > widenedTracks[1] * 1.3, `dragging the border widened the first column: ${widenedTracks}`);
|
|
await page.screenshot({ path: 'reports/editor/column-widths.png', fullPage: true });
|
|
// The same value is visible as numbers in the design panel of the row.
|
|
await treeSelect(await frame.locator(`#${newSectionId} .block-row`).first().getAttribute('id'));
|
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
|
const layoutGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: 'Layout' }) }).first();
|
|
if (!(await layoutGroup.evaluate(element => element.open))) await layoutGroup.locator('summary').click();
|
|
assert.match(await page.locator('#style-columnWidths').inputValue(), /^\d+(\.\d)? \d+(\.\d)?$/, 'shares are stored per column');
|
|
// On a phone the columns stack, so there is no border to drag - and one undo restores equal columns.
|
|
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
|
await treeSelect(firstColumnId);
|
|
await page.waitForFunction(() => document.querySelectorAll('.ov-resize').length === 0);
|
|
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
|
|
await page.locator('#undo').click(); await previewReady();
|
|
const restoredTracks = await columnTracks();
|
|
assert.ok(Math.abs(restoredTracks[0] - restoredTracks[1]) < 2, `undo restores equal columns: ${restoredTracks}`);
|
|
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
|
|
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
|
|
const publicBefore = await (await fetch(origin)).text();
|
|
assert.equal(publicBefore.includes(newSectionId), false, 'draft remains private');
|
|
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
|
const publishedEmpty = await (await fetch(origin)).text();
|
|
assert.ok(publishedEmpty.includes(newSectionId));
|
|
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
|
|
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
|
|
await treeSelect(newSectionId);
|
|
await page.locator('#delete').click();
|
|
await page.locator('#save').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
|
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
|
await page.reload(); await previewReady();
|
|
checks.push({ name: 'empty sections and columns: keyboard insertion, exact drop target, undo, responsive preview, reload and public isolation', passed: true });
|
|
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 addFromLibrary('button[data-add="text"]');
|
|
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 tab('Elemente');
|
|
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);
|
|
// Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld.
|
|
await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click();
|
|
const documentDialog = page.locator('dialog.document-dialog');
|
|
await documentDialog.waitFor();
|
|
await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' });
|
|
await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') });
|
|
await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor();
|
|
assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error');
|
|
const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
|
assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations');
|
|
await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click();
|
|
await documentDialog.waitFor({ state: 'detached' });
|
|
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
|
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
|
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
|
// Eine eigene Seite als Linkziel: gesucht und gewählt, nicht als Pfad abgetippt.
|
|
await page.getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
|
const pageDialog = page.locator('dialog.page-dialog');
|
|
await pageDialog.locator('[role="option"]').first().waitFor();
|
|
const pageAxe = await new AxeBuilder({ page }).include('dialog.page-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
|
assert.deepEqual(pageAxe.violations.map(v => v.id), [], 'the page dialog has no accessibility violations');
|
|
await pageDialog.locator('[role="option"]').first().click();
|
|
await pageDialog.waitFor({ state: 'detached' });
|
|
assert.match(await page.locator('#property-href').inputValue(), /^\/[a-z0-9\-/]*$/);
|
|
checks.push({ name: 'a page as a link target, picked by search', passed: true });
|
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
|
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
|
// Verknüpfte Dinge kommen aus Listen: Formular, Kategorie und Schlagwort sind Auswahlfelder.
|
|
for (const [type, id] of [['form', 'property-form'], ['postlist', 'property-category'], ['postlist', 'property-tag']]) {
|
|
await addFromLibrary(`button[data-add="${type}"]`);
|
|
assert.equal(await page.locator(`#${id}`).evaluate(node => node.tagName), 'SELECT', `${id} is a choice, not a typed address`);
|
|
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: 'forms, categories and tags are picked from lists', passed: true });
|
|
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 werden Zeile und Spalte moeglich - eine einzelne Spalte darf seit 1.16.1
|
|
// direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente). Die Sektion kommt aus
|
|
// dem Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
|
|
// einem anderen Dokument.
|
|
await tab('Struktur');
|
|
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
|
|
assert.equal(await page.locator('#properties-title').textContent(), 'Sektion');
|
|
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
|
|
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Sektion moeglich');
|
|
|
|
// Und in der Zeile wird die Spalte moeglich.
|
|
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
|
|
await addFromLibrary('button[data-row="3"]');
|
|
assert.equal(await page.locator('#properties-title').textContent(), '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();
|
|
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
|
|
await tab('Elemente');
|
|
const mitBild = await page.locator('#library button .library-preview svg').count();
|
|
const knoepfe = await page.locator('#library button').count();
|
|
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
|
|
|
|
// Die Suche greift ueber alle Gruppen und blendet leere Gruppen aus.
|
|
await page.locator('#library-search').fill('galerie');
|
|
await page.locator('#library-found').filter({ hasText: 'von' }).waitFor();
|
|
assert.equal(await page.locator('#library button:visible').count(), 1, 'genau die Galerie bleibt');
|
|
assert.equal(await page.locator('#library .library-group:visible').count(), 1, 'nur ihre Gruppe bleibt');
|
|
// Auch der Gruppenname findet: "Aufbau" gehoert zu Sektion, Zeile und Spalte.
|
|
await page.locator('#library-search').fill('aufbau');
|
|
assert.equal(await page.locator('#library button:visible').count(), 5, 'Sektion, drei Zeilen, Spalte');
|
|
await page.locator('#library-search').fill('gibtesnicht');
|
|
await page.locator('#library-found').filter({ hasText: 'Kein Element' }).waitFor();
|
|
await page.locator('#library-search').fill('');
|
|
assert.equal(await page.locator('#library button:visible').count(), knoepfe, 'leere Suche zeigt wieder alles');
|
|
|
|
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 tab('Struktur');
|
|
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
|
|
assert.equal(await page.locator('#structure .tree-item').count(), 3);
|
|
await page.getByLabel('Element auf der Seite 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 auf der Seite 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();
|
|
// Enter in the tree only selects; editing is the button next to the selected row.
|
|
await page.locator('#structure [data-edit-block="headline"]').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').click();
|
|
await page.locator('#style-color-choices [data-color="@link"]').click();
|
|
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 treeSelect('hero');
|
|
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();
|
|
|
|
// --- Die Optionen selbst ------------------------------------------------------------------
|
|
// Geraetechips je Option, Abstandsrahmen mit Kette, Ziehfeld.
|
|
|
|
// Der Chip zeigt, wo ein abweichender Wert liegt: Innenabstand hat oben gerade Desktop und
|
|
// Mobil bekommen, Tablet nicht.
|
|
const chip = device => page.locator(`#style-padding-unit`).locator('xpath=ancestor::div[contains(@class,"side-wrap")]/preceding-sibling::div[contains(@class,"design-side-head")][1]')
|
|
.locator(`.device-chip[data-device="${device}"]`);
|
|
await chip('desktop').waitFor();
|
|
assert.equal(await chip('desktop').evaluate(el => el.classList.contains('device-chip-own')), true, 'Desktop hat einen eigenen Innenabstand');
|
|
assert.equal(await chip('mobile').evaluate(el => el.classList.contains('device-chip-own')), true, 'Mobil hat einen eigenen Innenabstand');
|
|
assert.equal(await chip('tablet').evaluate(el => el.classList.contains('device-chip-own')), false, 'Tablet erbt');
|
|
assert.equal(await chip('desktop').getAttribute('aria-pressed'), 'true', 'Desktop ist die bearbeitete Groesse');
|
|
|
|
// Ein Klick auf den Chip wechselt Bedienfeld und Vorschau zusammen.
|
|
await chip('mobile').click();
|
|
assert.equal(await page.locator('[data-viewport="mobile"]').getAttribute('aria-pressed'), 'true');
|
|
await chip('desktop').click();
|
|
assert.equal(await page.locator('[data-viewport="desktop"]').getAttribute('aria-pressed'), 'true');
|
|
|
|
// Der Rahmen aendert ohne Kette nur die eine Seite.
|
|
await setLength('style-padding-left', 24);
|
|
await frameValue('#hero', 'paddingLeft', '24px');
|
|
await frameValue('#hero', 'paddingRight', '0px');
|
|
// Mit Kette werden alle vier gleichgezogen.
|
|
await page.locator('.side-link').first().click();
|
|
assert.equal(await page.locator('.side-link').first().getAttribute('aria-pressed'), 'true');
|
|
await setLength('style-padding-top', 40);
|
|
for (const seite of ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']) await frameValue('#hero', seite, '40px');
|
|
await page.locator('.side-link').first().click();
|
|
|
|
// Die gemeinsame Einheit schreibt die gesetzten Seiten um, ohne Zahlen zu verlieren.
|
|
await page.locator('#style-padding-unit').selectOption('rem');
|
|
await frameValue('#hero', 'paddingTop', '640px');
|
|
|
|
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
|
|
await treeSelect('headline');
|
|
await openGroup('Typografie');
|
|
const scrub = page.locator('#style-fontSize');
|
|
await scrub.scrollIntoViewIfNeeded();
|
|
const box = await scrub.boundingBox();
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2, { steps: 10 });
|
|
await page.mouse.up();
|
|
const gezogen = Number(await scrub.inputValue());
|
|
assert.equal(gezogen, 92, `Ziehen um 20 px erhoeht 72 auf 92, gemessen: ${gezogen}`);
|
|
await frameValue('#headline', 'fontSize', '92px');
|
|
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
|
await frameValue('#headline', 'fontSize', '72px');
|
|
checks.push({ name: 'device chips, spacing box and scrub fields', passed: true });
|
|
|
|
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
|
|
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
|
|
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
|
|
await treeSelect('hero');
|
|
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
|
await page.locator('#style-padding-unit').selectOption('px');
|
|
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
|
|
await setLength('style-padding-top', 96);
|
|
await frameValue('#hero', 'paddingTop', '96px');
|
|
await frameValue('#hero', 'paddingLeft', '0px');
|
|
await openGroup('Abstand');
|
|
await page.locator('#style-padding-top').scrollIntoViewIfNeeded();
|
|
await page.screenshot({ path: 'reports/editor/options.png' });
|
|
console.log('Per option device chips, spacing box and scrub fields passed.');
|
|
|
|
// The background group is collapsed by default, and the readable field next to the swatch is
|
|
// what a person types into.
|
|
await openGroup('Hintergrund');
|
|
await page.locator('#style-backgroundColor-hover').click();
|
|
const hoverColor = page.locator('#style-backgroundColor-hover-hex');
|
|
await hoverColor.fill('#112233');
|
|
await hoverColor.press('Enter');
|
|
// 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 tab('Elemente');
|
|
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 treeSelect('headline');
|
|
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 addFromLibrary('[data-add="gallery"]');
|
|
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 treeSelect(imageId);
|
|
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 treeSelect(galleryId);
|
|
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');
|
|
for (const [effect, transform] of [['left', 'translateX(-56px)'], ['right', 'translateX(56px)']]) {
|
|
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption(effect);
|
|
await frame.locator(`#${galleryId}.fx-entrance-${effect}`).waitFor();
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await page.evaluate(id => {
|
|
const element = document.querySelector('#preview').contentDocument.getElementById(id);
|
|
window.observedEntrance = [];
|
|
const animate = element.animate.bind(element);
|
|
element.animate = (frames, options) => { window.observedEntrance.push(frames); return animate(frames, options); };
|
|
element.scrollIntoView();
|
|
}, galleryId);
|
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
|
|
await page.waitForFunction(() => window.observedEntrance.length > 0);
|
|
assert.equal(await page.evaluate(() => window.observedEntrance[0][0].transform), transform);
|
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
|
|
}
|
|
// Default setting: the preview plays even when the browser asks for less motion (Flatsome mode).
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.evaluate(() => { window.observedEntrance = []; });
|
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
|
|
await page.waitForFunction(() => window.observedEntrance.length > 0);
|
|
assert.equal(await page.locator('#motion-status').isVisible(), false, 'no motion note while the site ignores the preference');
|
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
|
|
// Setting "Browservorgabe beachten", as the preview would carry it: nothing plays, the note shows.
|
|
await page.evaluate(() => { document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'); window.observedEntrance = []; });
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
await page.waitForTimeout(200);
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.locator('#motion-status').waitFor({ state: 'visible' });
|
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
|
|
await page.waitForTimeout(800);
|
|
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback when respected');
|
|
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
await page.locator('#motion-status').waitFor({ state: 'hidden' });
|
|
checks.push({ name: 'distinct left/right entrance playback, Flatsome-mode playback and respected reduced motion in the sandbox', passed: true });
|
|
await page.getByLabel('Einblendung beim Erscheinen', { 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 (await tab('Elemente'), 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 treeSelect('headline');
|
|
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 addFromLibrary('[data-add="text"]');
|
|
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 treeToggle(presetSection);
|
|
await treeToggle(galleryId);
|
|
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.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 });
|
|
|
|
// --- Plugins: einschalten, Baustein einfuegen, Eigenschaften aus dem Schema, Vorschau ------
|
|
// Das Referenzplugin "Bildvergleich" liegt im Repository. Aus ist es zuerst; eingeschaltet
|
|
// erscheint sein Baustein im Werkzeugkasten, das Panel entsteht aus dem Manifest, und die
|
|
// Vorschau laedt sein Stylesheet - das Skript nicht, wie bei eigenem Code.
|
|
await page.goto(origin + '/admin/plugins');
|
|
await axe('plugins');
|
|
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click();
|
|
await page.locator('.notice.ok').filter({ hasText: 'eingeschaltet' }).waitFor();
|
|
await page.screenshot({ path: 'reports/editor/plugins.png', fullPage: true });
|
|
await page.goto(origin + '/admin/pages/test-home');
|
|
await tab('Struktur');
|
|
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
|
await treeSelect('headline');
|
|
await addFromLibrary('[data-add="vergleich.bildvergleich"]');
|
|
await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor();
|
|
await page.getByLabel('Beschriftung vorher', { exact: true }).fill('Alt');
|
|
await page.getByLabel('Startposition des Reglers (%)', { exact: true }).fill('30');
|
|
for (const label of ['Bild vorher', 'Bild nachher']) {
|
|
await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Bild aus Mediathek wählen', exact: true }).click();
|
|
await page.locator('#media-dialog [data-media-id]').first().click();
|
|
await page.locator('#media-insert').click();
|
|
await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Anderes Bild wählen', exact: true }).waitFor();
|
|
}
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await page.waitForFunction(() => {
|
|
const doc = document.querySelector('#preview').contentDocument;
|
|
return Boolean(doc.querySelector('.block-bildvergleich input[type="range"][value="30"]')) && Boolean(doc.querySelector('link[href^="/plugin-assets/vergleich/assets/bildvergleich.css"]')) && !doc.querySelector('script[src^="/plugin-assets/"]');
|
|
});
|
|
await page.screenshot({ path: 'reports/editor/plugin-block.png', fullPage: true });
|
|
await page.locator('#save').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
|
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
|
{
|
|
const antwort = await page.request.get(origin + '/');
|
|
const html = await antwort.text();
|
|
assert.match(html, /class="block-bildvergleich bildvergleich-rund/, 'Plugin-Baustein auf der Website');
|
|
assert.match(html, /<script src="\/plugin-assets\/vergleich\/assets\/bildvergleich\.js\?v=1\.1\.0" defer><\/script>/);
|
|
assert.match(antwort.headers()['content-security-policy'] ?? '', /script-src 'self'/);
|
|
const css = await page.request.get(origin + '/plugin-assets/vergleich/assets/bildvergleich.css');
|
|
assert.equal(css.status(), 200);
|
|
}
|
|
// Einstellungen des Plugins: aus dem Schema erzeugt, gespeichert, auf der Website sichtbar.
|
|
await page.goto(origin + '/admin/plugins');
|
|
await page.getByLabel('Standardbeschriftung vorher', { exact: true }).fill('Alt');
|
|
await page.getByLabel('Griff des Reglers', { exact: true }).selectOption('punkt');
|
|
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
|
|
await page.locator('.notice.ok').filter({ hasText: 'Einstellungen' }).waitFor();
|
|
assert.match(await (await page.request.get(origin + '/')).text(), /bildvergleich-griff-punkt/);
|
|
// Die eigene Adresse des Plugins antwortet als JSON mit den Einstellungen.
|
|
{
|
|
const daten = await page.request.get(origin + '/vergleich/daten');
|
|
assert.equal(daten.status(), 200);
|
|
assert.equal((await daten.json()).labels.before, 'Alt');
|
|
}
|
|
// Die Seitenvorlage des Plugins im Werkzeugkasten, der Container mit seiner Kinderregel.
|
|
await page.goto(origin + '/admin/pages/test-home');
|
|
await tab('Struktur');
|
|
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
|
await tab('Elemente');
|
|
await page.locator('#presets [data-preset="plugin:vergleich:abschnitt"]').click();
|
|
await page.locator('#properties-title').filter({ hasText: 'Sektion' }).waitFor();
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await page.waitForFunction(() => document.querySelector('#preview').contentDocument.querySelectorAll('.block-bildvergleich').length >= 2);
|
|
await treeSelect('headline');
|
|
await addFromLibrary('[data-add="vergleich.galerie"]');
|
|
await page.locator('#properties-title').filter({ hasText: 'Vergleichsgalerie' }).waitFor();
|
|
// Ein Bildvergleich landet in der Galerie; ein Text passt nicht hinein und wird daneben eingefuegt.
|
|
await addFromLibrary('[data-add="vergleich.bildvergleich"]');
|
|
await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor();
|
|
await page.waitForFunction(() => document.querySelector('#preview').contentDocument.querySelector('.block-vergleichsgalerie .block-bildvergleich') !== null);
|
|
await treeSelect(await page.evaluate(() => document.querySelector('#preview').contentDocument.querySelector('.block-vergleichsgalerie').id));
|
|
await addFromLibrary('[data-add="text"]');
|
|
await page.waitForFunction(() => {
|
|
const doc = document.querySelector('#preview').contentDocument;
|
|
return doc.querySelector('.block-vergleichsgalerie .block-text') === null && doc.querySelectorAll('.block-text').length > 0;
|
|
});
|
|
await page.locator('#undo').click();
|
|
await page.locator('#undo').click(); await page.locator('#undo').click(); await page.locator('#undo').click();
|
|
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
|
await page.screenshot({ path: 'reports/editor/plugin-container.png', fullPage: true });
|
|
|
|
// Aus: der Baustein bleibt im Dokument, die Website zeigt den Kommentar, der Editor den Platzhalter.
|
|
await page.goto(origin + '/admin/plugins');
|
|
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Ausschalten', exact: true }).click();
|
|
await page.locator('.notice.ok').filter({ hasText: 'ausgeschaltet' }).waitFor();
|
|
assert.match(await (await page.request.get(origin + '/')).text(), /<!-- Block [^ ]+ \(vergleich\.bildvergleich\) wird von dieser Installation nicht dargestellt\. -->/);
|
|
await page.goto(origin + '/admin/pages/test-home');
|
|
await tab('Struktur');
|
|
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
|
await page.locator('#structure').getByText('Baustein vergleich.bildvergleich').first().waitFor();
|
|
await page.goto(origin + '/admin/plugins');
|
|
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click();
|
|
await page.locator('.notice.ok').waitFor();
|
|
checks.push({ name: 'plugin switched on, block inserted from the toolbox, schema panel, assets on the public page, switched off keeps the block', passed: true });
|
|
|
|
// --- Der Baustein „Eigenes HTML“ (ADR 106) -------------------------------------------------
|
|
// Markup des Betreibers, unveraendert bis auf die oeffentliche Seite. Geprueft wird der Weg,
|
|
// den ein Administrator geht: einfuegen, eintippen, Vorschau, veroeffentlichen - und dass
|
|
// <script> und <iframe> mit einer Meldung abgewiesen werden statt still zu verschwinden.
|
|
await page.goto(origin + '/admin/pages/test-home');
|
|
await treeSelect('headline');
|
|
await addFromLibrary('[data-add="html"]');
|
|
await page.locator('#properties-title').filter({ hasText: 'Eigenes HTML' }).waitFor();
|
|
await page.getByLabel('HTML', { exact: true }).fill('<table class="preise"><tbody><tr><td>Miete</td><td>ab 8 €/m²</td></tr></tbody></table>');
|
|
// Erst der Fokuswechsel schickt den Wert ins Dokument; die Vorschau folgt kurz darauf.
|
|
await page.locator('#preview-status').click();
|
|
await page.waitForFunction(() => Boolean(document.querySelector('#preview').contentDocument.querySelector('.block-html table.preise')), null, { timeout: 20000 });
|
|
await page.locator('#save').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
|
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
|
|
{
|
|
const html = await (await page.request.get(origin + '/')).text();
|
|
assert.match(html, /class="block-html"><table class="preise">/, 'Eigenes HTML unverändert auf der Website');
|
|
// Der Baustein selbst bringt kein Skript auf die Seite - die Tabelle steht da, wie sie
|
|
// eingegeben wurde, und sonst nichts. (Dass eine Seite ohne Skript weiterhin
|
|
// script-src 'none' sendet, prüft CmsTest; hier trägt das Plugin von vorhin schon eines bei.)
|
|
assert.equal(html.includes('<table class="preise"><tbody><tr><td>Miete</td>'), true, 'Markup Zeichen für Zeichen');
|
|
}
|
|
for (const [markup, meldung] of [
|
|
['<script>alert(1)</script>', /Eigenes HTML führt keine Skripte aus/],
|
|
['<iframe src="https://example.com"></iframe>', /Externer Inhalt/],
|
|
]) {
|
|
await page.getByLabel('HTML', { exact: true }).fill(markup);
|
|
await page.locator('#preview-status').click();
|
|
await page.locator('#save').click();
|
|
// Abgewiesen wird schon die Vorschau, und das Speichern meldet es in der Leiste über dem
|
|
// Editor - die Seite bleibt, wie sie veröffentlicht ist.
|
|
await page.locator('#message').filter({ hasText: meldung }).waitFor();
|
|
}
|
|
await page.getByLabel('HTML', { exact: true }).fill('<p class="notiz">Wieder gut.</p>');
|
|
await page.locator('#preview-status').click();
|
|
await page.locator('#save').click();
|
|
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
|
|
await page.screenshot({ path: 'reports/editor/own-html.png', fullPage: true });
|
|
checks.push({ name: 'own HTML block: inserted, previewed, published unchanged, script and iframe refused', 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();
|
|
// The privacy page is chosen by search, the way the 404 page is: type, then take the hit.
|
|
await page.getByLabel('Datenschutzseite', { exact: true }).fill('grundlagen');
|
|
await page.locator('#privacyPath-results [role=option]').filter({ hasText: '/grundlagen' }).first().click();
|
|
assert.equal(await page.locator('.content-picker:has(#privacyPath) .picker-value').inputValue(), '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('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).');
|
|
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 tab('Struktur');
|
|
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
|
await treeSelect('headline');
|
|
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 (await tab('Elemente'), 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();
|
|
// Das Vorschaubild einer Seite: aus der Mediathek gewählt, nicht als Kennung abgetippt.
|
|
await page.setViewportSize({ width: 1600, height: 1100 });
|
|
await page.goto(origin + '/admin');
|
|
const sheet = page.locator('details.sheet').first();
|
|
await sheet.locator('summary').click();
|
|
const ogField = sheet.locator('[data-media-field]').filter({ has: page.locator('input[name="ogMediaId"]') });
|
|
await ogField.getByRole('button', { name: 'Bild wählen oder hochladen', exact: true }).click();
|
|
await page.locator('#media-dialog [data-media-id]').first().click();
|
|
await page.locator('#media-insert').click();
|
|
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
|
const chosenOg = await ogField.locator('input[name="ogMediaId"]').inputValue();
|
|
assert.match(chosenOg, /^[a-f0-9]{32}$/);
|
|
assert.equal(await ogField.locator('.media-field-thumb').isVisible(), true, 'the chosen image is shown');
|
|
await axe('content settings with an image field');
|
|
await ogField.locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
|
await page.waitForLoadState('load');
|
|
const savedOg = page.locator('input[name="ogMediaId"]').first();
|
|
assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving');
|
|
checks.push({ name: 'image fields in the content settings pick from the library', passed: true });
|
|
// Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt.
|
|
await page.goto(origin + '/admin/forms');
|
|
const neu = page.locator('#neues-formular');
|
|
if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click();
|
|
await neu.locator('[name="name"]').fill('Anfrage Prüfung');
|
|
await neu.locator('[name="slug"]').fill('anfrage-pruefung');
|
|
await neu.locator('[name="recipient"]').fill('info@example.com');
|
|
const karte = index => neu.locator('[data-field-list] [data-field]').nth(index);
|
|
await karte(0).locator('[data-field-label]').fill('E-Mail');
|
|
await karte(0).locator('[data-field-type]').selectOption('email');
|
|
await neu.locator('[data-add-field]').click();
|
|
await karte(1).locator('[data-field-label]').fill('Art');
|
|
await karte(1).locator('[data-field-type]').selectOption('radio');
|
|
await karte(1).locator('[data-field-options-input]').fill('Wohnung, Haus');
|
|
await neu.locator('[data-add-field]').click();
|
|
await karte(2).locator('[data-field-label]').fill('Etage');
|
|
await karte(2).getByLabel('Nur zeigen, wenn', { exact: true }).selectOption({ label: 'Art' });
|
|
await karte(2).getByLabel('Wohnung', { exact: true }).check();
|
|
assert.equal(await karte(2).locator('[data-field-showif]').inputValue(), 'art');
|
|
assert.equal(await karte(2).locator('[data-field-showvalues]').inputValue(), 'Wohnung');
|
|
await neu.getByLabel('Antwortadresse aus Feld', { exact: true }).selectOption({ label: 'E-Mail' });
|
|
assert.equal(await neu.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
|
await karte(1).getByRole('button', { name: 'Bild für „Wohnung“ wählen', exact: true }).click();
|
|
await page.locator('#media-dialog [data-media-id]').first().click();
|
|
await page.locator('#media-insert').click();
|
|
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
|
assert.match(await karte(1).locator('[data-field-images-input]').inputValue(), /^Wohnung = [a-f0-9]{32}$/);
|
|
assert.equal(await karte(1).locator('.field-image-picker-thumb').first().isVisible(), true);
|
|
await axe('form editor with pickers');
|
|
await neu.getByRole('button', { name: 'Formular anlegen', exact: true }).click();
|
|
await page.waitForLoadState('load');
|
|
const gespeichert = page.locator('form[action$="/forms/anfrage-pruefung"]');
|
|
await gespeichert.waitFor({ state: 'attached' });
|
|
assert.equal(await gespeichert.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
|
assert.equal(await gespeichert.locator('[data-field-showif]').nth(2).inputValue(), 'art');
|
|
checks.push({ name: 'form editor: condition, reply address and option images are picked', passed: true });
|
|
// Menü: der eigene Link bekommt eine Seite per Suche, ein Seitenlink sein Sprungziel per Auswahl.
|
|
await page.goto(origin + '/admin/menus');
|
|
await page.locator('.menu-add details summary').click();
|
|
await page.locator('#link-url').locator('xpath=..').getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
|
await page.locator('dialog.page-dialog [role="option"]').first().click();
|
|
await page.locator('dialog.page-dialog').waitFor({ state: 'detached' });
|
|
assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/);
|
|
await page.locator('#link-label').fill('Eigener Prüflink');
|
|
await page.locator('#menu-add-url').click();
|
|
await page.locator('#menu-search').focus();
|
|
await page.locator('#menu-search-results [role="option"]').first().click();
|
|
await page.locator('#menu-add-page').click();
|
|
const neuerEintrag = page.locator('#menu-items .menu-entry').last();
|
|
const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true });
|
|
assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice');
|
|
assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang');
|
|
// Die ganze Seite: seit der Ziehknopf neben der Zusammenfassung steht, gibt es keinen Knopf
|
|
// mehr in einem Knopf.
|
|
await neuerEintrag.locator('summary').click();
|
|
await axe('menu editor');
|
|
await page.screenshot({ path: 'reports/editor/menu-editor.png' });
|
|
assert.equal(await neuerEintrag.locator('summary [data-menu-drag]').count(), 0, 'no button inside the summary');
|
|
// Ziehknopf per Tastatur: Pfeil nach oben verschiebt den Eintrag.
|
|
const eintraege = page.locator('#menu-items .menu-entry');
|
|
const anzahl = await eintraege.count();
|
|
assert.ok(anzahl >= 2);
|
|
const titel = await neuerEintrag.locator('.menu-entry-title').textContent();
|
|
await eintraege.nth(anzahl - 1).locator('[data-menu-drag]').focus();
|
|
await page.keyboard.press('ArrowUp');
|
|
assert.equal(await eintraege.nth(anzahl - 2).locator('.menu-entry-title').textContent(), titel, 'the entry moved up');
|
|
assert.equal(await eintraege.nth(anzahl - 2).locator('[data-menu-drag]').evaluate(node => node === document.activeElement), true, 'the handle keeps focus');
|
|
checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true });
|
|
// Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld.
|
|
await page.goto(origin + '/admin/posts');
|
|
await page.locator('summary').filter({ hasText: 'Neuen Beitrag anlegen' }).click();
|
|
await page.locator('#new-title').fill('Prüfbeitrag');
|
|
await page.locator('form.create-form button[type="submit"]').click();
|
|
await page.waitForLoadState('load');
|
|
await page.goto(origin + '/admin/posts');
|
|
let beitrag = page.locator('details.sheet').first();
|
|
await beitrag.locator('summary').click();
|
|
await beitrag.locator('input[name="categories"]').fill('Projekte');
|
|
await beitrag.locator('input[name="categories"]').locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
|
await page.waitForLoadState('load');
|
|
await page.goto(origin + '/admin/posts');
|
|
beitrag = page.locator('details.sheet').first();
|
|
await beitrag.locator('summary').click();
|
|
const kategorie = beitrag.locator('input[name="categories"]');
|
|
await kategorie.fill('');
|
|
const vorschlag = beitrag.locator('.term-chip', { hasText: 'Projekte' }).first();
|
|
await vorschlag.click();
|
|
assert.equal(await kategorie.inputValue(), 'Projekte');
|
|
assert.equal(await vorschlag.getAttribute('aria-pressed'), 'true');
|
|
await vorschlag.click();
|
|
assert.equal(await kategorie.inputValue(), '');
|
|
await axe('post settings with term suggestions');
|
|
checks.push({ name: 'post settings suggest existing categories', passed: true });
|
|
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();
|
|
}
|