Moved from wayBetter for X; an app passes its own origin, process name, scripts and fixtures.
189 lines
11 KiB
JavaScript
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;
|
|
}
|