Files
site-builder/craft/scripts/e2e-color-system.mjs
T
shadowdaoandClaude Opus 5.5 93b3cc35af Colour e2e: baseline commit overridable (COLOR_BASE_COMMIT)
On the release branch mobile-parity's <=767px head rules land before the
colour merge, so 'identical to ab7a100' cannot hold; run with
COLOR_BASE_COMMIT=<commit before the colour merge> there. Measured: vs ab7a100
the only difference is those two documented rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:23:17 -08:00

441 lines
28 KiB
JavaScript

#!/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=<dir> 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. On a branch that also carries other head changes (e.g. the release
// branch, where mobile-parity's <=767px stacking rules land first) point this at the commit just
// before the colour merge: COLOR_BASE_COMMIT=<sha>.
const BASE_COMMIT = process.env.COLOR_BASE_COMMIT || 'ab7a100';
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('</head>')[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: published document identical to the pre-feature baseline (${BASE_COMMIT})`, 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);