The sweep renders every served page in a headless Chromium at 390, 768, 1024, 1280 and 1600 in light and dark (the hero at rest and at each step), reads every visible run of text and flags text covered by another element, clipped by overflow hidden, or past the viewport; a fixture with one deliberate overlap of each kind is flagged before any sweep is trusted. It runs on a build box when this machine has no browser (infra/build-server/overlap-browser.sh installs Chromium there without root). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
440 lines
38 KiB
JavaScript
440 lines
38 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 < 2) continue;
|
|
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>`;
|
|
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 (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; });
|