443 lines
39 KiB
JavaScript
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; });
|