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 env = { ...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 }); 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: { ...env, SITE_ORIGIN: origin } }); const report = 'reports/color-picker'; 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: 1600, height: 1050 } }); 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 api = `${origin}/admin/api/pages/${fixture.homeId}`; const data = await (await context.request.get(api)).json(); const schema = await (await context.request.get(origin + '/admin/api/design')).json(); const deep = schema.colors.find(c => c.key === 'deep'); const accent = schema.colors.find(c => c.key === 'accent'); assert.ok(deep && accent); data.document.blocks.unshift({ id: 'qa-color', type: 'section', style: { desktop: { backgroundColor: '@deep', color: '#ffffff', gradientFrom: '#11223344', gradientTo: '#55667788', hover: { backgroundColor: '@accent' }, padding: { top: '40px', bottom: '40px' }, } }, children: [{ id: 'qa-color-text', type: 'text', props: { text: '

Farben direkt im Blick

' } }] }); assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200); await page.goto(`${origin}/admin/pages/${fixture.homeId}`); const select = async () => { await page.locator('#structure [data-block-id="qa-color"]').click(); await page.locator('[data-panel="design"]').click(); const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Hintergrund$/ }) }); if (!(await group.getAttribute('open'))) { if (!(await group.evaluate(el => el.open))) await group.locator('summary').click(); } }; await select(); const trigger = (key = 'backgroundColor') => page.locator(`#style-${key}`); const choices = (key = 'backgroundColor') => page.locator(`#style-${key}-choices`); const sample = async (hex, key = 'backgroundColor') => { await page.waitForFunction(({ hex, key }) => document.querySelector(`#style-${key} .color-detail`)?.textContent.includes(hex), { hex, key }); assert.ok(await trigger(key).locator('.color-sample-ink').evaluate(el => el.style.backgroundColor !== '' && el.style.backgroundColor !== 'transparent')); }; const choose = async (token, key = 'backgroundColor') => { await trigger(key).click(); await choices(key).locator(`[data-color="${token}"]`).click(); }; const frameColor = async hex => { const rgb = `rgb(${[1,3,5].map(i => parseInt(hex.slice(i,i+2),16)).join(', ')})`; await page.waitForFunction(rgb => { const doc = document.querySelector('#preview').contentDocument, block = doc?.getElementById('qa-color'); return block && doc.defaultView.getComputedStyle(block).backgroundColor === rgb; }, rgb); }; await sample(deep.value); assert.match(await trigger().innerText(), new RegExp(deep.name)); await trigger().focus(); await page.keyboard.press('Enter'); assert.equal(await trigger().getAttribute('aria-expanded'), 'true'); await page.keyboard.press('Tab'); assert.equal(await choices().locator('[data-color]').first().evaluate(el => el === document.activeElement), true); await page.keyboard.press('Escape'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true); await choose('@accent'); await sample(accent.value); await frameColor(accent.value); await page.locator('#undo').click(); await sample(deep.value); await page.locator('#redo').click(); await sample(accent.value); await page.locator('#undo').click(); await sample(deep.value); checks.push('palette name and real token color, keyboard open/tab/escape, selection, canvas, undo and redo'); await page.locator('[data-viewport="tablet"]').click(); await sample(deep.value); assert.match(await trigger().innerText(), /Geerbt/); await sample(accent.value, 'backgroundColor-hover'); await choose('@accent'); await page.locator('[data-viewport="mobile"]').click(); await sample(accent.value); await page.locator('[data-viewport="tablet"]').click(); await page.getByRole('button', { name: 'Hintergrundfarbe zurücksetzen', exact: true }).click(); await sample(deep.value); await page.locator('[data-viewport="desktop"]').click(); checks.push('desktop/tablet/mobile inheritance including hover colors and reset'); await trigger().click(); await choices().locator('.color-native').evaluate(el => { window.colorPickerNode = el; el.focus(); for (const value of ['#112233', '#223344', '#334455']) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); } }); assert.equal(await choices().locator('.color-native').evaluate(el => el === window.colorPickerNode), true); await frameColor('#334455'); await choices().locator('.color-native').dispatchEvent('change'); await page.locator('#undo').click(); await sample(deep.value); await trigger().click(); await choices().locator('.color-native').evaluate(el => { el.value = '#445566'; el.dispatchEvent(new Event('change', { bubbles: true })); }); await sample('#445566'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true); await page.locator('#undo').click(); await sample(deep.value); await trigger().click(); const hex = page.locator('#style-backgroundColor-hex'); await hex.fill('ungueltig'); await hex.press('Enter'); assert.equal(await hex.getAttribute('aria-invalid'), 'true'); await sample(deep.value); await hex.fill('#abc'); await hex.press('Enter'); await sample('#aabbcc'); await frameColor('#aabbcc'); await page.locator('#undo').click(); await sample(deep.value); await trigger('gradientFrom').click(); await choices('gradientFrom').locator('.color-native').evaluate(el => { el.value = '#224466'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }); await sample('#22446644', 'gradientFrom'); await page.locator('#undo').click(); await sample('#11223344', 'gradientFrom'); checks.push('native live color changes retain DOM and form one undo step; invalid input refused; short hex expanded; gradient alpha preserved'); await choose('@accent'); await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); await page.reload(); await select(); await sample(accent.value); const saved = await (await context.request.get(api)).json(); assert.equal(saved.document.blocks[0].style.desktop.backgroundColor, '@accent'); assert.equal(saved.document.blocks[0].style.desktop.gradientFrom, '#11223344'); await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor(); const visitor = await context.newPage(); await visitor.goto(origin); assert.equal(await visitor.locator('#qa-color').evaluate(el => getComputedStyle(el).backgroundColor), `rgb(${[1,3,5].map(i => parseInt(accent.value.slice(i,i+2),16)).join(', ')})`); await visitor.close(); checks.push('save, reload and publication preserve palette references and alpha values'); for (const width of [1600, 390]) { await page.setViewportSize({ width, height: 1050 }); if (await trigger().getAttribute('aria-expanded') === 'false') await trigger().click(); await choices().scrollIntoViewIfNeeded(); await trigger().evaluate(el => { const panel = el.closest('.right-panel'); panel.scrollTop += el.getBoundingClientRect().top - panel.getBoundingClientRect().top - 16; }); const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); assert.equal(await choices().evaluate(el => el.scrollWidth <= el.clientWidth), true); await page.locator('.right-panel').screenshot({ path: `${report}/palette-${width}.png` }); } checks.push('open palette and editor pass axe and fit desktop/mobile without horizontal overflow'); assert.deepEqual(errors, []); await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2)); console.log('Color picker 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(); }