Moved from wayBetter for X; an app passes its own origin, process name, scripts and fixtures.
266 lines
14 KiB
JavaScript
266 lines
14 KiB
JavaScript
// Layout/theme performance probe for a running wayBetter app (release build started with
|
|
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port>). Plain CDP. Site pages are
|
|
// only read and reloaded, never clicked. Shell input is synthesized with CDP (no OS input, no focus
|
|
// change).
|
|
//
|
|
// SITE_ORIGIN=https://x.com node dev-tools/resize-perf.mjs <port> <case...>
|
|
// theme reload the first site tab 3x: first paint vs. theme applied, unthemed frames
|
|
// (needs THEME_GET and THEME_SET: the app's theme commands; THEME_PRESET=<id> measures
|
|
// with that theme and gives the owner's back)
|
|
// drag drag the first split divider for 2 s: shell frames, tabs_sync calls, native lag
|
|
// switch click between two tabs of one group 10x: click -> tab visible latency
|
|
// window (driven by window-resize.ps1 in parallel) shell resize -> native resize lag
|
|
//
|
|
// Lag is measured across processes with epoch timestamps (performance.timeOrigin + now) taken in
|
|
// the shell (when a layout was sent) and in the site tab (its own `resize` event, i.e. when the
|
|
// native bounds reached the page). SKIP_PATH=/compose leaves pages under that path out of `theme`.
|
|
const [port = '9333', ...cases] = process.argv.slice(2);
|
|
const origin = process.env.SITE_ORIGIN;
|
|
if (!origin) throw new Error('SITE_ORIGIN is required (the site\'s origin, e.g. https://x.com)');
|
|
const skipPath = process.env.SKIP_PATH ?? '';
|
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
async function targets() { return (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); }
|
|
function connect(target) {
|
|
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
let id = 0;
|
|
const pending = new Map();
|
|
ws.onmessage = event => { const msg = JSON.parse(event.data); if (msg.id) { pending.get(msg.id)?.(msg); pending.delete(msg.id); } };
|
|
const ready = new Promise(resolve => { ws.onopen = resolve; });
|
|
const send = async (method, params = {}) => { await ready; return new Promise(resolve => { const n = ++id; pending.set(n, resolve); ws.send(JSON.stringify({ id: n, method, params })); }); };
|
|
// A frozen page (a parked, hidden tab) never answers: give up after 5 s.
|
|
const evaluate = async expression => {
|
|
const result = await Promise.race([
|
|
send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }),
|
|
wait(5000).then(() => { throw new Error(`${target.url}: no answer`); }),
|
|
]);
|
|
if (result.result?.exceptionDetails) throw new Error(JSON.stringify(result.result.exceptionDetails).slice(0, 400));
|
|
return result.result?.result?.value;
|
|
};
|
|
return { send, evaluate, close: () => ws.close(), url: target.url, id: target.id };
|
|
}
|
|
const pct = (values, p) => { if (!values.length) return null; const s = [...values].sort((a, b) => a - b); return +s[Math.min(s.length - 1, Math.floor(p * s.length))].toFixed(1); };
|
|
const stats = values => ({ n: values.length, p50: pct(values, 0.5), p95: pct(values, 0.95), max: values.length ? +Math.max(...values).toFixed(1) : null });
|
|
|
|
const list = await targets();
|
|
const shell = connect(list.find(t => t.type === 'page' && t.url.startsWith('http://tauri.localhost')));
|
|
const xTargets = list.filter(t => t.type === 'page' && t.url.startsWith(`${origin}/`));
|
|
const xs = [];
|
|
for (const x of xTargets.map(connect)) {
|
|
try { await x.evaluate('1'); xs.push(x); } catch { x.close(); }
|
|
}
|
|
const results = {};
|
|
|
|
/** Shell recorder: frame times and every tabs_sync (send epoch, round trip, rects). */
|
|
const SHELL_PROBE = `(() => {
|
|
if (window.__perf) return;
|
|
const P = window.__perf = { frames: [], syncs: [], on: false };
|
|
const now = () => performance.timeOrigin + performance.now();
|
|
const loop = t => { if (P.on) P.frames.push(t); requestAnimationFrame(loop); };
|
|
requestAnimationFrame(loop);
|
|
// __TAURI_INTERNALS__.invoke is read-only; its IPC goes through fetch (http://ipc.localhost/<cmd>).
|
|
const original = window.fetch.bind(window);
|
|
window.fetch = (input, init) => {
|
|
const url = String(input && input.url || input);
|
|
if (!P.on || !url.includes('/tabs_sync')) return original(input, init);
|
|
let items = [];
|
|
try { items = JSON.parse(init.body).items; } catch {}
|
|
const entry = { at: now(), items: items.map(i => [i.id, i.x, i.y, i.width, i.height, i.visible]) };
|
|
P.syncs.push(entry);
|
|
return original(input, init).finally(() => { entry.rtt = now() - entry.at; });
|
|
};
|
|
})()`;
|
|
/** Site tab recorder: its own resize and visibility events, epoch timestamps. */
|
|
const X_PROBE = `(() => {
|
|
if (window.__perf) return;
|
|
const P = window.__perf = { resizes: [], vis: [] };
|
|
const now = () => performance.timeOrigin + performance.now();
|
|
addEventListener('resize', () => P.resizes.push([now(), innerWidth, innerHeight]));
|
|
document.addEventListener('visibilitychange', () => P.vis.push([now(), document.visibilityState]));
|
|
})()`;
|
|
|
|
async function arm() {
|
|
await shell.evaluate(SHELL_PROBE);
|
|
for (const x of xs) await x.evaluate(X_PROBE);
|
|
await shell.evaluate('(__perf.frames = [], __perf.syncs = [], __perf.on = true)');
|
|
for (const x of xs) await x.evaluate('(__perf.resizes = [], __perf.vis = [])');
|
|
}
|
|
async function collect() {
|
|
const shellData = await shell.evaluate('(__perf.on = false, { frames: __perf.frames, syncs: __perf.syncs })');
|
|
const xData = [];
|
|
for (const x of xs) xData.push({ id: await x.evaluate('(window.__wbTabId ?? null)'), ...(await x.evaluate('({ resizes: __perf.resizes, vis: __perf.vis, w: innerWidth, h: innerHeight })')) });
|
|
return { shellData, xData };
|
|
}
|
|
/** For every site tab resize: the latest earlier sync that carried that width for some tab = its cause. */
|
|
function lagOf(shellData, xData) {
|
|
const lags = [];
|
|
let unmatched = 0;
|
|
for (const x of xData) {
|
|
for (const [at, w, h] of x.resizes) {
|
|
const cause = [...shellData.syncs].reverse().find(s => s.at <= at && s.items.some(i => Math.abs(i[3] - w) <= 1));
|
|
if (cause) lags.push(at - cause.at); else unmatched++;
|
|
}
|
|
}
|
|
return { lag: stats(lags), unmatched };
|
|
}
|
|
const frameStats = frames => stats(frames.slice(1).map((t, i) => t - frames[i]));
|
|
|
|
if (cases.includes('theme')) {
|
|
// A visible site page (a hidden one gets no animation frames to sample).
|
|
let x = null;
|
|
for (const candidate of xs) if (!x && await candidate.evaluate(`document.visibilityState === 'visible' && !(${JSON.stringify(skipPath)} && location.pathname.startsWith(${JSON.stringify(skipPath)}))`)) x = candidate;
|
|
if (!x) throw new Error('no visible site tab');
|
|
const themeGet = process.env.THEME_GET;
|
|
const themeSet = process.env.THEME_SET;
|
|
if (!themeGet || !themeSet) throw new Error('the theme case needs THEME_GET and THEME_SET (the app\'s theme commands)');
|
|
results.themeTarget = x.url;
|
|
// THEME_PRESET=nord: measure with that theme, then give the owner's theme back.
|
|
const invoke = (cmd, args) => shell.evaluate(`window.__TAURI_INTERNALS__.invoke(${JSON.stringify(cmd)}, ${JSON.stringify(args)})`);
|
|
const original = await invoke(themeGet, {});
|
|
if (process.env.THEME_PRESET) { await invoke(themeSet, { theme: { preset: process.env.THEME_PRESET } }); await wait(1500); }
|
|
await x.send('Page.enable');
|
|
// Before every other document script: record paints, theme style arrival and each frame's body color.
|
|
const probe = `(() => {
|
|
const t0 = performance.now();
|
|
const P = window.__themeProbe = { style: null, vars: null, frames: [], dcl: null };
|
|
const seen = () => {
|
|
const s = document.getElementById('bt-theme');
|
|
if (P.style === null && s && s.textContent) P.style = performance.now();
|
|
const v = document.getElementById('wb-theme-vars') || document.getElementById('bt-theme-snapshot');
|
|
if (P.vars === null && v) P.vars = performance.now();
|
|
};
|
|
new MutationObserver(seen).observe(document, { childList: true, subtree: true, characterData: true });
|
|
document.addEventListener('DOMContentLoaded', () => { P.dcl = performance.now(); });
|
|
const frame = t => {
|
|
seen();
|
|
// The color the page shows: <html>'s (themes paint the canvas there, <body> stays transparent),
|
|
// else <body>'s (X's own inline color before a theme).
|
|
const shown = () => { const h = document.documentElement && getComputedStyle(document.documentElement).backgroundColor; const b = document.body && getComputedStyle(document.body).backgroundColor; return h && h !== 'rgba(0, 0, 0, 0)' ? h : b || h || ''; };
|
|
const bg = shown();
|
|
P.frames.push([Math.round(t), bg, !!(document.getElementById('bt-theme')?.textContent), !!document.body]);
|
|
if (t - t0 < 6000) requestAnimationFrame(frame);
|
|
};
|
|
requestAnimationFrame(frame);
|
|
})()`;
|
|
const { result } = await x.send('Page.addScriptToEvaluateOnNewDocument', { source: probe });
|
|
const runs = [];
|
|
for (let run = 0; run < 3; run++) {
|
|
await x.send('Page.reload', {});
|
|
await wait(7000);
|
|
const data = await x.evaluate(`(() => {
|
|
const P = window.__themeProbe;
|
|
const paint = Object.fromEntries(performance.getEntriesByType('paint').map(e => [e.name, Math.round(e.startTime)]));
|
|
const h = getComputedStyle(document.documentElement).backgroundColor;
|
|
const themedBg = h !== 'rgba(0, 0, 0, 0)' ? h : getComputedStyle(document.body).backgroundColor;
|
|
// Frames once there is a body to paint (paint timing entries are not exposed in every run).
|
|
const painted = P.frames.filter(f => f[3]);
|
|
const unthemed = painted.filter(f => f[1] !== themedBg);
|
|
return { paint, firstBody: painted[0]?.[0] ?? null, firstThemedFrame: P.frames.find(f => f[3] && f[1] === themedBg)?.[0] ?? null, style: P.style && Math.round(P.style), vars: P.vars && Math.round(P.vars), dcl: P.dcl && Math.round(P.dcl), themedBg,
|
|
framesAfterFirstPaint: painted.length, unthemedFrames: unthemed.length, lastUnthemedAt: unthemed.length ? unthemed[unthemed.length - 1][0] : null,
|
|
firstBg: painted[0]?.[1] ?? null };
|
|
})()`);
|
|
runs.push(data);
|
|
}
|
|
await x.send('Page.removeScriptToEvaluateOnNewDocument', { identifier: result.identifier });
|
|
if (process.env.THEME_PRESET) await invoke(themeSet, { theme: original });
|
|
results.theme = runs;
|
|
}
|
|
|
|
if (cases.includes('drag')) {
|
|
await arm();
|
|
const sash = await shell.evaluate(`(() => { const s = document.querySelector('.dv-split-view-container.dv-horizontal > .dv-sash-container > .dv-sash'); const b = s.getBoundingClientRect(); return [b.left + b.width / 2, b.top + b.height / 2]; })()`);
|
|
const [sx, sy] = sash;
|
|
const mouse = (type, x, buttons = 1) => shell.send('Input.dispatchMouseEvent', { type, x, y: sy, button: 'left', buttons, clickCount: 1 });
|
|
await mouse('mousePressed', sx);
|
|
const start = Date.now();
|
|
let steps = 0;
|
|
while (Date.now() - start < 2000) {
|
|
const phase = (Date.now() - start) / 2000;
|
|
await mouse('mouseMoved', sx - 200 * Math.sin(phase * Math.PI * 2));
|
|
steps++;
|
|
await wait(8);
|
|
}
|
|
await mouse('mouseMoved', sx);
|
|
const release = await shell.evaluate('performance.timeOrigin + performance.now()');
|
|
await mouse('mouseReleased', sx, 0);
|
|
await wait(1000);
|
|
const { shellData, xData } = await collect();
|
|
const lastX = Math.max(0, ...xData.flatMap(x => x.resizes.map(r => r[0])));
|
|
results.drag = {
|
|
moves: steps,
|
|
shellFrames: frameStats(shellData.frames.filter(t => true)),
|
|
syncs: shellData.syncs.length,
|
|
syncRtt: stats(shellData.syncs.map(s => s.rtt).filter(v => v != null)),
|
|
xResizes: xData.map(x => x.resizes.length),
|
|
...lagOf(shellData, xData),
|
|
settleAfterRelease: +(lastX - release).toFixed(1),
|
|
};
|
|
}
|
|
|
|
if (cases.includes('window')) {
|
|
// window-resize.ps1 resizes the (non-foreground) window for ~2 s while this records.
|
|
await arm();
|
|
await shell.evaluate(`(() => { __perf.win = []; addEventListener('resize', () => __perf.win.push([performance.timeOrigin + performance.now(), innerWidth])); })()`);
|
|
await wait(Number(process.env.WINDOW_MS ?? 3500));
|
|
const { shellData, xData } = await collect();
|
|
const win = await shell.evaluate('__perf.win');
|
|
const shellLag = [];
|
|
// Native first (the site tab resized before the shell even saw the window change) counts as <= 0.
|
|
for (const x of xData) for (const [at] of x.resizes) {
|
|
const cause = [...win].reverse().find(w => w[0] <= at);
|
|
const after = win.find(w => w[0] > at);
|
|
if (cause) shellLag.push(at - cause[0]); else if (after) shellLag.push(-(after[0] - at));
|
|
}
|
|
results.window = {
|
|
shellResizes: win.length,
|
|
shellFrames: frameStats(shellData.frames),
|
|
syncs: shellData.syncs.length,
|
|
syncRtt: stats(shellData.syncs.map(s => s.rtt).filter(v => v != null)),
|
|
xResizes: xData.map(x => x.resizes.length),
|
|
xAfterShellResize: stats(shellLag),
|
|
// With window-resize.ps1's printed restore time: how long the site tab keeps resizing after the window stopped.
|
|
lastXResize: Math.round(Math.max(0, ...xData.flatMap(x => x.resizes.map(r => r[0])))),
|
|
lastShellResize: Math.round(Math.max(0, ...win.map(w => w[0]))),
|
|
...lagOf(shellData, xData),
|
|
};
|
|
}
|
|
|
|
if (cases.includes('switch')) {
|
|
await arm();
|
|
// Two web tabs in one group: click the inactive one, wait, click back. Latency = click -> the
|
|
// clicked tab's page becomes visible.
|
|
const group = await shell.evaluate(`(() => {
|
|
const g = [...document.querySelectorAll('.dv-groupview')].map(g => g.closest('.dv-view')).find(v => v && v.querySelectorAll('.dv-tab').length >= 2);
|
|
return !!g;
|
|
})()`);
|
|
const latencies = [];
|
|
if (group) {
|
|
for (let i = 0; i < 20; i++) {
|
|
const clicked = await shell.evaluate(`(() => {
|
|
const tabs = [...document.querySelectorAll('.dv-tab')];
|
|
const next = tabs.find(t => t.classList.contains('dv-inactive-tab'));
|
|
const at = performance.timeOrigin + performance.now();
|
|
next.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
|
|
next.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }));
|
|
next.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, button: 0 }));
|
|
next.click();
|
|
return at;
|
|
})()`);
|
|
await wait(600);
|
|
const shown = [];
|
|
for (const x of xs) {
|
|
const vis = await x.evaluate('__perf.vis');
|
|
const hit = vis.find(v => v[1] === 'visible' && v[0] >= clicked);
|
|
if (hit) shown.push(hit[0] - clicked);
|
|
await x.evaluate('(__perf.vis = [])');
|
|
}
|
|
if (shown.length) latencies.push(Math.min(...shown));
|
|
}
|
|
}
|
|
const { shellData } = await collect();
|
|
results.switch = { group, latency: stats(latencies), syncRtt: stats(shellData.syncs.map(s => s.rtt).filter(v => v != null)) };
|
|
}
|
|
|
|
console.log(JSON.stringify(results, null, 1));
|
|
shell.close();
|
|
for (const x of xs) x.close();
|