#!/usr/bin/env node /** * Real-browser regression test (Firefox AND Chromium, headless): published/Preview documents must * stack multi-column layouts at phone width, must not overflow horizontally at 390px, and must lay * out EXACTLY as before at desktop width. * * npm run test:browser:mobile * MOBILE_PARITY_RECORD= write a layout snapshot (run on the base commit to get a baseline) * MOBILE_PARITY_BASELINE= additionally assert desktop (1280) boxes equal that snapshot * * Env: CHROMIUM_PATH / FIREFOX_PATH, MOBILE_ONLY=chromium|firefox. */ import { chromium, firefox } from '@playwright/test'; import { build as esbuild } from 'esbuild'; 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 fixDir = path.join(root, 'tests/fixtures/canvas-parity'); const out = await esbuild({ entryPoints: [path.join(root, 'scripts/e2e-mobile-parity-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 tmp = path.join(os.tmpdir(), `wsb-mobile-${process.pid}.mjs`); fs.writeFileSync(tmp, out.outputFiles[0].text); const ent = await import(tmp); fs.rmSync(tmp, { force: true }); const project = JSON.parse(fs.readFileSync(path.join(fixDir, 'ols.project.json'), 'utf8')); const docs = { 'synthetic (navbar showMobileMenu unset = existing)': ent.renderSynthetic(undefined), 'synthetic (navbar showMobileMenu false)': ent.renderSynthetic(false), 'synthetic (navbar showMobileMenu true)': ent.renderSynthetic(true), }; project.pages_craft_state.forEach((pg, i) => { docs[`ols/${pg.slug} (regenerated)`] = ent.renderStored(project, i); }); for (const slug of ['index', 'about', 'contact', 'brands']) { docs[`ols/${slug} (stored body, current head)`] = ent.renderOldBodyNewHead(project, fs.readFileSync(path.join(fixDir, `ols.${slug}.html`), 'utf8')); } function measure() { const vis = (el) => { const r = el.getBoundingClientRect(); return getComputedStyle(el).display !== 'none' && (r.width > 0 || r.height > 0); }; const els = [...document.body.querySelectorAll('*')].filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName)); const boxes = els.map((el, i) => { const r = el.getBoundingClientRect(); return [i, el.tagName, Math.round(r.x * 10) / 10, Math.round(r.y * 10) / 10, Math.round(r.width * 10) / 10, Math.round(r.height * 10) / 10, getComputedStyle(el).display]; }); let maxRight = 0; let worst = ''; for (const el of els) { if (!vis(el)) continue; const r = el.getBoundingClientRect(); if (r.right > maxRight) { maxRight = r.right; worst = el.tagName + '.' + (el.className || '') + ':' + (el.textContent || '').slice(0, 20); } } // Multi-column layouts: ColumnLayout wrappers (class cols_*) -> do their column children share a row? const cols = [...document.querySelectorAll('[class^="cols_"]')].map((w) => { const kids = [...w.children].filter((c) => !['STYLE', 'SCRIPT'].includes(c.tagName)); const tops = new Set(kids.map((k) => Math.round(k.getBoundingClientRect().top))); return { id: w.id, n: kids.length, rows: tops.size, dir: getComputedStyle(w).flexDirection, minW: Math.min(...kids.map((k) => k.getBoundingClientRect().width)) }; }); const rowEls = [...document.querySelectorAll('div')].filter((d) => /flex-direction:\s*row/.test(d.getAttribute('style') || '') && /display:\s*flex/.test(d.getAttribute('style') || '') && !/^cols_/.test(d.className)); const rows = rowEls.map((d) => getComputedStyle(d).flexDirection); const nav = document.querySelector('nav'); let navRight = 0; if (nav) for (const el of [nav, ...nav.querySelectorAll('*')]) { if (vis(el)) navRight = Math.max(navRight, el.getBoundingClientRect().right); } const navInfo = nav ? { w: nav.getBoundingClientRect().width, h: Math.round(nav.getBoundingClientRect().height), scrollW: nav.scrollWidth, hamburger: !!nav.querySelector('.navbar-hamburger') && getComputedStyle(nav.querySelector('.navbar-hamburger')).display } : null; return { innerW: window.innerWidth, maxRight: Math.round(maxRight * 10) / 10, navRight: Math.round(navRight * 10) / 10, worst, cols, rows, navInfo, boxes }; } const results = {}; let fail = 0; const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } }; const baseline = process.env.MOBILE_PARITY_BASELINE ? JSON.parse(fs.readFileSync(process.env.MOBILE_PARITY_BASELINE, 'utf8')) : null; async function run(name, launcher, exe) { const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) }); results[name] = {}; for (const [docName, html] of Object.entries(docs)) { results[name][docName] = {}; for (const w of [390, 1280]) { const page = await browser.newPage({ viewport: { width: w, height: 900 } }); // Deterministic: no network (web fonts / CDN icons would race the measurement). await page.route(/^https?:/, (r) => r.abort()); await page.setContent(html, { waitUntil: 'load' }); await page.waitForTimeout(50); const m = await page.evaluate(measure); await page.close(); results[name][docName][w] = m; const tag = `[${name} ${w}] ${docName}`; if (w === 390) { // ols/index's FeaturesGrid stacks via its data-wsb-grid hook, which a body stored before the hook // existed does not carry: only that stored body is exempt (it is fixed by republishing). ok(m.navRight <= 390.5, `${tag}: navbar overflows, reaches x=${m.navRight}`); if (docName !== 'ols/index (stored body, current head)') ok(m.maxRight <= 390.5, `${tag}: horizontal overflow, content reaches x=${m.maxRight} (${m.worst})`); for (const c of m.cols) { const optOut = c.id === 'optout'; if (optOut) ok(c.rows === 1 || c.n === 1, `${tag}: opt-out columns should stay side by side (${JSON.stringify(c)})`); else ok(c.rows === c.n, `${tag}: columns not stacked (${JSON.stringify(c)})`); } } if (w === 1280 && baseline) { const b = baseline[name]?.[docName]?.[1280]; if (b) { const sameCount = b.boxes.length === m.boxes.length; ok(sameCount, `${tag}: element count ${b.boxes.length} -> ${m.boxes.length}`); let diffs = 0; if (sameCount) for (let i = 0; i < b.boxes.length; i++) { const bb = b.boxes[i]; const mm = m.boxes[i]; if (bb.slice(2, 6).some((v, j) => Math.abs(v - mm[2 + j]) > 0.5) || bb[6] !== mm[6]) { if (diffs++ < 3) console.log(' diff', JSON.stringify(bb), JSON.stringify(mm)); } } ok(diffs === 0, `${tag}: ${diffs} element boxes changed at desktop`); } } } } await browser.close(); } const only = process.env.MOBILE_ONLY; if (!only || only === 'chromium') await run('chromium', chromium, process.env.CHROMIUM_PATH); if (!only || only === 'firefox') await run('firefox', firefox, process.env.FIREFOX_PATH); if (process.env.MOBILE_PARITY_RECORD) fs.writeFileSync(process.env.MOBILE_PARITY_RECORD, JSON.stringify(results)); // Human-readable summary at 390. for (const [b, per] of Object.entries(results)) { for (const [d, r] of Object.entries(per)) { const m = r[390]; console.log(`${b.padEnd(8)} 390 ${d.padEnd(55)} maxRight=${String(m.maxRight).padEnd(7)} cols(rows/n)=${m.cols.map((c) => c.rows + '/' + c.n).join(',') || '-'} nav=${m.navInfo ? m.navInfo.h + 'h/' + m.navInfo.hamburger + '/right=' + m.navRight : '-'}`); } } console.log(fail ? `\n${fail} FAILED` : '\nall passed'); process.exit(fail ? 1 : 0);