Add real-browser mobile parity test (Firefox + Chromium, 390/1280)
npm run test:browser:mobile; MOBILE_PARITY_RECORD/BASELINE compare desktop boxes against a base-commit snapshot. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e5492600e
commit
c4b6802cab
3 files changed
+205
No files matched your search
@@ -13,6 +13,7 @@
|
||||
"test:unit": "vitest run",
|
||||
"test:browser:bg": "node scripts/e2e-background.mjs",
|
||||
"test:browser:canvas": "node scripts/e2e-canvas-parity.mjs",
|
||||
"test:browser:mobile": "node scripts/e2e-mobile-parity.mjs",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Bundled by scripts/e2e-mobile-parity.mjs. Builds the documents the real-browser mobile test measures.
|
||||
// Uses only exports that exist before and after the mobile-parity fix so the SAME harness can record a baseline.
|
||||
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
|
||||
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type, custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
|
||||
});
|
||||
const LONG = 'Honest prices, straight answers and work that is finished when we say it will be, every time.';
|
||||
|
||||
/** ColumnLayout with `n` Container columns each holding one TextBlock. */
|
||||
function addColumns(nodes: Record<string, any>, id: string, props: Record<string, unknown>, n: number) {
|
||||
const linked: Record<string, string> = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = `${id}-c${i}`;
|
||||
const t = `${id}-t${i}`;
|
||||
nodes[c] = node('Container', { tag: 'div', style: { padding: '8px' } }, [t], { parent: id });
|
||||
nodes[t] = node('TextBlock', { text: `${LONG} (${id} col ${i + 1})`, style: { fontSize: '16px' } }, [], { parent: c });
|
||||
linked[`col-${i}`] = c;
|
||||
}
|
||||
nodes[id] = node('ColumnLayout', { columns: n, gap: '16px', anchorId: id, ...props }, [], { parent: 'ROOT', linkedNodes: linked });
|
||||
}
|
||||
|
||||
export const NAV_LINKS = ['Home', 'About us', 'Services', 'Our work', 'Pricing', 'Blog', 'Contact'].map((text, i, a) => ({
|
||||
text, href: '#' + text.toLowerCase().replace(/\s/g, '-'), ...(i === a.length - 1 ? { isCta: true } : {}),
|
||||
}));
|
||||
|
||||
export function syntheticState(navShowMobileMenu: boolean | undefined): string {
|
||||
const nodes: Record<string, any> = {};
|
||||
const kids: string[] = ['nav'];
|
||||
const navProps: Record<string, unknown> = { logoText: 'Acme Plumbing & Heating', links: NAV_LINKS, padding: '16px 24px' };
|
||||
if (navShowMobileMenu !== undefined) navProps.showMobileMenu = navShowMobileMenu;
|
||||
nodes.nav = node('Navbar', navProps, [], { parent: 'ROOT' });
|
||||
const specs: Array<[string, Record<string, unknown>, number]> = [
|
||||
['c2', { split: '50-50' }, 2],
|
||||
['centered', { split: '50-50', gap: '48px', style: { alignItems: 'center' } }, 2],
|
||||
['c3', { split: '33-33-33' }, 3],
|
||||
['c4', { split: '25-25-25-25' }, 4],
|
||||
['c6', { split: '16-16-16-16-16-16' }, 6],
|
||||
['sideL', { split: '30-70' }, 2],
|
||||
['sideR', { split: '70-30' }, 2],
|
||||
['optout', { split: '50-50', styleMobile: { flexDirection: 'row' } }, 2],
|
||||
];
|
||||
for (const [id, p, n] of specs) { addColumns(nodes, id, p, n); kids.push(id); }
|
||||
// An existing author-built flex row (already stacks via the global rule) must be unaffected.
|
||||
nodes.row = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['rt1', 'rt2'], { parent: 'ROOT' });
|
||||
nodes.rt1 = node('TextBlock', { text: 'Row item one', style: {} }, [], { parent: 'row' });
|
||||
nodes.rt2 = node('TextBlock', { text: 'Row item two', style: {} }, [], { parent: 'row' });
|
||||
kids.push('row');
|
||||
nodes.ROOT = node('Container', { tag: 'div', style: {} }, kids);
|
||||
return JSON.stringify(nodes);
|
||||
}
|
||||
|
||||
export function renderSynthetic(navShowMobileMenu: boolean | undefined): string {
|
||||
const state = syntheticState(navShowMobileMenu);
|
||||
const body = exportBodyHtml(state).html;
|
||||
return exportComposedPreviewHtml(state, body, { title: 'Synthetic', includeFonts: false });
|
||||
}
|
||||
|
||||
/** Stored OLS page rendered by the CURRENT frontend: regenerated body + current head. */
|
||||
export function renderStored(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 });
|
||||
}
|
||||
|
||||
/** The checked-in (OLD) stored body under the CURRENT head: "existing site, next publish". */
|
||||
export function renderOldBodyNewHead(project: any, storedHtml: string): string {
|
||||
const m = storedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
const body = m ? m[1] : storedHtml;
|
||||
return exportComposedPreviewHtml(project.pages_craft_state[0].craftState, body, { title: 'old', includeFonts: true, design: project.design });
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/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=<file> write a layout snapshot (run on the base commit to get a baseline)
|
||||
* MOBILE_PARITY_BASELINE=<file> 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 carries a customer HtmlBlock card grid that overflows on its own (out of scope here): for it,
|
||||
// only the navbar is held to the phone width.
|
||||
ok(m.navRight <= 390.5, `${tag}: navbar overflows, reaches x=${m.navRight}`);
|
||||
if (!docName.startsWith('ols/index')) 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);
|
||||
Reference in new issue
Block a user