#!/usr/bin/env node /** * REAL-EDITOR end-to-end test of the whole site colour system, headless Firefox AND Chromium. * Mounts the real editor (vite build + a mock WHP API that STORES saves, so reload restores them) and drives * the real UI: hex entry, pick from page / image, palette from image, persistence across reload, live brand * recolour (canvas == published by the frontend export == published by the backend generateCompiledHTML), * brand cap + guarded delete, contrast notes, and EyeDropper availability. * * npm run test:browser:colors * * Env: CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries * COLOR_SHOTS= write Firefox screenshots (shot-*.png) there * WHP_BACKEND_DIR backend worktree for the generateCompiledHTML leg (default /workspace/whp-wt-color-system) */ import { chromium, firefox } from '@playwright/test'; import { build as esbuild } from 'esbuild'; import { execFileSync } from 'node:child_process'; import http from 'node:http'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { startServer, testPng, TEST_PNG_COLORS } from './e2e-color-system-server.mjs'; import { buildProject } from './e2e-color-system-fixture.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const BACKEND_DIR = process.env.WHP_BACKEND_DIR || '/workspace/whp-wt-color-system'; // Pre-colour-feature baseline. 69fd34b = the first commit carrying the 2026.11.4 published head // (ColumnLayout stacking + Navbar wrap, fix/mobile-parity) and none of the colour feature, so "no brand // colours => published document unchanged" compares against the head that actually shipped before it. // (ab7a100, the old default, predates the 2026.11.4 head rules, so this check failed on main since then.) // Override with COLOR_BASE_COMMIT= on a branch with other deliberate head changes. const BASE_COMMIT = process.env.COLOR_BASE_COMMIT || '69fd34b'; // Deliberate head changes made AFTER the base, folded back before the comparison so it still proves the // colour feature changed nothing else. Each `from` must occur exactly once (checked). const HEAD_FOLDS = [ { why: 'legacy-grid: stored (pre-hook) card-grid signatures right after [data-wsb-grid] in the <=767px block', from: '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:32px;text-align:center"]{grid-template-columns:minmax(0,1fr)!important}[style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])),[style^="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat("][style$=",1fr);gap:24px"]:not(:has(>*>img[style^="width:100%;height:200px;object-fit:cover"])){grid-template-columns:minmax(0,1fr)!important}', to: '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}', }, { why: 'mobile-guard: card-grid / long-word / HtmlBlock media rules in the <=767px block; Navbar wrap moved to <=991px', from: '[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}body{overflow-wrap:break-word}[data-wsb-html] :is(img,video,iframe,embed,object,svg,canvas){max-width:100%}[data-wsb-html] :is(img,video){height:auto}[data-wsb-html] pre{max-width:100%;overflow-x:auto}}@media(max-width:991px){nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}', to: '[class^="cols_"]{flex-direction:column!important;align-items:stretch!important}nav[class^="nav_"],nav[class^="nav_"] .navbar-links{flex-wrap:wrap}}', }, ]; const foldHead = (html) => HEAD_FOLDS.reduce((h, f) => (h.split(f.from).length === 2 ? h.replace(f.from, () => f.to) : h), html); // Counted in order on the progressively folded document (a later fold's `from` may need an earlier fold applied). const foldsApplied = (html) => HEAD_FOLDS.map((f) => { const n = html.split(f.from).length - 1; if (n === 1) html = html.replace(f.from, () => f.to); return n; }); const SHOTS = process.env.COLOR_SHOTS || ''; if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true }); // ---------------------------------------------------------------- frontend exporter (HEAD, and the pre-feature base commit) async function loadExporter(entryDir, tag) { const out = await esbuild({ entryPoints: [path.join(entryDir, 'scripts', tag === 'base' ? 'e2e-canvas-export-entry.ts' : 'e2e-color-system-entry.ts')], bundle: true, write: false, format: 'esm', platform: 'node', jsx: 'automatic', logLevel: 'error', absWorkingDir: entryDir, define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' }, banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" }, }); const f = path.join(os.tmpdir(), `wsb-colors-export-${tag}-${process.pid}.mjs`); fs.writeFileSync(f, out.outputFiles[0].text); const m = await import(f); fs.rmSync(f, { force: true }); return m; } const head = await loadExporter(root, 'head'); let baseExporter = null; const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-colors-base-')); try { const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: root }).toString().trim(); const prefix = path.relative(top, root); // 'craft' execFileSync('bash', ['-c', `git -C '${top}' archive ${BASE_COMMIT} ${prefix}/src ${prefix}/scripts/e2e-canvas-export-entry.ts ${prefix}/package.json ${prefix}/tsconfig.json | tar -x -C '${baseDir}'`]); fs.symlinkSync(path.join(root, 'node_modules'), path.join(baseDir, prefix, 'node_modules')); baseExporter = await loadExporter(path.join(baseDir, prefix), 'base'); } catch (e) { console.log('NOTE base-commit exporter unavailable:', String(e.message).split('\n')[0]); } function backendHtml(project, idx = 0) { const inp = head.backendInputs(project, idx); return execFileSync('php', [path.join(root, 'scripts/e2e-color-system-backend.php'), BACKEND_DIR], { input: JSON.stringify(inp), maxBuffer: 1 << 26 }).toString(); } const haveBackend = fs.existsSync(path.join(BACKEND_DIR, 'web-files/api/site-builder.php')); const rootDecl = (html) => (html.match(/:root\{[^}]*\}/) || [''])[0]; // ---------------------------------------------------------------- build the real editor once const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wsb-colors-')); execFileSync(path.join(root, 'node_modules/.bin/vite'), ['build', '--outDir', buildDir, '--emptyOutDir', '--logLevel', 'error'], { cwd: root, stdio: 'inherit' }); // A second origin serving the same PNG without CORS headers = "an image from another website". const remote = http.createServer((_q, r) => { r.setHeader('content-type', 'image/png'); r.end(testPng()); }); await new Promise((r) => remote.listen(0, '127.0.0.1', r)); const REMOTE_URL = `http://127.0.0.1:${remote.address().port}/remote.png`; const toHex = (rgb) => { const m = /rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/.exec(rgb); return m ? '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('') : rgb; }; const hx = (c) => '#' + c.map((n) => n.toString(16).padStart(2, '0')).join(''); // ---------------------------------------------------------------- driver async function run(name, launcher, exe) { const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); const ctx = await browser.newContext({ viewport: { width: 1500, height: 1000 } }); await ctx.route(/^https?:\/\/(?!127\.0\.0\.1|localhost)/, (r) => r.abort()); const { server, state, base } = await startServer({ buildDir, project: buildProject(), extraAssets: [{ name: 'remote.png', url: REMOTE_URL, type: 'image/png', size: 1, modified: 2 }], }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e))); const dialogs = []; page.on('dialog', (d) => { dialogs.push(d.message()); d.dismiss().catch(() => {}); }); 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) => { const ok = JSON.stringify(got) === JSON.stringify(want); check(label, ok, ok ? JSON.stringify(got) : `${JSON.stringify(got)} expected ${JSON.stringify(want)}`); }; const shot = async (file) => { if (SHOTS && name === 'firefox') await page.screenshot({ path: path.join(SHOTS, file) }); }; const settle = (ms = 250) => page.waitForTimeout(ms); // ---- page helpers const frame = '.canvas-device-frame'; const targetSel = `${frame} [data-craft-container][style*="min-height: 80px"]`; const logoSel = `${frame} [data-zone-preview=header] > div > div`; const sm = page.locator('[data-testid=swatch-manager]'); const openSite = async () => { await page.click('#right-tab-site'); await sm.scrollIntoViewIfNeeded(); await settle(); }; const openElement = async () => { await page.click('#right-tab-element'); await settle(); }; const rows = () => sm.locator('[data-swatch-row]'); const rowData = async () => rows().evaluateAll((els) => els.map((e) => ({ id: e.getAttribute('data-swatch-row'), label: e.querySelector('[data-field=label]').value, hex: e.querySelector('[data-field=hex]').value, brand: e.querySelector('[data-action=brand]').getAttribute('aria-pressed') === 'true', live: !!e.querySelector('[data-testid=live-badge]'), }))); const loadEditor = async () => { await page.goto(base + '/'); await page.waitForSelector(`${frame} [data-craft-container]`, { timeout: 20000 }); await settle(1200); }; const selectTarget = async () => { await page.locator(targetSel).click({ position: { x: 30, y: 30 } }); await settle(400); await page.click('#right-tab-element'); await settle(300); }; const fieldOf = (label) => page.locator('.panel-right .guided-section', { hasText: label }).first(); const canvasBg = (sel) => page.locator(sel).evaluate((e) => getComputedStyle(e).backgroundColor); const save = async () => { const before = state.saves; await page.getByRole('button', { name: 'Save Draft' }).click(); await page.waitForFunction(() => /Saved/.test(document.body.innerText), null, { timeout: 8000 }).catch(() => {}); for (let i = 0; i < 40 && state.saves === before; i++) await settle(150); check('save went through the editor save path', state.saves > before, `saves=${state.saves}`); }; const pageNodes = () => JSON.parse(state.project.pages_craft_state[0].craftState); // ================================================================= 8 (first: needs only a loaded page) EyeDropper await loadEditor(); const eye = await page.evaluate(() => ({ present: 'EyeDropper' in window, secure: isSecureContext })); await openSite(); const screenBtnsSite = await sm.getByRole('button', { name: 'Pick from screen' }).count(); console.log(`[${name}] INFO window.EyeDropper present=${eye.present} secureContext=${eye.secure}`); if (name === 'firefox') eq('8 Firefox: Pick from screen button ABSENT', screenBtnsSite, 0); else eq(`8 Chromium: Pick from screen present only if window.EyeDropper (EyeDropper=${eye.present})`, screenBtnsSite, eye.present ? 1 : 0); // ================================================================= 1 Hex entry await sm.locator('[data-field=new-hex]').fill('zz'); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(); const hexErr = await sm.locator('[role=alert].color-hex-error').allInnerTexts(); eq('1 invalid hex "zz" rejected with message', hexErr, ['Enter a hex colour like #3b82f6 (3, 4, 6 or 8 digits).']); eq('1 invalid hex added no row', (await rowData()).length, 0); await sm.locator('[data-field=new-hex]').fill('#15202C'); await sm.locator('[data-field=new-label]').fill('Logo box'); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(); let rd = await rowData(); eq('1 "#15202C" + label added, hex normalised', rd.map((r) => [r.label, r.hex]), [['Logo box', '#15202c']]); eq('1 error cleared after a valid add', await sm.locator('[role=alert].color-hex-error').count(), 0); for (const [typed, want] of [['#abc', '#abc'], ['#11223344', '#11223344'], ['f60', '#f60']]) { await sm.locator('[data-field=new-hex]').fill(typed); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(150); rd = await rowData(); eq(`1 form "${typed}" accepted`, rd[rd.length - 1].hex, want); } // keep only Logo box for (let i = 0; i < 3; i++) { await rows().last().locator('[data-action=delete]').click(); await settle(150); } eq('1 cleanup leaves only Logo box', (await rowData()).map((r) => r.hex), ['#15202c']); // ================================================================= 2 Pick from page const logoBox = await page.locator(logoSel).first().boundingBox(); const hdrHex = await page.locator(`${frame} [data-zone-preview=header] > div`).first().evaluate((e) => getComputedStyle(e).backgroundColor); eq('2 fixture: header paints #0f172a', toHex(hdrHex), '#0f172a'); const designBefore = JSON.stringify(await page.evaluate(() => null)); const stylesBefore = await page.locator(targetSel).getAttribute('style'); await sm.getByRole('button', { name: 'Pick from page' }).click(); await settle(300); await page.mouse.move(logoBox.x + logoBox.width / 2, logoBox.y + logoBox.height / 2); await settle(200); const tipTxt = await page.evaluate(() => document.querySelector('[data-wsb-pick=tip]')?.textContent ?? null); check('2 pick mode shows a live tooltip while hovering the logo box', !!tipTxt && /#15202c/i.test(tipTxt), String(tipTxt)); await shot('shot-canvas-pick-mode-tooltip.png'); await page.mouse.click(logoBox.x + logoBox.width / 2, logoBox.y + logoBox.height / 2); await settle(400); const chooser = sm.locator('[data-testid=pick-chooser]'); const chooserCount = await chooser.count(); check('2 pick from page then offers the chooser (bg + text colours differ)', chooserCount === 1); const chooserBtns = chooserCount ? await chooser.locator('button').evaluateAll((bs) => bs.map((b) => b.innerText.replace(/\s+/g, ' ').trim())) : []; const logoComputed = toHex(await page.locator(logoSel).first().evaluate((e) => getComputedStyle(e).backgroundColor)); const bgBtn = chooserBtns.find((t) => /^Background/.test(t)) || ''; check('2 chooser background candidate equals the element computed colour', bgBtn.includes(logoComputed), `${logoComputed} :: ${JSON.stringify(chooserBtns)}`); check('2 chooser offers the match to the saved colour "Logo box"', /matches Logo box/.test(bgBtn), bgBtn); await shot('shot-canvas-pick-chooser.png'); await chooser.getByRole('button', { name: /^Background/ }).click(); await settle(300); rd = await rowData(); eq('2 picking an already-saved colour adds no duplicate', rd.length, 1); check('2 note says it is already saved', /Already saved as "Logo box"/.test(await sm.locator('[role=status]').innerText())); await openElement(); check('2 the pick click did NOT select a craft node', /Select something on the canvas/.test(await page.locator('.panel-right').innerText())); eq('2 target box untouched', await page.locator(targetSel).getAttribute('style'), stylesBefore); // token match: the page's white target box == the Background token await openSite(); const tb = await page.locator(targetSel).boundingBox(); await sm.getByRole('button', { name: 'Pick from page' }).click(); await settle(300); await page.mouse.click(tb.x + 30, tb.y + 30); await settle(400); // white bg + inherited text colour -> chooser; the Background candidate must report the token match const ch2 = await sm.locator('[data-testid=pick-chooser] button').evaluateAll((bs) => bs.map((b) => b.innerText.replace(/\s+/g, ' ').trim())); const tbComputed = toHex(await page.locator(targetSel).evaluate((e) => getComputedStyle(e).backgroundColor)); check('2 token match offered when the picked colour equals a site colour', ch2.some((t) => t.includes(tbComputed) && /matches Background/.test(t)), JSON.stringify(ch2)); await sm.locator('[data-testid=pick-chooser]').getByRole('button', { name: 'Cancel' }).click(); await settle(200); eq('2 still no extra swatch after cancel', (await rowData()).length, 1); // ================================================================= 3 Pick from image + palette await sm.getByRole('button', { name: 'Pick from image' }).click(); await page.waitForSelector('[data-testid=image-pick-modal]'); await page.locator('[data-testid=image-asset-test\\.png]').click(); const cv = page.locator('[data-testid=image-pick-canvas]'); await page.waitForFunction(() => { const c = document.querySelector('[data-testid=image-pick-canvas]'); return c && c.style.display !== 'none' && c.width > 0; }); const cb = await cv.boundingBox(); // known pixel: 75% across, 20% down = top-right quadrant (blue) const px = cb.x + cb.width * 0.75, py = cb.y + cb.height * 0.2; await page.mouse.move(px, py); await settle(200); eq('3 hover shows the exact pixel hex', (await page.locator('[data-testid=image-pick-hex]').innerText()), hx(TEST_PNG_COLORS.tr)); await shot('shot-pick-from-image-modal.png'); await page.mouse.click(px, py); await settle(300); rd = await rowData(); eq('3 clicking the pixel saved exactly that colour', rd.map((r) => r.hex), ['#15202c', hx(TEST_PNG_COLORS.tr)]); check('3 modal closed after the pick', (await page.locator('[data-testid=image-pick-modal]').count()) === 0); // other quadrant for another exact value await sm.getByRole('button', { name: 'Pick from image' }).click(); await page.locator('[data-testid=image-asset-test\\.png]').click(); await page.waitForFunction(() => { const c = document.querySelector('[data-testid=image-pick-canvas]'); return c && c.style.display !== 'none' && c.width > 0; }); const cb2 = await page.locator('[data-testid=image-pick-canvas]').boundingBox(); await page.mouse.click(cb2.x + cb2.width * 0.25, cb2.y + cb2.height * 0.75); await settle(300); rd = await rowData(); eq('3 bottom-left pixel exact', rd[rd.length - 1].hex, hx(TEST_PNG_COLORS.bl)); // cross-origin error await sm.getByRole('button', { name: 'Pick from image' }).click(); await page.locator('[data-testid=image-asset-remote\\.png]').click(); await page.waitForSelector('[data-testid=image-pick-error]'); const xo = await page.locator('[data-testid=image-pick-error]').innerText(); check('3 cross-origin src shows the plain-English error', /comes from another website/.test(xo) && /Upload it to this site first/.test(xo), xo); await page.locator('[data-testid=image-pick-close]').click(); // palette const swatchesBeforePalette = (await rowData()).map((r) => r.hex); await sm.getByRole('button', { name: 'Palette from image' }).click(); await page.waitForSelector('[data-testid=palette-from-image-modal]'); await page.locator('[data-testid=palette-from-image-modal] [data-testid=image-asset-test\\.png]').click(); await page.waitForSelector('[data-testid^=palette-row-]'); const palHexes = await page.locator('[data-testid^=palette-row-]').evaluateAll((els) => els.map((e) => e.getAttribute('data-testid').replace('palette-row-', ''))); check('3 palette shows 5-8 swatches', palHexes.length >= 5 && palHexes.length <= 8, `${palHexes.length}: ${palHexes.join(' ')}`); eq('3 palette auto-applied nothing (design unchanged)', (await rowData()).map((r) => r.hex), swatchesBeforePalette); await shot('shot-palette-from-image-modal.png'); const addHex = palHexes.find((h) => !swatchesBeforePalette.includes(h)); await page.locator(`[data-testid="palette-add-${addHex}"]`).click(); await settle(250); eq('3 Add adds exactly that swatch', (await rowData()).map((r) => r.hex), [...swatchesBeforePalette, addHex]); await page.locator('[data-testid=palette-close]').click(); // drop everything except Logo box while ((await rows().count()) > 1) { await rows().last().locator('[data-action=delete]').click(); await settle(120); } // ================================================================= 4 Persistence await sm.locator('[data-field=new-hex]').fill('#ff6600'); await sm.locator('[data-field=new-label]').fill('Brand orange'); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(200); await rows().last().locator('[data-action=brand]').click(); await sm.locator('[data-field=new-hex]').fill('#c8b08a'); await sm.locator('[data-field=new-label]').fill('Sand'); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(250); const before = await rowData(); eq('4 three swatches before save (one brand)', before.map((r) => [r.label, r.hex, r.brand]), [['Logo box', '#15202c', false], ['Brand orange', '#ff6600', true], ['Sand', '#c8b08a', false]]); // a low-contrast site colour so the Site panel shows a note in the screenshot (defaults already do) await sm.scrollIntoViewIfNeeded(); await shot('shot-site-design-saved-colours.png'); await save(); const stored = state.project.design?.swatches; check('4 stored project.json contains design.swatches', Array.isArray(stored) && stored.length === 3, JSON.stringify(stored)); const orangeStored = (stored || []).find((s) => s.label === 'Brand orange'); eq('4 stored brand swatch (hex, brand, slot)', orangeStored && [orangeStored.hex, orangeStored.brand, orangeStored.slot], ['#ff6600', true, 1]); const sandStored = (stored || []).find((s) => s.label === 'Sand'); eq('4 stored plain swatch', sandStored && [sandStored.hex, sandStored.brand, sandStored.slot], ['#c8b08a', false, undefined]); await loadEditor(); await openSite(); const after = await rowData(); eq('4 reload: panel restores labels/hex/brand', after.map((r) => [r.label, r.hex, r.brand]), before.map((r) => [r.label, r.hex, r.brand])); eq('4 reload: brand swatch shows the live badge, others not', after.map((r) => r.live), [false, true, false]); await selectTarget(); const bgField = fieldOf('Background Color'); const savedBtns = await bgField.locator('[data-swatch]').evaluateAll((bs) => bs.map((b) => [b.getAttribute('aria-label'), b.getAttribute('data-live')])); eq('4 reload: saved swatch buttons present in the Background colour field', savedBtns, [['Logo box #15202c', null], ['Brand orange #ff6600 (live brand colour, var(--wsb-brand-1))', 'true'], ['Sand #c8b08a', null]]); await bgField.scrollIntoViewIfNeeded(); await shot('shot-colour-field-saved-swatches-and-pick-tools.png'); // ================================================================= 5 Brand live recolour: canvas == frontend export == backend await bgField.locator('[data-live=true]').click(); await settle(400); eq('5 canvas: element paints the brand colour', toHex(await canvasBg(targetSel)), '#ff6600'); check('5 element stores the live token (inline style)', /background-color:\s*var\(--wsb-brand-1\)/.test(await page.locator(targetSel).getAttribute('style') || ''), await page.locator(targetSel).getAttribute('style')); await openSite(); const orangeRow = rows().nth(1); await orangeRow.locator('[data-field=hex]').fill('#00aa55'); await settle(500); eq('5 canvas element recoloured to the new brand hex', toHex(await canvasBg(targetSel)), '#00aa55'); await save(); const nodes = pageNodes(); eq('5 stored value on the node is the token, not the hex', nodes['target-box'].props.style.backgroundColor, 'var(--wsb-brand-1)'); eq('5 stored brand swatch hex updated', state.project.design.swatches.find((s) => s.slot === 1).hex, '#00aa55'); const canvasRgb = await canvasBg(targetSel); const publishedA = head.renderPublished(state.project, 0); const pub = await ctx.newPage(); const bgOf = async (html) => { await pub.setContent(html, { waitUntil: 'domcontentloaded' }); return pub.evaluate(() => { const e = [...document.querySelectorAll('div')].find((d) => d.style.minHeight === '80px'); return e ? getComputedStyle(e).backgroundColor : null; }); }; eq('5 published (frontend export): identical to the canvas colour', await bgOf(publishedA), canvasRgb); eq('5 published (frontend export) is the new brand colour', toHex(await bgOf(publishedA)), '#00aa55'); if (haveBackend) { const publishedB = backendHtml(state.project, 0); eq('5 published (backend generateCompiledHTML): identical to the canvas colour', await bgOf(publishedB), canvasRgb); const da = rootDecl(publishedA), db = rootDecl(publishedB); check('5 heads :root brand declaration byte-identical (frontend vs backend)', da === db && da.includes(';--wsb-brand-1:#00aa55'), da === db ? da.slice(-60) : `\n a=${da}\n b=${db}`); } else console.log(`[${name}] SKIP backend leg: ${BACKEND_DIR} not found`); // site WITHOUT brand colours: heads identical to the pre-feature baseline const plain = buildProject(); plain.design.swatches = [{ id: 'x', label: 'Plain', hex: '#c8b08a', brand: false }]; const noDesign = buildProject(); const pa = head.renderPublished(noDesign, 0); check('5 no-brand site: published head has no "brand" substring', !/brand/i.test(pa.split('')[0])); const pplain = head.renderPublished(plain, 0); eq('5 non-brand swatches change nothing in the published document', pplain, pa); if (haveBackend) eq('5 no-brand site: frontend :root == backend :root', rootDecl(pa), rootDecl(backendHtml(noDesign, 0))); if (baseExporter) { eq(`5 no-brand site: each later deliberate head change found exactly once (${HEAD_FOLDS.map((f) => f.why.split(':')[0]).join(', ')})`, foldsApplied(pa), HEAD_FOLDS.map(() => 1)); eq(`5 no-brand site: published document identical to the pre-feature baseline (${BASE_COMMIT}, later head changes folded back)`, foldHead(pa) === baseExporter.renderPublished(noDesign, 0), true); } else console.log(`[${name}] SKIP baseline comparison`); await pub.close(); // ================================================================= 6 Cap + guarded delete await openSite(); // brand slots so far: 1 (orange). Mark Logo box + Sand brand => 3. Add three more => 6. await rows().nth(0).locator('[data-action=brand]').click(); await rows().nth(2).locator('[data-action=brand]').click(); for (const [h, l] of [['#111111', 'Ink'], ['#222222', 'Slate'], ['#333333', 'Coal']]) { await sm.locator('[data-field=new-hex]').fill(h); await sm.locator('[data-field=new-label]').fill(l); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(150); await rows().last().locator('[data-action=brand]').click(); await settle(100); } rd = await rowData(); eq('6 six brand colours', rd.filter((r) => r.brand).length, 6); await sm.locator('[data-field=new-hex]').fill('#444444'); await sm.locator('[data-field=new-label]').fill('Seventh'); await sm.getByRole('button', { name: 'Add colour' }).click(); await settle(150); await rows().last().locator('[data-action=brand]').click(); await settle(200); eq('6 7th brand colour refused with the message', await sm.locator('[role=status]').innerText(), 'You can have up to 6 brand colours'); eq('6 the 7th stays a plain colour', (await rowData()).filter((r) => r.brand).length, 6); // deleting a referenced brand colour asks first (orange row = index 1, used by the target box) await rows().nth(1).locator('[data-action=delete]').click(); await settle(250); const dlg = sm.locator('[role=alertdialog]'); check('6 deleting a referenced brand colour asks for confirmation', (await dlg.count()) === 1); const dlgText = (await dlg.innerText()).replace(/\s+/g, ' '); check('6 confirmation names the swatch, the use count and the fixed colour', /Brand orange is used in 1 place/.test(dlgText) && /#00aa55/.test(dlgText), dlgText); eq('6 nothing deleted before confirming', (await rowData()).length, 7); eq('6 canvas colour unchanged before confirming', toHex(await canvasBg(targetSel)), '#00aa55'); await dlg.getByRole('button', { name: 'Cancel' }).click(); await settle(150); eq('6 Cancel keeps the swatch', (await rowData()).length, 7); await rows().nth(1).locator('[data-action=delete]').click(); await settle(150); await dlg.getByRole('button', { name: 'Replace and delete' }).click(); await settle(500); eq('6 swatch removed after confirming', (await rowData()).some((r) => r.label === 'Brand orange'), false); eq('6 canvas element keeps its colour', toHex(await canvasBg(targetSel)), '#00aa55'); const styleAfter = await page.locator(targetSel).getAttribute('style'); check('6 canvas element now holds the literal hex (no brand var)', /background-color:\s*(#00aa55|rgb\(0, 170, 85\))/i.test(styleAfter || '') && !/wsb-brand-1/.test(styleAfter || ''), styleAfter); await save(); eq('6 saved node holds the literal hex', pageNodes()['target-box'].props.style.backgroundColor, '#00aa55'); eq('6 saved swatches no longer contain Brand orange', state.project.design.swatches.some((s) => s.label === 'Brand orange'), false); eq('6 published page (frontend) still paints the colour', toHex(await (async () => { const p2 = await ctx.newPage(); await p2.setContent(head.renderPublished(state.project, 0)); const v = await p2.evaluate(() => getComputedStyle([...document.querySelectorAll('div')].find((d) => d.style.minHeight === '80px')).backgroundColor); await p2.close(); return v; })()), '#00aa55'); // ================================================================= 7 Contrast await page.locator(`${frame} p`).click(); await settle(400); await page.click('#right-tab-element'); await settle(300); const tf = fieldOf('Text Color'); await tf.locator('input[type=text]').first().fill('#ffffff'); await tf.locator('input[type=text]').first().press('Enter'); await settle(500); const note = await page.locator('.panel-right .contrast-note--fail').allInnerTexts(); check('7 white text on a light background shows the inline warning with the ratio', note.length >= 1 && /Low contrast 1\.00:1 \(needs 4\.5:1\)/.test(note[0]), JSON.stringify(note)); await page.locator('.panel-right').evaluate((e) => e.scrollTo?.(0, 0)); await shot('shot-text-field-inline-contrast.png'); await openSite(); const siteNotes = await page.locator('.panel-right .contrast-note--fail').allInnerTexts(); check('7 Site Design shows the site-level contrast notes', siteNotes.some((t) => /Button text on primary: Low contrast 3\.68:1/.test(t)), JSON.stringify(siteNotes.map((t) => t.replace(/\s+/g, ' ')))); await shot('shot-site-design-contrast-notes.png'); if (errors.length) { failed++; console.log(`[${name}] FAIL page errors:\n` + errors.join('\n')); } await browser.close(); server.close(); return failed; } let total = 0; try { total += await run('chromium', chromium, process.env.CHROMIUM_PATH); total += await run('firefox', firefox, process.env.FIREFOX_PATH); } finally { remote.close(); fs.rmSync(buildDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true }); } console.log(total ? `FAILED (${total})` : 'ALL PASS'); process.exit(total ? 1 : 0);