import { chromium } from '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/navigation-ux'; 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.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] }); 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}`); await page.locator('#structure [data-block-id="qa-anchor"]').click(); await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur(); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); 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.blocks.at(-1).anchor, 'kontakt-check'); assert.equal(stored.schemaVersion, 21); checks.push('named jump target created in editor and saved/published with stable block ID'); await page.goto(origin + '/admin/menus'); const rows = page.locator('#menu-items > li'); const originalFirst = await rows.first().locator('[data-key="label"]').inputValue(); const handle = rows.first().locator('[data-menu-drag]'); const start = await handle.boundingBox(), end = await rows.last().boundingBox(); await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down(); await page.mouse.move(start.x + start.width / 2, end.y + end.height - 2, { steps: 12 }); await page.mouse.up(); assert.equal(await rows.last().locator('[data-key="label"]').inputValue(), originalFirst); await page.locator('#menu-undo').click(); assert.equal(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst); await rows.first().locator('[data-menu-drag]').focus(); await page.keyboard.press('ArrowDown'); assert.notEqual(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst); await page.locator('#menu-undo').click(); await rows.first().locator('summary').click(); await rows.first().locator('[data-key="icon"]').selectOption('phone'); await rows.first().locator('[data-key="iconPosition"]').selectOption('after'); assert.equal(await rows.first().locator('.menu-entry-title > svg').count(), 1); await page.getByText('Eigener Link', { exact: true }).first().click(); await page.locator('#link-label').fill('Zum Kontaktbereich'); await page.locator('#link-url').fill('#kontakt-check'); await page.locator('#menu-add-url').click(); await page.getByRole('button', { name: 'Menü speichern', exact: true }).click(); await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor(); await page.reload(); assert.equal(await rows.first().locator('[data-key="icon"]').inputValue(), 'phone'); assert.equal(await rows.first().locator('[data-key="iconPosition"]').inputValue(), 'after'); await rows.first().locator('summary').click(); await page.screenshot({ path: `${report}/menus.png`, fullPage: true }); checks.push('pointer drag, keyboard ordering, undo, icon placement, fragment menu link and save/reload'); const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage(); await publicPage.goto(origin); assert.ok(await publicPage.locator('.nav-label > svg').count() > 0); await publicPage.getByRole('link', { name: 'Zum Kontaktbereich', exact: true }).first().click(); assert.ok(publicPage.url().endsWith('#kontakt-check')); assert.equal(await publicPage.locator('#kontakt-check').count(), 1); assert.ok(await publicPage.evaluate(() => window.scrollY) > 0); await plain.close(); execFileSync(php, ['-r', 'require "vendor/autoload.php"; $db=App\\Infrastructure\\ConnectionFactory::create($argv[1]); $db->executeStatement("UPDATE scheduled_task SET last_status = ?, last_message = ? WHERE name = ?", ["ok", "Fassung 99.0.0 ist verfügbar; installiert ist 1.8.4.", "update-check"]);', fixture.databaseUrl], { windowsHide: true }); await page.reload(); assert.equal(await page.getByRole('link', { name: 'Update 99.0.0 verfügbar. Aktualisierung öffnen.', exact: true }).count(), 1); assert.match(await page.locator('.version-update').getAttribute('href'), /system\?pruefen=1$/); await page.locator('.shell-foot').screenshot({ path: `${report}/update-hint.png` }); checks.push('public icon and native anchor navigation without JavaScript; cached update notice by version'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('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(); }