- Tables inside HtmlBlocks are left alone on phones: display:block (the only way to make one scroll) shrank a fitting width:100% table's rows from 349px to 77px at 390. Wide tables stay clipped as before. The <pre> rule (max-width:100%;overflow-x:auto, no display change) and the media rules stay. - Browser suite: width:100% table still spans the screen (fails on cfeeef3's rule: first row 78px); the 10-column bare table moved to htmlwide (clipped, page never scrolls sideways). - e2e-color-system: default COLOR_BASE_COMMITab7a100->69fd34b, the first commit with the 2026.11.4 published head (ColumnLayout stacking + Navbar wrap) and none of the colour feature.ab7a100predates that head, so check 5 failed on main itself. This branch's own head change (mobile guard) is folded back by exact string, asserted present once, before comparing, so the check still proves the colour feature changed nothing else. Passes on 69fd34b; fails (as it should) againstab7a100. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
17 KiB
JavaScript
284 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 that fit a phone with the net (iframe, img, width:100% 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 (fixed width, px grid, 4x1fr grid, <style> flex cards,
|
|
* min-width, a 10-column bare table): 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; a width:100% table still spans the screen; a wide
|
|
* table (htmlwide) is clipped and never makes the page scroll sideways.
|
|
* - 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);
|
|
}
|
|
// A width:100% table still spans its container: its first row is as wide as the table's parent content box.
|
|
const t = document.getElementById('full-table');
|
|
if (t) {
|
|
let host = t.parentElement;
|
|
while (host && getComputedStyle(host).display === 'contents') host = host.parentElement;
|
|
const cs = getComputedStyle(host);
|
|
const inner = host.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
out.fullTableRow = Math.round(t.querySelector('tr').getBoundingClientRect().width);
|
|
out.fullTableSpans = Math.abs(t.querySelector('tr').getBoundingClientRect().width - inner) <= 2;
|
|
}
|
|
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`);
|
|
ok(pr.fullTableSpans, `${tag}: a width:100% table no longer spans the screen (first row ${pr.fullTableRow}px)`);
|
|
}
|
|
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);
|