Files
shadowdaoandClaude Sonnet 5.5 501f276746 Colour pick mode: header/footer zone previews (pointer-events:none) are now pickable
Found by the real-editor e2e: picking over the header or its logo box returned the page colour behind it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 10:09:39 -08:00

176 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Real-browser test (headless Firefox AND Chromium) for src/utils/color-pick.ts:
* pickFromCanvas must return the exact computed hexes + site/brand matches,
* Esc must cancel, a pick click must NOT reach the clicked element (no craft
* node selection), and EyeDropper availability is reported per browser.
* npm run test:browser:colorpick (CHROMIUM_PATH / FIREFOX_PATH override binaries)
*/
import { build } from 'esbuild';
import { chromium, firefox } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import http from 'node:http';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await build({
entryPoints: [path.join(root, 'src/utils/color-pick.ts')],
bundle: true, write: false, format: 'iife', globalName: 'cp', platform: 'browser', logLevel: 'error',
});
const js = out.outputFiles[0].text;
const design = {
primaryColor: '#3b82f6', secondaryColor: '#64748b', accentColor: '#f59e0b', linkColor: '#2563eb',
successColor: '#16a34a', warningColor: '#d97706', errorColor: '#dc2626',
backgroundColor: '#ffffff', textColor: '#111827', mutedTextColor: '#6b7280', borderColor: '#e5e7eb',
swatches: [
{ id: 'b1', label: 'Logo orange', hex: '#FF6600', brand: true, slot: 3 },
{ id: 'p1', label: 'Sand', hex: '#c8b08a', brand: false },
],
};
const html = `<!doctype html><html><head><style>
body{margin:0;font:16px sans-serif}
.canvas-device-frame{--wsb-primary:#3b82f6;background:#ffffff;color:#111827;padding:10px;width:700px}
.box{height:60px;margin:6px 0;padding:4px}
</style></head><body>
<div class="canvas-device-frame">
<div id="tok" class="box" style="background:var(--wsb-primary);color:#ffffff">token bg</div>
<div id="brand" class="box" style="background:#ff6600;color:#111827">brand bg</div>
<div id="sand" class="box" style="background:rgba(200,176,138,1);color:#111827">swatch bg</div>
<div id="plain" class="box" style="background:#123456;color:#fedcba;border:3px solid #0a0b0c">plain + border</div>
<div id="outer" class="box" style="background:#112233;color:#445566"><div id="nested" style="color:#778899"><span id="deep">nested transparent</span></div></div>
<div id="alpha" class="box" style="background:rgba(18,52,86,.5);color:#111827">alpha</div>
<div data-zone-preview="header" style="pointer-events:none;user-select:none"><div id="zone" class="box" style="background:#15202c;color:#ffffff">header zone preview (pointer-events:none, like the editor's header/footer preview)</div></div>
<p id="txt" style="color:#6b7280;margin:6px 0">muted text on frame bg</p>
</div>
<div id="chrome" style="padding:10px">editor chrome</div>
</body></html>`;
// Served over http://localhost (a secure context) so EyeDropper availability is reported truthfully.
const server = http.createServer((_q, r) => { r.setHeader('content-type', 'text/html'); r.end(html); });
await new Promise((res) => server.listen(0, '127.0.0.1', res));
const url = `http://localhost:${server.address().port}/`;
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const page = await browser.newPage({ viewport: { width: 900, height: 900 } });
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.goto(url);
await page.addScriptTag({ content: js });
await page.evaluate((d) => {
window.__design = d;
window.__leaks = [];
for (const el of document.querySelectorAll('.canvas-device-frame, .canvas-device-frame *'))
for (const t of ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup'])
el.addEventListener(t, () => window.__leaks.push(t + '#' + el.id));
}, design);
let failed = 0;
const check = (label, ok, detail) => {
if (!ok) failed++;
console.log(`[${name}] ${ok ? 'PASS' : 'FAIL'} ${label}${detail !== undefined ? ': ' + detail : ''}`);
};
const eq = (label, got, want) => check(label, JSON.stringify(got) === JSON.stringify(want), JSON.stringify(got) + (JSON.stringify(got) === JSON.stringify(want) ? '' : ' expected ' + JSON.stringify(want)));
async function pick(id, { hover } = {}) {
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
const box = await page.locator('#' + id).boundingBox();
const x = box.x + 8, y = box.y + 8;
await page.mouse.move(x, y);
let tip = null;
if (hover) tip = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null);
await page.mouse.click(x, y);
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
return { res: await page.evaluate(() => window.__res), tip };
}
const cand = (res, k) => res.candidates.find((c) => c.kind === k);
// 1. token-driven background (CSS variable)
let { res, tip } = await pick('tok', { hover: true });
eq('token bg: hex', res.hex, '#3b82f6');
eq('token bg: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-primary)', label: 'Primary' });
eq('token bg: text candidate', cand(res, 'text').hex, '#ffffff');
check('token bg: hover tooltip shows live colour', tip === '#3b82f6', String(tip));
eq('token bg: no border candidate', cand(res, 'border'), undefined);
// 2. brand swatch (case-insensitive hex)
({ res } = await pick('brand'));
eq('brand: hex', res.hex, '#ff6600');
eq('brand: match', cand(res, 'background').match, { kind: 'swatch', ref: 'var(--wsb-brand-3)', label: 'Logo orange' });
// 3. plain saved swatch via rgba(...,1)
({ res } = await pick('sand'));
eq('swatch: hex', res.hex, '#c8b08a');
eq('swatch: match', cand(res, 'background').match, { kind: 'swatch', ref: '#c8b08a', label: 'Sand' });
// 4. plain bg + text + border, no match
({ res } = await pick('plain'));
eq('plain: hex = bg', res.hex, '#123456');
eq('plain: text', cand(res, 'text').hex, '#fedcba');
eq('plain: border', cand(res, 'border').hex, '#0a0b0c');
eq('plain: no matches', res.candidates.map((c) => c.match), [undefined, undefined, undefined]);
// 5. nested transparent child: text colour wins, background = painted ancestor
({ res } = await pick('deep'));
eq('nested: hex = inherited text colour', res.hex, '#778899');
eq('nested: background = nearest painted ancestor', cand(res, 'background').hex, '#112233');
eq('nested: no border', cand(res, 'border'), undefined);
// 6. alpha background -> 8-digit hex
({ res } = await pick('alpha'));
eq('alpha: hex', res.hex, '#12345680');
// 7. text on frame bg (frame background is the painted ancestor, matches Background token)
({ res } = await pick('txt'));
eq('text: hex', res.hex, '#6b7280');
eq('text: text match', cand(res, 'text').match, { kind: 'token', ref: 'var(--wsb-muted)', label: 'Muted text' });
eq('text: background match', cand(res, 'background').match, { kind: 'token', ref: 'var(--wsb-bg)', label: 'Background' });
// 7b. the editor's header/footer zone previews are pointer-events:none: they must still be pickable
({ res } = await pick('zone'));
eq('zone preview: hex', res.hex, '#15202c');
eq('zone preview: text candidate', cand(res, 'text').hex, '#ffffff');
// 8. none of those pick clicks reached the elements (no node selection)
eq('clicks never reached targets', await page.evaluate(() => window.__leaks), []);
// 9. Esc cancels; overlay cleaned up
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
await page.keyboard.press('Escape');
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
const esc = await page.evaluate(() => window.__res);
check('Esc cancels', esc.ok === false && esc.reason === 'cancelled', JSON.stringify(esc));
eq('overlay removed', await page.evaluate(() => document.querySelectorAll('[data-wsb-pick]').length), 0);
// 10. click outside the frame cancels
await page.evaluate(() => { window.__res = null; cp.pickFromCanvas({ design: window.__design }).then((r) => { window.__res = r; }); });
const cb = await page.locator('#chrome').boundingBox();
await page.mouse.click(cb.x + 5, cb.y + 5);
await page.waitForFunction(() => window.__res !== null, null, { timeout: 3000 });
check('outside click cancels', (await page.evaluate(() => window.__res)).reason === 'cancelled');
// 11. after a pick finishes, normal clicks flow again
await page.evaluate(() => { window.__leaks.length = 0; });
await page.locator('#tok').click();
check('clicks flow normally after picking', (await page.evaluate(() => window.__leaks)).includes('click#tok'));
// 12. EyeDropper availability (report)
const eye = await page.evaluate(() => ({ present: 'EyeDropper' in window, available: cp.eyedropperAvailable(), kind: cp.screenPickKind() }));
console.log(`[${name}] INFO EyeDropper: window.EyeDropper=${eye.present} eyedropperAvailable()=${eye.available} screenPickKind()=${eye.kind}`);
check('secure context', await page.evaluate(() => isSecureContext));
if (name === 'firefox') check('firefox: no screen pick (verdict C)', eye.kind === 'none');
else check('chromium: screenPickKind matches window.EyeDropper', eye.kind === (eye.present ? 'eyedropper' : 'none'));
if (errors.length) { failed++; console.log('page errors:', errors.join('\n')); }
await browser.close();
return failed;
}
let total = 0;
total += await run('chromium', chromium, process.env.CHROMIUM_PATH);
total += await run('firefox', firefox, process.env.FIREFOX_PATH);
server.close();
console.log(total ? `FAILED (${total})` : 'ALL PASS');
process.exit(total ? 1 : 0);