mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
|
|
const url = 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 || 45000);
|
|
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-root-runtime`;
|
|
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))];
|
|
}
|
|
|
|
async function main() {
|
|
await fs.mkdir(runDir, { recursive: true });
|
|
const browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({
|
|
viewport: { width: viewport[0] || 390, height: viewport[1] || 844 },
|
|
deviceScaleFactor: isMobile ? 3 : 1,
|
|
isMobile,
|
|
hasTouch: isMobile,
|
|
ignoreHTTPSErrors: true,
|
|
});
|
|
|
|
await context.addInitScript(() => {
|
|
const data = {
|
|
startedAt: performance.now(),
|
|
listeners: new Map(),
|
|
timers: new Map(),
|
|
timeouts: 0,
|
|
intervals: 0,
|
|
intervalFires: 0,
|
|
timeoutFires: 0,
|
|
webSocketMessages: [],
|
|
webSocketCounts: new Map(),
|
|
webSocketBytesByEvent: new Map(),
|
|
mutations: [],
|
|
mutationTargets: new Map(),
|
|
frames: [],
|
|
longTasks: [],
|
|
consoleErrors: [],
|
|
};
|
|
|
|
function localPercentile(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 targetName(target) {
|
|
if (target === window) return 'window';
|
|
if (target === document) return 'document';
|
|
if (target?.nodeType === Node.ELEMENT_NODE) {
|
|
const tag = target.tagName?.toLowerCase() || 'element';
|
|
const id = target.id ? `#${target.id}` : '';
|
|
const classes = String(target.className || '').split(/\s+/).filter(Boolean).slice(0, 2).join('.');
|
|
return `${tag}${id}${classes ? `.${classes}` : ''}`;
|
|
}
|
|
return target?.constructor?.name || String(target);
|
|
}
|
|
|
|
function listenerKey(target, type, kind) {
|
|
return `${kind}:${targetName(target)}:${String(type)}`;
|
|
}
|
|
|
|
const originalAdd = EventTarget.prototype.addEventListener;
|
|
const originalRemove = EventTarget.prototype.removeEventListener;
|
|
EventTarget.prototype.addEventListener = function add(type, listener, options) {
|
|
const key = listenerKey(this, type, 'add');
|
|
data.listeners.set(key, (data.listeners.get(key) || 0) + 1);
|
|
return originalAdd.call(this, type, listener, options);
|
|
};
|
|
EventTarget.prototype.removeEventListener = function remove(type, listener, options) {
|
|
const key = listenerKey(this, type, 'remove');
|
|
data.listeners.set(key, (data.listeners.get(key) || 0) + 1);
|
|
return originalRemove.call(this, type, listener, options);
|
|
};
|
|
|
|
const originalSetInterval = window.setInterval;
|
|
const originalSetTimeout = window.setTimeout;
|
|
window.setInterval = function patchedSetInterval(fn, delay, ...args) {
|
|
data.intervals += 1;
|
|
const key = `interval:${delay}`;
|
|
data.timers.set(key, (data.timers.get(key) || 0) + 1);
|
|
return originalSetInterval.call(this, (...innerArgs) => {
|
|
data.intervalFires += 1;
|
|
return typeof fn === 'function' ? fn(...innerArgs) : eval(fn);
|
|
}, delay, ...args);
|
|
};
|
|
window.setTimeout = function patchedSetTimeout(fn, delay, ...args) {
|
|
data.timeouts += 1;
|
|
const key = `timeout:${delay}`;
|
|
data.timers.set(key, (data.timers.get(key) || 0) + 1);
|
|
return originalSetTimeout.call(this, (...innerArgs) => {
|
|
data.timeoutFires += 1;
|
|
return typeof fn === 'function' ? fn(...innerArgs) : eval(fn);
|
|
}, delay, ...args);
|
|
};
|
|
|
|
const OriginalWebSocket = window.WebSocket;
|
|
if (OriginalWebSocket) {
|
|
window.WebSocket = function PatchedWebSocket(...args) {
|
|
const ws = new OriginalWebSocket(...args);
|
|
ws.addEventListener('message', (event) => {
|
|
const raw = event.data;
|
|
const text = typeof raw === 'string' ? raw : '';
|
|
const match = text.slice(0, 120).match(/\["([^"\]]+)/);
|
|
const eventName = match?.[1] || text.slice(0, 12);
|
|
data.webSocketCounts.set(eventName, (data.webSocketCounts.get(eventName) || 0) + 1);
|
|
data.webSocketBytesByEvent.set(
|
|
eventName,
|
|
(data.webSocketBytesByEvent.get(eventName) || 0) + (typeof raw === 'string' ? raw.length : raw?.byteLength || raw?.size || 0),
|
|
);
|
|
data.webSocketMessages.push({
|
|
at: Math.round(performance.now()),
|
|
size: typeof raw === 'string' ? raw.length : raw?.byteLength || raw?.size || 0,
|
|
prefix: text.slice(0, 80),
|
|
});
|
|
if (data.webSocketMessages.length > 1000) data.webSocketMessages.shift();
|
|
});
|
|
return ws;
|
|
};
|
|
window.WebSocket.prototype = OriginalWebSocket.prototype;
|
|
Object.assign(window.WebSocket, OriginalWebSocket);
|
|
}
|
|
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
data.longTasks.push({ startTime: entry.startTime, duration: entry.duration, name: entry.name });
|
|
}
|
|
}).observe({ type: 'longtask', buffered: true });
|
|
} catch {}
|
|
|
|
let lastFrame = performance.now();
|
|
requestAnimationFrame(function frame(now) {
|
|
data.frames.push(now - lastFrame);
|
|
lastFrame = now;
|
|
requestAnimationFrame(frame);
|
|
});
|
|
|
|
new MutationObserver((records) => {
|
|
let added = 0;
|
|
let removed = 0;
|
|
let attrs = 0;
|
|
let text = 0;
|
|
for (const record of records) {
|
|
added += record.addedNodes?.length || 0;
|
|
removed += record.removedNodes?.length || 0;
|
|
if (record.type === 'attributes') attrs += 1;
|
|
if (record.type === 'characterData') text += 1;
|
|
if (record.type === 'attributes' || record.type === 'characterData') {
|
|
const key = `${targetName(record.target)}:${record.type}:${record.attributeName || ''}`;
|
|
data.mutationTargets.set(key, (data.mutationTargets.get(key) || 0) + 1);
|
|
}
|
|
}
|
|
data.mutations.push({ at: Math.round(performance.now()), records: records.length, added, removed, attrs, text });
|
|
if (data.mutations.length > 1000) data.mutations.shift();
|
|
}).observe(document, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
characterData: true,
|
|
});
|
|
|
|
const originalConsoleError = console.error;
|
|
console.error = (...args) => {
|
|
data.consoleErrors.push(args.map((arg) => String(arg)).join(' ').slice(0, 500));
|
|
return originalConsoleError.apply(console, args);
|
|
};
|
|
|
|
window.__rootAudit = {
|
|
snapshot() {
|
|
const frames = data.frames.slice(5);
|
|
const messages = data.webSocketMessages;
|
|
const socketBuckets = new Map(
|
|
[...data.webSocketCounts.entries()].map(([event, count]) => [
|
|
event,
|
|
{ event, count, bytes: data.webSocketBytesByEvent.get(event) || 0 },
|
|
]),
|
|
);
|
|
return {
|
|
elapsedMs: performance.now() - data.startedAt,
|
|
listeners: [...data.listeners.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 40),
|
|
timers: [...data.timers.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 40),
|
|
timeouts: data.timeouts,
|
|
intervals: data.intervals,
|
|
timeoutFires: data.timeoutFires,
|
|
intervalFires: data.intervalFires,
|
|
webSocketMessageCount: messages.length,
|
|
webSocketBytes: messages.reduce((sum, message) => sum + message.size, 0),
|
|
webSocketEvents: [...socketBuckets.values()].sort((a, b) => b.count - a.count),
|
|
mutationsTotal: data.mutations.reduce((acc, row) => ({
|
|
records: acc.records + row.records,
|
|
added: acc.added + row.added,
|
|
removed: acc.removed + row.removed,
|
|
attrs: acc.attrs + row.attrs,
|
|
text: acc.text + row.text,
|
|
}), { records: 0, added: 0, removed: 0, attrs: 0, text: 0 }),
|
|
mutationTargets: [...data.mutationTargets.entries()]
|
|
.map(([key, count]) => ({ key, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 50),
|
|
mutationBursts: [...data.mutations].sort((a, b) => b.records - a.records).slice(0, 20),
|
|
frames: {
|
|
count: frames.length,
|
|
average: frames.reduce((sum, value) => sum + value, 0) / frames.length,
|
|
p95: localPercentile(frames, 0.95),
|
|
p99: localPercentile(frames, 0.99),
|
|
},
|
|
longTasks: {
|
|
count: data.longTasks.length,
|
|
top: [...data.longTasks].sort((a, b) => b.duration - a.duration).slice(0, 20),
|
|
},
|
|
consoleErrors: data.consoleErrors,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
const cdp = await context.newCDPSession(page);
|
|
await cdp.send('Performance.enable');
|
|
if (cpuThrottle > 1) await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottle });
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
await page.waitForLoadState('load', { timeout: 90000 }).catch(() => {});
|
|
await sleep(sampleMs);
|
|
const metrics = await cdp.send('Performance.getMetrics');
|
|
const snapshot = await page.evaluate(() => window.__rootAudit.snapshot());
|
|
const report = {
|
|
url,
|
|
runDir,
|
|
environment: { cpuThrottle, viewport: { width: viewport[0] || 390, height: viewport[1] || 844 }, isMobile, sampleMs },
|
|
metrics: Object.fromEntries(metrics.metrics.map((metric) => [metric.name, metric.value])),
|
|
snapshot,
|
|
};
|
|
await page.screenshot({ path: path.join(runDir, 'root-runtime.png'), fullPage: true });
|
|
await fs.writeFile(path.join(runDir, 'root-runtime-report.json'), JSON.stringify(report, null, 2));
|
|
console.log(JSON.stringify(report, null, 2));
|
|
await browser.close();
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|