feat: visual palette picker with accurate color previews (1.8.2)
This commit is contained in:
1 parent
c1b152b376
commit
55dab4bc1e
11 files changed
+347
-39
No files matched your search
@@ -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(); }
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user