// Prüft Menü-Buttons (ADR 110) auf einer isolierten Kopie der Pilotdaten: in der Menüverwaltung // einen eigenen Link als Button gestalten (Form, Symbol aus dem Fenster, Palettenfarbe, Telefon- // Anzeige), Vorschau, Speichern, Neuladen - und die öffentliche Seite auf Desktop und Telefon. // Screenshots unter reports/menu-buttons/. 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/menu-buttons'; await mkdir(report, { recursive: true }); let browser; 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: 1440, height: 1000 } }); const 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'); await page.goto(origin + '/admin/menus'); await page.locator('summary', { hasText: 'Eigener Link' }).click(); await page.locator('#link-label').fill('Angebot anfragen'); await page.locator('#link-url').fill('/kontakt'); await page.locator('#menu-add-url').click(); const row = page.locator('.menu-entry').last(); await row.locator('summary').click(); // Ohne Häkchen keine Button-Optionen. assert.equal(await row.locator('[data-button-options]').isVisible(), false); await row.getByLabel('Als Button darstellen').check(); const options = row.locator('[data-button-options]'); await options.waitFor(); await options.getByLabel('Form').selectOption('pill'); // Symbol über das Fenster. await row.getByRole('button', { name: /Symbol ändern/ }).click(); const dialog = page.getByRole('dialog', { name: 'Symbol wählen' }); await dialog.getByRole('button', { name: 'Pfeil nach rechts', exact: true }).click(); await dialog.waitFor({ state: 'detached' }); assert.equal(await row.locator('.icon-field-name').textContent(), 'Pfeil nach rechts'); await row.getByLabel('Symbol steht').selectOption('after'); // Fläche aus der Palette, Schrift als eigener Wert. await options.locator('.color-trigger').first().click(); await options.locator('.color-choices:not([hidden]) .color-option').nth(1).click(); const fill = await row.locator('input[data-key="buttonFill"]').inputValue(); assert.match(fill, /^@[a-z]/, 'Palettenfarbe als Verweis gespeichert'); await options.getByLabel('Symbol beim Überfahren').selectOption('slide'); await options.getByLabel('Auf dem Telefon').selectOption('bar-icon'); const sample = row.locator('.menu-button-preview .cms-button').first(); assert.equal(await sample.evaluate(el => getComputedStyle(el).borderRadius), '999px'); assert.equal(await row.locator('.menu-button-preview .button-icon svg').count(), 2, 'Symbol in Normal- und Hover-Vorschau'); const axe = await new AxeBuilder({ page }).include('.menu-entry:last-child').analyze(); assert.deepEqual(axe.violations.map(v => v.id), [], 'axe in der Menüzeile'); await row.screenshot({ path: `${report}/verwaltung.png` }); checks.push('Verwaltung: Häkchen zeigt Optionen, Pille, Symbol aus dem Fenster, Palettenfarbe als Verweis, Vorschau mit Symbol, axe ohne Befund'); // Zweiter Button bleibt auf dem Telefon im aufgeklappten Menü (Vorgabe). await page.locator('#link-label').fill('Rückruf'); await page.locator('#link-url').fill('/rueckruf'); await page.locator('#menu-add-url').click(); const second = page.locator('.menu-entry').last(); await second.locator('summary').click(); await second.getByLabel('Als Button darstellen').check(); await second.locator('select[data-key="buttonAppearance"]').selectOption('outline'); assert.equal(await second.locator('select[data-key="buttonMobile"]').inputValue(), 'menu'); await page.getByRole('button', { name: 'Menü speichern' }).click(); await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor(); await page.reload(); const saved = page.locator('.menu-entry').filter({ hasText: 'Angebot anfragen' }).last(); await saved.locator('summary').click(); assert.equal(await saved.getByLabel('Als Button darstellen').isChecked(), true); assert.equal(await saved.locator('select[data-key="buttonShape"]').inputValue(), 'pill'); assert.equal(await saved.locator('select[data-key="buttonMobile"]').inputValue(), 'bar-icon'); assert.equal(await saved.locator('.icon-field-name').textContent(), 'Pfeil nach rechts'); checks.push('gespeichert und nach Neuladen gleich'); // Öffentliche Seite: Desktop im Menü, Telefon neben dem Menü-Knopf. const site = await (await browser.newContext({ viewport: { width: 1440, height: 900 } })).newPage(); site.on('pageerror', error => errors.push(error.message)); await site.goto(origin + '/'); const desktop = site.locator('.site-header .block-menu > .menu-list a.menu-button'); await desktop.first().waitFor(); assert.equal(await desktop.first().isVisible(), true); const look = await desktop.first().evaluate(el => ({ radius: getComputedStyle(el).borderRadius, bg: getComputedStyle(el).backgroundColor, deco: getComputedStyle(el).textDecorationLine })); assert.equal(look.radius, '999px'); assert.equal(look.deco, 'none'); assert.equal(await site.locator('.site-header .menu-actions').first().isVisible(), false, 'Auf dem Desktop keine Zweitausgabe'); await site.locator('.site-header').screenshot({ path: `${report}/desktop.png` }); await site.setViewportSize({ width: 390, height: 844 }); await site.reload(); const bar = site.locator('.site-header .menu-actions a.menu-button'); assert.equal(await bar.first().isVisible(), true, 'Auf dem Telefon neben dem Menü-Knopf'); assert.match(await bar.first().getAttribute('class'), /icon-only/, 'Dort nur als Symbol'); assert.equal(await bar.first().getAttribute('href'), '/kontakt'); const line = await site.evaluate(() => { const a = document.querySelector('.site-header .menu-actions a'), s = document.querySelector('.site-header .menu-burger > summary'); return Math.abs(a.getBoundingClientRect().top + a.getBoundingClientRect().height / 2 - (s.getBoundingClientRect().top + s.getBoundingClientRect().height / 2)); }); await site.locator('.site-header').screenshot({ path: `${report}/telefon.png` }); assert.ok(line < 4, `Symbol und Menü-Knopf nicht in einer Zeile: ${line}px`); await site.locator('.site-header .menu-burger > summary').first().click(); assert.equal(await site.locator('.site-header .menu-burger-body .menu-keep-visible').first().isVisible(), false, 'Im aufgeklappten Menü nicht doppelt'); const inMenu = site.locator('.site-header .menu-burger-body a.menu-button', { hasText: 'Rückruf' }); assert.equal(await inMenu.isVisible(), true, 'Button im aufgeklappten Menü'); assert.equal(await site.locator('.site-header .menu-actions a', { hasText: 'Rückruf' }).count(), 0, 'nicht neben dem Menü-Knopf'); const fit = await inMenu.evaluate(a => ({ a: a.getBoundingClientRect().width, list: a.closest('.menu-list').getBoundingClientRect().width, border: getComputedStyle(a).borderTopStyle })); assert.ok(Math.abs(fit.a - fit.list) < 2, `volle Breite im Menü: ${fit.a} von ${fit.list}`); assert.equal(fit.border, 'solid', 'Darstellung „Rahmen“ bleibt im Menü'); await site.screenshot({ path: `${report}/telefon-offen.png` }); const axeSite = await new AxeBuilder({ page: site }).include('.site-header').analyze(); assert.deepEqual(axeSite.violations.map(v => v.id), [], 'axe im Kopf der Website'); const width = await site.evaluate(() => document.documentElement.scrollWidth); assert.ok(width <= 390, `waagerechtes Scrollen: ${width}`); checks.push('Website: Button im Menü (Pille, ohne Unterstreichung), auf dem Telefon neben dem Menü-Knopf, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite, axe ohne Befund, kein waagerechtes Scrollen'); 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(); }