163 lines
11 KiB
JavaScript
163 lines
11 KiB
JavaScript
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(); }
|