feat: visual palette picker with accurate color previews (1.8.2)
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

This commit is contained in:
kantarci committed 2026-09-14 12:42:21 +02:00
1 parent c1b152b376
commit 55dab4bc1e
11 files changed
+347 -39

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
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: '<p>Farben direkt im Blick</p>' } }] });
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(); }
+6 -4
View File
@@ -487,7 +487,8 @@ try {
await frameValue('#headline', 'fontSize', '72px');
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link');
await page.locator('#style-color').click();
await page.locator('#style-color-choices [data-color="@link"]').click();
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
const contrast = page.locator('.contrast-note');
await contrast.waitFor();
@@ -572,9 +573,10 @@ try {
// The background group is collapsed by default, and the readable field next to the swatch is
// what a person types into.
await openGroup('Hintergrund');
const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]');
await page.locator('#style-backgroundColor-hover').click();
const hoverColor = page.locator('#style-backgroundColor-hover-hex');
await hoverColor.fill('#112233');
await hoverColor.blur();
await hoverColor.press('Enter');
// The preview carries its stylesheet as template text, applied by the editor as a constructed
// stylesheet; the hover rule has to arrive through that path, not as a style element.
await page.waitForFunction(() => {
@@ -857,7 +859,7 @@ try {
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
assert.equal(await visitorPage.locator('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).');
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
// And the way back: the consent page can refuse again, which blocks the embed once more.
await visitorPage.goto(origin + '/einwilligung');