- Tables inside HtmlBlocks are left alone on phones: display:block (the only way to make one scroll) shrank a fitting width:100% table's rows from 349px to 77px at 390. Wide tables stay clipped as before. The <pre> rule (max-width:100%;overflow-x:auto, no display change) and the media rules stay. - Browser suite: width:100% table still spans the screen (fails on cfeeef3's rule: first row 78px); the 10-column bare table moved to htmlwide (clipped, page never scrolls sideways). - e2e-color-system: default COLOR_BASE_COMMITab7a100->69fd34b, the first commit with the 2026.11.4 published head (ColumnLayout stacking + Navbar wrap) and none of the colour feature.ab7a100predates that head, so check 5 failed on main itself. This branch's own head change (mobile guard) is folded back by exact string, asserted present once, before comparing, so the check still proves the colour feature changed nothing else. Passes on 69fd34b; fails (as it should) againstab7a100. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
455 lines
30 KiB
JavaScript
455 lines
30 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. 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=<sha> 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: '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);
|
|
const foldsApplied = (html) => HEAD_FOLDS.map((f) => html.split(f.from).length - 1);
|
|
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: 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);
|