// Prüft das Symbol am Button im echten Editor auf einer isolierten Kopie der Pilotdaten: Auswahl- // fenster mit Kategorien, Tastatur, Stellung, Bewegung, nur Symbol, Farbe und Größe unter Design, // Rückgängig, Speichern und Neuladen. Screenshots unter reports/button-icons/. 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/button-icons'; 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)); const selectBlock = async id => { await page.getByRole('tab', { name: 'Struktur', exact: true }).click(); await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`#structure [data-edit-block="${id}"]`).click(); }; 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-icons', type: 'section', children: [ { id: 'qa-icon-button', type: 'button', props: { text: 'Jetzt anrufen', href: 'tel:+4972296992826' } }, ] }); assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200); const inPreview = fn => page.evaluate(fn); const waitPreview = async expected => page.waitForFunction(expected => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-icon-button'); if (!el) return false; const path = el.querySelector('.button-icon svg path')?.getAttribute('d') ?? null; const classes = el.className; return (expected.path === undefined || (expected.path === null ? path === null : path?.startsWith(expected.path))) && (expected.first === undefined || ((el.firstChild?.nodeType === 1 && el.firstChild.classList.contains('button-icon')) === expected.first)) && (expected.has ?? []).every(name => classes.includes(name)) && (expected.not ?? []).every(name => !classes.includes(name)); }, expected); await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await selectBlock('qa-icon-button'); const field = page.locator('.icon-field'); await field.waitFor(); assert.equal(await field.locator('.icon-field-name').textContent(), 'Pfeil schräg'); assert.equal(await page.locator('.icon-choice').count(), 0, 'Im Panel steht keine Kachelwand.'); await waitPreview({ path: 'M6.5 17.5', first: false }); await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-vorher.png` }).catch(() => {}); checks.push('Panel: nur das gewählte Symbol (Pfeil schräg) mit „Ändern …“'); // Das Fenster: Kategorien links, Symbole mit Namen, geöffnet bei der Kategorie des Pfeils. await page.getByRole('button', { name: /Symbol ändern, aktuell: Pfeil schräg/ }).click(); const dialog = page.getByRole('dialog', { name: 'Symbol wählen' }); await dialog.waitFor(); assert.equal(await dialog.getByRole('tab').count(), 7); assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Pfeile'); assert.equal(await page.evaluate(() => document.activeElement?.dataset.icon), 'arrow-up-right', 'Fokus auf dem gewählten Symbol'); const axe = await new AxeBuilder({ page }).include('.icon-dialog').analyze(); assert.deepEqual(axe.violations.map(v => v.id), [], 'axe im Auswahlfenster'); await dialog.getByRole('tab', { name: 'Haus & Handwerk' }).click(); await dialog.screenshot({ path: `${report}/fenster.png` }); // Escape schließt ohne Änderung, der Fokus geht zurück zum Knopf. await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'detached' }); assert.match(await page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? ''), /Symbol ändern/); await waitPreview({ path: 'M6.5 17.5' }); checks.push('Fenster: 7 Kategorien, Fokus auf dem Gewählten, axe ohne Befund, Escape ändert nichts'); // Tastatur: Kategorie mit Pfeiltaste wechseln, Symbol wählen. await page.getByRole('button', { name: /Symbol ändern/ }).click(); await dialog.getByRole('tab', { name: 'Pfeile' }).focus(); await page.keyboard.press('ArrowDown'); assert.equal(await dialog.getByRole('tab', { selected: true }).textContent(), 'Kontakt & Ort'); await dialog.getByRole('button', { name: 'Telefon', exact: true }).click(); await dialog.waitFor({ state: 'detached' }); await waitPreview({ path: 'M6 3h4', first: false }); assert.equal(await field.locator('.icon-field-name').textContent(), 'Telefon'); checks.push('Telefon über die Kategorie „Kontakt & Ort“ gewählt'); await page.getByRole('button', { name: 'Vor dem Text', exact: true }).click(); await waitPreview({ first: true, has: ['icon-start'] }); await page.getByRole('button', { name: 'Rückt', exact: true }).click(); await waitPreview({ has: ['icon-motion-slide'] }); await page.getByRole('button', { name: 'Erscheint', exact: true }).click(); await waitPreview({ has: ['icon-motion-reveal'], not: ['icon-motion-slide'] }); await page.locator('#undo').click(); await waitPreview({ has: ['icon-motion-slide'] }); await page.locator('#redo').click(); await waitPreview({ has: ['icon-motion-reveal'] }); checks.push('Stellung und Bewegung, Rückgängig und Wiederholen'); await page.getByLabel('Nur das Symbol zeigen').check(); await waitPreview({ has: ['icon-only'] }); const label = await inPreview(() => document.querySelector('#preview').contentDocument.querySelector('#qa-icon-button .button-label')?.textContent); assert.equal(label, 'Jetzt anrufen', 'Beschriftung bleibt für Vorlesesoftware'); await page.locator('.properties, #properties, aside').first().screenshot({ path: `${report}/panel-nachher.png` }).catch(() => {}); checks.push('Nur Symbol: Beschriftung unsichtbar im Link'); // Design: Gruppe „Symbol“ mit Farbe, Fläche, Größe, Drehung. await page.getByRole('button', { name: 'Farbe, Größe und Fläche des Symbols → Design' }).click(); const group = page.locator('.design-group[data-group="icon"]'); await group.waitFor(); assert.equal(await group.evaluate(el => el.open), true); for (const name of ['Symbolfarbe', 'Fläche hinter dem Symbol', 'Symbolgröße', 'Innenabstand der Fläche', 'Ecken der Fläche', 'Drehung', 'Abstand zum Text']) { assert.ok(await group.getByText(name, { exact: true }).count() > 0, `${name} fehlt in der Gruppe Symbol`); } await group.locator('#style-iconSize').fill('24'); await group.locator('#style-iconSize').press('Enter'); await page.waitForFunction(() => { const doc = document.querySelector('#preview')?.contentDocument, svg = doc?.querySelector('#qa-icon-button .button-icon svg'); return svg && Math.round(svg.getBoundingClientRect().width) === 24; }); await group.screenshot({ path: `${report}/design-symbol.png` }); checks.push('Design → Symbol: alle sieben Werte da, Größe 24 px wirkt in der Vorschau'); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); const stored = (await (await context.request.get(api)).json()).document; const button = stored.blocks[0].children[0]; assert.equal(stored.schemaVersion, 26); assert.deepEqual(button.props, { text: 'Jetzt anrufen', href: 'tel:+4972296992826', icon: 'phone', iconPosition: 'start', iconMotion: 'reveal', iconOnly: true }); assert.equal(button.style.desktop.iconSize, '24px'); await page.reload(); await selectBlock('qa-icon-button'); await page.locator('.icon-field').waitFor(); assert.equal(await page.locator('.icon-field-name').textContent(), 'Telefon'); assert.equal(await page.getByLabel('Nur das Symbol zeigen').isChecked(), true); checks.push('gespeichert als Schema v26 mit Größe unter Design, nach Neuladen gleich'); // So sieht es aus: Pfeil in einer runden Fläche, die sich beim Überfahren dreht und umfärbt, // daneben ein Button nur aus Symbol. const look = (await (await context.request.get(api)).json()); look.document.blocks[0].children = [ { id: 'qa-look-a', type: 'button', props: { text: 'Projekt anfragen', href: '/kontakt', icon: 'arrow-right', iconMotion: 'slide' }, style: { desktop: { iconColor: '#ffffff', iconBackgroundColor: '#b71c22', iconPadding: '6px', iconRadius: '999px', iconSize: '16px', hover: { iconBackgroundColor: '#1e2a20', iconRotate: '-45deg' } } } }, { id: 'qa-look-b', type: 'button', props: { text: 'Anrufen', href: 'tel:+4972296992826', icon: 'phone', iconOnly: true } }, { id: 'qa-look-c', type: 'button', props: { text: 'Prospekt laden', href: '/', icon: 'download', iconPosition: 'start' } }, ]; assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': look.csrfToken }, data: { document: look.document, expectedVersion: look.version } })).status(), 200); await page.reload(); const frame = page.frameLocator('#preview'); await frame.locator('#qa-look-a .button-icon').waitFor(); const section = frame.locator('#qa-icons'); await section.screenshot({ path: `${report}/aussehen-normal.png` }); const styles = await frame.locator('#qa-look-a .button-icon').evaluate(el => { const s = getComputedStyle(el); return { bg: s.backgroundColor, radius: s.borderRadius, color: s.color }; }); assert.deepEqual(styles, { bg: 'rgb(183, 28, 34)', radius: '999px', color: 'rgb(255, 255, 255)' }); await frame.locator('#qa-look-a').hover(); await page.waitForTimeout(400); await section.screenshot({ path: `${report}/aussehen-hover.png` }); assert.equal(await frame.locator('#qa-look-a .button-icon').evaluate(el => getComputedStyle(el).rotate), '-45deg'); const only = await frame.locator('#qa-look-b').evaluate(el => ({ name: el.textContent.trim(), w: Math.round(el.getBoundingClientRect().width), h: Math.round(el.getBoundingClientRect().height) })); assert.equal(only.name, 'Anrufen'); assert.ok(Math.abs(only.w - only.h) <= 2, `Nur-Symbol-Button ist nicht quadratisch: ${only.w}x${only.h}`); checks.push('Aussehen: farbige runde Fläche, Drehung beim Überfahren, quadratischer Nur-Symbol-Button, Symbol vor dem Text'); await selectBlock('qa-look-c'); // Kein Symbol: Nur-Symbol fällt weg, die Einstellungen verschwinden. await page.getByRole('button', { name: /Symbol ändern/ }).click(); await dialog.getByRole('button', { name: 'Kein Symbol', exact: true }).click(); await page.waitForFunction(() => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-look-c'); return el && !el.querySelector('.button-icon'); }); assert.equal(await page.getByRole('button', { name: 'Vor dem Text', exact: true }).count(), 0); checks.push('Kein Symbol entfernt auch „nur Symbol“'); assert.deepEqual(errors, []); await writeFile(`${report}/report.json`, JSON.stringify({ origin, checks }, null, 2)); console.log(checks.map(check => `✓ ${check}`).join('\n')); } finally { await browser?.close(); server.kill(); }