test(perf): the harness that turns "feels slow" into a number

Two scripts, any Lux surface, from a URL. mobile-sweep reads scrollWidth vs
clientWidth AND every element's box at 393x852 — a body that cannot scroll is
not proof the content fits, it is proof the content is unreachable. perf adds
4x CPU + Slow-3G over CDP and reports LCP with the element that won it, CLS, an
INP proxy, long tasks, and bytes by type.

First run against production, as the before number:

  explore.lux.network        overflowPx  clippedEls  worstRight  small<44
    /                                 0          64        1113        53
    /blocks                           4           6         397        15
    /txs                            823        2820        1216        14
    /accounts                         0           0           0        11

  exchange.lux.network
    /                                 0           5         402        17
    /swap                             0           0           0        18
    /explore                          0         445         927         7
    /positions                        0           0           0         6

/txs pushes a 393px phone sideways by 823px and puts 2820 elements off the side
of the screen; /explore on the exchange clips 445 of them into a body that will
not scroll to reach them.

Screenshots are gated on a positive assertion — more than 200 characters of
text — because a shot of a spinner, a login wall or an unstyled flash is worse
than no shot.
This commit is contained in:
2026-07-27 18:39:49 -07:00
parent 6865d8ead1
commit dee054786f
5 changed files with 227 additions and 0 deletions
+3
View File
@@ -1,4 +1,7 @@
ECDL
Kaniko
khtml
parsdao
platformvm
reqs
ttfb
+3
View File
@@ -33,6 +33,9 @@
],
// words - list of words to be always considered correct
"words": [
"KHTML",
"reqs",
"ttfb",
"platformvm",
"aatx",
"CLOB",
+34
View File
@@ -0,0 +1,34 @@
# perf — the two numbers a claim needs
An improvement without a before number is a claim. These two scripts produce the
before number, for any Lux surface, from a URL.
```
node tools/perf/mobile-sweep.mjs https://explore.lux.network "explore LIVE" "/,/blocks,/txs"
node tools/perf/perf.mjs https://explore.lux.network "explore LIVE" "/,/txs"
```
`mobile-sweep` is layout only, so it needs no throttling and runs a route per
second. It reports, at an iPhone 15 Pro logical viewport (393x852, DPR 3, touch):
- **overflowPx** — `scrollWidth - clientWidth` on the document. The page scrolls
sideways.
- **clippedEls** — elements whose box lies outside the viewport. This is the
number that matters when `overflowPx` is 0: a body that cannot scroll does not
mean the content fits, it means the content is *unreachable*. Read it with
`perf.mjs`'s containment check before calling it a defect — an element inside
an `overflow-x: auto` ancestor is scrollable, not clipped.
- **small<44** — interactive elements under 44px tall, the floor Apple and
Google both publish.
`perf.mjs` adds 4x CPU throttling and a Slow-3G network profile over CDP and
reports LCP (with the element that won it), CLS, an INP proxy, long-task count
and total blocking time, bytes by type, and the same layout audit. Pass
`--desktop` for 1440x900. `SHOT_DIR=… ` captures a screenshot, but only when the
page has more than 200 characters of text — a screenshot of a spinner or a login
wall is worse than no screenshot.
Both take a real origin, so they measure production the same way they measure a
`next start` or a static `out/` on localhost. Compare like with like: serve
local builds through something that sends brotli, or every local LCP will look
three times worse than the CDN it is bound for.
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Mobile layout sweep over many routes: horizontal overflow (scrollWidth vs
// clientWidth AND per-element bounding boxes, because a body with overflow
// hidden reports 0 while content is silently CLIPPED), sub-44px touch targets,
// and safe-area usage. Layout only, so no CPU/network throttling needed.
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';
const base = process.argv[2], label = process.argv[3];
const routes = process.argv[4].split(',');
const W = Number(process.env.VW || 393), H = Number(process.env.VH || 852);
const probe = `(() => {
const de = document.documentElement, vw = de.clientWidth;
const docOverflow = Math.max(de.scrollWidth, document.body.scrollWidth) - vw;
const out = [], small = [];
for (const el of document.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const cs = getComputedStyle(el);
if (cs.position === 'fixed' && r.width <= vw + 2) continue;
if (r.right > vw + 1 || r.left < -1) out.push({ tag: el.tagName.toLowerCase(), cls: String(el.className || '').slice(0, 80), l: Math.round(r.left), r: Math.round(r.right), w: Math.round(r.width) });
}
for (const el of document.querySelectorAll('a,button,[role="button"],input,select,summary,[role="tab"],[role="menuitem"],[role="switch"]')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0 || getComputedStyle(el).visibility === 'hidden') continue;
if (r.height < 44) small.push({ tag: el.tagName.toLowerCase(), t: (el.textContent || '').trim().slice(0, 24), w: Math.round(r.width), h: Math.round(r.height), cls: String(el.className || '').slice(0, 60) });
}
return { vw, docOverflow, clipped: out.length, worstRight: out.reduce((m, o) => Math.max(m, o.r), 0), offenders: out.slice(0, 6), small: small.length, smallSample: small.slice(0, 6), bodyChars: (document.body.innerText || '').length };
})()`;
const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 3, isMobile: true, hasTouch: true });
const page = await ctx.newPage();
const rows = [];
for (const route of routes) {
try {
await page.goto(base + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(700);
const r = await page.evaluate(probe);
rows.push({ route, ...r });
console.log(`${route.padEnd(42)} overflowPx ${String(r.docOverflow).padStart(5)} clippedEls ${String(r.clipped).padStart(4)} worstRight ${String(r.worstRight).padStart(5)} small<44 ${String(r.small).padStart(4)} chars ${r.bodyChars}`);
} catch (e) { console.log(`${route.padEnd(42)} FAIL ${String(e.message).slice(0, 60)}`); rows.push({ route, fail: String(e.message).slice(0, 120) }); }
}
await b.close();
const ok = rows.filter((r) => !r.fail);
console.log(`\n${label} @ ${W}x${H}: routes ${ok.length} with body overflow ${ok.filter((r) => r.docOverflow > 0).length} with clipped elements ${ok.filter((r) => r.clipped > 0).length} total clipped els ${ok.reduce((s, r) => s + r.clipped, 0)} total small targets ${ok.reduce((s, r) => s + r.small, 0)}`);
if (process.env.JSON_OUT) writeFileSync(process.env.JSON_OUT, JSON.stringify(rows, null, 2));
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env node
// Field-shaped lab measurement: LCP / CLS / INP-proxy under 4x CPU throttle and
// a Slow-3G network profile, plus a mobile-viewport overflow + touch-target audit.
//
// node perf.mjs <baseUrl> <label> <route>[,<route>...] [--desktop]
//
// Every capture asserts a POSITIVE string in the DOM first. A spinner, a login
// wall or an unstyled flash is not a measurement.
import { chromium } from 'playwright';
import { writeFileSync } from 'node:fs';
const base = process.argv[2];
const label = process.argv[3];
const routes = (process.argv[4] || '/').split(',');
const desktop = process.argv.includes('--desktop');
const SHOT_DIR = process.env.SHOT_DIR;
// iPhone 15 Pro logical viewport; DPR 3, real UA, touch on.
const MOBILE = { viewport: { width: 393, height: 852 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' };
const DESKTOP = { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 };
const SLOW3G = { offline: false, downloadThroughput: (400 * 1024) / 8, uploadThroughput: (400 * 1024) / 8, latency: 400 };
const collector = `
window.__pm = { lcp: 0, cls: 0, longTasks: [], lcpEl: '' };
new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__pm.lcp = e.startTime; window.__pm.lcpEl = (e.element && (e.element.tagName + (e.element.className ? '.' + String(e.element.className).slice(0,60) : ''))) || e.url || ''; } })
.observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__pm.cls += e.value; })
.observe({ type: 'layout-shift', buffered: true });
new PerformanceObserver((l) => { for (const e of l.getEntries()) window.__pm.longTasks.push(Math.round(e.duration)); })
.observe({ type: 'longtask', buffered: true });
`;
const audit = `(() => {
const de = document.documentElement, b = document.body;
const docOverflow = Math.max(de.scrollWidth, b.scrollWidth) - de.clientWidth;
const offenders = [];
const vw = de.clientWidth;
for (const el of document.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > vw + 1 || r.left < -1) {
const cs = getComputedStyle(el);
if (cs.position === 'fixed' && r.width <= vw + 2) continue;
offenders.push({ tag: el.tagName.toLowerCase(), cls: String(el.className || '').slice(0, 70), left: Math.round(r.left), right: Math.round(r.right), w: Math.round(r.width) });
}
}
// Touch targets: interactive leaves smaller than 44x44 CSS px.
const small = [];
for (const el of document.querySelectorAll('a,button,[role="button"],input,select,summary,[role="tab"],[role="menuitem"]')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (getComputedStyle(el).visibility === 'hidden') continue;
if (r.height < 44 || r.width < 24) small.push({ tag: el.tagName.toLowerCase(), t: (el.textContent||'').trim().slice(0,28), w: Math.round(r.width), h: Math.round(r.height), cls: String(el.className||'').slice(0,50) });
}
const scrollables = [...document.querySelectorAll('*')].filter(e => { const cs = getComputedStyle(e); return /auto|scroll/.test(cs.overflowY + cs.overflowX) && e.scrollHeight > e.clientHeight + 8; });
const noMomentum = scrollables.filter(e => getComputedStyle(e).webkitOverflowScrolling !== 'touch' && getComputedStyle(e).overscrollBehavior === 'auto').length;
return {
docOverflow, offenders: offenders.slice(0, 12), offenderCount: offenders.length,
smallTargets: small.slice(0, 12), smallCount: small.length,
scrollables: scrollables.length, noMomentum,
safeAreaUsed: /env\\(safe-area/.test([...document.styleSheets].map(s => { try { return [...s.cssRules].map(r => r.cssText).join(''); } catch { return ''; } }).join('')),
bodyChars: (document.body.innerText || '').length,
imgNoDims: [...document.images].filter(i => !i.getAttribute('width') && !i.style.aspectRatio && !getComputedStyle(i).aspectRatio.includes('/')).length,
imgCount: document.images.length,
};
})()`;
const rows = [];
const browser = await chromium.launch();
for (const route of routes) {
const ctx = await browser.newContext(desktop ? DESKTOP : MOBILE);
await ctx.addInitScript(collector);
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
await cdp.send('Network.enable');
await cdp.send('Network.emulateNetworkConditions', SLOW3G);
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
const bytes = { js: 0, css: 0, img: 0, font: 0, other: 0, reqs: 0 };
page.on('response', async (r) => {
bytes.reqs++;
const ct = (r.headers()['content-type'] || '').split(';')[0];
let n = Number(r.headers()['content-length'] || 0);
if (!n) { try { n = (await r.body()).length; } catch { n = 0; } }
if (/javascript/.test(ct)) bytes.js += n;
else if (/css/.test(ct)) bytes.css += n;
else if (/image|svg/.test(ct)) bytes.img += n;
else if (/font/.test(ct)) bytes.font += n;
else bytes.other += n;
});
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
const t0 = Date.now();
let status = 0;
try { const r = await page.goto(base + route, { waitUntil: 'domcontentloaded', timeout: 120000 }); status = r ? r.status() : 0; } catch (e) { errs.push('NAV ' + e.message); }
try { await page.waitForLoadState('networkidle', { timeout: 45000 }); } catch { /* slow-3g budget */ }
await page.waitForTimeout(2500);
const loadMs = Date.now() - t0;
// INP proxy: drive the first real control and measure event->next-paint.
let inp = null;
try {
const target = page.locator('button:visible, a[href]:visible').first();
await target.scrollIntoViewIfNeeded({ timeout: 3000 });
inp = await page.evaluate(async () => {
const el = document.querySelector('button, a[href]');
if (!el) return null;
const t = performance.now();
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
return Math.round(performance.now() - t);
});
} catch { /* nothing interactive */ }
const pm = await page.evaluate('window.__pm').catch(() => ({}));
const a = await page.evaluate(audit).catch((e) => ({ err: String(e) }));
const nav = await page.evaluate(() => { const n = performance.getEntriesByType('navigation')[0]; return n ? { ttfb: Math.round(n.responseStart), domInteractive: Math.round(n.domInteractive), load: Math.round(n.loadEventEnd) } : {}; });
const row = { route, status, label, viewport: desktop ? 'desktop-1440' : 'mobile-393',
lcp: Math.round(pm.lcp || 0), lcpEl: pm.lcpEl, cls: Number((pm.cls || 0).toFixed(4)),
inpProxy: inp, longTasks: (pm.longTasks || []).length, longTaskMs: (pm.longTasks || []).reduce((x, y) => x + y, 0),
loadMs, ...nav, bytes, errors: errs.length, errSample: [...new Set(errs)].slice(0, 3), ...a };
rows.push(row);
const kb = (n) => (n / 1024).toFixed(0);
console.log(`${label} ${row.viewport} ${route} :: HTTP ${status} LCP ${row.lcp}ms CLS ${row.cls} INPp ${inp}ms longTasks ${row.longTasks}/${row.longTaskMs}ms JS ${kb(bytes.js)}KB reqs ${bytes.reqs} overflow ${a.docOverflow}px/${a.offenderCount}el smallTargets ${a.smallCount} err ${errs.length} bodyChars ${a.bodyChars}`);
if (SHOT_DIR && a.bodyChars > 200) {
const name = `${label}-${desktop ? 'desk' : 'mob'}-${route.replace(/[^a-z0-9]/gi, '_') || 'root'}.png`;
await page.screenshot({ path: `${SHOT_DIR}/${name}`, fullPage: false });
row.shot = `${SHOT_DIR}/${name}`;
}
await ctx.close();
}
await browser.close();
if (process.env.JSON_OUT) { writeFileSync(process.env.JSON_OUT, JSON.stringify(rows, null, 2)); console.log('json ->', process.env.JSON_OUT); }