Stored pages (generated at editor-save time) from before release 2026.11.5 carry FeaturesGrid / Testimonials / NumberCounter grids without data-wsb-grid, so a republish kept repeat(N,1fr) on phones (OLS home: body 594px wide at 360). The <=767px block now matches the exact inline styles those emitters stored (whole-attribute signatures; no spaces, as in git history and every stored OLS/TTM page). Gallery shares the prefix with a free gap and keeps its columns: the two colliding signatures carry :not(:has(> * > Gallery thumbnail)) in their own rule (a browser without :has drops only that rule). Customer grids never match. Mirrored byte-for-byte by WHP generateCompiledHTML (fix/legacy-grid-backend). Tests: vitest selector contract (legacy shapes match, == current emitters minus the hook, Gallery and customer grids never match, pretty == minified); head parity pins updated; new real-browser suite test:browser:legacy-grid (Chromium + Firefox, 360/390/768/1280) on the stored OLS home page + synthetic legacy grids; colors suite folds the new rules back like the mobile-guard ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
10 KiB
JavaScript
180 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Legacy card grids: real-browser (Chromium AND Firefox, headless) proof that the published head stacks card grids
|
|
* stored BEFORE the data-wsb-grid hook existed (no re-save needed), and changes nothing else.
|
|
*
|
|
* npm run test:browser:legacy-grid
|
|
*
|
|
* Documents (scripts/e2e-legacy-grid-entry.ts, real exporter head):
|
|
* ols - the OLS Digital home page exactly as stored on the site (tests/fixtures/legacy-grid: header +
|
|
* page + footer, no data-wsb-* attributes; its FeaturesGrid is the legacy inline repeat(3,1fr))
|
|
* olsResaved - the same page with the hook added, i.e. what a re-save stores today
|
|
* synthetic - every card-grid emitter in its stored form (FeaturesGrid, Testimonials 2/3/4, NumberCounter 4),
|
|
* Galleries whose gap collides with a card-grid signature (3 cols gap 32px + lightbox, 4 cols gap
|
|
* 24px) plus a default one, and an unwrapped customer grid (legacy HtmlBlock content)
|
|
* measured at 360 / 390 / 768 / 1280.
|
|
*
|
|
* Asserted (every doc, every width unless noted):
|
|
* - no horizontal overflow: body.scrollWidth <= viewport and the right edge of every visible element box and text
|
|
* line <= viewport (html,body{overflow-x:clip} hides overflow from documentElement.scrollWidth), and the page
|
|
* cannot be scrolled sideways
|
|
* - <=767px: every legacy card grid is one card per row; every Gallery keeps more than one column; the customer
|
|
* grid keeps its 3 columns. Discriminating signal: with the legacy rules removed from the head, the same
|
|
* legacy card grids are back to several per row (so the rules, not something else, stack them)
|
|
* - <=767px: the legacy OLS page is pixel-identical to its re-saved (hooked) form
|
|
* - 768 and 1280: every element box AND the full-page screenshot are identical with vs without the legacy rules
|
|
*
|
|
* Env: CHROMIUM_PATH / FIREFOX_PATH (when the Playwright-pinned builds are not installed), LEGACY_ONLY=chromium|firefox.
|
|
*/
|
|
import { chromium, firefox } from '@playwright/test';
|
|
import { build as esbuild } from 'esbuild';
|
|
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-legacy-grid-entry.ts')],
|
|
bundle: true, write: false, format: 'iife', platform: 'browser', jsx: 'automatic', logLevel: 'error',
|
|
loader: { '.html': 'text' },
|
|
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']);
|
|
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) => `${el.tagName.toLowerCase()} "${(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);
|
|
}
|
|
const grids = [...document.querySelectorAll('div')].filter((d) => getComputedStyle(d).display === 'grid').map((g) => {
|
|
const kids = [...g.children].filter((k) => !skip.has(k.tagName) && k.getBoundingClientRect().height > 0);
|
|
const firstTop = kids.length ? Math.round(kids[0].getBoundingClientRect().top) : 0;
|
|
const kind = g.closest('#customer') ? 'customer'
|
|
: g.querySelector(':scope > * > img[style^="width:100%;height:200px;object-fit:cover"]') ? 'gallery' : 'card';
|
|
return { kind, hook: g.hasAttribute('data-wsb-grid'), n: kids.length, perRow: kids.filter((k) => Math.round(k.getBoundingClientRect().top) === firstTop).length };
|
|
});
|
|
return { vw, docSW: document.documentElement.scrollWidth, bodySW: document.body.scrollWidth, maxRight: Math.round(maxRight * 10) / 10, worst, grids };
|
|
}
|
|
|
|
function sidewaysScroll() {
|
|
const y = window.scrollY;
|
|
window.scrollTo(5000, y);
|
|
const x = window.scrollX;
|
|
window.scrollTo(0, y);
|
|
return x;
|
|
}
|
|
|
|
function boxes() {
|
|
return [...document.body.querySelectorAll('*')]
|
|
.filter((e) => !['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName))
|
|
.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(','); });
|
|
}
|
|
|
|
let fail = 0;
|
|
const ok = (c, m) => { if (!c) { fail++; console.log(' FAIL', m); } };
|
|
const summary = [];
|
|
|
|
async function open(browser, html, w) {
|
|
const page = await browser.newPage({ viewport: { width: w, height: 900 } });
|
|
await page.route(/^https?:/, (r) => r.abort()); // deterministic: no fonts/CDN/images racing the layout
|
|
await page.setContent(html, { waitUntil: 'load' });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await page.waitForTimeout(50);
|
|
return page;
|
|
}
|
|
|
|
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.wsbLegacyGridDocs());
|
|
const css = await gen.evaluate(() => window.wsbResponsiveCss);
|
|
await gen.close();
|
|
// The legacy rules = everything between the [data-wsb-grid] rule and body{overflow-wrap...} in the head CSS.
|
|
const HOOK = '[data-wsb-grid]{grid-template-columns:minmax(0,1fr)!important}';
|
|
const legacy = css.slice(css.indexOf(HOOK) + HOOK.length, css.indexOf('body{overflow-wrap'));
|
|
ok(legacy.includes('[style^=') && legacy.includes(':has('), `[${name}] could not isolate the legacy rules from the head CSS: ${legacy}`);
|
|
const bodyOf = (h) => h.slice(h.indexOf('<body'));
|
|
ok(bodyOf(docs.olsResaved).includes('data-wsb-grid') && !bodyOf(docs.ols).includes('data-wsb'), `[${name}] fixture: ols must be hook-free and olsResaved hooked`);
|
|
const without = Object.fromEntries(Object.entries(docs).map(([k, v]) => {
|
|
ok(v.split(legacy).length === 2, `[${name}] ${k}: the head does not carry the legacy rules exactly once`);
|
|
return [k, v.replace(legacy, '')];
|
|
}));
|
|
|
|
for (const [docName, html] of Object.entries(docs)) {
|
|
for (const w of WIDTHS) {
|
|
const tag = `[${name} ${w}] ${docName}`;
|
|
const mobile = w <= MOBILE_MAX;
|
|
const page = await open(browser, html, w);
|
|
const m = await page.evaluate(measure);
|
|
const sx = await page.evaluate(sidewaysScroll);
|
|
ok(sx === 0, `${tag}: the page scrolls sideways (scrollX=${sx})`);
|
|
ok(m.bodySW <= w, `${tag}: body.scrollWidth ${m.bodySW} > ${w}`);
|
|
ok(m.maxRight <= w + 0.5, `${tag}: content reaches x=${m.maxRight}: ${m.worst}`);
|
|
|
|
const base = await open(browser, without[docName], w);
|
|
const mb = await base.evaluate(measure);
|
|
const fmt = (g) => g.map((x) => `${x.kind[0]}${x.perRow}/${x.n}`).join(',');
|
|
summary.push(`${name.padEnd(8)} ${String(w).padEnd(4)} ${docName.padEnd(10)} bodySW=${m.bodySW} maxRight=${m.maxRight} grids=${fmt(m.grids)} | without rules: bodySW=${mb.bodySW} maxRight=${mb.maxRight}${mb.maxRight > w + 0.5 ? ' (' + mb.worst + ')' : ''} grids=${fmt(mb.grids)}`);
|
|
|
|
const cards = m.grids.filter((g) => g.kind === 'card' && g.n > 1);
|
|
if (docName !== 'olsResaved') ok(cards.length > 0, `${tag}: no legacy card grid found (fixture broken)`);
|
|
if (mobile) {
|
|
for (const g of cards) ok(g.perRow === 1, `${tag}: a legacy card grid keeps ${g.perRow} per row on a phone`);
|
|
for (const g of m.grids.filter((x) => x.kind === 'gallery')) ok(g.perRow > 1, `${tag}: a Gallery lost its columns (${g.perRow} per row)`);
|
|
for (const g of m.grids.filter((x) => x.kind === 'customer')) ok(g.perRow === 3, `${tag}: the customer grid was reflowed (${g.perRow} per row)`);
|
|
if (docName !== 'olsResaved') {
|
|
const legacyCards = mb.grids.filter((g) => g.kind === 'card' && g.n > 1 && !g.hook);
|
|
ok(legacyCards.length > 0 && legacyCards.every((g) => g.perRow > 1), `${tag}: without the legacy rules the card grids were expected to keep their columns (${fmt(legacyCards)})`);
|
|
}
|
|
} else {
|
|
for (const g of cards) ok(g.perRow > 1, `${tag}: a card grid lost its columns at ${w} (${g.perRow} per row)`);
|
|
const [b1, b2] = [await page.evaluate(boxes), await base.evaluate(boxes)];
|
|
const diffs = b1.length !== b2.length ? Infinity : b1.filter((b, i) => b !== b2[i]).length;
|
|
ok(diffs === 0, `${tag}: ${diffs} element boxes differ with vs without the legacy rules`);
|
|
const [s1, s2] = [await page.screenshot({ fullPage: true }), await base.screenshot({ fullPage: true })];
|
|
ok(s1.equals(s2), `${tag}: full-page pixels differ with vs without the legacy rules`);
|
|
}
|
|
if (mobile && docName === 'ols') {
|
|
const resaved = await open(browser, docs.olsResaved, w);
|
|
const [s1, s2] = [await page.screenshot({ fullPage: true }), await resaved.screenshot({ fullPage: true })];
|
|
ok(s1.equals(s2), `${tag}: the legacy page differs from its re-saved (hooked) form on a phone`);
|
|
await resaved.close();
|
|
}
|
|
await base.close();
|
|
await page.close();
|
|
}
|
|
}
|
|
await browser.close();
|
|
}
|
|
|
|
const only = process.env.LEGACY_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);
|
|
console.log('\n' + summary.join('\n'));
|
|
console.log(fail ? `\n${fail} FAILED` : '\nall passed');
|
|
process.exit(fail ? 1 : 0);
|