#!/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