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 environment = { ...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: environment }); 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: { ...environment, SITE_ORIGIN: origin } }); const report = 'reports/live-search'; 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: 1440, height: 1000 } }); 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 search = () => page.locator('[data-live-search] input[type="search"]'); const rows = () => page.locator('.rows article h3 a'); const settled = () => page.waitForFunction(() => /^\d+ Treffer/.test(document.querySelector('#search-status')?.textContent) && !document.querySelector('#list-results').hasAttribute('aria-busy')); const type = async value => { await search().fill(value); await settled(); }; const count = async expected => { await settled(); assert.equal(await rows().count(), expected); }; const mark = async () => page.evaluate(() => { window.searchDocumentMarker = 'same-document'; }); const sameDocument = async () => assert.equal(await page.evaluate(() => window.searchDocumentMarker), 'same-document'); const next = () => page.locator('.pager').first().getByRole('link', { name: 'Weiter' }); await page.goto(origin + '/admin/posts'); await mark(); assert.equal(await rows().count(), 20); await next().click(); await count(9); await sameDocument(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await page.goBack(); await count(20); await page.goForward(); await count(9); await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await count(29); assert.equal(new URL(page.url()).searchParams.has('seite'), false); await type('nichtvorhandener-qa-begriff'); await count(0); await sameDocument(); assert.equal(await search().evaluate(input => input === document.activeElement), true); assert.equal(await page.locator('.create-form input[name="_list_query"]').inputValue(), 'nichtvorhandener-qa-begriff'); await type(''); await count(29); await page.locator('[data-search-reset]').click(); await count(20); assert.equal(await page.getByLabel('Pro Seite', { exact: true }).inputValue(), '20'); await page.getByLabel('Status', { exact: true }).selectOption('trash'); await count(0); await page.goBack(); await count(20); assert.equal(await page.getByLabel('Status', { exact: true }).inputValue(), 'all'); checks.push('posts: typing, focus, filter, page size, pagination, reset, back/forward and form context without navigation'); // Let an old server response arrive after the latest query has already completed. let delayed; const oldRequest = new Promise(resolve => { delayed = resolve; }); await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'old-delayed-search', async route => { const response = await route.fetch(); delayed(); await new Promise(resolve => setTimeout(resolve, 800)); await route.fulfill({ response }).catch(() => {}); }); await search().fill('old-delayed-search'); await oldRequest; await type(''); await count(20); await page.waitForTimeout(1000); await count(20); await page.unrouteAll({ behavior: 'wait' }); let requests = 0; const countRequests = request => { if (request.headers()['x-requested-with'] === 'XMLHttpRequest') requests++; }; page.on('request', countRequests); await search().pressSequentially('fast-query', { delay: 25 }); await count(0); assert.equal(requests, 1, 'rapid typing is debounced'); page.off('request', countRequests); await type(''); await count(20); await search().dispatchEvent('compositionstart'); await search().fill('ime-query'); await page.waitForTimeout(400); assert.equal(await rows().count(), 20); await search().dispatchEvent('compositionend'); await count(0); await type(''); await count(20); checks.push('stale responses ignored; rapid input debounced; IME composition waits for completed text'); await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'error-query', route => route.fulfill({ status: 500, body: 'Unavailable' })); await search().fill('error-query'); await page.locator('#search-error').waitFor({ state: 'visible' }); assert.equal(await rows().count(), 20); assert.equal(await search().inputValue(), 'error-query'); await page.unrouteAll({ behavior: 'wait' }); await page.getByRole('button', { name: 'Erneut suchen' }).click(); await count(0); assert.equal(await page.locator('#search-error').isVisible(), false); await type(''); await count(20); await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'expired-session', route => route.fulfill({ status: 401, body: '' })); await search().fill('expired-session'); await page.getByRole('alert').filter({ hasText: 'erneut anmelden' }).waitFor(); assert.equal(await rows().count(), 20); await page.unrouteAll({ behavior: 'wait' }); await type(''); await count(20); checks.push('server error and expired session preserve results and input; retry succeeds'); await page.goto(origin + '/admin'); await mark(); await type('Impressum'); await count(1); await sameDocument(); assert.match(await rows().first().innerText(), /Impressum/); await page.screenshot({ path: `${report}/pages-search.png`, fullPage: true }); await page.reload(); assert.equal(await search().inputValue(), 'Impressum'); assert.equal(await rows().count(), 1); await type(''); await count(4); const entry = page.locator('.rows article').filter({ hasText: 'Impressum' }).first(); await entry.locator('summary').first().click(); await entry.getByRole('combobox', { name: 'Übergeordnete Seite (optional)' }).fill('Datenschutz'); await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().waitFor(); await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().click(); assert.ok(await entry.locator('.picker-value').inputValue()); let pending; const pendingRequest = new Promise(resolve => { pending = resolve; }); await page.route(url => url.pathname === '/admin' && url.searchParams.get('q') === 'late-edit-search', async route => { const response = await route.fetch(); pending(); await new Promise(resolve => setTimeout(resolve, 600)); await route.fulfill({ response }).catch(() => {}); }); await search().fill('late-edit-search'); await pendingRequest; await entry.getByLabel('Titel für Suchergebnisse').fill('Ungespeicherte Bearbeitung'); await page.waitForTimeout(800); assert.equal(await entry.getByLabel('Titel für Suchergebnisse').inputValue(), 'Ungespeicherte Bearbeitung'); assert.match(await page.locator('#search-status').innerText(), /angehalten/); await page.unrouteAll({ behavior: 'wait' }); checks.push('pages: live title search, reloadable URLs, reinitialized parent picker after AJAX'); await page.goto(origin + '/admin/media'); await mark(); const library = await (await context.request.get(origin + '/admin/api/media')).json(); const target = library.items.filter(item => !item.archived).at(-1); assert.ok(target, 'pilot has media'); await type(target.title); assert.ok((await page.locator('.library-body strong').allTextContents()).includes(target.title)); assert.equal(await page.locator('form[action$="/media/upload"] input[name="q"]').inputValue(), target.title); await sameDocument(); await page.screenshot({ path: `${report}/media-search.png`, fullPage: true }); await page.getByLabel('Status', { exact: true }).selectOption('1'); await settled(); assert.equal(new URL(page.url()).searchParams.get('archiv'), '1'); await type('nichtvorhandenes-bild'); assert.equal(await page.locator('.library-tile').count(), 0); await type(''); assert.ok(await page.locator('.library-tile').count()); const older = page.locator('.pager a[rel="next"]'); if (await older.count()) { await older.click(); await settled(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await sameDocument(); } checks.push('media: global title search, archive filter, empty/reset states, pagination, upload context'); for (const width of [1440, 390]) { await page.setViewportSize({ width, height: 900 }); for (const route of ['/admin', '/admin/posts', '/admin/media']) { await page.goto(origin + route); await type('nichtvorhandener-begriff'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`); const audit = await new AxeBuilder({ page }).analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, targets: v.nodes.map(n => n.target) })), [], `${route} ${width}`); if (width === 390) await page.screenshot({ path: `${report}/${route.split('/').pop()}-mobile.png`, fullPage: true }); } } const plain = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState() }); const fallback = await plain.newPage(); await fallback.goto(origin + '/admin'); await fallback.getByLabel('Seiten durchsuchen').fill('Impressum'); await fallback.getByRole('button', { name: 'Anwenden', exact: true }).click(); assert.equal(await fallback.locator('.rows article h3 a').count(), 1); await plain.close(); checks.push('desktop/mobile axe and overflow checks; HTML fallback without JavaScript'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Live search browser 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(); }