Files
zeekay dee054786f 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.
2026-07-27 18:39:49 -07:00

140 lines
8.0 KiB
JavaScript

#!/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); }