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' })); execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } }); 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: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin }, }); await mkdir('reports/admin-quality', { recursive: true }); let browser, page; const checks = []; try { for (let i = 0; i < 60; i++) { try { if ((await fetch(origin)).ok) break; } catch {} if (i === 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 }, reducedMotion: 'no-preference' }); page = await context.newPage(); page.setDefaultTimeout(15000); const errors = []; page.on('pageerror', error => errors.push(error.message)); page.on('dialog', dialog => dialog.accept()); if (process.argv.includes('--visual-only')) { await page.goto(origin); await page.evaluate(async () => { const images = [...document.images]; images.forEach(image => { image.loading = 'eager'; }); await Promise.all(images.map(image => image.decode().catch(() => {}))); await document.fonts.ready; }); await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true, animations: 'disabled' }); const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1440, height: 1000 } }); const noScript = await plain.newPage(); await noScript.goto(origin); assert.equal(await noScript.locator('h1').isVisible(), true); assert.equal(await noScript.locator('#hero-slider .slider-slide').count(), 4); assert.ok(await noScript.locator('main').innerText().then(text => text.includes('Dienstleistungen') || text.includes('Full-Service'))); await noScript.screenshot({ path: 'reports/admin-quality/pilot-without-javascript.png', fullPage: true }); await plain.close(); 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/forms'); await page.locator('.form-list a').first().click(); await page.locator('.form-def').waitFor(); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' }); console.log('Public no-JavaScript baseline and visual captures passed.'); } else { if (!process.argv.includes('--admin-only')) { await page.goto(origin); await page.mouse.move(1, 1); const slider = page.locator('#hero-slider'); const geometry = () => slider.evaluate(el => ({ left: el.scrollLeft, width: el.clientWidth, total: el.scrollWidth, slides: el.children.length, columns: getComputedStyle(el).gridAutoColumns })); const before = await geometry(); await page.waitForTimeout(8000); const after = await geometry(); console.log('Pilot slider:', JSON.stringify({ before, after })); if (process.argv.includes('--probe')) { console.log('Motion:', await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(el => ({ id: el.id, name: getComputedStyle(el).animationName, duration: getComputedStyle(el).animationDuration, timeline: getComputedStyle(el).animationTimeline })))); } else { assert.ok(after.left > before.left + 20, 'the real pilot slider advances'); } await page.waitForTimeout(7100); const second = await geometry(); console.log('Second slider:', JSON.stringify(second), await slider.locator('xpath=following-sibling::*[1]').innerText()); assert.ok(second.left > after.left + 20, 'autoplay advances more than once'); const controls = slider.locator('xpath=following-sibling::*[1]'); await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click(); await page.waitForTimeout(700); const manual = await geometry(); console.log('Manual slider:', JSON.stringify(manual), await controls.innerText()); assert.ok(manual.left < second.left - 20); await page.mouse.move(1, 1); await page.waitForTimeout(7200); console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText()); assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay'); await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).click(); await page.mouse.move(1, 1); await page.waitForTimeout(7700); assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation'); await page.emulateMedia({ reducedMotion: 'reduce' }); // Seit 1.11 entscheidet die Website (Einstellungen → Bewegung): Standard ist „immer abspielen“, // nur mit „Systemvorgabe beachten“ (Klasse motion-respect) hält „Bewegung reduzieren“ die // Automatik an. Geprüft wird das Verhalten, das die Einstellung verspricht. const respects = await page.evaluate(() => document.documentElement.classList.contains('motion-respect')); const play = controls.locator('.slider-play'); assert.equal(await play.isDisabled(), respects, 'the play button follows the motion setting'); const reduced = await geometry(); await page.waitForTimeout(7100); if (respects) assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay when the site respects it'); else assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'autoplay keeps running when the site plays motion always'); await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click(); assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion'); await page.emulateMedia({ reducedMotion: 'no-preference' }); checks.push({ name: 'pilot slider: repeated autoplay, manual pause, resume, reduced motion', passed: true }); await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true }); } if (process.argv.includes('--audit')) { execFileSync(process.execPath, ['scripts/audit.mjs'], { stdio: 'inherit', windowsHide: true, env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/admin-quality/lighthouse' } }); } 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'); for (const [name, path] of [['pages', '/admin'], ['posts', '/admin/posts'], ['menus', '/admin/menus'], ['forms', '/admin/forms'], ['areas', '/admin/partials']]) { const response = await page.goto(origin + path); assert.equal(response.status(), 200, path); await page.screenshot({ path: `reports/admin-quality/${name}.png`, fullPage: true }); const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name); checks.push({ name, passed: true }); } await page.goto(origin + '/admin/posts'); const rowLinks = () => page.locator('.rows article h3 a'); assert.equal(await rowLinks().count(), 20); const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href)); await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click(); await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9); assert.equal(await rowLinks().count(), 9); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false); await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 29); assert.equal(await rowLinks().count(), 29); await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff'); await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 0); assert.equal(await rowLinks().count(), 0); await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9); await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4); checks.push({ name: 'separate pages/posts, bounded pages, no overlap, size/search/clamping', passed: true }); await page.goto(origin + '/admin/menus'); const entries = page.locator('#menu-items > li'); const initialCount = await entries.count(); const search = page.getByRole('combobox', { name: 'Inhalte suchen' }); await search.fill('Impressum'); await page.getByRole('option').filter({ hasText: 'Impressum' }).first().waitFor(); await search.press('ArrowDown'); await search.press('Enter'); await page.getByRole('button', { name: 'Zum Menü hinzufügen', exact: true }).click(); assert.equal(await entries.count(), initialCount + 1); await entries.last().getByRole('button', { name: 'Als Unterpunkt', exact: true }).click(); assert.equal(await entries.last().getAttribute('data-level'), '2'); await entries.last().getByRole('button', { name: 'Entfernen', exact: true }).click(); assert.equal(await entries.count(), initialCount); await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); assert.equal(await entries.count(), initialCount + 1); assert.equal(await entries.last().getAttribute('data-level'), '2'); const menuSave = () => Promise.all([ page.waitForResponse(response => response.url().includes('/menus/header') && response.request().method() === 'POST'), page.getByRole('button', { name: 'Menü speichern', exact: true }).click(), ]).then(([response]) => response); assert.equal((await menuSave()).status(), 200); await page.reload(); assert.equal(await entries.count(), initialCount + 1); assert.equal(await entries.last().getAttribute('data-level'), '2'); await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click(); await page.locator('#link-label').fill('QA Kontakt'); await page.locator('#link-url').fill('javascript:alert(1)'); await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click(); assert.equal((await menuSave()).status(), 422); assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)'); await page.locator('#menu-error').waitFor({ state: 'visible' }); await entries.last().locator('summary').click(); await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt'); assert.equal((await menuSave()).status(), 200); const externalSave = await page.locator('#menu-editor').evaluate(async form => { const response = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }); return response.status; }); assert.equal(externalSave, 200); await page.getByLabel('Name des Menüs').fill('QA lokaler Stand'); assert.equal((await menuSave()).status(), 409); assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand'); await page.locator('#menu-error').waitFor({ state: 'visible' }); checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true }); await page.goto(origin + '/admin/forms'); await page.locator('.form-list a').first().click(); const form = page.locator('.form-def').first(), message = form.locator('[name="success"]'); // Der Abschnitt „Zustellung“ ist zugeklappt, bis man ihn öffnet. const openDelivery = async () => { await form.locator('details.form-section').first().evaluate(section => { section.open = true; }); }; await openDelivery(); const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth })); assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width'); await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.'); await form.getByRole('button', { name: 'Formular speichern', exact: true }).click(); await page.waitForLoadState('load'); await openDelivery(); assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.'); checks.push({ name: 'wide multiline form response persists', passed: true }); await page.goto(origin + '/admin/partials'); await page.getByText('Neuen Bereich anlegen', { exact: true }).click(); await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion'); await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click(); await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); // Die Sektion im Reiter „Struktur“ wählen, dann den Text aus „Elemente“ hineinsetzen. await page.getByRole('tab', { name: 'Struktur', exact: true }).click(); await page.locator('#structure .tree-item').first().click(); await page.getByRole('tab', { name: 'Elemente', exact: true }).click(); await page.locator('#library [data-add="text"]').click(); await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.'); await page.getByLabel('Text', { exact: true }).press('Tab'); await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor(); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor(); await page.reload(); await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor(); await page.goto(origin + '/admin/partials'); const areaLinks = await rowLinks().evaluateAll(links => links.map(link => link.href)); for (const url of areaLinks) { assert.equal((await page.goto(url)).status(), 200); await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); await page.locator('#select-page').click(); const title = page.getByLabel('Seitentitel', { exact: true }); await title.fill((await title.inputValue()) + ' QA'); await title.press('Tab'); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor(); await page.reload(); await page.locator('#select-page').click(); await page.waitForFunction(() => document.querySelector('#save-status')?.textContent !== 'Wird geladen …'); assert.match(await title.inputValue(), / QA$/); } checks.push({ name: 'real pilot areas open, edit, preview, save and reload', passed: true }); await page.goto(origin + '/admin/pages/' + fixture.homeId); await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor(); await page.locator('#preview-motion').click(); const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider'); // Measure in the outer page: Playwright's in-frame animation stability check can stall in // a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry. const nextPreview = preview.getByRole('button', { name: 'Nächste Folie', exact: true }); await nextPreview.waitFor(); await page.waitForTimeout(1500); const previewBox = await nextPreview.boundingBox(); await page.waitForTimeout(150); const settledBox = await nextPreview.boundingBox(); assert.ok(Math.abs(settledBox.y - previewBox.y) < 1 && settledBox.width > 0, 'preview control has stable visible geometry'); await page.mouse.click(settledBox.x + settledBox.width / 2, settledBox.y + settledBox.height / 2); await page.waitForTimeout(800); assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20)); assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin'); // Derselbe Knopf stoppt wieder; er sagt es in seiner Beschriftung und in aria-pressed. assert.equal(await page.locator('#preview-motion').getAttribute('aria-label'), 'Bewegung in der Vorschau stoppen'); await page.locator('#preview-motion').click(); assert.equal(await page.locator('#preview-motion').getAttribute('aria-pressed'), 'false'); assert.equal(await preview.locator('.slider-controls').count(), 0); checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true }); for (const width of [1024, 390]) { await page.setViewportSize({ width, height: 844 }); for (const route of ['/admin/posts', '/admin/menus', '/admin/forms']) { await page.goto(origin + route); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`); const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], `${route} ${width}`); await page.screenshot({ path: `reports/admin-quality/${route.split('/').pop()}-${width}.png` }); } } checks.push({ name: 'tablet/mobile layouts fit and pass axe', passed: true }); assert.deepEqual(errors, []); await writeFile('reports/admin-quality/result.json', JSON.stringify({ checks }, null, 2)); console.log('Pilot and administration checks passed.'); } } catch (error) { if (page) await page.screenshot({ path: 'reports/admin-quality/failure.png', fullPage: true }).catch(() => {}); throw error; } finally { await browser?.close(); server.kill(); }