waybetter/dev-tools/perf-probe.mjs
CRT 5c64f2f6f8 dev-tools: the generic development harnesses (perf probes, close check, tab and webview stress)
Moved from wayBetter for X; an app passes its own origin, process name, scripts and fixtures.
2026-09-30 14:48:06 +07:00

189 lines
11 KiB
JavaScript

// Performance probe for what a wayBetter app injects into its site's tabs. Loads the app's
// document-start scripts (the tab bridge, its observer, its page runtime) as init scripts, like the
// native tab webview does, into headless Chromium and measures: script evaluation time, fetch/XHR
// wrapper layers and their synchronous overhead, Response clones and large JSON.parse calls made by
// the scripts, MutationObserver callbacks / rAF / timer work attributed to them, and main-thread
// long tasks. The app supplies the scripts and its scenarios (fixtures that intercept every request
// locally, or a live page); see wayBetter for X's scripts/perf-probe.mjs.
//
// import { chromium } from 'playwright';
// import { runPerfProbe } from '<waybetter>/dev-tools/perf-probe.mjs';
// const results = await runPerfProbe({ chromium, scripts: [{ name: 'runtime', code }], seconds: 8, runs: 3,
// nativeReplies: { some_command: [] },
// scenarios: [{ name: 'fixture /home', url: 'https://site.test/home', route: async context => { … } }] });
//
// Numbers are headless Chromium, not WebView2; compare runs on the same machine only. PROFILE=1
// adds the scripts' self time per function.
// ------------------------------------------------------------------------------------------ probe
function prelude(names, replies) {
// Work is "ours" when one of the probed scripts is on the stack (they are evaluated as bt-<name>.js).
const pattern = new RegExp('bt-(?:' + names.join('|') + ')\\.js');
const ours = () => pattern.test(new Error().stack || '');
const P = window.__probe = {
evals: [], fetchSets: 0, xhrOpenSets: 0, xhrSendSets: 0, fetchCalls: 0, fetchOverheadMs: 0, xhrCalls: 0,
clones: 0, cloneReads: 0, bigParses: 0, bigParseMs: 0, bigParseBytes: 0,
mo: { ours: 0, oursMs: 0, oursObservers: 0, page: 0 }, raf: { ours: 0, oursMs: 0 }, timers: { ours: 0, oursMs: 0, intervals: 0 },
longtasks: [], outerStart: 0,
};
const nativeFetch = window.fetch;
let current = function probeInner(...a) {
if (P.outerStart) { P.fetchOverheadMs += performance.now() - P.outerStart; P.outerStart = 0; }
return Reflect.apply(nativeFetch, this, a);
};
Object.defineProperty(window, 'fetch', {
configurable: true, get: () => current,
set: value => { if (ours()) P.fetchSets++; current = value; },
});
const xproto = XMLHttpRequest.prototype;
for (const [key, counter] of [['open', 'xhrOpenSets'], ['send', 'xhrSendSets']]) {
let fn = xproto[key];
Object.defineProperty(xproto, key, { configurable: true, get: () => fn, set: v => { if (ours()) P[counter]++; fn = v; } });
}
const clone = Response.prototype.clone;
const cloned = new WeakSet();
Response.prototype.clone = function () { P.clones++; const c = clone.call(this); cloned.add(c); return c; };
for (const reader of ['text', 'json', 'arrayBuffer']) {
const orig = Response.prototype[reader];
Response.prototype[reader] = function () { if (cloned.has(this)) P.cloneReads++; return orig.call(this); };
}
const parse = JSON.parse;
JSON.parse = function (text, reviver) {
if (typeof text === 'string' && text.length > 20000 && ours()) {
const t = performance.now();
try { return parse.call(this, text, reviver); } finally { P.bigParses++; P.bigParseMs += performance.now() - t; P.bigParseBytes += text.length; }
}
return parse.call(this, text, reviver);
};
const MO = window.MutationObserver;
window.MutationObserver = class extends MO {
constructor(cb) {
const mine = ours();
super((records, obs) => {
if (!mine) { P.mo.page++; return cb(records, obs); }
const t = performance.now();
try { return cb(records, obs); } finally { P.mo.ours++; P.mo.oursMs += performance.now() - t; }
});
if (mine) P.mo.oursObservers++;
}
};
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = cb => {
if (!ours()) return raf(cb);
return raf(ts => { const t = performance.now(); try { cb(ts); } finally { P.raf.ours++; P.raf.oursMs += performance.now() - t; } });
};
for (const name of ['setTimeout', 'setInterval']) {
const orig = window[name].bind(window);
window[name] = (cb, ms, ...rest) => {
if (typeof cb !== 'function' || !ours()) return orig(cb, ms, ...rest);
if (name === 'setInterval') P.timers.intervals++;
return orig((...a) => { const t = performance.now(); try { cb(...a); } finally { P.timers.ours++; P.timers.oursMs += performance.now() - t; } }, ms, ...rest);
};
}
try {
new PerformanceObserver(list => { for (const e of list.getEntries()) P.longtasks.push(e.duration); })
.observe({ type: 'longtask', buffered: true });
} catch { /* unsupported */ }
// What the native app provides to site tabs: every command answers null unless `replies` names it.
const invoke = (cmd) => Promise.resolve(cmd in replies ? replies[cmd] : null);
window.__TAURI_INTERNALS__ = { invoke };
window.__TAURI__ = { core: { invoke } };
}
function postlude() {
const P = window.__probe;
const inner = window.fetch;
// The outermost layer: the site's own calls start here.
const outer = function probeOuter(...a) { P.fetchCalls++; P.outerStart = performance.now(); return Reflect.apply(inner, this, a); };
const set = Object.getOwnPropertyDescriptor(window, 'fetch').set;
Object.defineProperty(window, 'fetch', { configurable: true, get: () => outer, set });
const open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...a) { P.xhrCalls++; return open.apply(this, a); };
}
const wrap = ({ name, code }) =>
`window.__probe.evals.push(['${name}', performance.now()]);\n${code}\n;window.__probe.evals.push(['${name}:end', performance.now()]);\n//# sourceURL=bt-${name}.js`;
async function run(browser, options, { name, url, route, scroll }) {
const { scripts, seconds, nativeReplies = {} } = options;
const userAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${browser.version().split('.')[0]}.0.0.0 Safari/537.36`;
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, userAgent });
if (route) await route(context);
await context.addInitScript({ content: `(${prelude})(${JSON.stringify(scripts.map(script => script.name))}, ${JSON.stringify(nativeReplies)});\n//# sourceURL=bt-probe-prelude.js` });
for (const script of scripts) await context.addInitScript({ content: wrap(script) });
await context.addInitScript({ content: `(${postlude})();\n//# sourceURL=bt-probe-postlude.js` });
const page = await context.newPage();
const cdp = await context.newCDPSession(page);
await cdp.send('Performance.enable');
if (process.env.PROFILE) { await cdp.send('Profiler.enable'); await cdp.send('Profiler.setSamplingInterval', { interval: 50 }); await cdp.send('Profiler.start'); }
const started = Date.now();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
const deadline = started + seconds * 1000;
while (Date.now() < deadline) {
if (scroll) await page.mouse.wheel(0, 700).catch(() => {});
await page.waitForTimeout(500);
}
const result = await page.evaluate(() => {
const P = window.__probe;
const nav = performance.getEntriesByType('navigation')[0];
const paint = Object.fromEntries(performance.getEntriesByType('paint').map(e => [e.name, e.startTime]));
const evals = {};
for (let i = 0; i < P.evals.length; i += 2) evals[P.evals[i][0]] = +(P.evals[i + 1][1] - P.evals[i][1]).toFixed(2);
return {
evalMs: evals, fetchWrapperLayers: P.fetchSets, xhrOpenLayers: P.xhrOpenSets, xhrSendLayers: P.xhrSendSets,
fetchCalls: P.fetchCalls, fetchOverheadMsTotal: +P.fetchOverheadMs.toFixed(2), xhrCalls: P.xhrCalls,
responseClones: P.clones, cloneReads: P.cloneReads, bigJsonParses: P.bigParses, bigJsonParseMs: +P.bigParseMs.toFixed(1),
bigJsonParseMB: +(P.bigParseBytes / 1e6).toFixed(1),
observers: P.mo.oursObservers, observerCallbacks: P.mo.ours, observerMs: +P.mo.oursMs.toFixed(1), pageObserverCallbacks: P.mo.page,
rafCallbacks: P.raf.ours, rafMs: +P.raf.oursMs.toFixed(1), timerCallbacks: P.timers.ours, timerMs: +P.timers.oursMs.toFixed(1), intervals: P.timers.intervals,
longTasks: P.longtasks.length, longTaskMs: Math.round(P.longtasks.reduce((a, b) => a + b, 0)),
domContentLoadedMs: nav ? Math.round(nav.domContentLoadedEventEnd) : null, fcpMs: paint['first-contentful-paint'] != null ? Math.round(paint['first-contentful-paint']) : null,
};
});
const secs = (Date.now() - started) / 1000;
if (process.env.PROFILE) {
const { profile } = await cdp.send('Profiler.stop');
const dt = new Map(); const byId = new Map(profile.nodes.map(n => [n.id, n]));
const counts = new Map(); profile.samples.forEach((id, i) => counts.set(id, (counts.get(id) ?? 0) + (profile.timeDeltas[i] ?? 0)));
for (const [id, us] of counts) { const n = byId.get(id); const f = n.callFrame; if (!/bt-/.test(f.url)) continue; const k = f.url.replace(/.*bt-/, '') + ':' + (f.functionName || '(anon)') + ':' + f.lineNumber; dt.set(k, (dt.get(k) ?? 0) + us / 1000); }
result.selfMs = Object.fromEntries([...dt].sort((a, b) => b[1] - a[1]).slice(0, 25).map(([k, v]) => [k, +v.toFixed(1)]));
}
// Whole-page main-thread totals (the site or fixture + ours), from Chromium's own counters.
const metrics = Object.fromEntries((await cdp.send('Performance.getMetrics')).metrics.map(m => [m.name, m.value]));
result.pageScriptMs = Math.round(metrics.ScriptDuration * 1000);
result.pageTaskMs = Math.round(metrics.TaskDuration * 1000);
result.pageLayoutMs = Math.round(metrics.LayoutDuration * 1000);
result.pageStyleMs = Math.round(metrics.RecalcStyleDuration * 1000);
result.layoutCount = metrics.LayoutCount;
result.styleRecalcCount = metrics.RecalcStyleCount;
result.observerCallbacksPerSec = +(result.observerCallbacks / secs).toFixed(1);
result.ourMainThreadMsPerSec = +((result.observerMs + result.rafMs + result.timerMs + result.fetchOverheadMsTotal + result.bigJsonParseMs) / secs).toFixed(2);
await context.close();
return { scenario: name, ...result };
}
/**
* Runs every scenario `runs` times and returns the median of each numeric field per scenario.
* `scripts`: `{ name, code }` in injection order (none: the page alone, for a baseline).
* `scenarios`: `{ name, url, route?(context), scroll? }`.
*/
export async function runPerfProbe(options) {
const { chromium, scenarios, runs = 3 } = options;
const settings = { scripts: options.scripts ?? [], seconds: options.seconds ?? 8, nativeReplies: options.nativeReplies ?? {} };
const browser = await chromium.launch({ headless: true });
const results = [];
try {
for (const scenario of scenarios) {
const all = [];
for (let i = 0; i < runs; i++) all.push(await run(browser, settings, scenario));
const out = { ...all[0] };
for (const [key, value] of Object.entries(out)) {
if (typeof value !== 'number') continue;
const sorted = all.map(result => result[key]).sort((a, b) => a - b);
out[key] = sorted[Math.floor(sorted.length / 2)];
}
results.push(out);
}
} finally {
await browser.close();
}
return results;
}