#!/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.
*
* 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().
*
* 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=
write canvas + published PNGs there
* CANVAS_CHROME_DUMP= 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: , published: {: }}
* 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 });
}
// 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.
// ---------------------------------------------------------------- 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('', () => ``);
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