Files
KantSites/scripts/admin-quality-check.mjs
T
kantarciandClaude Opus 5 5d55a65c22 Put the menu drag handle next to the summary instead of inside it
A button inside a summary is an interactive element in an interactive element; screen
readers announce it unreliably. The handle keeps its look and its mouse, touch and arrow
key behaviour. Also corrects the anchor hints and waits for the menu error in the admin check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:59:21 +02:00

235 lines
17 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' }));
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.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' });
assert.equal(await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).isDisabled(), true);
const reduced = await geometry(); await page.waitForTimeout(7100);
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
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');
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
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();
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();
await page.locator('#structure .tree-item').first().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.getByRole('button', { name: 'Bewegung testen', exact: true }).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');
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
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(); }