2026-11-01 08:23:19 -08:00
#!/usr/bin/env node
/**
* Real-browser regression test: the editor CANVAS must lay page content out
* exactly like the PUBLISHED page ("the site looks different in the builder
* than published"). jsdom has no layout, so this mounts the REAL editor (a
* vite build served by a tiny mock API) in headless Firefox AND Chromium,
* loads a stored project, and compares -- element by element -- against the
* same page's published HTML rendered in a plain page whose viewport equals
* the canvas content width.
*
2026-11-01 09:18:29 -08:00
* Stored state is loaded VERBATIM (blank style values included). Allowed editor-only
* differences (empty-container drop zone, empty-image placeholder) are neutralised in the
* canvas before measuring; see the ALLOWED block in run().
*
2026-11-01 08:23:19 -08:00
* Compared per matched element: bounding box (x/y/w/h, within 1px) and the
* computed font-size / line-height / margin / padding / colour / font-family
* / font-weight / display. Editor-only chrome (the dashed header/footer zone
* separators) is compensated for, not ignored.
*
* npm run test:browser:canvas
*
* Env:
* CHROMIUM_PATH / FIREFOX_PATH override the Playwright-pinned binaries
* WHP_CANVAS_SPECS extra comma-separated spec.json paths
* (e.g. real customer sites kept out of git)
* CANVAS_SHOTS=<dir> write canvas + published PNGs there
* CANVAS_CHROME_DUMP=<file> write a computed-style snapshot of the
* editor chrome (everything OUTSIDE the
* canvas frame) to diff before/after a change
* CANVAS_ONLY=chromium|firefox run a single browser
*
* A spec is {name, project: <project.json>, published: {<slug>: <full html>}}
* with paths relative to the spec file. Only pages that have a published
* file are compared.
*/
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' ;
const root = path . resolve ( path . dirname ( fileURLToPath ( import . meta . url )), '..' );
const VIEW_H = 1000 ;
const EDITOR_W = 1500 ;
const TOL = 1 ; // px
// ---------------------------------------------------------------- specs
const specPaths = [
path . join ( root , 'tests/fixtures/canvas-parity/ols.spec.json' ),
...( process . env . WHP_CANVAS_SPECS || '' ). split ( ',' ). map (( s ) => s . trim ()). filter ( Boolean ),
];
// `published` is either {slug: html file} or "generate" = render what a publish of the stored
// state emits (scripts/e2e-canvas-export-entry.ts) -- for sites whose saved HTML is stale.
let renderPublished = null ;
async function loadExporter () {
if ( renderPublished ) return renderPublished ;
const out = await esbuild ({
entryPoints : [ path . join ( root , 'scripts/e2e-canvas-export-entry.ts' )],
bundle : true , write : false , format : 'esm' , platform : 'node' , jsx : 'automatic' , logLevel : 'error' ,
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-export- ${ process . pid } .mjs` );
fs . writeFileSync ( f , out . outputFiles [ 0 ]. text );
renderPublished = ( await import ( f )). renderPublished ;
fs . rmSync ( f , { force : true });
return renderPublished ;
}
const specs = [];
for ( const p of specPaths ) {
const dir = path . dirname ( p );
const s = JSON . parse ( fs . readFileSync ( p , 'utf8' ));
const project = JSON . parse ( fs . readFileSync ( path . resolve ( dir , s . project ), 'utf8' ));
let published ;
if ( s . published === 'generate' ) {
const render = await loadExporter ();
const only = s . pages || project . pages_craft_state . map (( pg ) => pg . slug );
published = {};
project . pages_craft_state . forEach (( pg , i ) => { if ( only . includes ( pg . slug )) published [ pg . slug ] = render ( project , i ); });
} else {
published = Object . fromEntries ( Object . entries ( s . published ). map (([ k , v ]) => [ k , fs . readFileSync ( path . resolve ( dir , v ), 'utf8' )]));
}
specs . push ({ name : s . name , project , published });
}
2026-11-01 09:11:52 -08:00
// NOTE: stored state is loaded VERBATIM, blank style values included (e.g.
// {padding:'20px', paddingTop:''} left by the style panel). The harness must test what
// the editor really renders; an earlier version stripped blanks and thereby hid a wipe bug.
2026-11-01 08:23:19 -08:00
// ---------------------------------------------------------------- build + serve the real editor
const buildDir = fs . mkdtempSync ( path . join ( os . tmpdir (), 'wsb-canvas-' ));
execFileSync ( path . join ( root , 'node_modules/.bin/vite' ), [ 'build' , '--outDir' , buildDir , '--emptyOutDir' , '--logLevel' , 'error' ], { cwd : root , stdio : 'inherit' });
let currentProject = null ;
const mime = { '.js' : 'text/javascript' , '.css' : 'text/css' , '.html' : 'text/html' , '.png' : 'image/png' , '.svg' : 'image/svg+xml' };
const server = http . createServer (( req , res ) => {
const u = new URL ( req . url , 'http://x' );
if ( u . pathname === '/api/site-builder.php' ) {
res . setHeader ( 'content-type' , 'application/json' );
if ( u . searchParams . get ( 'action' ) === 'load' ) {
return res . end ( JSON . stringify ({ success : true , rev : 'r1' , limits : {}, project : currentProject }));
}
return res . end ( JSON . stringify ({ success : true }));
}
const p = u . pathname === '/' ? '/index.html' : u . pathname ;
if ( p === '/index.html' ) {
const cfg = { user : 't' , apiUrl : '/api/site-builder.php' , csrfToken : 'x' , siteId : 1 , siteDomain : 'example.test' , siteName : 'Parity' , backUrl : '/' , isRoot : false };
const h = fs . readFileSync ( path . join ( buildDir , 'index.html' ), 'utf8' )
. replace ( '</head>' , () => `<script>window.WHP_CONFIG= ${ JSON . stringify ( cfg ) } ;</script></head>` );
res . setHeader ( 'content-type' , 'text/html' );
return res . end ( h );
}
const f = path . join ( buildDir , p );
if ( f . startsWith ( buildDir ) && fs . existsSync ( f ) && fs . statSync ( f ). isFile ()) {
res . setHeader ( 'content-type' , mime [ path . extname ( f )] || 'application/octet-stream' );
return res . end ( fs . readFileSync ( f ));
}
res . statusCode = 404 ;
res . end ( 'nf' );
});
await new Promise (( r ) => server . listen ( 0 , '127.0.0.1' , r ));
const base = `http://127.0.0.1: ${ server . address (). port } ` ;
// ---------------------------------------------------------------- in-page measurer (runs in BOTH documents)
function measure ({ rootSel , isCanvas , skipNav }) {
const rootEl = document . querySelector ( rootSel );
const rr = rootEl . getBoundingClientRect ();
const bl = isCanvas ? parseFloat ( getComputedStyle ( rootEl ). borderLeftWidth ) || 0 : 0 ;
const bt = isCanvas ? parseFloat ( getComputedStyle ( rootEl ). borderTopWidth ) || 0 : 0 ;
const ox = rr . left + bl ;
const oy = rr . top + bt ;
// Editor-only dashed zone separators shift everything after them by their width.
const zones = isCanvas ? [... rootEl . querySelectorAll ( '[data-zone-preview]' )] : [];
const adjustY = ( el ) => {
let adj = 0 ;
for ( const z of zones ) {
const cs = getComputedStyle ( z );
const zone = z . getAttribute ( 'data-zone-preview' );
const bw = parseFloat ( zone === 'header' ? cs . borderBottomWidth : cs . borderTopWidth ) || 0 ;
if ( zone === 'header' && ! z . contains ( el ) && ( z . compareDocumentPosition ( el ) & Node . DOCUMENT_POSITION_FOLLOWING )) adj += bw ;
if ( zone === 'footer' && ( z === el || z . contains ( el ))) adj += bw ;
}
return adj ;
};
const SKIP = new Set ([ 'STYLE' , 'SCRIPT' , 'NOSCRIPT' , 'LINK' , 'META' , 'BR' , 'HEAD' , 'TITLE' , 'PATH' , 'CIRCLE' , 'RECT' , 'LINE' , 'POLYLINE' , 'POLYGON' , 'G' , 'USE' , 'DEFS' ]);
const norm = ( s ) => s . replace ( /\s+/g , '' );
// Visible text only (never <style>/<script> bodies), whitespace-insensitive:
// React renders no inter-element whitespace, the stored HTML does.
const textOf = ( el ) => {
let t = '' ;
const w = document . createTreeWalker ( el , NodeFilter . SHOW_TEXT );
for ( let n = w . nextNode (); n ; n = w . nextNode ()) {
const pt = n . parentElement && n . parentElement . tagName ;
if ( pt !== 'STYLE' && pt !== 'SCRIPT' ) t += n . nodeValue ;
}
return norm ( t );
};
const normStyle = ( s ) => norm (( s || '' ). replace ( /\s*([:;])\s*/g , '$1' ). replace ( /;$/ , '' ));
// Order-insensitive identity of an element's inline style (React and the exporter
// serialise the same declarations in different orders); editor-only props dropped.
const EDITOR_PROPS = /^(cursor|outline|user-select|pointer-events|position)/ ;
const styleKey = ( el ) => Array . from ( el . style ). filter (( p ) => ! EDITOR_PROPS . test ( p )). sort ()
. map (( p ) => ` ${ p } : ${ el . style . getPropertyValue ( p ) } ` ). join ( ';' ). replace ( /\s+/g , '' ). slice ( 0 , 120 );
const counts = new Map ();
const out = {};
const walk = ( el ) => {
for ( const c of el . children ) {
if ( SKIP . has ( c . tagName . toUpperCase ())) continue ;
const tag = c . tagName . toLowerCase ();
// Navbar link ORDER follows page order, which the harness rotates to open page N: skip links.
if ( skipNav && tag === 'a' && c . closest ( 'nav' )) continue ;
// Editor-only wrapper boxes (Craft frame div, RenderNode `position:relative` host, header/footer zone-preview host) have no
// counterpart in the published DOM: descend through them without measuring.
const bare = tag === 'div' && ( c . attributes . length === 0
|| normStyle ( c . getAttribute ( 'style' )) === 'max-width:1200px;margin:0auto'
|| ( isCanvas && c . attributes . length === 1 && c . hasAttribute ( 'style' ) && normStyle ( c . getAttribute ( 'style' )) === 'position:relative' ));
if ( c . hasAttribute ( 'data-zone-preview' ) || bare ) { walk ( c ); continue ; }
const text = textOf ( c ). slice ( 0 , 40 );
const ident = tag === 'img' ? ` ${ c . getAttribute ( 'alt' ) || '' } `
: text || styleKey ( c );
const k0 = ` ${ tag } | ${ ident } ` ;
const n = ( counts . get ( k0 ) || 0 ) + 1 ;
counts . set ( k0 , n );
const key = ` ${ k0 } # ${ n } ` ;
const r = c . getBoundingClientRect ();
const cs = getComputedStyle ( c );
out [ key ] = {
tag ,
x : r . left - ox , y : r . top - oy - adjustY ( c ), w : r . width , h : r . height ,
fontSize : cs . fontSize , lineHeight : cs . lineHeight , fontFamily : cs . fontFamily , fontWeight : cs . fontWeight ,
color : cs . color , display : cs . display , textAlign : cs . textAlign ,
margin : ` ${ cs . marginTop } ${ cs . marginRight } ${ cs . marginBottom } ${ cs . marginLeft } ` ,
padding : ` ${ cs . paddingTop } ${ cs . paddingRight } ${ cs . paddingBottom } ${ cs . paddingLeft } ` ,
};
walk ( c );
}
};
walk ( rootEl );
return out ;
}
const PROPS = [ 'fontSize' , 'lineHeight' , 'fontFamily' , 'fontWeight' , 'color' , 'display' , 'textAlign' , 'margin' , 'padding' ];
function chromeSnapshot () {
const frame = document . querySelector ( '.canvas-device-frame' );
const rows = {};
let i = 0 ;
for ( const el of document . body . querySelectorAll ( '*' )) {
if ( frame && ( frame === el || frame . contains ( el ))) continue ;
if ( el . closest ( '[data-zone-preview]' )) continue ;
const cs = getComputedStyle ( el );
const k = ` ${ i ++ } : ${ el . tagName . toLowerCase () } . ${ String ( el . className && el . className . baseVal !== undefined ? el . className . baseVal : el . className ). slice ( 0 , 40 ) } ` ;
rows [ k ] = [ cs . margin , cs . padding , cs . fontSize , cs . lineHeight , cs . color , cs . display , cs . fontFamily ]. join ( '|' );
}
return rows ;
}
// ---------------------------------------------------------------- driver
async function run ( name , launcher , exe ) {
const browser = await launcher . launch ({ executablePath : exe || undefined , ...( name === 'chromium' ? { args : [ '--no-sandbox' ] } : {}) });
let failed = 0 ;
const offline = ( route ) => route . abort ();
const shotDir = process . env . CANVAS_SHOTS ;
if ( shotDir ) fs . mkdirSync ( shotDir , { recursive : true });
for ( const spec of specs ) {
const pages = spec . project . pages_craft_state || [];
for ( const [ idx , pg ] of pages . entries ()) {
const pubHtml = spec . published [ pg . slug ];
if ( ! pubHtml ) continue ;
const label = ` ${ spec . name } / ${ pg . slug } ` ;
// The editor opens pages_craft_state[0]; rotate so page `idx` is first.
2026-11-01 09:11:52 -08:00
currentProject = { ... spec . project , pages_craft_state : [ pg , ... pages . filter (( _ , j ) => j !== idx )] };
2026-11-01 08:23:19 -08:00
const ctx = await browser . newContext ({ viewport : { width : EDITOR_W , height : VIEW_H } });
await ctx . route ( /^https?:\/\/(?!127\.0\.0\.1)/ , offline ); // no web fonts / icon CDN: identical fallbacks both sides
const ed = await ctx . newPage ();
const errors = [];
ed . on ( 'pageerror' , ( e ) => errors . push ( String ( e )));
await ed . goto ( base + '/' );
await ed . waitForSelector ( '.canvas-device-frame [data-craft-container]' , { timeout : 20000 });
await ed . waitForTimeout ( 1500 ); // header/footer zone previews + layout settle
const canvasW = await ed . evaluate (() => document . querySelector ( '.canvas-device-frame' ). clientWidth );
2026-11-01 09:18:29 -08:00
// ALLOWED editor-only differences (deliberate UX with no published counterpart), neutralised
// before measuring so they don't mask real diffs:
// - the 40px drop zone on EMPTY containers (Container sets data-wsb-dropzone only then)
// - the placeholder <img> shown for an image node with no src (toHtml emits nothing)
await ed . evaluate (() => {
const f = document . querySelector ( '.canvas-device-frame' );
f . querySelectorAll ( '[data-wsb-dropzone]' ). forEach (( el ) => el . style . setProperty ( 'min-height' , '0' , 'important' ));
f . querySelectorAll ( 'img[src^="data:image/svg"]' ). forEach (( el ) => el . style . setProperty ( 'display' , 'none' , 'important' ));
});
2026-11-01 08:23:19 -08:00
const canvas = await ed . evaluate ( measure , { rootSel : '.canvas-device-frame' , isCanvas : true , skipNav : idx > 0 });
if ( shotDir ) await ed . screenshot ({ path : path . join ( shotDir , ` ${ label . replace ( '/' , '_' ) } . ${ name } .canvas.png` ) });
if ( process . env . CANVAS_CHROME_DUMP && idx === 0 && spec === specs [ 0 ]) {
fs . writeFileSync ( ` ${ process . env . CANVAS_CHROME_DUMP } . ${ name } .json` , JSON . stringify ( await ed . evaluate ( chromeSnapshot ), null , 1 ));
}
const chromeBody = await ed . evaluate (() => getComputedStyle ( document . body ). fontSize );
// Published: viewport so that the layout width (excluding any scrollbar) equals the canvas content width.
let w = canvasW ;
const pub = await ctx . newPage ();
let published ;
for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) {
await pub . setViewportSize ({ width : w , height : VIEW_H });
await pub . setContent ( pubHtml , { waitUntil : 'domcontentloaded' });
await pub . waitForTimeout ( 300 );
const cw = await pub . evaluate (() => document . documentElement . clientWidth );
if ( cw === canvasW ) break ;
w += canvasW - cw ;
}
published = await pub . evaluate ( measure , { rootSel : 'body' , isCanvas : false , skipNav : idx > 0 });
if ( shotDir ) await pub . screenshot ({ path : path . join ( shotDir , ` ${ label . replace ( '/' , '_' ) } . ${ name } .published.png` ) });
const pk = Object . keys ( published );
const matched = pk . filter (( k ) => k in canvas );
const diffs = [];
for ( const k of matched ) {
const a = canvas [ k ], b = published [ k ];
for ( const f of [ 'x' , 'y' , 'w' , 'h' ]) if ( Math . abs ( a [ f ] - b [ f ]) > TOL ) diffs . push ([ k , f , a [ f ]. toFixed ( 1 ), b [ f ]. toFixed ( 1 )]);
for ( const f of PROPS ) if ( a [ f ] !== b [ f ]) diffs . push ([ k , f , a [ f ], b [ f ]]);
}
const ratio = matched . length / pk . length ;
const bad = new Set ( diffs . map (( d ) => d [ 0 ]));
const ok = diffs . length === 0 && ratio >= 0.6 && errors . length === 0 && chromeBody === '13px' ;
if ( ! ok ) failed ++ ;
console . log ( `[ ${ name } ] ${ ok ? 'PASS' : 'FAIL' } ${ label } : ${ matched . length } / ${ pk . length } published elements matched, ${ bad . size } elements differ ( ${ diffs . length } property diffs), canvas width ${ canvasW } , editor body font ${ chromeBody } ` );
if ( diffs . length ) {
// Summarise: property -> count + first examples
const byProp = {};
for ( const [ k , f , a , b ] of diffs ) ( byProp [ f ] ||= []). push ( ` ${ k . split ( '#' )[ 0 ]. slice ( 0 , 44 ) } canvas= ${ a } published= ${ b } ` );
for ( const [ f , rows ] of Object . entries ( byProp )) console . log ( ` ${ f } : ${ rows . length } x e.g. ${ rows . slice ( 0 , 3 ). join ( ' ;; ' ) } ` );
}
if ( errors . length ) console . log ( ' page errors:' , errors . join ( '\n' ));
if ( process . env . CANVAS_JSON ) {
fs . writeFileSync ( ` ${ process . env . CANVAS_JSON } . ${ name } . ${ label . replace ( '/' , '_' ) } .json` , JSON . stringify ({ canvas , published , diffs }, null , 1 ));
}
await ctx . close ();
}
}
await browser . close ();
return failed ;
}
let total = 0 ;
try {
if ( process . env . CANVAS_ONLY !== 'firefox' ) total += await run ( 'chromium' , chromium , process . env . CHROMIUM_PATH );
if ( process . env . CANVAS_ONLY !== 'chromium' ) total += await run ( 'firefox' , firefox , process . env . FIREFOX_PATH );
} finally {
server . close ();
fs . rmSync ( buildDir , { recursive : true , force : true });
}
console . log ( total ? `FAILED: ${ total } page comparison(s) differ` : 'OK: canvas content matches published' );
process . exit ( total ? 1 : 0 );