A site can have further languages (Settings → Languages). A page is built once; each language adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111, document schema v27, migration 20261009000100 for page_translation). - Editor: DE | EN switch, a list of all texts with original, state (missing, translated, original changed) and filter; structure stays with the main language. Undo, drafts, publishing and revisions cover all languages at once. - Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale, sitemap; links to translated pages follow the language; a language switch block. - Header, footer and linked sections translate the same way; menu entries carry labels per language; fixed template texts come from a small dictionary. - Forms: labels, help, options, button and success message per language; messages and the step script speak the language, the submission and the email stay in the main language. - Posts, archives, category names, consent text and the 404 page follow the language. - Single-language sites see none of it and never touch the new table. The forms administration becomes an overview with one row per form and its own edit page; fields are one line each with details collapsed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1261 lines
87 KiB
JavaScript
1261 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/neu');
|
|
const neu = page.locator('#neues-formular');
|
|
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();
|
|
}
|