Files
KantSites/scripts/admin-quality-check.mjs
T
kantarciandClaude Fable 5.1 160c394c3f
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Rework forms, statistics, design and the sidebar foot in 1.15.0
Forms are an editor now: one card per field with add and remove, the
technical name derived from the label, options only for a choice, and a
new "Aussehen" section with a button label and four colours (field border
and background, button and button text) that travel to the page as one
rule per form inside the hashed style block. A live preview draws the
form with the site's default button and names the text contrast.

Statistics open with six figures (views against the same span before,
average per day, busiest day, pages, referred views, missing addresses),
a chart with grid lines and a title per bar, a weekday chart and ranked
lists with share bars. The design screen shows the palette as colour
cards with seven ready-made palettes, picks the base colours from the
palette, previews every font in itself and the typography as a sample.
The sidebar ends in an account card with role and a logout icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:59:30 +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)');
assert.equal(await page.locator('#menu-error').isVisible(), true);
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');
assert.equal(await page.locator('#menu-error').isVisible(), true);
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(); }