fix(site-builder): editor canvas renders content with the published base reset
Published pages/Preview ship a head reset (*{margin:0;padding:0}, img block,
a inherit, body line-height 1.6 / #1f2937 / 16px); the editor canvas had none,
so h1-h6/p margins, line-height and inherited size/colour differed.
- editor.css: one commented block scoping the reset to .canvas-device-frame,
every selector zero-specificity (:where) so inline styles / class rules win
exactly as when published; editor chrome untouched (snapshot identical).
- tokens.ts: canvas heading/link token rules made zero-specificity too.
- utils/css-reset.ts: single source for the reset declarations (html-export
imports it; test asserts byte-identity with the backend head and that the
editor.css block carries the same declarations).
- scripts/e2e-canvas-parity.mjs (npm run test:browser:canvas): real editor vs
published HTML in Firefox + Chromium, boxes within 1px + computed styles.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76f0ba9ca1
commit
dc415db2d3
15 files changed
+774
-6
No files matched your search
@@ -0,0 +1,15 @@
|
||||
// Bundled (esbuild) by scripts/e2e-canvas-parity.mjs and run in Node: renders what a publish
|
||||
// of the CURRENT stored state would emit, composed like TopBar's Preview
|
||||
// (header + body + footer under the real document head). Used for specs whose
|
||||
// checked-in published HTML is stale relative to the stored state.
|
||||
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
|
||||
|
||||
export function renderPublished(project: any, pageIdx: number): string {
|
||||
const page = project.pages_craft_state[pageIdx];
|
||||
const hdr = project.header_craft_state ? exportBodyHtml(project.header_craft_state, 'header').html : '';
|
||||
const ftr = project.footer_craft_state ? exportBodyHtml(project.footer_craft_state, 'footer').html : '';
|
||||
const body = exportBodyHtml(page.craftState).html;
|
||||
return exportComposedPreviewHtml(page.craftState, hdr + body + ftr, {
|
||||
title: page.name, includeFonts: true, headCode: project.design?.headCode, design: project.design,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/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 });
|
||||
}
|
||||
|
||||
// A stored `style` often carries a shorthand plus BLANK longhands
|
||||
// ({padding:'20px', paddingTop:''}). The editor renders those through React, which
|
||||
// assigns '' to the longhand and thereby wipes the shorthand in the canvas (the
|
||||
// export drops blanks, so published keeps it). That is a SEPARATE bug from the
|
||||
// base-reset parity this test guards (same class as fix/bg-shorthand-longhand), so by
|
||||
// default the blanks are stripped from the loaded state to isolate the reset.
|
||||
// CANVAS_RAW=1 keeps the stored state verbatim (shows that bug's residue).
|
||||
function stripBlankStyles(project) {
|
||||
if (process.env.CANVAS_RAW) return project;
|
||||
const clean = (json) => {
|
||||
const nodes = JSON.parse(json);
|
||||
for (const n of Object.values(nodes)) {
|
||||
const st = n && n.props && n.props.style;
|
||||
if (st && typeof st === 'object') for (const k of Object.keys(st)) if (st[k] === '') delete st[k];
|
||||
}
|
||||
return JSON.stringify(nodes);
|
||||
};
|
||||
const out = { ...project };
|
||||
for (const k of ['craft_state', 'header_craft_state', 'footer_craft_state']) if (typeof out[k] === 'string' && out[k]) out[k] = clean(out[k]);
|
||||
out.pages_craft_state = (project.pages_craft_state || []).map((pg) => ({ ...pg, craftState: typeof pg.craftState === 'string' ? clean(pg.craftState) : pg.craftState }));
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 = stripBlankStyles({ ...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);
|
||||
Reference in new issue
Block a user