49 lines
6.2 KiB
JavaScript
49 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Lateral scroll (the project lead, 7 October 2026: "the website big container also has some lateral scroll movement that needs fixing").
|
|
// Renders every route at five widths in both themes and fails when document.documentElement.scrollWidth exceeds clientWidth:
|
|
// the page itself must never scroll sideways; only a table, code or diagram may scroll inside its own overflow-x container.
|
|
// On a failure it names the widest elements that run past the viewport, so the cause is fixed rather than the body clipped.
|
|
// node tools/ci/scroll-width-check.mjs --self-test a fixture with a 100vw band plus 20 px must fail; the clean one passes
|
|
// node tools/ci/scroll-width-check.mjs --site [dir] every *.html of the site (default site/)
|
|
// options: --widths 390,768,1024,1280,1440 --themes dark,light --only <substring>
|
|
// Needs a browser: Playwright from $IGNEUM_PLAYWRIGHT_DIR, /srv/builds/_bin/overlap (the box) or this tree's node_modules;
|
|
// without one it says so and exits 0 (the box and CI have it; the Mac renders nothing). Pages are served by tools/site/serve.mjs.
|
|
import { readdirSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { join, dirname } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawn } from 'node:child_process';
|
|
import http from 'node:http';
|
|
const here = dirname(fileURLToPath(import.meta.url)), repo = join(here, '..', '..');
|
|
const args = process.argv.slice(2);
|
|
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
|
|
const WIDTHS = opt('--widths', '390,768,1024,1280,1440').split(',').map(Number), THEMES = opt('--themes', 'dark,light').split(','), ONLY = opt('--only', '');
|
|
function playwright() { for (const root of [process.env.IGNEUM_PLAYWRIGHT_DIR, '/srv/builds/_bin/overlap', repo].filter(Boolean)) { try { return createRequire(join(root, 'package.json'))('playwright'); } catch (e) { } } return null; }
|
|
const pw = playwright();
|
|
if (!pw) { console.log('scroll-width-check: no Playwright here (the box and CI have it); nothing checked'); process.exit(0); }
|
|
const INSPECT = `(() => { const de = document.documentElement, sw = de.scrollWidth, cw = de.clientWidth; const wide = []; if (sw > cw) { for (const el of document.querySelectorAll('body *')) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > cw + 1 && !el.closest('[style*="overflow-x"],.tbl,pre,.diagram,.scroll-x')) { const cs = getComputedStyle(el); if (cs.position === 'fixed') continue; wide.push({ right: Math.round(r.right), width: Math.round(r.width), what: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.') : '') }); } } wide.sort((a, b) => b.right - a.right); } return { sw, cw, wide: wide.slice(0, 4) }; })()`;
|
|
async function serve(dir) { const port = 4700 + Math.floor(Math.random() * 200); const child = spawn(process.execPath, [join(repo, 'tools', 'site', 'serve.mjs'), String(port), dir, 'http://127.0.0.1:1'], { stdio: 'ignore' }); await new Promise((res, rej) => { let n = 0; const t = setInterval(() => { http.get(`http://127.0.0.1:${port}/`, () => { clearInterval(t); res(); }).on('error', () => { if (++n > 80) { clearInterval(t); rej(new Error('serve.mjs did not answer')); } }); }, 100); }); return { port, child }; }
|
|
async function sweep(dir, routes) {
|
|
const { port, child } = await serve(dir); const browser = await pw.chromium.launch(); const bad = []; let n = 0;
|
|
try {
|
|
for (const w of WIDTHS) { const ctx = await browser.newContext({ viewport: { width: w, height: w < 500 ? 844 : 900 }, deviceScaleFactor: 1, reducedMotion: 'reduce' }); const page = await ctx.newPage();
|
|
for (const t of THEMES) for (const route of routes) { await page.goto(`http://127.0.0.1:${port}${route}?theme=${t}&motion=off`, { waitUntil: 'load', timeout: 30000 }); await page.waitForTimeout(150); const r = await page.evaluate(INSPECT); n++; if (r.sw > r.cw) bad.push(`${route} at ${w} ${t}: scrollWidth ${r.sw} > clientWidth ${r.cw}; widest: ${r.wide.map((x) => x.what + ' right ' + x.right).join(', ') || 'none found below body'}`); }
|
|
await ctx.close(); }
|
|
} finally { await browser.close(); child.kill(); }
|
|
return { bad, n };
|
|
}
|
|
const routes = (dir) => readdirSync(dir).filter((f) => f.endsWith('.html') && f !== '404.html' && (!ONLY || f.includes(ONLY))).map((f) => '/' + f.replace(/\.html$/, '').replace(/^index$/, ''));
|
|
if (args.includes('--self-test')) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'scroll-fixture-'));
|
|
const page = (band) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>fixture</title><style>body{margin:0}.band{${band}height:120px;background:#333}.tbl{overflow-x:auto}.tbl table{width:1600px}</style></head><body><div class="band"></div><div class="tbl"><table><tr><td>a table may scroll inside its own box</td></tr></table></div><p>text</p></body></html>`;
|
|
writeFileSync(join(dir, 'broken.html'), page('width:calc(100vw + 20px);')); writeFileSync(join(dir, 'clean.html'), page('width:100%;')); writeFileSync(join(dir, 'index.html'), page('width:100%;')); // serve.mjs answers / from index.html
|
|
const r = await sweep(dir, ['/broken', '/clean']); rmSync(dir, { recursive: true, force: true });
|
|
const brokenHit = r.bad.filter((b) => b.startsWith('/broken')).length, cleanHit = r.bad.filter((b) => b.startsWith('/clean')).length;
|
|
if (brokenHit !== WIDTHS.length * THEMES.length || cleanHit !== 0) { console.error('scroll-width-check self-test: the fixture did not behave', r.bad); process.exit(1); }
|
|
console.log(`scroll-width-check self-test: the 100vw + 20 px band fails at every width (${brokenHit}), the clean page and its scrolling table pass`); process.exit(0);
|
|
}
|
|
const dir = opt('--site', join(repo, 'site'));
|
|
const r = await sweep(dir, routes(dir));
|
|
if (r.bad.length) { console.error(`scroll-width-check: ${r.bad.length} of ${r.n} renders scroll sideways:`); for (const b of r.bad) console.error(' ' + b); process.exit(1); }
|
|
console.log(`scroll-width-check: ${r.n} renders, none scrolls sideways (${WIDTHS.join(', ')} wide, ${THEMES.join(' and ')})`);
|