#!/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 = `
token bg
brand bg
swatch bg
plain + border
nested transparent
alpha

muted text on frame bg

editor chrome
`; // 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' }); // 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);