/** * Layout probe over CDP: horizontal-overflow + interaction checks at fixed viewports. * * Uses Node's built-in WebSocket client (Node >= 22) so nothing has to be installed. * * node scripts/probe-layout.mjs [baseUrl] * * Requires a headless Chrome with remote debugging and a static server on `baseUrl` * (default http://127.0.0.1:4321, i.e. `python -m http.server 4321 --directory dist`). * * Checks per page × width: * - documentElement.scrollWidth === innerWidth (no horizontal scroll) * - names every element whose right edge exceeds the viewport by > 1px (so a failure * is actionable instead of "something overflows") * - the /posts/ featured block collapses on the first keystroke and comes back when * the filter is cleared */ const BASE = process.argv[2] ?? 'http://127.0.0.1:4321'; const CDP = process.argv[3] ?? 'http://127.0.0.1:9333'; const PAGES = [ '/', '/posts/', '/zh/posts/', '/categories/', '/categories/case-studies/', '/zh/categories/case-studies/', '/categories/devops/', '/tags/docker/', '/about/', ]; const WIDTHS = [1920, 1280, 960, 768, 430, 390, 375, 320]; async function target(url) { const res = await fetch(`${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' }); if (!res.ok) throw new Error(`cannot create target: ${res.status} ${await res.text()}`); return res.json(); } function connect(wsUrl) { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); ws.addEventListener('open', () => resolve(ws)); ws.addEventListener('error', reject); }); } let nextId = 0; function send(ws, method, params = {}) { const id = ++nextId; return new Promise((resolve, reject) => { const onMessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.id !== id) return; ws.removeEventListener('message', onMessage); if (msg.error) reject(new Error(`${method}: ${msg.error.message}`)); else resolve(msg.result); }; ws.addEventListener('message', onMessage); ws.send(JSON.stringify({ id, method, params })); }); } async function evaluate(ws, expression) { const r = await send(ws, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, userGesture: true, }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.text); return r.result.value; } const OVERFLOW_JS = `(() => { const vw = window.innerWidth; const offenders = []; for (const el of document.querySelectorAll('body *')) { const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; if (r.right > vw + 1 || r.left < -1) { offenders.push({ tag: el.tagName.toLowerCase(), cls: (el.className && String(el.className).slice(0, 60)) || '', left: Math.round(r.left), right: Math.round(r.right), }); } } return { vw, scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, docScroll: document.documentElement.scrollHeight, offenders: offenders.slice(0, 6), }; })()`; const COLLAPSE_JS = `(async () => { const feat = document.getElementById('archive-featured'); const q = document.getElementById('archive-q'); const status = document.getElementById('archive-status-text'); const clear = document.getElementById('archive-clear'); if (!feat || !q) return { skipped: true }; const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const before = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() }; q.value = 'docker'; q.dispatchEvent(new Event('input', { bubbles: true })); await wait(420); const during = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim(), clearVisible: clear ? !clear.hidden : null, inert: feat.inert === true, visibleRows: [...document.querySelectorAll('.archive-row')].filter((r) => !r.hidden).length, }; clear?.click(); await wait(420); const after = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() }; return { before, during, after }; })()`; const results = []; const fails = []; for (const path of PAGES) { const t = await target('about:blank'); const ws = await connect(t.webSocketDebuggerUrl); await send(ws, 'Page.enable'); await send(ws, 'Runtime.enable'); for (const width of WIDTHS) { await send(ws, 'Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700, }); await send(ws, 'Page.navigate', { url: BASE + path }); await new Promise((r) => setTimeout(r, 700)); const m = await evaluate(ws, OVERFLOW_JS); const ok = m.scrollWidth <= m.clientWidth + 1; results.push({ path, width, ok, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders.length }); if (!ok || m.offenders.length) { fails.push({ path, width, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders }); } } // Interaction check only where the featured block exists (desktop + phone widths). if (path === '/posts/' || path === '/zh/posts/') { for (const width of [1280, 390]) { await send(ws, 'Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700, }); await send(ws, 'Page.navigate', { url: BASE + path }); await new Promise((r) => setTimeout(r, 700)); const c = await evaluate(ws, COLLAPSE_JS); results.push({ path, width, collapse: c }); if (!c.skipped) { const good = c.before.collapsed === 'false' && c.during.collapsed === 'true' && c.during.h === 0 && c.during.inert === true && c.during.clearVisible === true && /docker/.test(String(c.during.status)) === false && c.after.collapsed === 'false' && c.after.h > 100; if (!good) fails.push({ path, width, collapse: c }); else console.log(`collapse ok ${path} @${width}: ${c.before.status} → ${c.during.status} → ${c.after.status} (h ${c.before.h} → ${c.during.h} → ${c.after.h})`); } } } await send(ws, 'Page.close'); ws.close(); } const byWidth = results.filter((r) => r.ok !== undefined); const okCount = byWidth.filter((r) => r.ok).length; console.log(`\nviewport checks: ${okCount}/${byWidth.length} clean`); console.log(`pages × widths: ${PAGES.length} × ${WIDTHS.length}`); if (fails.length) { console.log('\nFAILURES:'); for (const f of fails) console.log(JSON.stringify(f)); process.exit(1); } console.log('no horizontal overflow, featured collapse verified');