igneum/tools/ci/overlap-check.mjs

443 lines
39 KiB
JavaScript

#!/usr/bin/env node
// The text-overlap sweep: renders every served page of the site, and the miner and wallet UIs through their mocks, in a
// headless Chromium at five widths in light and dark (the home hero at rest and at each of its three steps), reads every
// visible run of text and flags what a reader cannot read: text COVERED by another element (a pill over a caption, a label
// over a value, a card over its neighbour, text under the header), text CLIPPED by an overflow-hidden ancestor, and text
// running OUTSIDE the viewport. The owner found the first one on 7 October 2026: the home hero's step pill sat on the
// caption's second line ("Two thirds of the weight sign. The checkpoint locks.") at every desktop width.
//
// node tools/ci/overlap-check.mjs --self-test a fixture with one deliberate overlap of each kind is flagged, a
// clean one passes (the known-failed-first rule; runs before any sweep)
// node tools/ci/overlap-check.mjs --site [dir] the site tree (default site/), every *.html, the rewrites, the hero steps
// node tools/ci/overlap-check.mjs --apps [tree] the miner UI (tools/ui-mock/server.mjs) and the wallet UI
// (tools/ui-mock/wallet.mjs) from a repository tree (default this one);
// a UI directory absent from the tree is reported and skipped
// node tools/ci/overlap-check.mjs --previews <dir> the owner's preview set: twelve app screens, dark, 1280 and 900 wide, PNG
// options: --widths 390,768,1024,1280,1600 --themes light,dark --shots <dir> (a PNG per render) --json <file> (the findings)
// --jobs 4 (parallel pages) --only <substring> (page names) --local (never go to the box) --box N (which box)
//
// Where it runs: a browser is needed. The script resolves Playwright from this tree's node_modules, $IGNEUM_PLAYWRIGHT_DIR
// or /srv/builds/_bin/overlap (the box install, infra/build-server/overlap-browser.sh). Without one, and unless --local,
// it ships its inputs to a build box (rsync into /srv/builds/_overlap/<worktree>/, nothing heavy on the Mac) and runs
// there under nice 10, shots and JSON coming back; CI installs Playwright in the workflow and runs it in place.
//
// How a cover is decided: for every run of text, five points along its visible part are hit-tested (elementFromPoint,
// with pointer-events forced on so decorative captions are hit too); a point whose top element is neither the text's
// element, nor inside it, nor one of its ancestors is covered when something between that element and the common
// ancestor paints (a background, a border, an image, a canvas, or text of its own). A transparent wrapper over text is
// not a cover. A dialog or a toast that is MEANT to sit over the page (role=dialog, aria-modal, the update card, a toast)
// is a cover by design and is not flagged; the ALLOW table below carries each with its reason. Animations and
// transitions are frozen at their end state and smooth scrolling is off before measuring, so a reveal in flight is not a
// finding. Clipping by an ancestor with text-overflow: ellipsis is reported as "truncated", a note, not a failure.
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, dirname, resolve, extname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..', '..');
const args = process.argv.slice(2);
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : dflt; };
const has = (name) => args.includes(name);
const WIDTHS = String(opt('--widths', '390,768,1024,1280,1600')).split(',').map(Number);
const THEMES = String(opt('--themes', 'light,dark')).split(',');
const JOBS = Number(opt('--jobs', 4));
const ONLY = opt('--only', '');
const SHOTS = opt('--shots', '');
const JSON_OUT = opt('--json', '');
const HEIGHT_FOR = (w, app) => app ? (w <= 900 ? 700 : 800) : (w <= 390 ? 844 : w <= 768 ? 1024 : w <= 1024 ? 768 : w <= 1280 ? 800 : 900);
// covers that are meant to sit over the page: a match on the cover's selector path, with the reason
const ALLOW = [
{ re: /\[role="?dialog"?\]|\[aria-modal|#upd\b|\.upd-card|\.update-card/, why: 'the update card is one centred card over everything (miner and wallet, by design)' },
{ re: /\.toast\b|#toast\b/, why: 'a toast sits over the page for a few seconds' },
{ re: /\.nav-sheet\b/, why: 'the open menu sheet covers the page on purpose' },
{ re: /\.tip\b/, why: 'a chart tooltip follows the pointer over the chart' },
];
// ---------- the in-page measurer (serialised into the page; no closure over this file) ----------
async function measureInPage(opts) {
const tol = opts.tol || 2, vw = innerWidth, vh = innerHeight;
const st = document.createElement('style'); st.id = '__ov_style';
st.textContent = '*{pointer-events:auto!important}html{scroll-behavior:auto!important}*,*::before,*::after{transition:none!important;animation-duration:0s!important;animation-delay:0s!important}';
const skip = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TITLE', 'OPTION', 'SELECT', 'TEXTAREA', 'HEAD', 'META', 'LINK']);
const alpha = (c) => { if (!c || c === 'transparent') return 0; const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return 1; const p = m[1].split(/[\s,\/]+/).map(parseFloat); return p.length >= 4 ? p[3] : 1; };
const ownText = (el) => { for (const n of el.childNodes) if (n.nodeType === 3 && n.data.trim()) return true; return false; };
const paints = (el) => {
if (['IMG', 'CANVAS', 'VIDEO', 'PICTURE', 'IFRAME', 'INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName)) return true;
if (el.tagName === 'svg') return false; // an <svg> root hit with no shape under the point is an empty area; a filled shape is hit as itself
const cs = getComputedStyle(el);
if (cs.backgroundImage !== 'none' || alpha(cs.backgroundColor) > 0.02) return true;
for (const side of ['Top', 'Bottom', 'Left', 'Right']) if (parseFloat(cs['border' + side + 'Width']) > 0 && alpha(cs['border' + side + 'Color']) > 0.02) return true;
return ownText(el);
};
const visible = (el) => {
if (el.checkVisibility && !el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true })) return false; // closed <details>, display none, content-visibility
for (let e = el; e && e.nodeType === 1; e = e.parentElement) { if (e.hidden) return false; const cs = getComputedStyle(e); if (cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0 || cs.contentVisibility === 'hidden') return false; if (e.tagName === 'DETAILS' && !e.open && !(e.firstElementChild && e.firstElementChild.tagName === 'SUMMARY' && e.firstElementChild.contains(el))) return false; }
return true;
};
// the author's pointer-events, read before the override below: a cover with pointer-events none and no text of its own is
// decorative (a glow, a gradient, a vignette) and never hides text on purpose
const noPointer = new WeakSet(); for (const e of document.querySelectorAll('*')) if (getComputedStyle(e).pointerEvents === 'none') noPointer.add(e);
document.head.appendChild(st);
const sel = (el) => { const parts = []; for (let e = el, i = 0; e && e !== document.body && i < 4; e = e.parentElement, i++) { let s = e.tagName.toLowerCase(); if (e.id) s += '#' + e.id; else if (e.classList.length) s += '.' + [...e.classList].slice(0, 2).join('.'); parts.unshift(s); } return parts.join(' > '); };
const inter = (a, b) => ({ l: Math.max(a.l, b.l), t: Math.max(a.t, b.t), r: Math.min(a.r, b.r), b: Math.min(a.b, b.b) });
const findings = [], keys = new Set();
const add = (kind, el, by, text, q, how) => { const k = kind + '|' + sel(el) + '|' + (by ? sel(by) : '') + '|' + text.slice(0, 30); if (keys.has(k)) return; keys.add(k); findings.push({ kind, text, el: sel(el), by: by ? sel(by) : '', how, box: [Math.round(q.left), Math.round(q.top + scrollY), Math.round(q.width), Math.round(q.height)] }); };
const docH = Math.min(document.documentElement.scrollHeight, 30000);
let runs = 0;
for (let y0 = 0; y0 < docH; y0 += vh) {
const lastWindow = y0 + vh >= docH;
scrollTo(0, y0);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const visCache = new Map(), clipCache = new Map();
const clipChain = (el) => { // the ancestors (and the element) that clip or scroll, once per element per window
let c = clipCache.get(el); if (c) return c; c = [];
for (let a = el; a && a !== document.documentElement; a = a.parentElement) {
const acs = getComputedStyle(a); const ox = acs.overflowX, oy = acs.overflowY;
const cx = ox === 'hidden' || ox === 'clip', cy = oy === 'hidden' || oy === 'clip', scrolls = /auto|scroll/.test(ox + ' ' + oy);
if (!cx && !cy && !scrolls) continue;
if (a.clientWidth === 0 && a.clientHeight === 0) continue;
c.push({ a, cx, cy, scrolls, srOnly: a.clientWidth <= 1 || a.clientHeight <= 1 });
}
clipCache.set(el, c); return c;
};
let n;
while ((n = walker.nextNode())) {
if (!n.data.trim()) continue;
const el = n.parentElement; if (!el || skip.has(el.tagName)) continue;
let v = visCache.get(el); if (v === undefined) { v = !el.closest('select,textarea') && visible(el); if (v) { const cs = getComputedStyle(el); if (parseFloat(cs.fontSize) < 1 || alpha(cs.color) < 0.05) v = false; } visCache.set(el, v); }
if (!v) continue;
const cs = getComputedStyle(el);
const range = document.createRange(); range.selectNodeContents(n);
const text = n.data.replace(/\s+/g, ' ').trim().slice(0, 70);
for (const q of range.getClientRects()) {
if (q.width <= 1 || q.height <= 1 || q.bottom < 0 || q.top > vh) continue;
runs++;
let vis = { l: q.left, t: q.top, r: q.right, b: q.bottom }, clipper = null, cut = 0, srOnly = false;
for (const { a, cx, cy, scrolls, srOnly: sr } of clipChain(el)) {
if (sr) { srOnly = true; break; } // the visually-hidden pattern: a 1 px box for screen readers
const ab = a.getBoundingClientRect();
const box = { l: ab.left + a.clientLeft, t: ab.top + a.clientTop, r: ab.left + a.clientLeft + a.clientWidth, b: ab.top + a.clientTop + a.clientHeight };
if (scrolls && !cx && !cy) {
// a table wrapper or a <pre> scrolls sideways by design (no vertical scrolling of its own); a pane that scrolls
// vertically AND is wider than its box (the wallet's main, a settings page) hides text sideways
if (a.scrollHeight > a.clientHeight + tol && a.scrollWidth > a.clientWidth + tol && (vis.r > box.r + tol || vis.l < box.l - tol)) add('outside', el, a, text, q, Math.round(vis.r > box.r + tol ? vis.r - box.r : box.l - vis.l) + ' px past the edge of ' + sel(a) + ', a pane that scrolls both ways');
vis = inter(vis, box); continue;
}
const cutHere = Math.max(cx ? Math.max(box.l - vis.l, vis.r - box.r) : 0, cy ? Math.max(box.t - vis.t, vis.b - box.b) : 0);
if (cutHere > tol && cutHere > cut) { cut = cutHere; clipper = a; }
vis = inter(vis, { l: cx ? box.l : -1e9, t: cy ? box.t : -1e9, r: cx ? box.r : 1e9, b: cy ? box.b : 1e9 });
}
if (srOnly) continue;
if (vis.r - vis.l > 2 && (vis.l < -tol || vis.r > vw + tol)) add('outside', el, null, text, q, (vis.l < -tol ? Math.round(-vis.l) + ' px past the left edge' : Math.round(vis.r - vw) + ' px past the right edge') + ' at ' + vw + ' px');
if (clipper) {
const ccs = getComputedStyle(clipper);
const ellipsis = (ccs.textOverflow === 'ellipsis' || cs.textOverflow === 'ellipsis') && (ccs.whiteSpace === 'nowrap' || cs.whiteSpace === 'nowrap');
add(ellipsis ? 'truncated' : 'clipped', el, clipper, text, q, Math.round(cut) + ' px cut by ' + sel(clipper));
}
if (vis.r - vis.l < 2 || vis.b - vis.t < Math.min(q.height * 0.5, 8)) continue; // a sliver at a scroll pane's edge is scrolled away, not covered (the 1 px drawer bar under the miner's Earnings page, 7 Oct 2026)
const ym = (vis.t + vis.b) / 2; if (ym < 0 || ym > vh) continue;
const w = vis.r - vis.l; let covered = 0, by = null, pts = 0;
for (const f of [0.08, 0.3, 0.5, 0.7, 0.92]) {
const x = vis.l + w * f; if (x < 0 || x > vw) continue; pts++;
const top = document.elementFromPoint(x, ym);
if (!top || top === el || el.contains(top) || top.contains(el)) continue;
let p = top, painted = null, fixed = null;
while (p && !p.contains(el)) { const pcs = getComputedStyle(p); if (!fixed && (pcs.position === 'fixed' || pcs.position === 'sticky')) fixed = p; if (!painted && paints(p) && !(noPointer.has(p) && !ownText(p) && !/^(IMG|CANVAS|VIDEO)$/.test(p.tagName))) painted = p; p = p.parentElement; }
if (!painted) continue;
if (fixed) { // a bar that travels with the viewport: content scrolling under it is by design; what sits under it at rest (the top, first window) or at the end (the bottom, last window) is not
const fr = fixed.getBoundingClientRect(); const atTop = fr.top <= 2 && y0 === 0, atBottom = fr.bottom >= vh - 2 && lastWindow;
if (!atTop && !atBottom) continue;
}
covered++; if (!by) by = painted;
}
if (covered) add('covered', el, by, text, q, covered + ' of ' + pts + ' points under ' + sel(by));
}
}
}
scrollTo(0, 0);
const scrollW = document.documentElement.scrollWidth, wide = [];
if (scrollW > vw + tol) { // name the outermost elements that reach past the viewport (the cause of a sideways page)
for (const e of document.body.querySelectorAll('*')) { if (!visible(e)) continue; const r = e.getBoundingClientRect(); if (r.right > vw + tol && r.width > 2 && !(e.parentElement && e.parentElement.getBoundingClientRect().right > vw + tol && e.parentElement !== document.body)) wide.push({ el: sel(e), right: Math.round(r.right), width: Math.round(r.width) }); }
wide.sort((a, b) => b.right - a.right);
}
st.remove();
const status = document.getElementById('scene-status');
return { findings, runs, vw, vh, docH, scrollW, wide: wide.slice(0, 4), status: status ? status.textContent : '' };
}
// ---------- servers ----------
function freePort() { return new Promise((res) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); }); }
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff2': 'font/woff2', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain', '.xml': 'application/xml' };
function serveSite(dir) {
// the site as Vercel serves it: cleanUrls, the two rewrites, /api/* answering nothing (the pages show their rest state)
const rewrites = [[/^\/block\/[^/]+$/, '/block.html'], [/^\/address\/[^/]+$/, '/address.html'], [/^\/benchmarks$/, '/miners.html']];
return new Promise((res) => {
const srv = http.createServer((req, rs) => {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (p.startsWith('/api/')) { rs.writeHead(503, { 'Content-Type': 'application/json' }); return rs.end('{}'); }
for (const [re, to] of rewrites) if (re.test(p)) p = to;
if (p === '/') p = '/index.html';
let f = join(dir, p);
if (!extname(f) && existsSync(f + '.html')) f += '.html';
if (existsSync(f) && statSync(f).isDirectory()) f = join(f, 'index.html');
if (!f.startsWith(dir) || !existsSync(f) || statSync(f).isDirectory()) { const nf = join(dir, '404.html'); rs.writeHead(404, { 'Content-Type': 'text/html' }); return rs.end(existsSync(nf) ? readFileSync(nf) : 'not found'); }
rs.writeHead(200, { 'Content-Type': MIME[extname(f)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); rs.end(readFileSync(f));
});
srv.listen(0, '127.0.0.1', () => res({ port: srv.address().port, close: () => srv.close() }));
});
}
async function spawnMock(script, uiDir) {
const port = await freePort();
const child = spawn(process.execPath, [script, String(port)].concat(uiDir ? [uiDir] : []), { stdio: ['ignore', 'ignore', 'inherit'] });
for (let i = 0; i < 50; i++) { await new Promise((r) => setTimeout(r, 100)); const ok = await new Promise((r) => { const s = net.connect(port, '127.0.0.1'); s.on('connect', () => { s.destroy(); r(true); }); s.on('error', () => r(false)); }); if (ok) break; }
return { port, close: () => child.kill() };
}
// ---------- targets ----------
function siteTargets(dir, port) {
const base = `http://127.0.0.1:${port}`;
const pages = readdirSync(dir).filter((f) => f.endsWith('.html')).sort();
const out = [];
for (const f of pages) {
const name = f.replace(/\.html$/, '');
const path = name === 'index' ? '/' : name === '404' ? '/no-such-page' : '/' + name;
out.push({ surface: 'site', name: name === 'index' ? 'home' : name, url: base + path, app: false });
}
out.push({ surface: 'site', name: 'block-id', url: base + '/block/1234', app: false }, { surface: 'site', name: 'address-id', url: base + '/address/0x7e5f4552091a69125d5dfcb7b8c2659029395bdf', app: false });
if (existsSync(join(dir, 'verify', 'test.html'))) out.push({ surface: 'site', name: 'verify-test', url: base + '/verify/test.html', app: false });
// the home hero at rest and at each step: the step buttons pin the stage (and its caption) before the sweep
const home = out.find((t) => t.name === 'home'); if (home) home.steps = [0, 1, 2];
return out;
}
function minerTargets(port) {
const u = (q) => `http://127.0.0.1:${port}/t/mock/?${q}`;
const screens = [['setup-welcome', 'scenario=fresh&screen=welcome'], ['setup-cards', 'scenario=fresh&screen=cards'], ['setup-region', 'scenario=fresh&screen=region'], ['setup-address', 'scenario=fresh&screen=address'], ['cards-none', 'scenario=nocards&screen=cards']];
const pages = ['overview', 'cards', 'earnings', 'prove', 'settings'];
const scenarios = ['live', 'nvidia', 'syncing', 'nodedown', 'nocards', 'integrated', 'clock', 'paused', 'heat', 'intel', 'firstwait', 'firstblock', 'ladder', 'job', 'biglog'];
const out = screens.map(([n, q]) => ({ surface: 'miner', name: n, url: u(q), app: true }));
for (const s of scenarios) for (const p of pages) { if (s !== 'live' && s !== 'nvidia' && p !== 'overview' && p !== 'cards' && !(s === 'ladder' && p === 'earnings') && !(s === 'job' && p === 'settings')) continue; out.push({ surface: 'miner', name: `${s}-${p}`, url: u(`scenario=${s}&page=${p}`), app: true }); }
for (const k of ['available', 'downloading', 'ready', 'waiting', 'applying', 'urgent', 'manual', 'error', 'updated']) out.push({ surface: 'miner', name: `update-${k}`, url: u(`scenario=live&page=overview&update=${k}&card=1`), app: true });
out.push({ surface: 'miner', name: 'job-running', url: u('scenario=live&page=overview&job=running'), app: true }, { surface: 'miner', name: 'job-failed', url: u('scenario=live&page=overview&job=failed'), app: true });
return out;
}
function walletTargets(port) {
const u = (q) => `http://127.0.0.1:${port}/t/mock/?${q}`;
// the scenarios of tools/ui-mock/wallet.mjs (wallet-0.1.5): welcome, unlock, home, public, scanning, nonode, plain, empty, update
const out = [['lock', 'scenario=unlock'], ['lock-touch', 'scenario=unlock&bio=touch'], ['welcome', 'scenario=welcome'], ['create', 'scenario=welcome&screen=create'], ['import', 'scenario=welcome&screen=import'],
['home', 'scenario=home&page=home'], ['send', 'scenario=home&page=send'], ['receive', 'scenario=home&page=receive'], ['history', 'scenario=home&page=history'], ['settings', 'scenario=home&page=settings'],
['public-home', 'scenario=public&page=home'], ['public-settings', 'scenario=public&page=settings'], ['scanning-home', 'scenario=scanning&page=home'], ['nonode-home', 'scenario=nonode&page=home'], ['nonode-send', 'scenario=nonode&page=send'],
['plain-send', 'scenario=plain&page=send'], ['empty-home', 'scenario=empty&page=home'], ['empty-history', 'scenario=empty&page=history'], ['update-card', 'scenario=update&page=home'], ['update-downloading', 'scenario=home&page=home&update=downloading&card=1']];
return out.map(([n, q]) => ({ surface: 'wallet', name: n, url: u(q), app: true }));
}
// ---------- the sweep ----------
async function loadPlaywright() {
const dirs = [root, process.env.IGNEUM_PLAYWRIGHT_DIR, '/srv/builds/_bin/overlap'].filter(Boolean);
for (const d of dirs) { try { const req = createRequire(join(d, 'package.json')); return req('playwright'); } catch (e) { /* next */ } }
return null;
}
async function renderAll(pw, targets, label) {
const browser = await pw.chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--font-render-hinting=none'] });
const jobs = [];
for (const t of targets) for (const w of WIDTHS) for (const theme of THEMES) {
if (t.app && w < 720) continue; // the apps are desktop windows; 720 px is their smallest layout
jobs.push({ t, w, theme });
}
const results = []; let next = 0, done = 0; const t0 = Date.now();
async function worker() {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 1, colorScheme: 'dark', reducedMotion: 'no-preference' });
const page = await ctx.newPage();
page.on('pageerror', () => {});
let curTheme = '';
while (next < jobs.length) {
const j = jobs[next++]; const { t, w, theme } = j;
if (theme !== curTheme) { await page.emulateMedia({ colorScheme: theme }); curTheme = theme; }
await page.setViewportSize({ width: w, height: HEIGHT_FOR(w, t.app) });
const url = t.app ? t.url + '&theme=' + theme : t.url;
try {
await page.goto(url, { waitUntil: 'load', timeout: 20000 });
await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {});
await page.waitForTimeout(t.app ? 700 : 300);
const variants = [{ step: 'rest', act: null }].concat((t.steps || []).map((s) => ({ step: 'step' + (s + 1), act: s })));
for (const v of variants) {
if (v.act !== null) { const b = page.locator(`#scene-steps [data-stage="${v.act}"]`); if (await b.count()) { await b.first().click({ force: true }); await page.waitForTimeout(80); } }
const m = await page.evaluate(measureInPage, { tol: 2 });
const key = `${t.surface}/${t.name} ${w}px ${theme}${v.step === 'rest' ? '' : ' ' + v.step}`;
if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: join(SHOTS, `${t.surface}__${t.name}__${w}__${theme}${v.step === 'rest' ? '' : '__' + v.step}.png`), fullPage: !t.app }).catch(() => {}); }
results.push({ key, surface: t.surface, name: t.name, width: w, theme, step: v.step, status: m.status, runs: m.runs, scrollW: m.scrollW, findings: m.findings });
}
} catch (e) { results.push({ key: `${t.surface}/${t.name} ${w}px ${theme}`, surface: t.surface, name: t.name, width: w, theme, step: 'rest', error: String(e.message || e).split('\n')[0], findings: [] }); }
done++;
}
await ctx.close();
}
await Promise.all(Array.from({ length: Math.min(JOBS, jobs.length) }, worker));
await browser.close();
return { results, secs: (Date.now() - t0) / 1000, renders: jobs.length, label };
}
function allowed(f) { return ALLOW.find((a) => a.re.test(f.by || '')); }
function report(run) {
const fails = [], notes = [], errors = [];
for (const r of run.results) {
if (r.error) { errors.push(`${r.key}: ${r.error}`); continue; }
if (r.scrollW > r.width + 2) fails.push(`${r.key}: the page scrolls sideways (${r.scrollW} px wide in a ${r.width} px viewport; past the edge: ${(r.wide || []).map((w) => w.el + ' to ' + w.right + ' px').join('; ') || 'no visible element, an overflowing pseudo-element or margin'})`);
for (const f of r.findings) {
const a = allowed(f);
const line = `${r.key}${r.status ? ' [' + r.status + ']' : ''}: ${f.kind.toUpperCase()} "${f.text}" in ${f.el} (${f.how})`;
if (a) notes.push(line + ' [allowed: ' + a.why + ']'); else if (f.kind === 'truncated') notes.push(line); else fails.push(line);
}
}
return { fails, notes, errors };
}
// ---------- the self-test: known failed first ----------
const FIXTURE_BAD = `<!doctype html><meta charset=utf-8><style>body{margin:0;font:14px/1.5 sans-serif;width:390px}
.scene{position:relative;height:300px;background:#222;color:#ddd}
.cap{position:absolute;left:12px;bottom:20px;max-width:52%}
.pill{position:absolute;left:50%;bottom:40px;transform:translateX(-50%);background:#444;border:1px solid #777;padding:8px 12px;white-space:nowrap}
.clip{overflow:hidden;height:21px;width:200px}.clip p{margin:0}
.far{position:absolute;left:600px;top:10px;white-space:nowrap}
.wrap{position:relative;height:40px}.label{position:absolute;left:0;top:0}.value{position:absolute;left:10px;top:4px}
</style>
<div class=scene><div class=cap><strong>03 / Locking</strong><br>Two thirds of the weight sign. The checkpoint locks. The caption runs on under the pill.</div><div class=pill>01 Mine 02 Prove 03 Lock</div></div>
<div class=clip><p>A first line that fits</p><p>A second line cut off by overflow hidden</p></div>
<div class=wrap><span class=label>Hash rate</span><span class=value>124 MH/s</span></div>
<p class=far>Text pushed past the right edge of the viewport</p>`;
const FIXTURE_GOOD = `<!doctype html><meta charset=utf-8><style>body{margin:0;font:14px/1.5 sans-serif;padding:16px;overflow-x:hidden}
.scene{position:relative;height:300px;background:#222;color:#ddd}.cap{position:absolute;left:12px;bottom:20px;max-width:52%}
.pill{position:absolute;left:50%;top:12px;transform:translateX(-50%);background:#444;border:1px solid #777;padding:8px 12px;white-space:nowrap}
.card{border:1px solid #999;padding:12px;margin:12px 0}.ell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:160px}
.behind{position:absolute;inset:0;z-index:1}
</style>
<div class=scene><div class=cap><strong>03 / Locking</strong><br>Two thirds of the weight sign. The checkpoint locks.</div><div class=pill>01 Mine 02 Prove 03 Lock</div><a class=behind href=#></a></div>
<div class=card><h2>A card</h2><p>Some words with <a href=#>a link</a> and <strong>bold</strong> inline.</p><p class=ell>A long single line that ends in an ellipsis by design</p></div>
<button>A button</button> <span>beside text</span>
<div style="position:fixed;left:0;right:0;bottom:0;height:120px;overflow:auto"><p style="margin:0;line-height:20px">line 1<br>line 2<br>line 3<br>line 4<br>line 5<br>line 6<br>line 7 scrolled half under the pane's edge<br>line 8<br>line 9</p></div>
<div style="position:fixed;left:0;right:0;bottom:0;height:0;border-top:1px solid #777;box-shadow:0 -12px 30px #000"></div>`;
async function selfTest(pw) {
const srv = http.createServer((req, rs) => { rs.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); rs.end(req.url.includes('good') ? FIXTURE_GOOD : FIXTURE_BAD); });
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port;
const savedW = WIDTHS.splice(0, WIDTHS.length, 390), savedT = THEMES.splice(0, THEMES.length, 'dark');
const run = await renderAll(pw, [{ surface: 'fixture', name: 'bad', url: `http://127.0.0.1:${port}/bad`, app: false }, { surface: 'fixture', name: 'good', url: `http://127.0.0.1:${port}/good`, app: false }], 'self-test');
WIDTHS.splice(0, WIDTHS.length, ...savedW); THEMES.splice(0, THEMES.length, ...savedT);
srv.close();
const bad = run.results.find((r) => r.name === 'bad'), good = run.results.find((r) => r.name === 'good');
const kinds = (r) => new Set(r.findings.filter((f) => !allowed(f)).map((f) => f.kind));
const problems = [];
if (!bad || bad.error) problems.push('the bad fixture did not render: ' + (bad && bad.error));
else {
const k = kinds(bad);
if (!bad.findings.some((f) => f.kind === 'covered' && /Two thirds/.test(f.text) && /pill/.test(f.by))) problems.push('the pill over the caption was not flagged (covered)');
if (!bad.findings.some((f) => f.kind === 'clipped' && /second line/.test(f.text))) problems.push('the line under overflow hidden was not flagged (clipped)');
if (!bad.findings.some((f) => f.kind === 'outside' && /past the right/.test(f.text))) problems.push('the text past the viewport was not flagged (outside)');
if (!bad.findings.some((f) => f.kind === 'covered' && /Hash rate|124 MH/.test(f.text))) problems.push('the label over the value was not flagged (covered, text over text)');
if (!k.has('covered') || !k.has('clipped') || !k.has('outside')) problems.push('kinds found on the bad fixture: ' + [...k].join(','));
}
if (!good || good.error) problems.push('the good fixture did not render: ' + (good && good.error));
else { const f = good.findings.filter((x) => !allowed(x) && x.kind !== 'truncated'); if (f.length) problems.push('the clean fixture was flagged: ' + f.map((x) => x.kind + ' ' + JSON.stringify(x.text) + ' by ' + x.by).join('; ')); if (!good.findings.some((x) => x.kind === 'truncated')) problems.push('the ellipsis line was not noted as truncated');
if (good.findings.some((x) => /line [67]/.test(x.text) && x.kind === 'covered')) problems.push('a line scrolled under a pane edge with a 1 px bar was flagged as covered'); }
if (problems.length) { console.error('overlap-check self-test FAILED:\n ' + problems.join('\n ')); if (bad) console.error(JSON.stringify(bad.findings, null, 1)); return false; }
console.log(`overlap-check self-test passed: the pill over the caption, the clipped line, the label over the value and the text past the edge are flagged; the clean page passes, its ellipsis noted (${run.secs.toFixed(1)} s)`);
return true;
}
// ---------- the previews (the owner's set) ----------
async function previews(pw, dir, miner, wallet) {
mkdirSync(dir, { recursive: true });
const set = [];
if (miner) { const u = (q) => `http://127.0.0.1:${miner.port}/t/mock/?${q}`; set.push(['miner-setup', u('scenario=fresh&screen=welcome')], ['miner-mine-live', u('scenario=live&page=overview')], ['miner-earnings', u('scenario=live&page=earnings')], ['miner-prove', u('scenario=live&page=prove')], ['miner-settings', u('scenario=live&page=settings')], ['miner-update-card', u('scenario=live&page=overview&update=ready&card=1')]); }
if (wallet) { const u = (q) => `http://127.0.0.1:${wallet.port}/t/mock/?${q}`; set.push(['wallet-lock', u('scenario=unlock&bio=touch')], ['wallet-home', u('scenario=home&page=home')], ['wallet-send', u('scenario=home&page=send')], ['wallet-receive', u('scenario=home&page=receive')], ['wallet-settings', u('scenario=home&page=settings')], ['wallet-update-card', u('scenario=update&page=home')]); }
const browser = await pw.chromium.launch({ args: ['--no-sandbox', '--disable-gpu'] });
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2, colorScheme: 'dark' });
const page = await ctx.newPage(); const files = [];
for (const [name, url] of set) for (const [w, h] of [[1280, 800], [900, 700]]) {
await page.setViewportSize({ width: w, height: h });
await page.goto(url + '&theme=dark', { waitUntil: 'load', timeout: 20000 });
await page.waitForLoadState('networkidle', { timeout: 2500 }).catch(() => {}); await page.waitForTimeout(900);
const f = join(dir, `${name}__${w}.png`); await page.screenshot({ path: f }); files.push(f);
}
await browser.close();
console.log('previews: ' + files.length + ' files in ' + dir); return files;
}
// ---------- remote: ship the inputs to a box and run there ----------
function hostFile(box) { const b = join(os.homedir(), '.config', 'igneum', 'build-server'); return box === '1' ? b : b + '-' + box; }
async function runRemote() {
const box = String(opt('--box', '2'));
const hf = hostFile(box); if (!existsSync(hf)) { console.error(`overlap-check: no browser here and no box host file at ${hf}; install Playwright (npm i playwright && npx playwright install chromium) or run infra/build-server/run-from-mac.sh --box ${box} <ip>`); return 2; }
const host = readFileSync(hf, 'utf8').trim(); const key = join(os.homedir(), '.ssh', 'igneum_ed25519');
const wt = basename(root); const remote = `/srv/builds/_overlap/${wt}`;
const ssh = ['-i', key, '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new'];
const rs = (src, dst) => { const r = spawnSync('rsync', ['-a', '--delete', '-e', `ssh ${ssh.join(' ')}`, src.endsWith('/') ? src : src + '/', `${host}:${dst}/`], { stdio: 'inherit' }); if (r.status !== 0) throw new Error('rsync failed for ' + src); };
spawnSync('ssh', ssh.concat([host, `mkdir -p ${remote}/tools/ci ${remote}/tools/ui-mock ${remote}/app ${remote}/brand`]), { stdio: 'inherit' });
const files = ['tools/ci/overlap-check.mjs']; for (const f of files) spawnSync('scp', ['-q', '-i', key, join(root, f), `${host}:${remote}/${f}`], { stdio: 'inherit' });
const rargs = [];
if (has('--self-test')) rargs.push('--self-test');
const siteDir = opt('--site', ''); if (siteDir) { const d = resolve(siteDir === true ? join(root, 'site') : siteDir); rs(d, `${remote}/site`); rargs.push('--site', 'site'); }
const appsTree = opt('--apps', ''); const prev = opt('--previews', '');
if (appsTree || prev) {
const tree = resolve(appsTree && appsTree !== true ? appsTree : root);
rs(existsSync(join(tree, 'tools', 'ui-mock', 'server.mjs')) ? join(tree, 'tools', 'ui-mock') : join(root, 'tools', 'ui-mock'), `${remote}/tools/ui-mock`);
for (const d of ['app/igneum-app/ui', 'app/igneum-wallet/ui']) if (existsSync(join(tree, d))) { spawnSync('ssh', ssh.concat([host, `mkdir -p ${remote}/${d}`]), { stdio: 'inherit' }); rs(join(tree, d), `${remote}/${d}`); }
const coin = join(tree, 'brand', 'igneum-coin-1024.png'); if (existsSync(coin)) spawnSync('scp', ['-q', '-i', key, coin, `${host}:${remote}/brand/`], { stdio: 'inherit' });
if (appsTree) rargs.push('--apps', '.'); if (prev) rargs.push('--previews', 'previews');
}
for (const k of ['--widths', '--themes', '--jobs', '--only']) if (opt(k, '')) rargs.push(k, String(opt(k)));
if (SHOTS) rargs.push('--shots', 'shots'); if (JSON_OUT) rargs.push('--json', 'findings.json');
const cmd = `cd ${remote} && . /srv/builds/_bin/overlap/env.sh && nice -n 10 node tools/ci/overlap-check.mjs --local ${rargs.map((a) => `'${a}'`).join(' ')}`;
console.log(`overlap-check: no browser on this machine; running on box ${box} (${host}:${remote})`);
const r = spawnSync('ssh', ssh.concat([host, cmd]), { stdio: 'inherit' });
const back = (what, to) => spawnSync('rsync', ['-a', '-e', `ssh ${ssh.join(' ')}`, `${host}:${remote}/${what}`, to], { stdio: 'inherit' });
if (SHOTS) { mkdirSync(SHOTS, { recursive: true }); back('shots/', SHOTS); }
if (JSON_OUT) back('findings.json', JSON_OUT);
if (prev) { mkdirSync(prev, { recursive: true }); back('previews/', prev); console.log('previews copied to ' + resolve(prev)); }
return r.status === null ? 1 : r.status;
}
// ---------- main ----------
(async () => {
if (!has('--self-test') && !opt('--site', '') && !opt('--apps', '') && !opt('--previews', '')) { console.error('usage: overlap-check.mjs --self-test | --site [dir] | --apps [tree] | --previews <dir> [--widths ..] [--themes ..] [--shots dir] [--json file] [--local|--box N]'); process.exit(2); }
const pw = await loadPlaywright();
if (!pw) { if (has('--local')) { console.error('overlap-check: Playwright is not installed here (npm i playwright; npx playwright install chromium) and --local forbids the box'); process.exit(2); } process.exit(await runRemote()); }
let code = 0; const all = [];
if (has('--self-test')) { if (!(await selfTest(pw))) { process.exitCode = 1; return; } }
const closers = [];
try {
const appsTree = opt('--apps', ''); const prev = opt('--previews', '');
let miner = null, wallet = null;
if (appsTree || prev) {
const tree = resolve(appsTree && appsTree !== true ? appsTree : root);
// both mocks travel with the app tree (their scenarios match that UI): tools/ui-mock/server.mjs and wallet.mjs
const mockDir = existsSync(join(tree, 'tools', 'ui-mock', 'server.mjs')) ? join(tree, 'tools', 'ui-mock') : join(root, 'tools', 'ui-mock');
if (existsSync(join(tree, 'app', 'igneum-app', 'ui', 'index.html'))) { miner = await spawnMock(join(mockDir, 'server.mjs'), null); closers.push(miner.close); } else console.log('overlap-check: no app/igneum-app/ui in ' + tree + '; the miner UI is skipped');
if (existsSync(join(tree, 'app', 'igneum-wallet', 'ui', 'index.html')) && existsSync(join(mockDir, 'wallet.mjs'))) { wallet = await spawnMock(join(mockDir, 'wallet.mjs'), null); closers.push(wallet.close); } else console.log('overlap-check: no app/igneum-wallet/ui in ' + tree + '; the wallet UI is skipped');
if (prev) await previews(pw, resolve(prev), miner, wallet);
if (appsTree) { const targets = (miner ? minerTargets(miner.port) : []).concat(wallet ? walletTargets(wallet.port) : []).filter((t) => !ONLY || t.name.includes(ONLY)); if (targets.length) all.push(await renderAll(pw, targets, 'apps')); }
}
const siteDir = opt('--site', '');
if (siteDir) {
const dir = resolve(siteDir === true ? join(root, 'site') : siteDir);
const srv = await serveSite(dir); closers.push(srv.close);
const targets = siteTargets(dir, srv.port).filter((t) => !ONLY || t.name.includes(ONLY));
all.push(await renderAll(pw, targets, 'site'));
}
} finally { for (const c of closers) try { c(); } catch (e) { /* closed */ } }
if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(all, null, 1));
for (const run of all) {
const { fails, notes, errors } = report(run);
const renders = run.results.length, runs = run.results.reduce((a, r) => a + (r.runs || 0), 0);
console.log(`overlap-check (${run.label}): ${renders} renders, ${runs} text runs read, ${fails.length} overlaps, ${notes.length} notes, ${errors.length} render errors in ${run.secs.toFixed(1)} s`);
for (const n of notes) console.log(' note ' + n);
for (const e of errors) { console.log(' ERROR ' + e); code = 1; }
for (const f of fails) { console.log(' FAIL ' + f); code = 1; }
}
process.exitCode = code; // not process.exit: stdout to a pipe (ssh, the gate's log) is asynchronous and exit would drop the tail
})().catch((e) => { console.error('overlap-check: ' + (e.stack || e)); process.exitCode = 1; });