import { chromium } from 'playwright'; import AxeBuilder from '@axe-core/playwright'; import assert from 'node:assert/strict'; import { execFileSync, spawn } from 'node:child_process'; import { mkdir, writeFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; import net from 'node:net'; const php = process.env.PHP_BINARY || 'php'; const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true })); const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory }; execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env }); 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', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } }); const report = 'reports/accordion'; await mkdir(report, { recursive: true }); let browser, page; const checks = [], errors = []; try { for (let attempt = 0; attempt < 60; attempt++) { try { if ((await fetch(origin)).ok) break; } catch {} if (attempt === 59) throw new Error('Pilot test server did not start'); await new Promise(resolve => setTimeout(resolve, 200)); } browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } }); page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message)); await page.goto(origin + '/admin/login'); 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'); const api = `${origin}/admin/api/pages/${fixture.homeId}`; const data = await (await context.request.get(api)).json(); data.document.blocks.unshift({ id: 'qa-accordion', type: 'accordion', children: [ { id: 'qa-entry', type: 'accordionitem', props: { title: 'Was ist enthalten?', open: false }, children: [] }, { id: 'qa-open', type: 'accordionitem', props: { title: 'Schon geöffnet', open: true }, children: [{ id: 'qa-existing', type: 'text', props: { text: '
Vorhandener Inhalt bleibt.
' } }] }, ] }, { id: 'qa-empty-accordion', type: 'accordion', children: [] }); assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200); await page.goto(`${origin}/admin/pages/${fixture.homeId}`); const frame = page.frameLocator('#preview'); const ready = async () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); const select = async id => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator('[data-panel="content"]').click(); }; await ready(); await frame.locator('#qa-entry > summary').click(); assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true); assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false); assert.equal(await page.locator('#save').isDisabled(), true, 'Revealing the editor body must not change the document'); await frame.locator('#qa-entry [data-editor-insert]').focus(); await frame.locator('#qa-entry [data-editor-insert]').press('Enter'); assert.match(await page.locator('#insert-hint').innerText(), /Was ist enthalten/); await page.locator('#library [data-add="text"]').click(); await ready(); await frame.locator('#qa-entry .block-text').waitFor(); const textId = await frame.locator('#qa-entry .block-text').getAttribute('id'); await page.getByLabel('Text', { exact: true }).fill('Text direkt im Akkordeon.'); await ready(); assert.equal(await frame.locator('#qa-entry .block-text').innerText(), 'Text direkt im Akkordeon.'); await page.locator('#undo').click(); await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${textId}`).count(), 0); await page.locator('#redo').click(); await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-entry #${textId}`).count(), 1); checks.push('closed entries reveal editable bodies and empty insert areas without changing public state; text insertion, editing and undo/redo'); await select('qa-accordion'); assert.equal(await page.locator('#library [data-add="text"]').isDisabled(), true, 'Group selection must not put text outside the accordion'); await page.getByRole('button', { name: 'Eintrag 1: Was ist enthalten? bearbeiten', exact: true }).click(); await page.getByRole('button', { name: '+ Inhalt hinzufügen', exact: true }).click(); await page.locator('#library [data-add="button"]').click(); await ready(); const firstButtonId = await frame.locator('#qa-entry a.button').getAttribute('id'); assert.equal(await frame.locator('#qa-entry .accordion-body > [id]').last().getAttribute('id'), firstButtonId, 'Add content appends after existing content'); await select('qa-open'); await page.locator('#library [data-add="button"]').dragTo(frame.locator('#qa-open .accordion-body'), { targetPosition: { x: 40, y: 20 } }); await ready(); await frame.locator('#qa-open a.button').waitFor(); const draggedId = await frame.locator('#qa-open a.button').getAttribute('id'); await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${draggedId}`).count(), 0); await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-open #${draggedId}`).count(), 1); checks.push('entry selection in inspector, append content and drag/drop into an open body with undo/redo'); await frame.locator('#qa-accordion > .editor-add-content [data-editor-add-entry]').click(); await page.getByLabel('Titel', { exact: true }).fill('Ein neuer Eintrag'); await ready(); const newEntryId = await frame.locator('#qa-accordion > details').last().getAttribute('id'); assert.equal(await frame.locator('#qa-accordion > details').count(), 3); assert.equal(await frame.locator(`#${newEntryId}`).evaluate(el => el.open), true); await select('qa-empty-accordion'); await page.getByRole('button', { name: '+ Eintrag hinzufügen', exact: true }).click(); await ready(); assert.equal(await frame.locator('#qa-empty-accordion > details').count(), 1); await page.getByLabel('Titel', { exact: true }).fill('Erster Eintrag'); await ready(); await page.locator('#tree-search').fill('Ein neuer Eintrag'); assert.equal(await page.locator(`#structure [data-block-id="${newEntryId}"]`).count(), 1); await page.locator('#tree-search').fill(''); await select('qa-entry'); await page.locator('[data-panel="design"]').click(); await page.locator('#library [data-add="accordion"]').click(); await ready(); assert.equal(await page.locator('#properties-title').innerText(), 'Akkordeon-Eintrag'); assert.equal(await page.getByLabel('Titel', { exact: true }).inputValue(), 'Eine Frage'); await page.locator('#undo').click(); await ready(); checks.push('add entries in canvas and inspector, recover empty accordion, search by title, new accordion starts with its editable entry selected'); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); await page.reload(); await ready(); await select('qa-entry'); assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false); assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true); const saved = await (await context.request.get(api)).json(); const accordion = saved.document.blocks.find(b => b.id === 'qa-accordion'); assert.equal(accordion.children[0].props.open, false); assert.equal(accordion.children[1].props.open, true); assert.deepEqual(accordion.children[0].children.map(b => b.id), [textId, firstButtonId]); assert.ok(!JSON.stringify(saved.document).includes('editor-empty')); await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); const plain = await browser.newContext({ javaScriptEnabled: false }); const visitor = await plain.newPage(); await visitor.goto(origin); assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false); assert.equal(await visitor.locator('#qa-open').evaluate(el => el.open), true); assert.equal(await visitor.locator('[data-editor-insert],[data-editor-add-entry],.editor-empty').count(), 0); await visitor.locator('#qa-entry > summary').click(); assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), true); assert.equal(await visitor.locator(`#qa-entry #${textId}`).isVisible(), true); await visitor.locator('#qa-entry > summary').press('Enter'); assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false); await plain.close(); checks.push('save/reload/publication keep children and original open flags; published accordion toggles without JavaScript and contains no editing aids'); for (const width of [1600, 390]) { await page.setViewportSize({ width, height: 1050 }); await select('qa-entry'); await ready(); await page.locator('[data-viewport="mobile"]').click(); await ready(); await frame.locator('#qa-entry').scrollIntoViewIfNeeded(); const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); await page.screenshot({ path: `${report}/editor-${width}.png`, fullPage: true }); } checks.push('desktop/mobile editor passes axe and layout checks with editable accordion preview'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Accordion checks passed:', checks.length, 'groups.'); } catch (error) { if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {}); throw error; } finally { await browser?.close(); server.kill(); }