mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
285 lines
9.4 KiB
JavaScript
285 lines
9.4 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
|
|
const baseUrl = process.argv[2] || 'https://rover.otter.land';
|
|
const outDir = process.argv[3] || path.resolve('perf/results');
|
|
const cpuThrottle = Number(process.env.CPU_THROTTLE || 6);
|
|
const viewport = (process.env.VIEWPORT || '390x844').split('x').map((part) => Number(part));
|
|
const isMobile = process.env.MOBILE !== '0';
|
|
const sampleMs = Number(process.env.SAMPLE_MS || 22000);
|
|
const routes = (process.env.ROUTES || '/,/spectate,/mini,/display,/scanner')
|
|
.split(',')
|
|
.map((route) => route.trim())
|
|
.filter(Boolean);
|
|
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-route-sweep`;
|
|
const runDir = path.join(outDir, runId);
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
function percentile(values, p) {
|
|
if (!values.length) return 0;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];
|
|
}
|
|
|
|
function interestingMetrics(metrics) {
|
|
const wanted = [
|
|
'Documents',
|
|
'JSEventListeners',
|
|
'Nodes',
|
|
'LayoutCount',
|
|
'RecalcStyleCount',
|
|
'LayoutDuration',
|
|
'RecalcStyleDuration',
|
|
'ScriptDuration',
|
|
'TaskDuration',
|
|
'JSHeapUsedSize',
|
|
'JSHeapTotalSize',
|
|
];
|
|
return Object.fromEntries(
|
|
metrics.metrics
|
|
.filter((metric) => wanted.includes(metric.name))
|
|
.map((metric) => [metric.name, metric.value]),
|
|
);
|
|
}
|
|
|
|
function summarizeCpuProfile(profile) {
|
|
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
|
const totals = new Map();
|
|
let totalUs = 0;
|
|
for (let i = 0; i < (profile.samples || []).length; i += 1) {
|
|
const delta = profile.timeDeltas?.[i] || 0;
|
|
totalUs += delta;
|
|
const frame = nodes.get(profile.samples[i])?.callFrame || {};
|
|
const key = `${frame.url || '(browser)'}::${frame.functionName || '(anonymous)'}:${frame.lineNumber}:${frame.columnNumber}`;
|
|
const row = totals.get(key) || {
|
|
url: frame.url || '',
|
|
functionName: frame.functionName || '(anonymous)',
|
|
lineNumber: frame.lineNumber,
|
|
columnNumber: frame.columnNumber,
|
|
selfMs: 0,
|
|
samples: 0,
|
|
};
|
|
row.selfMs += delta / 1000;
|
|
row.samples += 1;
|
|
totals.set(key, row);
|
|
}
|
|
return {
|
|
totalProfileMs: totalUs / 1000,
|
|
topSelfTime: [...totals.values()].sort((a, b) => b.selfMs - a.selfMs).slice(0, 15),
|
|
};
|
|
}
|
|
|
|
async function installProbe(context) {
|
|
await context.addInitScript(() => {
|
|
const originalAdd = EventTarget.prototype.addEventListener;
|
|
const originalRemove = EventTarget.prototype.removeEventListener;
|
|
const listenerRows = new Map();
|
|
|
|
function targetName(target) {
|
|
if (target === window) return 'window';
|
|
if (target === document) return 'document';
|
|
if (target?.nodeType === Node.ELEMENT_NODE) {
|
|
const tag = target.tagName?.toLowerCase() || 'element';
|
|
return target.id ? `${tag}#${target.id}` : tag;
|
|
}
|
|
return target?.constructor?.name || String(target);
|
|
}
|
|
|
|
function stack() {
|
|
return String(new Error().stack || '')
|
|
.split('\n')
|
|
.slice(3, 7)
|
|
.map((line) => line.trim().replace(location.origin, ''))
|
|
.join('\n');
|
|
}
|
|
|
|
function note(kind, target, type) {
|
|
const key = `${targetName(target)}::${type}::${stack()}`;
|
|
const row = listenerRows.get(key) || {
|
|
target: targetName(target),
|
|
type: String(type),
|
|
stack: stack(),
|
|
adds: 0,
|
|
removes: 0,
|
|
};
|
|
row[kind] += 1;
|
|
listenerRows.set(key, row);
|
|
}
|
|
|
|
EventTarget.prototype.addEventListener = function patchedAdd(type, listener, options) {
|
|
note('adds', this, type);
|
|
return originalAdd.call(this, type, listener, options);
|
|
};
|
|
EventTarget.prototype.removeEventListener = function patchedRemove(type, listener, options) {
|
|
note('removes', this, type);
|
|
return originalRemove.call(this, type, listener, options);
|
|
};
|
|
|
|
window.__sweepProbe = {
|
|
longTasks: [],
|
|
frames: [],
|
|
intervalDrifts: [],
|
|
listenerSnapshot() {
|
|
return [...listenerRows.values()]
|
|
.map((row) => ({ ...row, churn: row.adds + row.removes }))
|
|
.sort((a, b) => b.churn - a.churn)
|
|
.slice(0, 20);
|
|
},
|
|
};
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
window.__sweepProbe.longTasks.push({
|
|
name: entry.name,
|
|
startTime: entry.startTime,
|
|
duration: entry.duration,
|
|
});
|
|
}
|
|
}).observe({ type: 'longtask', buffered: true });
|
|
} catch {}
|
|
|
|
let lastFrame = performance.now();
|
|
const frameLoop = (now) => {
|
|
window.__sweepProbe.frames.push(now - lastFrame);
|
|
lastFrame = now;
|
|
requestAnimationFrame(frameLoop);
|
|
};
|
|
requestAnimationFrame(frameLoop);
|
|
|
|
let lastTick = performance.now();
|
|
setInterval(() => {
|
|
const now = performance.now();
|
|
window.__sweepProbe.intervalDrifts.push(now - lastTick - 250);
|
|
lastTick = now;
|
|
}, 250);
|
|
});
|
|
}
|
|
|
|
async function runRoute(browser, route) {
|
|
const context = await browser.newContext({
|
|
viewport: { width: viewport[0] || 390, height: viewport[1] || 844 },
|
|
deviceScaleFactor: isMobile ? 3 : 1,
|
|
isMobile,
|
|
hasTouch: isMobile,
|
|
ignoreHTTPSErrors: true,
|
|
});
|
|
await installProbe(context);
|
|
const page = await context.newPage();
|
|
const cdp = await context.newCDPSession(page);
|
|
await cdp.send('Performance.enable');
|
|
await cdp.send('Profiler.enable');
|
|
if (cpuThrottle > 1) {
|
|
await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottle });
|
|
}
|
|
|
|
const consoleErrors = [];
|
|
const errorResponses = [];
|
|
page.on('console', (message) => {
|
|
if (['error', 'warning'].includes(message.type())) {
|
|
consoleErrors.push({ type: message.type(), text: message.text().slice(0, 500) });
|
|
}
|
|
});
|
|
page.on('response', (response) => {
|
|
if (response.status() >= 400) {
|
|
errorResponses.push({ url: response.url(), status: response.status() });
|
|
}
|
|
});
|
|
|
|
const url = new URL(route, baseUrl).toString();
|
|
const startWall = Date.now();
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
await page.waitForLoadState('load', { timeout: 90000 }).catch(() => {});
|
|
await sleep(4000);
|
|
const startMetrics = interestingMetrics(await cdp.send('Performance.getMetrics'));
|
|
await cdp.send('Profiler.start');
|
|
await sleep(sampleMs / 2);
|
|
await page.mouse.wheel(0, 900).catch(() => {});
|
|
await sleep(sampleMs / 2);
|
|
const { profile } = await cdp.send('Profiler.stop');
|
|
const endMetrics = interestingMetrics(await cdp.send('Performance.getMetrics'));
|
|
const probe = await page.evaluate(() => ({
|
|
longTasks: window.__sweepProbe.longTasks,
|
|
frames: window.__sweepProbe.frames.slice(5),
|
|
intervalDrifts: window.__sweepProbe.intervalDrifts,
|
|
listeners: window.__sweepProbe.listenerSnapshot(),
|
|
}));
|
|
const screenshotName = route === '/' ? 'root' : route.replace(/^\//, '').replace(/\W+/g, '-');
|
|
await page.screenshot({ path: path.join(runDir, `${screenshotName || 'root'}.png`), fullPage: true });
|
|
await context.close();
|
|
|
|
const delta = Object.fromEntries(
|
|
Object.keys(endMetrics).map((key) => [key, endMetrics[key] - (startMetrics[key] || 0)]),
|
|
);
|
|
const frames = probe.frames;
|
|
const longTasks = probe.longTasks.sort((a, b) => b.duration - a.duration);
|
|
return {
|
|
route,
|
|
finalUrl: page.url(),
|
|
elapsedMs: Date.now() - startWall,
|
|
metrics: { start: startMetrics, end: endMetrics, delta },
|
|
cpu: summarizeCpuProfile(profile),
|
|
responsiveness: {
|
|
longTaskCount: longTasks.length,
|
|
worstLongTaskMs: longTasks[0]?.duration || 0,
|
|
topLongTasks: longTasks.slice(0, 8),
|
|
frameCount: frames.length,
|
|
averageFrameMs: frames.reduce((sum, value) => sum + value, 0) / frames.length,
|
|
p95FrameMs: percentile(frames, 0.95),
|
|
p99FrameMs: percentile(frames, 0.99),
|
|
intervalDriftP95Ms: percentile(probe.intervalDrifts, 0.95),
|
|
intervalDriftP99Ms: percentile(probe.intervalDrifts, 0.99),
|
|
},
|
|
listenerChurn: probe.listeners,
|
|
consoleErrors,
|
|
errorResponses,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
await fs.mkdir(runDir, { recursive: true });
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
args: ['--autoplay-policy=no-user-gesture-required'],
|
|
});
|
|
const results = [];
|
|
for (const route of routes) {
|
|
console.error(`profiling ${route} at ${cpuThrottle}x`);
|
|
results.push(await runRoute(browser, route));
|
|
}
|
|
await browser.close();
|
|
|
|
const report = {
|
|
baseUrl,
|
|
runDir,
|
|
environment: {
|
|
cpuThrottle,
|
|
viewport: { width: viewport[0] || 390, height: viewport[1] || 844 },
|
|
isMobile,
|
|
sampleMs,
|
|
routes,
|
|
},
|
|
results,
|
|
rankedByAverageFrame: [...results].sort(
|
|
(a, b) => b.responsiveness.averageFrameMs - a.responsiveness.averageFrameMs,
|
|
),
|
|
rankedByTaskDuration: [...results].sort(
|
|
(a, b) => b.metrics.delta.TaskDuration - a.metrics.delta.TaskDuration,
|
|
),
|
|
rankedByListenerChurn: [...results].sort((a, b) => {
|
|
const churnA = a.listenerChurn.reduce((sum, row) => sum + row.churn, 0);
|
|
const churnB = b.listenerChurn.reduce((sum, row) => sum + row.churn, 0);
|
|
return churnB - churnA;
|
|
}),
|
|
};
|
|
await fs.writeFile(path.join(runDir, 'route-sweep-report.json'), JSON.stringify(report, null, 2));
|
|
console.log(JSON.stringify(report, null, 2));
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|