Files
KantSites/scripts/menu-button-check.mjs
T
kantarciandClaude Opus 5.5 13f6759df8 Let menu entries appear as buttons with icon, colours and phone placement
The menu editor gets "Als Button darstellen" per entry: shape, appearance and
size from the button design, fill and ink with hover and palette, icon colour,
icon from the dialog, motion, icon-only and where the button sits on the phone
(in the opened menu, beside the burger, or there as an icon only). Colours go
into the page stylesheet under a stable per-button id because the CSP forbids
inline styles. ADR 110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:36:12 +02:00

136 lines
9.5 KiB
JavaScript

// 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();
}