Files
site-builder/craft/scripts/e2e-canvas-parity.mjs
T

305 lines
16 KiB
JavaScript

#!/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.
*
* 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 });
}
// 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('</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.
currentProject = { ...spec.project, pages_craft_state: [pg, ...pages.filter((_, j) => j !== idx)] };
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);
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);