2026-11-01 10:12:38 -08:00
#!/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' ;
2026-11-01 15:16:53 -08:00
// 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 = [
2026-11-01 16:26:11 -08:00
{
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}' ,
},
2026-11-01 15:16:53 -08:00
{
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 );
2026-11-01 16:26:11 -08:00
// 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 ; });
2026-11-01 10:12:38 -08:00
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 ) {
2026-11-01 15:16:53 -08:00
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 );
2026-11-01 10:12:38 -08:00
} 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 );