mirror of
https://github.com/luxfi/explore.git
synced 2026-09-24 00:28:18 +00:00
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:
@@ -1,4 +1,7 @@
|
||||
ECDL
|
||||
Kaniko
|
||||
khtml
|
||||
parsdao
|
||||
platformvm
|
||||
reqs
|
||||
ttfb
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
],
|
||||
// words - list of words to be always considered correct
|
||||
"words": [
|
||||
"KHTML",
|
||||
"reqs",
|
||||
"ttfb",
|
||||
"platformvm",
|
||||
"aatx",
|
||||
"CLOB",
|
||||
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user