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/responsive-navigation'; 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.schemaVersion = 22; const visibility = ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden']; const text = (id, title, visible = 'all') => ({ id, type: 'text', props: { text: `

${title}

` }, layout: { visibility: visible } }); const nav = (id, layout = 'horizontal', collapse) => ({ id, type: 'menu', props: { slot: 'header', layout, ...(collapse ? { collapse } : {}) } }); data.document.blocks.unshift({ id: 'qa-responsive', type: 'section', children: [ { id: 'qa-title', type: 'heading', props: { text: 'Gerätesichtbarkeit und Navigation', level: 2 } }, nav('qa-menu'), nav('qa-mobile-menu', 'horizontal', 'mobile'), nav('qa-never-menu', 'horizontal', 'never'), nav('qa-vertical', 'vertical'), nav('qa-burger', 'burger'), ...visibility.map(value => text(`qa-${value}`, value, value)), { id: 'qa-parent', type: 'column', layout: { visibility: 'mobile' }, style: { desktop: { display: 'flex' }, mobile: { display: 'grid' } }, children: [text('qa-child', 'Vererbte Sichtbarkeit')] }, { id: 'qa-tabs', type: 'tabs', children: [ { id: 'qa-tab-desktop', type: 'tabitem', props: { title: 'Desktop-Tab' }, layout: { visibility: 'desktop' }, children: [text('qa-tab-text-d', 'Desktopinhalt')] }, { id: 'qa-tab-mobile', type: 'tabitem', props: { title: 'Mobil-Tab' }, layout: { visibility: 'mobile' }, children: [text('qa-tab-text-m', 'Mobilinhalt')] }, { id: 'qa-tab-all', type: 'tabitem', props: { title: 'Immer-Tab' }, children: [text('qa-tab-text-a', 'Gemeinsamer Inhalt')] }, ] }, { id: 'qa-slider', type: 'slider', props: { desktopVisible: 1, tabletVisible: 1, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }, children: [text('qa-slide-d', 'Desktopfolie', 'desktop'), text('qa-slide-m', 'Mobilfolie', 'mobile'), text('qa-slide-a', 'Gemeinsame Folie')] }, ] }); const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } }); assert.equal(created.status(), 200, await created.text()); await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await page.locator('#structure [data-block-id="qa-all"]').click(); await page.locator('#block-visibility').selectOption('mobile'); const previewHidden = async hidden => page.waitForFunction(hidden => { const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-all'); return el && (el.getClientRects().length === 0) === hidden; }, hidden); await previewHidden(true); await page.locator('#undo').click(); await previewHidden(false); await page.locator('#redo').click(); await previewHidden(true); await page.locator('#structure [data-block-id="qa-all"]').click(); await page.locator('#block-visibility').selectOption('all'); await previewHidden(false); await page.locator('#block-visibility').focus(); await page.locator('#block-visibility').press('ArrowDown'); await page.locator('#block-visibility').press('Tab'); assert.equal(await page.locator('#block-visibility').inputValue(), 'desktop'); await page.locator('#undo').click(); await page.locator('#structure [data-block-id="qa-menu"]').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet'); await page.locator('#property-collapse').selectOption('never'); await page.locator('#undo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet'); await page.locator('#redo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'never'); await page.locator('#property-collapse').selectOption('tablet'); await page.locator('[data-viewport="tablet"]').click(); const preview = page.frameLocator('#preview'); await preview.locator('#qa-menu > .menu-burger > summary').click(); assert.equal(await preview.locator('#qa-menu > .menu-burger').getAttribute('open'), ''); await page.screenshot({ path: `${report}/editor-tablet.png`, fullPage: true }); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); await page.reload(); await page.locator('#structure [data-block-id="qa-menu"]').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet'); await page.locator('#structure [data-block-id="qa-mobile"]').click(); assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile'); await page.locator('[data-panel="design"]').click(); assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile'); await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); const stored = (await (await context.request.get(api)).json()).document; assert.equal(stored.schemaVersion, 22); assert.deepEqual(stored.blocks[0].children.find(b => b.id === 'qa-parent').style, data.document.blocks[0].children.find(b => b.id === 'qa-parent').style); checks.push('Editor: visibility keyboard selection, hidden block selection, reset, undo/redo, hamburger preview, save/reload/publish and preserved layout'); const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1600, height: 1050 } }); const publicPage = await plain.newPage(); await publicPage.goto(origin); const necessary = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true }); if (await necessary.isVisible()) await necessary.click(); for (const width of [1600, 1025, 1024, 768, 767, 390]) { await publicPage.setViewportSize({ width, height: 1050 }); const device = width > 1024 ? 'desktop' : width > 767 ? 'tablet' : 'mobile'; for (const value of visibility) { const shown = value === 'all' || value === device || (value.startsWith('hide-') && value !== `hide-${device}`); assert.equal(await publicPage.locator(`#qa-${value}`).isVisible(), shown, `${width}: ${value}`); } assert.equal(await publicPage.locator('#qa-child').isVisible(), device === 'mobile'); if (device === 'mobile') assert.equal(await publicPage.locator('#qa-parent').evaluate(el => getComputedStyle(el).display), 'grid'); assert.equal(await publicPage.locator('#qa-menu > .menu-burger > summary').isVisible(), width <= 1024); assert.equal(await publicPage.locator('#qa-menu > .menu-list').isVisible(), width > 1024); assert.equal(await publicPage.locator('#qa-mobile-menu > .menu-burger > summary').isVisible(), width <= 767); assert.equal(await publicPage.locator('#qa-never-menu > .menu-list').isVisible(), true); assert.equal(await publicPage.locator('#qa-vertical > .menu-list').isVisible(), true); assert.equal(await publicPage.locator('#qa-burger > .menu-burger > summary').isVisible(), true); if ([1600, 1024, 390].includes(width)) { await publicPage.evaluate(() => window.scrollTo(0, 0)); await publicPage.screenshot({ path: `${report}/public-${width}.png`, fullPage: true }); } } const summary = publicPage.locator('#qa-menu > .menu-burger > summary'); await summary.focus(); await publicPage.keyboard.press('Enter'); assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), ''); await publicPage.keyboard.press('Tab'); assert.ok(await publicPage.evaluate(() => document.activeElement.closest('#qa-menu > .menu-burger'))); const branch = publicPage.locator('#qa-menu .menu-burger .menu-branch > summary').first(); if (await branch.count()) { await branch.focus(); await publicPage.keyboard.press('Space'); assert.ok(await branch.evaluate(el => el.parentElement.open)); } await summary.focus(); await publicPage.keyboard.press('Space'); assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), null); await summary.press('Enter'); const firstLink = publicPage.locator('#qa-menu .menu-burger a:visible').first(); const href = await firstLink.getAttribute('href'); await firstLink.click(); assert.equal(publicPage.url(), new URL(href, origin).href); await plain.close(); checks.push('Public without JavaScript: six viewport boundaries, every visibility preset, parent visibility, default and manual menu variants, keyboard toggle and real navigation'); const tablet = await browser.newContext({ javaScriptEnabled: false, hasTouch: true, isMobile: true, viewport: { width: 1366, height: 1024 } }); const ipad = await tablet.newPage(); await ipad.goto(origin); assert.equal(await ipad.locator('#qa-menu > .menu-burger > summary').isVisible(), true); assert.equal(await ipad.locator('#qa-menu > .menu-list').isVisible(), false); await ipad.locator('#qa-menu > .menu-burger > summary').tap(); assert.equal(await ipad.locator('#qa-menu > .menu-burger').getAttribute('open'), ''); await ipad.locator('#qa-menu').screenshot({ path: `${report}/large-tablet.png` }); await tablet.close(); checks.push('Large tablet: 1366 px touch viewport uses the hamburger and opens by touch without JavaScript'); const enhanced = await browser.newContext({ viewport: { width: 1600, height: 1050 }, bypassCSP: true }); const dynamic = await enhanced.newPage(); await dynamic.goto(origin); await dynamic.locator('#qa-tabs.tabs-ready').waitFor(); const audit = async () => { const result = await new AxeBuilder({ page: dynamic }).include('#qa-menu').include('#qa-tabs').include('#qa-slider + .slider-controls').withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze(); assert.deepEqual(result.violations.map(item => ({ id: item.id, nodes: item.nodes.map(node => node.target) })), []); }; await audit(); assert.equal(await dynamic.locator('#qa-tab-desktop').isVisible(), true); await dynamic.setViewportSize({ width: 390, height: 850 }); await dynamic.locator('#qa-tab-mobile').waitFor({ state: 'visible' }); await dynamic.locator('#qa-menu > .menu-burger > summary').click(); await audit(); await dynamic.locator('#qa-tab-mobile-tab').focus(); await dynamic.keyboard.press('ArrowRight'); assert.equal(await dynamic.locator('#qa-tab-all').isVisible(), true); await dynamic.keyboard.press('ArrowRight'); assert.equal(await dynamic.locator('#qa-tab-mobile').isVisible(), true); const controls = dynamic.locator('#qa-slider + .slider-controls'); await controls.scrollIntoViewIfNeeded(); assert.match(await controls.locator('.slider-position').innerText(), /\/ 2$/); assert.equal(await controls.getByRole('button', { name: 'Folie 1 anzeigen' }).isVisible(), false); await controls.getByRole('button', { name: 'Nächste Folie' }).click(); await dynamic.waitForFunction(() => document.querySelector('#qa-slider + .slider-controls .slider-position').textContent === '2 / 2'); await enhanced.close(); checks.push('Enhanced groups: tabs select a visible panel and skip hidden tabs; slider skips hidden slides and counts visible ones; desktop/mobile WCAG checks of navigation, tabs and slider controls'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Responsive navigation 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(); }