Files
site-builder/craft/scripts/e2e-mobile-guard.mjs
T
shadowdaoandClaude Opus 5.5 cfeeef37ce Mobile guard: HtmlBlock wrapper never gets a box; phone net is media + per-table/pre scroll only
Review of bb4fb75: overflow-x:auto on [data-wsb-html] forced overflow-y:auto (clipped dropdowns/shadows),
broke position:sticky inside customer HTML, and display:block!important merged multi-root blocks that are
direct flex/grid items. Now the wrapper stays display:contents on every viewport and no rule targets it;
at <=767px only its descendants are touched: media max-width:100% (img/video height:auto), and a table or
<pre> becomes its own sideways scroller (not when already inside an inline overflow wrapper). No
overflow-wrap:anywhere (it shrank min-content); body{overflow-wrap:break-word} inherits through the wrapper.
Deliberately wide customer markup (fixed widths, px grids) is clipped as before; the publish warning is
its guard, and its wording now says the excess is cut off. width= attributes are no longer flagged (they
size only media and tables, which the net handles). stripMediaBlocks lowercases once.

Browser suite: htmlblocks probes at every width (wrapper display:contents, sticky sticks, absolutely
positioned dropdown visible on top, multi-root block = separate flex items / grid cells, bare table
scrolls on its own); new htmlwide doc asserts wide blocks stay clipped and the page never scrolls sideways.
All probes fail on bb4fb75's net and pass now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:09:22 -08:00

276 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* Mobile guard: real-browser (Chromium AND Firefox, headless) regression suite for published pages.
* jsdom has no layout engine, so none of this can be asserted in vitest.
*
* npm run test:browser:mobile-guard
*
* Builds three representative documents with the REAL exporter (scripts/e2e-mobile-guard-entry.ts):
* components - Navbar with 8 links, hero, ColumnLayout 2/3/4/30-70, FeaturesGrid, Testimonials,
* NumberCounter, Gallery, PricingTable, ReviewsStrip, a 1600px image, long word + URL
* htmlblocks - customer HtmlBlocks the phone net makes fit (iframe, img, bare wide table, pre, URL), an
* intentional overflow-x:auto table, and probes: sticky bar, absolutely positioned dropdown,
* multi-root blocks as direct flex items / grid cells, collapsing margins, an inline run
* htmlwide - customer HtmlBlocks wider than a phone BY DESIGN (fixed width, px grid, 4x1fr grid, <style>
* flex cards, min-width): clipped as before, the publish warning is their guard
* and measures them at 360 / 390 / 768 / 1280.
*
* Asserted:
* - documentElement.scrollWidth <= viewport and the page cannot be scrolled sideways (every doc, every width)
* - body.scrollWidth <= viewport and the right edge of every visible element box and text line <= viewport,
* unless it sits inside a scroll/clip container that itself fits (reachable / intentionally clipped):
* components at every width, htmlblocks at <=767px (the HtmlBlock net is phone-only). htmlwide: the wide
* content is still clipped (not reflowed) at <=767px.
* - ColumnLayout rows stack (one column per row) at <=767px; component card grids are one column at
* <=767px and keep more than one column at 768 (tablet).
* - the Navbar fits the viewport and wraps its links onto more than one row at 360/390.
* - the intentional overflow-x:auto table still scrolls; the bare wide table scrolls on its own on phones.
* - htmlblocks probes, every width: the HtmlBlock wrapper is display:contents, sticky still sticks, the
* dropdown is visible on top, multi-root blocks stay separate flex items / grid cells.
* - desktop (1280) is unchanged by the mobile hooks: every element box AND the full-page screenshot are
* identical after removing them in the page (unwrap [data-wsb-html], drop data-wsb-grid).
* Optional cross-commit proof of the same thing:
* MOBILE_GUARD_RECORD=<file> write the 1280 boxes (run this on the base commit)
* MOBILE_GUARD_BASELINE=<file> assert 1280 boxes equal that recording
*
* Env: CHROMIUM_PATH / FIREFOX_PATH (when the Playwright-pinned builds are not installed),
* MOBILE_ONLY=chromium|firefox.
*/
import { chromium, firefox } from '@playwright/test';
import { build as esbuild } from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const out = await esbuild({
entryPoints: [path.join(root, 'scripts/e2e-mobile-guard-entry.ts')],
bundle: true, write: false, format: 'iife', platform: 'browser', jsx: 'automatic', logLevel: 'error',
define: { 'process.env.NODE_ENV': '"production"', __EDITOR_BUILD__: '"test"' },
});
const js = out.outputFiles[0].text;
const WIDTHS = [360, 390, 768, 1280];
const MOBILE_MAX = 767;
/** Runs in the page. */
function measure() {
const vw = window.innerWidth;
const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
// An ancestor (below body) that scrolls or clips horizontally and itself fits the viewport keeps its
// content reachable (auto/scroll) or deliberately hidden (hidden/clip): not page overflow.
const contained = (el) => {
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
if (getComputedStyle(a).overflowX !== 'visible' && a.getBoundingClientRect().right <= vw + 0.5) return true;
}
return false;
};
const label = (el) => {
const host = el.closest('[id]');
return `${el.tagName.toLowerCase()}${host ? ' in #' + host.id : ''} "${(el.textContent || '').trim().slice(0, 24)}"`;
};
let maxRight = 0; let worst = '';
const bump = (right, el) => { if (right > maxRight) { maxRight = right; worst = label(el); } };
for (const el of document.body.querySelectorAll('*')) {
if (skip.has(el.tagName)) continue;
const r = el.getBoundingClientRect();
if (!(r.width || r.height) || getComputedStyle(el).visibility === 'hidden' || contained(el)) continue;
bump(r.right, el);
}
const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
const el = n.parentElement;
if (!el || skip.has(el.tagName) || !n.textContent.trim() || contained(n)) continue;
const range = document.createRange(); range.selectNodeContents(n);
for (const r of range.getClientRects()) if (r.width) bump(r.right, el);
}
// Distinct rows among boxes: a box starts a new row when its top is below the previous row's bottom.
const rowsOf = (kids) => {
const rs = kids.map((k) => k.getBoundingClientRect()).filter((r) => r.height > 0).sort((a, b) => a.top - b.top);
let rows = 0; let bottom = -Infinity;
for (const r of rs) { if (r.top >= bottom - 1) { rows++; bottom = r.bottom; } else bottom = Math.max(bottom, r.bottom); }
return rows;
};
const realKids = (w) => [...w.children].filter((c) => !skip.has(c.tagName));
const cols = [...document.querySelectorAll('[class^="cols_"]')].map((w) => ({ n: realKids(w).length, rows: rowsOf(realKids(w)) }));
// Every CSS grid the first-party components emit (inline display:grid), keyed by its section.
const grids = [...document.querySelectorAll('div')].filter((d) => getComputedStyle(d).display === 'grid' && !d.closest('[data-wsb-html]') && !d.closest('[id^="hb"]')).map((g) => {
const kids = realKids(g).filter((k) => k.getBoundingClientRect().height > 0);
const firstTop = kids.length ? Math.round(kids[0].getBoundingClientRect().top) : 0;
return {
where: (g.closest('section')?.textContent || '').trim().slice(0, 30),
hook: g.hasAttribute('data-wsb-grid'),
// A grid of images (Gallery) shrinks its thumbnails instead of overflowing; it may keep its columns.
imagesOnly: kids.length > 0 && kids.every((k) => k.tagName === 'IMG' || k.querySelector('img')),
n: kids.length, perRow: kids.filter((k) => Math.round(k.getBoundingClientRect().top) === firstTop).length,
};
});
const nav = document.querySelector('nav');
let navRight = 0; let linkRows = null;
if (nav) {
for (const el of [nav, ...nav.querySelectorAll('*')]) { const r = el.getBoundingClientRect(); if (r.width || r.height) navRight = Math.max(navRight, r.right); }
const links = [...nav.querySelectorAll('.navbar-link, .navbar-cta')].filter((a) => a.getBoundingClientRect().height > 0);
linkRows = links.length ? rowsOf(links) : null; // null: links collapsed behind the hamburger
}
const sc = document.getElementById('intentional-scroll');
let scrolls = null;
if (sc) { sc.scrollLeft = 50; scrolls = sc.scrollWidth > sc.clientWidth && sc.scrollLeft > 0; sc.scrollLeft = 0; }
return {
vw, docSW: document.documentElement.scrollWidth, bodySW: document.body.scrollWidth,
maxRight: Math.round(maxRight * 10) / 10, worst, cols, grids, navRight: Math.round(navRight * 10) / 10, linkRows, scrolls,
};
}
/** Runs in the page: can the page be scrolled sideways? (scrollX after asking for 5000px.) */
function sidewaysScroll() {
const y = window.scrollY;
window.scrollTo(5000, y);
const x = window.scrollX;
window.scrollTo(0, y);
return x;
}
/** Runs in the page (htmlblocks doc): the net must not box, clip, unstick or merge customer HTML. */
function probes() {
const out = {};
out.wrapperDisplays = [...new Set([...document.querySelectorAll('[data-wsb-html]')].map((w) => getComputedStyle(w).display))];
// Multi-root block as direct grid items: two separate cells side by side.
const cells = [...document.querySelectorAll('.cell-probe')].map((c) => c.getBoundingClientRect());
out.cellsSeparate = cells.length === 2 && Math.abs(cells[0].top - cells[1].top) < 1 && cells[1].left > cells[0].right;
// Multi-root block as direct flex items: no box between the roots and the flex container.
const roots = [...document.querySelectorAll('.root-probe')];
const flexBox = roots[0]?.closest('[style*="display:flex"]');
out.rootsAreFlexItems = roots.length === 2 && roots.every((r) => {
let p = r.parentElement;
while (p && getComputedStyle(p).display === 'contents') p = p.parentElement;
return p === flexBox;
});
// Sticky: scroll 300px into its tall container; the bar must stay pinned at the top of the viewport.
const box = document.getElementById('sticky-box');
const bar = document.getElementById('sticky-probe');
if (box && bar) {
window.scrollTo(0, window.scrollY + box.getBoundingClientRect().top + 300);
out.stickyTop = Math.round(bar.getBoundingClientRect().top * 10) / 10;
window.scrollTo(0, 0);
}
// Absolutely positioned dropdown hanging below its block: its last item must be visible and on top.
const last = document.getElementById('dropdown-last');
if (last) {
// window.scrollTo, not scrollIntoView: scrollIntoView would also scroll a clipping ancestor to reveal it.
window.scrollTo(0, window.scrollY + last.getBoundingClientRect().top - 200);
const r = last.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + Math.min(10, r.width / 2), r.top + r.height / 2);
out.dropdownVisible = !!hit && (hit === last || last.contains(hit));
window.scrollTo(0, 0);
}
// Wide bare table: it scrolls by itself, so its last cell is reachable.
const t = document.getElementById('bare-table');
if (t) { t.scrollLeft = 10000; out.tableScrolls = t.scrollWidth > t.clientWidth && t.scrollLeft > 0; t.scrollLeft = 0; }
return out;
}
/** Runs in the page. Element boxes, ignoring display:contents hook wrappers (they have no box). */
function boxes() {
return [...document.body.querySelectorAll('*')]
.filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName) && !e.hasAttribute('data-wsb-html'))
.map((e) => { const r = e.getBoundingClientRect(); return [e.tagName, ...[r.x, r.y, r.width, r.height].map((v) => Math.round(v * 10) / 10)].join(','); });
}
/** Runs in the page: remove the mobile hooks (unwrap the HtmlBlock wrapper, drop the grid hook). */
function stripHooks() {
for (const w of [...document.querySelectorAll('[data-wsb-html]')]) w.replaceWith(...w.childNodes);
for (const g of document.querySelectorAll('[data-wsb-grid]')) g.removeAttribute('data-wsb-grid');
}
let fail = 0;
const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } };
const baseline = process.env.MOBILE_GUARD_BASELINE ? JSON.parse(fs.readFileSync(process.env.MOBILE_GUARD_BASELINE, 'utf8')) : null;
const record = {};
const summary = [];
async function run(name, launcher, exe) {
const browser = await launcher.launch({ executablePath: exe || undefined, ...(name === 'chromium' ? { args: ['--no-sandbox'] } : {}) });
const gen = await browser.newPage();
await gen.setContent('<!doctype html><html><body></body></html>');
await gen.addScriptTag({ content: js });
const docs = await gen.evaluate(() => window.wsbMobileGuardDocs());
await gen.close();
record[name] = {};
for (const [docName, html] of Object.entries(docs)) {
for (const w of WIDTHS) {
const page = await browser.newPage({ viewport: { width: w, height: 900 } });
await page.route(/^https?:/, (r) => r.abort()); // deterministic: no fonts/CDN racing the layout
await page.setContent(html, { waitUntil: 'load' });
await page.waitForTimeout(50);
const m = await page.evaluate(measure);
const tag = `[${name} ${w}] ${docName}`;
const mobile = w <= MOBILE_MAX;
summary.push(`${name.padEnd(8)} ${String(w).padEnd(4)} ${docName.padEnd(11)} docSW=${m.docSW} bodySW=${m.bodySW} maxRight=${m.maxRight}${m.maxRight > w + 0.5 ? ' (' + m.worst + ')' : ''} cols=${m.cols.map((c) => c.rows + '/' + c.n).join(',') || '-'} grids/row=${m.grids.map((g) => g.perRow + '/' + g.n + (g.hook ? 'h' : '')).join(',') || '-'} nav=${m.navRight}/${m.linkRows}rows${m.scrolls === null ? '' : ' scroller=' + m.scrolls}`);
ok(m.docSW <= w, `${tag}: documentElement.scrollWidth ${m.docSW} > ${w}`);
const sx = await page.evaluate(sidewaysScroll);
ok(sx === 0, `${tag}: the page scrolls sideways (scrollX=${sx})`);
if (docName === 'htmlwide') {
// Wider than a phone by design (fixed widths, px grids): the net does not reflow or box it, so it stays
// clipped by html,body{overflow-x:clip} exactly as before (the publish warning is the guard for these),
// and the page never scrolls sideways (asserted above).
if (mobile) ok(m.maxRight > w + 0.5, `${tag}: the wide fixed block was expected to stay clipped (content ends at x=${m.maxRight})`);
} else if (docName === 'components' || mobile) {
// Customer HTML the net makes fit is held to the viewport only where the net applies (<=767px).
ok(m.bodySW <= w, `${tag}: body.scrollWidth ${m.bodySW} > ${w}`);
ok(m.maxRight <= w + 0.5, `${tag}: content reaches x=${m.maxRight} (clipped on a real device): ${m.worst}`);
}
ok(m.navRight <= w + 0.5, `${tag}: navbar reaches x=${m.navRight}`);
if (docName === 'components' && w <= 390) ok(m.linkRows > 1, `${tag}: navbar links did not wrap (${m.linkRows} row)`);
if (docName === 'htmlblocks' && mobile) ok(m.linkRows === null, `${tag}: hamburger navbar shows its links on a phone`);
// The 1000px table only needs to scroll where it is wider than its box.
if (m.scrolls !== null && w <= 768) ok(m.scrolls, `${tag}: the intentional overflow-x:auto table no longer scrolls`);
if (docName === 'htmlblocks') {
const pr = await page.evaluate(probes);
summary.push(`${' '.repeat(26)}probes ${JSON.stringify(pr)}`);
ok(pr.wrapperDisplays.length === 1 && pr.wrapperDisplays[0] === 'contents', `${tag}: HtmlBlock wrapper has a box (display ${pr.wrapperDisplays})`);
ok(pr.cellsSeparate, `${tag}: a multi-root HtmlBlock in a grid no longer fills separate cells`);
ok(pr.rootsAreFlexItems, `${tag}: a multi-root HtmlBlock in a flex row is no longer separate flex items`);
ok(pr.stickyTop !== undefined && Math.abs(pr.stickyTop) <= 1, `${tag}: position:sticky inside an HtmlBlock stopped sticking (top=${pr.stickyTop})`);
ok(pr.dropdownVisible, `${tag}: an absolutely positioned dropdown inside an HtmlBlock is clipped or covered`);
if (mobile) ok(pr.tableScrolls, `${tag}: the wide bare table does not scroll on its own`);
}
if (docName === 'components') {
for (const c of m.cols) ok(mobile ? c.rows === c.n : c.rows === 1, `${tag}: ColumnLayout ${mobile ? 'not stacked' : 'stacked'} (${c.rows} rows / ${c.n} cols)`);
for (const g of m.grids) {
if (g.n < 2) continue;
if (mobile && !g.imagesOnly) ok(g.perRow === 1, `${tag}: grid "${g.where}" keeps ${g.perRow} columns on a phone`);
if (!mobile && g.hook) ok(g.perRow > 1, `${tag}: grid "${g.where}" lost its columns (${g.perRow} per row)`);
}
}
if (w === 1280) {
const before = await page.evaluate(boxes);
const shotBefore = await page.screenshot({ fullPage: true });
await page.evaluate(stripHooks);
await page.waitForTimeout(20);
const after = await page.evaluate(boxes);
const shotAfter = await page.screenshot({ fullPage: true });
ok(shotBefore.equals(shotAfter), `${tag}: full-page pixels differ with vs without the mobile hooks`);
const diffs = before.length !== after.length ? Infinity : before.filter((b, i) => b !== after[i]).length;
ok(diffs === 0, `${tag}: ${diffs} element boxes differ with vs without the mobile hooks (${before.length}/${after.length} elements)`);
record[name][docName] = after;
const b = baseline?.[name]?.[docName];
if (b) {
const d = b.length !== before.length ? Infinity : b.filter((x, i) => x !== before[i]).length;
if (d) for (let i = 0, shown = 0; i < Math.min(b.length, before.length) && shown < 3; i++) if (b[i] !== before[i]) { shown++; console.log(' diff', b[i], '->', before[i]); }
ok(d === 0, `${tag}: ${d} element boxes differ from the recorded baseline (${b.length} -> ${before.length} elements)`);
}
}
await page.close();
}
}
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_GUARD_RECORD) fs.writeFileSync(process.env.MOBILE_GUARD_RECORD, JSON.stringify(record));
console.log('\n' + summary.join('\n'));
console.log(fail ? `\n${fail} FAILED` : '\nall passed');
process.exit(fail ? 1 : 0);