#!/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(''); 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(' { 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);