mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-25 14:24:58 +02:00
refactor
This commit is contained in:
594
.github/scripts/chrome.mts
vendored
Normal file
594
.github/scripts/chrome.mts
vendored
Normal file
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import { heapSnapshotCategory, HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||
|
||||
type ChromeHandle = {
|
||||
process: ChildProcessWithoutNullStreams;
|
||||
port: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
status?: number;
|
||||
mimeType?: string;
|
||||
encodedDataLength: number;
|
||||
decodedBodyLength: number;
|
||||
fromDiskCache: boolean;
|
||||
fromServiceWorker: boolean;
|
||||
finished: boolean;
|
||||
failed: boolean;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type NetworkSummary = {
|
||||
requestCount: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
async function waitForProcessExit(child: ChildProcessWithoutNullStreams) {
|
||||
await new Promise<void>(resolvePromise => {
|
||||
if (child.exitCode != null) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
const killTimer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolvePromise();
|
||||
}, 5_000).unref();
|
||||
child.once('exit', () => {
|
||||
clearTimeout(killTimer);
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
function findChrome() {
|
||||
const envChrome = process.env.CHROME_BIN ?? process.env.GOOGLE_CHROME_BIN;
|
||||
if (envChrome != null && envChrome !== '') return envChrome;
|
||||
|
||||
const candidates = process.platform === 'win32'
|
||||
? [
|
||||
'chrome.exe',
|
||||
'msedge.exe',
|
||||
]
|
||||
: [
|
||||
'google-chrome',
|
||||
'google-chrome-stable',
|
||||
'chromium',
|
||||
'chromium-browser',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync(candidate, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.status === 0) return candidate;
|
||||
}
|
||||
|
||||
throw new Error('Could not find Chrome or Chromium. Set CHROME_BIN to the browser executable.');
|
||||
}
|
||||
|
||||
async function launchChrome(label: string): Promise<ChromeHandle> {
|
||||
const chrome = findChrome();
|
||||
const port = label === 'base' ? 9222 : 9223;
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), `misskey-browser-metrics-${label}-`));
|
||||
const child = spawn(chrome, [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-sandbox',
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'about:blank',
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`));
|
||||
child.stderr.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`));
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 30_000) {
|
||||
if (child.exitCode != null) throw new Error(`Chrome exited early with code ${child.exitCode}`);
|
||||
try {
|
||||
await fetchJson(`http://127.0.0.1:${port}/json/version`);
|
||||
return {
|
||||
process: child,
|
||||
port,
|
||||
userDataDir,
|
||||
};
|
||||
} catch {
|
||||
await util.sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for Chrome DevTools Protocol');
|
||||
}
|
||||
|
||||
type CdpResponse<T = any> = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
function selectorReadyExpression(selector: string, options: { visible?: boolean; enabled?: boolean } = {}) {
|
||||
return `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (el == null) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (${options.visible === true ? 'true' : 'false'} && (style.visibility === 'hidden' || style.display === 'none' || rect.width === 0 || rect.height === 0)) return false;
|
||||
if (${options.enabled === true ? 'true' : 'false'} && (el.disabled || el.getAttribute('aria-disabled') === 'true')) return false;
|
||||
return true;
|
||||
})()`;
|
||||
}
|
||||
|
||||
class CdpClient {
|
||||
private nextId = 1;
|
||||
private callbacks = new Map<number, {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
private eventHandlers = new Map<string, Set<(params: any) => void>>();
|
||||
private ws: WebSocket;
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
ws.addEventListener('message', event => {
|
||||
const message = JSON.parse(String(event.data)) as CdpResponse;
|
||||
if (message.id != null) {
|
||||
const callback = this.callbacks.get(message.id);
|
||||
if (callback == null) return;
|
||||
this.callbacks.delete(message.id);
|
||||
if (message.error != null) {
|
||||
callback.reject(new Error(`${message.error.message} (${message.error.code})`));
|
||||
} else {
|
||||
callback.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method != null) {
|
||||
for (const handler of this.eventHandlers.get(message.method) ?? []) {
|
||||
handler(message.params);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
for (const callback of this.callbacks.values()) {
|
||||
callback.reject(new Error('CDP websocket closed'));
|
||||
}
|
||||
this.callbacks.clear();
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(wsUrl: string) {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
ws.addEventListener('open', () => resolvePromise(), { once: true });
|
||||
ws.addEventListener('error', () => reject(new Error(`Failed to connect to ${wsUrl}`)), { once: true });
|
||||
});
|
||||
return new CdpClient(ws);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: any) => void) {
|
||||
const handlers = this.eventHandlers.get(method) ?? new Set();
|
||||
handlers.add(handler);
|
||||
this.eventHandlers.set(method, handlers);
|
||||
}
|
||||
|
||||
send<T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
|
||||
return new Promise<T>((resolvePromise, reject) => {
|
||||
this.callbacks.set(id, {
|
||||
resolve: resolvePromise,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
type ChromeOptions = {
|
||||
scenarioTimeoutMs: number;
|
||||
};
|
||||
|
||||
export class Chrome {
|
||||
private handle: ChromeHandle;
|
||||
public cdp: CdpClient;
|
||||
public networkRequests: NetworkRequest[] = [];
|
||||
private scenarioTimeoutMs: number;
|
||||
|
||||
constructor(handle: ChromeHandle, cdpClient: CdpClient, options: ChromeOptions) {
|
||||
this.handle = handle;
|
||||
this.cdp = cdpClient;
|
||||
this.scenarioTimeoutMs = options.scenarioTimeoutMs;
|
||||
}
|
||||
|
||||
static async create(label: string, options: ChromeOptions): Promise<Chrome> {
|
||||
const chromeHandle = await launchChrome(label);
|
||||
const url = await fetchJson<{ webSocketDebuggerUrl: string }>(
|
||||
`http://127.0.0.1:${chromeHandle.port}/json/new?${encodeURIComponent('about:blank')}`,
|
||||
{ method: 'PUT' },
|
||||
).catch(async () => await fetchJson<{ webSocketDebuggerUrl: string }>(
|
||||
`http://127.0.0.1:${chromeHandle.port}/json/new?${encodeURIComponent('about:blank')}`,
|
||||
));
|
||||
const cdpClient = await CdpClient.connect(url.webSocketDebuggerUrl);
|
||||
return new Chrome(chromeHandle, cdpClient, options);
|
||||
}
|
||||
|
||||
public async enableNetworkTracking() {
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
|
||||
this.cdp.on('Network.requestWillBeSent', params => {
|
||||
if (params.request?.url == null) return;
|
||||
const row: NetworkRequest = {
|
||||
requestId: params.requestId,
|
||||
url: params.request.url,
|
||||
method: params.request.method ?? 'GET',
|
||||
resourceType: params.type ?? 'Other',
|
||||
startedAt: params.timestamp ?? 0,
|
||||
encodedDataLength: 0,
|
||||
decodedBodyLength: 0,
|
||||
fromDiskCache: false,
|
||||
fromServiceWorker: false,
|
||||
finished: false,
|
||||
failed: false,
|
||||
};
|
||||
requests.set(params.requestId, row);
|
||||
this.networkRequests.push(row);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.responseReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.status = params.response?.status;
|
||||
row.mimeType = params.response?.mimeType;
|
||||
row.fromDiskCache = params.response?.fromDiskCache === true;
|
||||
row.fromServiceWorker = params.response?.fromServiceWorker === true;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.dataReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.decodedBodyLength += params.dataLength ?? 0;
|
||||
row.encodedDataLength += params.encodedDataLength ?? 0;
|
||||
});
|
||||
|
||||
this.cdp.on('Network.loadingFinished', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.finished = true;
|
||||
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
|
||||
});
|
||||
|
||||
this.cdp.on('Network.loadingFailed', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.failed = true;
|
||||
row.finished = true;
|
||||
row.errorText = params.errorText;
|
||||
});
|
||||
|
||||
await this.cdp.send('Network.enable');
|
||||
await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await this.cdp.send('Network.setBypassServiceWorker', { bypass: true });
|
||||
await this.cdp.send('Page.enable');
|
||||
await this.cdp.send('Runtime.enable');
|
||||
await this.cdp.send('Performance.enable');
|
||||
}
|
||||
|
||||
public async evaluate<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
const result = await this.cdp.send<{
|
||||
result: { value: T };
|
||||
exceptionDetails?: unknown;
|
||||
}>('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
if (result.exceptionDetails != null) {
|
||||
throw new Error(`Runtime.evaluate failed: ${JSON.stringify(result.exceptionDetails)}`);
|
||||
}
|
||||
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
public async waitForSelector(selector: string, options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? this.scenarioTimeoutMs;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const ready = await this.evaluate<boolean>(selectorReadyExpression(selector, options), 5_000);
|
||||
if (ready) return true;
|
||||
await util.sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async waitForAnySelector(selectors: string[], options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? this.scenarioTimeoutMs;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
for (const selector of selectors) {
|
||||
const ready = await this.evaluate<boolean>(selectorReadyExpression(selector, options), 5_000);
|
||||
if (ready) return selector;
|
||||
}
|
||||
await util.sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async click(selector: string) {
|
||||
const found = await this.waitForSelector(selector, { visible: true, enabled: true });
|
||||
if (!found) throw new Error(`Selector was not clickable: ${selector}`);
|
||||
await this.evaluate<void>(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (el == null) throw new Error('Element not found');
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
el.click();
|
||||
})()`);
|
||||
}
|
||||
|
||||
public async maybeClick(selector: string, timeoutMs = 3_000) {
|
||||
if (await this.waitForSelector(selector, { visible: true, enabled: true, timeoutMs })) {
|
||||
await this.click(selector);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async setValue(selector: string, value: string) {
|
||||
const found = await this.waitForSelector(selector, { visible: true, enabled: true });
|
||||
if (!found) throw new Error(`Selector was not editable: ${selector}`);
|
||||
await this.evaluate<void>(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (el == null) throw new Error('Element not found');
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
el.focus();
|
||||
const proto = Object.getPrototypeOf(el);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
if (descriptor?.set != null) {
|
||||
descriptor.set.call(el, ${JSON.stringify(value)});
|
||||
} else {
|
||||
el.value = ${JSON.stringify(value)};
|
||||
}
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(value)} }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
})()`);
|
||||
}
|
||||
|
||||
public async waitForText(text: string, timeoutMs = this.scenarioTimeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const found = await this.evaluate<boolean>(`document.body?.innerText?.includes(${JSON.stringify(text)}) === true`, 5_000);
|
||||
if (found) return true;
|
||||
await util.sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async collectPerformance(): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
|
||||
const webVitals = await this.evaluate<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
|
||||
const longTasks = performance.getEntriesByType('longtask');
|
||||
const resourceEntries = performance.getEntriesByType('resource');
|
||||
return {
|
||||
firstPaintMs: paintEntries['first-paint'],
|
||||
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
|
||||
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
|
||||
loadEventEndMs: navigation?.loadEventEnd,
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
|
||||
resourceEntryCount: resourceEntries.length,
|
||||
domElements: document.getElementsByTagName('*').length,
|
||||
};
|
||||
})()`);
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap,
|
||||
webVitals,
|
||||
};
|
||||
}
|
||||
|
||||
public async takeHeapSnapshot(savePath?: string) {
|
||||
const chunks: string[] = [];
|
||||
this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => {
|
||||
chunks.push(params.chunk);
|
||||
});
|
||||
|
||||
await this.cdp.send('HeapProfiler.enable');
|
||||
await this.cdp.send('HeapProfiler.collectGarbage');
|
||||
await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
||||
|
||||
const content = chunks.join('');
|
||||
if (savePath != null) {
|
||||
await writeFile(savePath, content);
|
||||
}
|
||||
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
this.cdp.close();
|
||||
if (this.handle.process.exitCode == null) {
|
||||
this.handle.process.kill();
|
||||
}
|
||||
await waitForProcessExit(this.handle.process);
|
||||
await rm(this.handle.userDataDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 200,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isMeasurableRequest(row: NetworkRequest) {
|
||||
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
|
||||
}
|
||||
|
||||
export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string): NetworkSummary {
|
||||
const origin = new URL(baseUrl).origin;
|
||||
const rows = requestRows.filter(isMeasurableRequest);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
|
||||
for (const row of rows) {
|
||||
const summary = byResourceType[row.resourceType] ?? {
|
||||
requests: 0,
|
||||
encodedBytes: 0,
|
||||
decodedBodyBytes: 0,
|
||||
};
|
||||
summary.requests += 1;
|
||||
summary.encodedBytes += row.encodedDataLength;
|
||||
summary.decodedBodyBytes += row.decodedBodyLength;
|
||||
byResourceType[row.resourceType] = summary;
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: rows.length,
|
||||
finishedRequestCount: rows.filter(row => row.finished).length,
|
||||
failedRequestCount: rows.filter(row => row.failed).length,
|
||||
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
|
||||
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
|
||||
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
|
||||
sameOriginEncodedBytes: rows
|
||||
.filter(row => isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
thirdPartyEncodedBytes: rows
|
||||
.filter(row => !isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
byResourceType,
|
||||
largestRequests: rows
|
||||
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
|
||||
.slice(0, 15)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
status: row.status,
|
||||
encodedBytes: row.encodedDataLength,
|
||||
decodedBodyBytes: row.decodedBodyLength,
|
||||
})),
|
||||
failedRequests: rows
|
||||
.filter(row => row.failed)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
errorText: row.errorText,
|
||||
status: row.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as util from './utility.mts';
|
||||
import { heapSnapshotCategory, type HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||
import { BrowserMeasurement, Chrome, NetworkSummary, summarizeNetwork } from './chrome.mts';
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2);
|
||||
|
||||
@@ -20,100 +20,6 @@ const sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT',
|
||||
const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 8, 1);
|
||||
const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots');
|
||||
|
||||
type ChromeHandle = {
|
||||
process: ChildProcessWithoutNullStreams;
|
||||
port: number;
|
||||
userDataDir: string;
|
||||
};
|
||||
|
||||
type CdpResponse<T = any> = {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: T;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
status?: number;
|
||||
mimeType?: string;
|
||||
encodedDataLength: number;
|
||||
decodedBodyLength: number;
|
||||
fromDiskCache: boolean;
|
||||
fromServiceWorker: boolean;
|
||||
finished: boolean;
|
||||
failed: boolean;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
type NetworkSummary = {
|
||||
requestCount: number;
|
||||
finishedRequestCount: number;
|
||||
failedRequestCount: number;
|
||||
cachedRequestCount: number;
|
||||
serviceWorkerRequestCount: number;
|
||||
totalEncodedBytes: number;
|
||||
totalDecodedBodyBytes: number;
|
||||
sameOriginEncodedBytes: number;
|
||||
thirdPartyEncodedBytes: number;
|
||||
byResourceType: Record<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
largestRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
status?: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}[];
|
||||
failedRequests: {
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
errorText?: string;
|
||||
status?: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type BrowserMeasurement = {
|
||||
label: string;
|
||||
timestamp: string;
|
||||
url: string;
|
||||
scenario: string;
|
||||
durationMs: number;
|
||||
network: NetworkSummary;
|
||||
performance: {
|
||||
cdpMetrics: Record<string, number>;
|
||||
runtimeHeap?: {
|
||||
usedSize: number;
|
||||
totalSize: number;
|
||||
};
|
||||
webVitals: {
|
||||
firstPaintMs?: number;
|
||||
firstContentfulPaintMs?: number;
|
||||
domContentLoadedEventEndMs?: number;
|
||||
loadEventEndMs?: number;
|
||||
longTaskCount: number;
|
||||
longTaskDurationMs: number;
|
||||
maxLongTaskDurationMs: number;
|
||||
resourceEntryCount: number;
|
||||
domElements: number;
|
||||
};
|
||||
};
|
||||
heapSnapshot: HeapSnapshotData;
|
||||
};
|
||||
|
||||
type BrowserMeasurementSample = BrowserMeasurement & {
|
||||
round: number;
|
||||
};
|
||||
@@ -129,201 +35,6 @@ type BrowserMetricsReport = {
|
||||
samples: BrowserMeasurementSample[];
|
||||
};
|
||||
|
||||
class CdpClient {
|
||||
private nextId = 1;
|
||||
private callbacks = new Map<number, {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
}>();
|
||||
private eventHandlers = new Map<string, Set<(params: any) => void>>();
|
||||
private ws: WebSocket;
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
ws.addEventListener('message', event => {
|
||||
const message = JSON.parse(String(event.data)) as CdpResponse;
|
||||
if (message.id != null) {
|
||||
const callback = this.callbacks.get(message.id);
|
||||
if (callback == null) return;
|
||||
this.callbacks.delete(message.id);
|
||||
if (message.error != null) {
|
||||
callback.reject(new Error(`${message.error.message} (${message.error.code})`));
|
||||
} else {
|
||||
callback.resolve(message.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method != null) {
|
||||
for (const handler of this.eventHandlers.get(message.method) ?? []) {
|
||||
handler(message.params);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
for (const callback of this.callbacks.values()) {
|
||||
callback.reject(new Error('CDP websocket closed'));
|
||||
}
|
||||
this.callbacks.clear();
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(wsUrl: string) {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
ws.addEventListener('open', () => resolvePromise(), { once: true });
|
||||
ws.addEventListener('error', () => reject(new Error(`Failed to connect to ${wsUrl}`)), { once: true });
|
||||
});
|
||||
return new CdpClient(ws);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: any) => void) {
|
||||
const handlers = this.eventHandlers.get(method) ?? new Set();
|
||||
handlers.add(handler);
|
||||
this.eventHandlers.set(method, handlers);
|
||||
}
|
||||
|
||||
send<T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
|
||||
return new Promise<T>((resolvePromise, reject) => {
|
||||
this.callbacks.set(id, {
|
||||
resolve: resolvePromise,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
function findChrome() {
|
||||
const envChrome = process.env.CHROME_BIN ?? process.env.GOOGLE_CHROME_BIN;
|
||||
if (envChrome != null && envChrome !== '') return envChrome;
|
||||
|
||||
const candidates = process.platform === 'win32'
|
||||
? [
|
||||
'chrome.exe',
|
||||
'msedge.exe',
|
||||
]
|
||||
: [
|
||||
'google-chrome',
|
||||
'google-chrome-stable',
|
||||
'chromium',
|
||||
'chromium-browser',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync(candidate, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.status === 0) return candidate;
|
||||
}
|
||||
|
||||
throw new Error('Could not find Chrome or Chromium. Set CHROME_BIN to the browser executable.');
|
||||
}
|
||||
|
||||
async function launchChrome(label: string): Promise<ChromeHandle> {
|
||||
const chrome = findChrome();
|
||||
const port = label === 'base' ? 9222 : 9223;
|
||||
const userDataDir = await mkdtemp(join(tmpdir(), `misskey-browser-metrics-${label}-`));
|
||||
const child = spawn(chrome, [
|
||||
'--headless=new',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-background-networking',
|
||||
'--disable-default-apps',
|
||||
'--disable-extensions',
|
||||
'--disable-sync',
|
||||
'--metrics-recording-only',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--no-sandbox',
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'about:blank',
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`));
|
||||
child.stderr.on('data', data => process.stderr.write(`[chrome:${label}] ${data}`));
|
||||
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 30_000) {
|
||||
if (child.exitCode != null) throw new Error(`Chrome exited early with code ${child.exitCode}`);
|
||||
try {
|
||||
await fetchJson(`http://127.0.0.1:${port}/json/version`);
|
||||
return {
|
||||
process: child,
|
||||
port,
|
||||
userDataDir,
|
||||
};
|
||||
} catch {
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for Chrome DevTools Protocol');
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child: ChildProcessWithoutNullStreams) {
|
||||
await new Promise<void>(resolvePromise => {
|
||||
if (child.exitCode != null) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
const killTimer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolvePromise();
|
||||
}, 5_000).unref();
|
||||
child.once('exit', () => {
|
||||
clearTimeout(killTimer);
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function closeChrome(handle: ChromeHandle) {
|
||||
if (handle.process.exitCode == null) {
|
||||
handle.process.kill();
|
||||
}
|
||||
await waitForProcessExit(handle.process);
|
||||
await rm(handle.userDataDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async function connectPage(port: number) {
|
||||
const page = await fetchJson<{ webSocketDebuggerUrl: string }>(
|
||||
`http://127.0.0.1:${port}/json/new?${encodeURIComponent('about:blank')}`,
|
||||
{ method: 'PUT' },
|
||||
).catch(async () => await fetchJson<{ webSocketDebuggerUrl: string }>(
|
||||
`http://127.0.0.1:${port}/json/new?${encodeURIComponent('about:blank')}`,
|
||||
));
|
||||
return await CdpClient.connect(page.webSocketDebuggerUrl);
|
||||
}
|
||||
|
||||
function startServer(label: string, repoDir: string) {
|
||||
process.stderr.write(`[${label}] Starting Misskey test server\n`);
|
||||
const child = spawn(util.commandName('pnpm'), ['start:test'], {
|
||||
@@ -383,7 +94,7 @@ async function waitForServer(child: ChildProcessWithoutNullStreams) {
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await sleep(1_000);
|
||||
await util.sleep(1_000);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${baseUrl}`);
|
||||
}
|
||||
@@ -412,307 +123,51 @@ async function prepareInstance() {
|
||||
});
|
||||
}
|
||||
|
||||
function installNetworkTracker(cdp: CdpClient): NetworkRequest[] {
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const requestRows: NetworkRequest[] = [];
|
||||
|
||||
cdp.on('Network.requestWillBeSent', params => {
|
||||
if (params.request?.url == null) return;
|
||||
const row: NetworkRequest = {
|
||||
requestId: params.requestId,
|
||||
url: params.request.url,
|
||||
method: params.request.method ?? 'GET',
|
||||
resourceType: params.type ?? 'Other',
|
||||
startedAt: params.timestamp ?? 0,
|
||||
encodedDataLength: 0,
|
||||
decodedBodyLength: 0,
|
||||
fromDiskCache: false,
|
||||
fromServiceWorker: false,
|
||||
finished: false,
|
||||
failed: false,
|
||||
};
|
||||
requests.set(params.requestId, row);
|
||||
requestRows.push(row);
|
||||
});
|
||||
|
||||
cdp.on('Network.responseReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.status = params.response?.status;
|
||||
row.mimeType = params.response?.mimeType;
|
||||
row.fromDiskCache = params.response?.fromDiskCache === true;
|
||||
row.fromServiceWorker = params.response?.fromServiceWorker === true;
|
||||
});
|
||||
|
||||
cdp.on('Network.dataReceived', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.decodedBodyLength += params.dataLength ?? 0;
|
||||
row.encodedDataLength += params.encodedDataLength ?? 0;
|
||||
});
|
||||
|
||||
cdp.on('Network.loadingFinished', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.finished = true;
|
||||
row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0);
|
||||
});
|
||||
|
||||
cdp.on('Network.loadingFailed', params => {
|
||||
const row = requests.get(params.requestId);
|
||||
if (row == null) return;
|
||||
row.failed = true;
|
||||
row.finished = true;
|
||||
row.errorText = params.errorText;
|
||||
});
|
||||
|
||||
return requestRows;
|
||||
}
|
||||
|
||||
function isMeasurableRequest(row: NetworkRequest) {
|
||||
return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:');
|
||||
}
|
||||
|
||||
function summarizeNetwork(requestRows: NetworkRequest[]): NetworkSummary {
|
||||
const origin = new URL(baseUrl).origin;
|
||||
const rows = requestRows.filter(isMeasurableRequest);
|
||||
const byResourceType = {} as NetworkSummary['byResourceType'];
|
||||
|
||||
for (const row of rows) {
|
||||
const summary = byResourceType[row.resourceType] ?? {
|
||||
requests: 0,
|
||||
encodedBytes: 0,
|
||||
decodedBodyBytes: 0,
|
||||
};
|
||||
summary.requests += 1;
|
||||
summary.encodedBytes += row.encodedDataLength;
|
||||
summary.decodedBodyBytes += row.decodedBodyLength;
|
||||
byResourceType[row.resourceType] = summary;
|
||||
}
|
||||
|
||||
function isSameOrigin(url: string) {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount: rows.length,
|
||||
finishedRequestCount: rows.filter(row => row.finished).length,
|
||||
failedRequestCount: rows.filter(row => row.failed).length,
|
||||
cachedRequestCount: rows.filter(row => row.fromDiskCache).length,
|
||||
serviceWorkerRequestCount: rows.filter(row => row.fromServiceWorker).length,
|
||||
totalEncodedBytes: rows.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
totalDecodedBodyBytes: rows.reduce((sum, row) => sum + row.decodedBodyLength, 0),
|
||||
sameOriginEncodedBytes: rows
|
||||
.filter(row => isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
thirdPartyEncodedBytes: rows
|
||||
.filter(row => !isSameOrigin(row.url))
|
||||
.reduce((sum, row) => sum + row.encodedDataLength, 0),
|
||||
byResourceType,
|
||||
largestRequests: rows
|
||||
.toSorted((a, b) => b.encodedDataLength - a.encodedDataLength)
|
||||
.slice(0, 15)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
status: row.status,
|
||||
encodedBytes: row.encodedDataLength,
|
||||
decodedBodyBytes: row.decodedBodyLength,
|
||||
})),
|
||||
failedRequests: rows
|
||||
.filter(row => row.failed)
|
||||
.map(row => ({
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
resourceType: row.resourceType,
|
||||
errorText: row.errorText,
|
||||
status: row.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluate<T>(cdp: CdpClient, expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
const result = await cdp.send<{
|
||||
result: { value: T };
|
||||
exceptionDetails?: unknown;
|
||||
}>('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
if (result.exceptionDetails != null) {
|
||||
throw new Error(`Runtime.evaluate failed: ${JSON.stringify(result.exceptionDetails)}`);
|
||||
}
|
||||
|
||||
return result.result.value;
|
||||
}
|
||||
|
||||
function selectorReadyExpression(selector: string, options: { visible?: boolean; enabled?: boolean } = {}) {
|
||||
return `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (el == null) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (${options.visible === true ? 'true' : 'false'} && (style.visibility === 'hidden' || style.display === 'none' || rect.width === 0 || rect.height === 0)) return false;
|
||||
if (${options.enabled === true ? 'true' : 'false'} && (el.disabled || el.getAttribute('aria-disabled') === 'true')) return false;
|
||||
return true;
|
||||
})()`;
|
||||
}
|
||||
|
||||
async function waitForSelector(cdp: CdpClient, selector: string, options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? scenarioTimeoutMs;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const ready = await evaluate<boolean>(cdp, selectorReadyExpression(selector, options), 5_000);
|
||||
if (ready) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForAnySelector(cdp: CdpClient, selectors: string[], options: { timeoutMs?: number; visible?: boolean; enabled?: boolean } = {}) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? scenarioTimeoutMs;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
for (const selector of selectors) {
|
||||
const ready = await evaluate<boolean>(cdp, selectorReadyExpression(selector, options), 5_000);
|
||||
if (ready) return selector;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function click(cdp: CdpClient, selector: string) {
|
||||
const found = await waitForSelector(cdp, selector, { visible: true, enabled: true });
|
||||
if (!found) throw new Error(`Selector was not clickable: ${selector}`);
|
||||
await evaluate<void>(cdp, `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (el == null) throw new Error('Element not found');
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
el.click();
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function maybeClick(cdp: CdpClient, selector: string, timeoutMs = 3_000) {
|
||||
if (await waitForSelector(cdp, selector, { visible: true, enabled: true, timeoutMs })) {
|
||||
await click(cdp, selector);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function setValue(cdp: CdpClient, selector: string, value: string) {
|
||||
const found = await waitForSelector(cdp, selector, { visible: true, enabled: true });
|
||||
if (!found) throw new Error(`Selector was not editable: ${selector}`);
|
||||
await evaluate<void>(cdp, `(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (el == null) throw new Error('Element not found');
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
el.focus();
|
||||
const proto = Object.getPrototypeOf(el);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
if (descriptor?.set != null) {
|
||||
descriptor.set.call(el, ${JSON.stringify(value)});
|
||||
} else {
|
||||
el.value = ${JSON.stringify(value)};
|
||||
}
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(value)} }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function waitForText(cdp: CdpClient, text: string, timeoutMs = scenarioTimeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const found = await evaluate<boolean>(cdp, `document.body?.innerText?.includes(${JSON.stringify(text)}) === true`, 5_000);
|
||||
if (found) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runSignupAndPostScenario(cdp: CdpClient) {
|
||||
async function runSignupAndPostScenario(chrome: Chrome) {
|
||||
const noteText = `Frontend browser metrics ${Date.now()}`;
|
||||
|
||||
await cdp.send('Page.navigate', { url: `${baseUrl}/` });
|
||||
const initialSelector = await waitForAnySelector(cdp, ['[data-cy-signup]', '[data-cy-open-post-form]'], { visible: true, timeoutMs: scenarioTimeoutMs });
|
||||
await chrome.cdp.send('Page.navigate', { url: `${baseUrl}/` });
|
||||
const initialSelector = await chrome.waitForAnySelector(['[data-cy-signup]', '[data-cy-open-post-form]'], { visible: true, timeoutMs: scenarioTimeoutMs });
|
||||
if (initialSelector == null) throw new Error('Timed out waiting for the signup or timeline entry point');
|
||||
|
||||
if (await waitForSelector(cdp, '[data-cy-signup]', { visible: true, enabled: true, timeoutMs: 5_000 })) {
|
||||
await click(cdp, '[data-cy-signup]');
|
||||
if (await chrome.waitForSelector('[data-cy-signup]', { visible: true, enabled: true, timeoutMs: 5_000 })) {
|
||||
await chrome.click('[data-cy-signup]');
|
||||
|
||||
if (await waitForSelector(cdp, '[data-cy-signup-rules-continue]', { visible: true, timeoutMs: 5_000 })) {
|
||||
await click(cdp, '[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]');
|
||||
await maybeClick(cdp, '[data-cy-modal-dialog-ok]', 5_000);
|
||||
await click(cdp, '[data-cy-signup-rules-continue]');
|
||||
if (await chrome.waitForSelector('[data-cy-signup-rules-continue]', { visible: true, timeoutMs: 5_000 })) {
|
||||
await chrome.click('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]');
|
||||
await chrome.maybeClick('[data-cy-modal-dialog-ok]', 5_000);
|
||||
await chrome.click('[data-cy-signup-rules-continue]');
|
||||
}
|
||||
|
||||
await setValue(cdp, '[data-cy-signup-username] input', 'alice');
|
||||
await setValue(cdp, '[data-cy-signup-password] input', 'alice1234');
|
||||
await setValue(cdp, '[data-cy-signup-password-retype] input', 'alice1234');
|
||||
if (await waitForSelector(cdp, '[data-cy-signup-invitation-code] input', { visible: true, enabled: true, timeoutMs: 2_000 })) {
|
||||
await setValue(cdp, '[data-cy-signup-invitation-code] input', 'test-invitation-code');
|
||||
await chrome.setValue('[data-cy-signup-username] input', 'alice');
|
||||
await chrome.setValue('[data-cy-signup-password] input', 'alice1234');
|
||||
await chrome.setValue('[data-cy-signup-password-retype] input', 'alice1234');
|
||||
if (await chrome.waitForSelector('[data-cy-signup-invitation-code] input', { visible: true, enabled: true, timeoutMs: 2_000 })) {
|
||||
await chrome.setValue('[data-cy-signup-invitation-code] input', 'test-invitation-code');
|
||||
}
|
||||
await click(cdp, '[data-cy-signup-submit]');
|
||||
await chrome.click('[data-cy-signup-submit]');
|
||||
}
|
||||
|
||||
const firstReadySelector = await waitForAnySelector(cdp, [
|
||||
const firstReadySelector = await chrome.waitForAnySelector([
|
||||
'[data-cy-user-setup] [data-cy-modal-window-close]',
|
||||
'[data-cy-open-post-form]',
|
||||
], { visible: true, enabled: true, timeoutMs: scenarioTimeoutMs });
|
||||
if (firstReadySelector == null) throw new Error('Timed out waiting for signed-in home timeline');
|
||||
|
||||
if (firstReadySelector === '[data-cy-user-setup] [data-cy-modal-window-close]') {
|
||||
await click(cdp, '[data-cy-user-setup] [data-cy-modal-window-close]');
|
||||
await maybeClick(cdp, '[data-cy-modal-dialog-ok]', 5_000);
|
||||
await chrome.click('[data-cy-user-setup] [data-cy-modal-window-close]');
|
||||
await chrome.maybeClick('[data-cy-modal-dialog-ok]', 5_000);
|
||||
}
|
||||
|
||||
await click(cdp, '[data-cy-open-post-form]');
|
||||
await setValue(cdp, '[data-cy-post-form-text]', noteText);
|
||||
await click(cdp, '[data-cy-open-post-form-submit]');
|
||||
await chrome.click('[data-cy-open-post-form]');
|
||||
await chrome.setValue('[data-cy-post-form-text]', noteText);
|
||||
await chrome.click('[data-cy-open-post-form-submit]');
|
||||
|
||||
if (!await waitForText(cdp, noteText, scenarioTimeoutMs)) {
|
||||
if (!await chrome.waitForText(noteText, scenarioTimeoutMs)) {
|
||||
throw new Error('The first timeline note did not appear');
|
||||
}
|
||||
|
||||
await sleep(settleMs);
|
||||
}
|
||||
|
||||
async function collectPerformance(cdp: CdpClient): Promise<BrowserMeasurement['performance']> {
|
||||
const cdpMetricsResult = await cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics');
|
||||
const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value]));
|
||||
const runtimeHeap = await cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined);
|
||||
const webVitals = await evaluate<BrowserMeasurement['performance']['webVitals']>(cdp, `(() => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0];
|
||||
const paintEntries = Object.fromEntries(performance.getEntriesByType('paint').map(entry => [entry.name, entry.startTime]));
|
||||
const longTasks = performance.getEntriesByType('longtask');
|
||||
const resourceEntries = performance.getEntriesByType('resource');
|
||||
return {
|
||||
firstPaintMs: paintEntries['first-paint'],
|
||||
firstContentfulPaintMs: paintEntries['first-contentful-paint'],
|
||||
domContentLoadedEventEndMs: navigation?.domContentLoadedEventEnd,
|
||||
loadEventEndMs: navigation?.loadEventEnd,
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskDurationMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskDurationMs: longTasks.reduce((max, entry) => Math.max(max, entry.duration), 0),
|
||||
resourceEntryCount: resourceEntries.length,
|
||||
domElements: document.getElementsByTagName('*').length,
|
||||
};
|
||||
})()`);
|
||||
|
||||
return {
|
||||
cdpMetrics,
|
||||
runtimeHeap,
|
||||
webVitals,
|
||||
};
|
||||
await util.sleep(settleMs);
|
||||
}
|
||||
|
||||
function emptyHeapSnapshotData(): HeapSnapshotData {
|
||||
@@ -729,17 +184,6 @@ function emptyHeapSnapshotData(): HeapSnapshotData {
|
||||
};
|
||||
}
|
||||
|
||||
function categorizeHeapNode(type: string, name: string): keyof typeof heapSnapshotCategory {
|
||||
if (/^(ArrayBuffer|SharedArrayBuffer|DataView|(?:Big)?(?:Int|Uint|Float)(?:8|16|32|64)?(?:Clamped)?Array)$/u.test(name)) return 'typedArrays';
|
||||
if (type === 'code' || type === 'closure') return 'code';
|
||||
if (type === 'string' || type === 'concatenated string' || type === 'sliced string' || type === 'symbol') return 'strings';
|
||||
if (type === 'array') return 'jsArrays';
|
||||
if (type === 'hidden' || type === 'synthetic' || type === 'object shape') return 'systemObjects';
|
||||
if (type === 'native') return 'otherNonJsObjects';
|
||||
if (type === 'object' || type === 'regexp' || type === 'number' || type === 'bigint') return 'otherJsObjects';
|
||||
return 'otherNonJsObjects';
|
||||
}
|
||||
|
||||
function collapseBreakdown(entries: Map<string, number>) {
|
||||
const sorted = [...entries]
|
||||
.filter(([, value]) => value > 0)
|
||||
@@ -753,49 +197,6 @@ function collapseBreakdown(entries: Map<string, number>) {
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshot(snapshot: any): HeapSnapshotData {
|
||||
const result = emptyHeapSnapshotData();
|
||||
const nodeFields = snapshot.snapshot.meta.node_fields as string[];
|
||||
const nodeTypes = snapshot.snapshot.meta.node_types as unknown[][];
|
||||
const nodes = snapshot.nodes as number[];
|
||||
const strings = snapshot.strings as string[];
|
||||
const fieldCount = nodeFields.length;
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
const typeNames = nodeTypes[typeOffset] as string[];
|
||||
const breakdownMaps = {} as Record<keyof typeof heapSnapshotCategory, Map<string, number>>;
|
||||
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category !== 'total') breakdownMaps[category] = new Map();
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
|
||||
const type = typeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||
const name = strings[nodes[offset + nameOffset]] ?? '';
|
||||
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
|
||||
const category = categorizeHeapNode(type, name);
|
||||
|
||||
result.categories.total += selfSize;
|
||||
result.nodeCounts.total += 1;
|
||||
result.categories[category] += selfSize;
|
||||
result.nodeCounts[category] += 1;
|
||||
|
||||
const label = `${type}: ${name || '(anonymous)'}`;
|
||||
breakdownMaps[category].set(label, (breakdownMaps[category].get(label) ?? 0) + selfSize);
|
||||
}
|
||||
|
||||
result.breakdowns = {} as HeapSnapshotData['breakdowns'];
|
||||
for (const [category, entries] of Object.entries(breakdownMaps) as [keyof typeof heapSnapshotCategory, Map<string, number>][]) {
|
||||
const collapsed = collapseBreakdown(entries);
|
||||
if (Object.keys(collapsed).length > 0) {
|
||||
result.breakdowns[category] = collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) {
|
||||
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
|
||||
if (finiteValues.length === 0) return defaultValue;
|
||||
@@ -899,6 +300,60 @@ function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): Brows
|
||||
};
|
||||
}
|
||||
|
||||
function categorizeHeapNode(type: string, name: string): keyof typeof heapSnapshotCategory {
|
||||
if (/^(ArrayBuffer|SharedArrayBuffer|DataView|(?:Big)?(?:Int|Uint|Float)(?:8|16|32|64)?(?:Clamped)?Array)$/u.test(name)) return 'typedArrays';
|
||||
if (type === 'code' || type === 'closure') return 'code';
|
||||
if (type === 'string' || type === 'concatenated string' || type === 'sliced string' || type === 'symbol') return 'strings';
|
||||
if (type === 'array') return 'jsArrays';
|
||||
if (type === 'hidden' || type === 'synthetic' || type === 'object shape') return 'systemObjects';
|
||||
if (type === 'native') return 'otherNonJsObjects';
|
||||
if (type === 'object' || type === 'regexp' || type === 'number' || type === 'bigint') return 'otherJsObjects';
|
||||
return 'otherNonJsObjects';
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshot(snapshot: any): HeapSnapshotData {
|
||||
const result = emptyHeapSnapshotData();
|
||||
const nodeFields = snapshot.snapshot.meta.node_fields as string[];
|
||||
const nodeTypes = snapshot.snapshot.meta.node_types as unknown[][];
|
||||
const nodes = snapshot.nodes as number[];
|
||||
const strings = snapshot.strings as string[];
|
||||
const fieldCount = nodeFields.length;
|
||||
const typeOffset = nodeFields.indexOf('type');
|
||||
const nameOffset = nodeFields.indexOf('name');
|
||||
const selfSizeOffset = nodeFields.indexOf('self_size');
|
||||
const typeNames = nodeTypes[typeOffset] as string[];
|
||||
const breakdownMaps = {} as Record<keyof typeof heapSnapshotCategory, Map<string, number>>;
|
||||
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category !== 'total') breakdownMaps[category] = new Map();
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < nodes.length; offset += fieldCount) {
|
||||
const type = typeNames[nodes[offset + typeOffset]] ?? 'unknown';
|
||||
const name = strings[nodes[offset + nameOffset]] ?? '';
|
||||
const selfSize = nodes[offset + selfSizeOffset] ?? 0;
|
||||
const category = categorizeHeapNode(type, name);
|
||||
|
||||
result.categories.total += selfSize;
|
||||
result.nodeCounts.total += 1;
|
||||
result.categories[category] += selfSize;
|
||||
result.nodeCounts[category] += 1;
|
||||
|
||||
const label = `${type}: ${name || '(anonymous)'}`;
|
||||
breakdownMaps[category].set(label, (breakdownMaps[category].get(label) ?? 0) + selfSize);
|
||||
}
|
||||
|
||||
result.breakdowns = {} as HeapSnapshotData['breakdowns'];
|
||||
for (const [category, entries] of Object.entries(breakdownMaps) as [keyof typeof heapSnapshotCategory, Map<string, number>][]) {
|
||||
const collapsed = collapseBreakdown(entries);
|
||||
if (Object.keys(collapsed).length > 0) {
|
||||
result.breakdowns[category] = collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) {
|
||||
const summary = emptyHeapSnapshotData();
|
||||
|
||||
@@ -958,47 +413,21 @@ function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSam
|
||||
};
|
||||
}
|
||||
|
||||
async function takeHeapSnapshot(cdp: CdpClient, savePath?: string) {
|
||||
const chunks: string[] = [];
|
||||
cdp.on('HeapProfiler.addHeapSnapshotChunk', params => {
|
||||
chunks.push(params.chunk);
|
||||
});
|
||||
|
||||
await cdp.send('HeapProfiler.enable');
|
||||
await cdp.send('HeapProfiler.collectGarbage');
|
||||
await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
||||
|
||||
const content = chunks.join('');
|
||||
if (savePath != null) {
|
||||
await writeFile(savePath, content);
|
||||
}
|
||||
|
||||
return summarizeHeapSnapshot(JSON.parse(content));
|
||||
}
|
||||
|
||||
async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) {
|
||||
let chrome: ChromeHandle | null = null;
|
||||
let cdp: CdpClient | null = null;
|
||||
let chrome: Chrome | null = null;
|
||||
|
||||
try {
|
||||
await prepareInstance();
|
||||
|
||||
chrome = await launchChrome(label);
|
||||
cdp = await connectPage(chrome.port);
|
||||
|
||||
const networkRequests = installNetworkTracker(cdp);
|
||||
await cdp.send('Network.enable');
|
||||
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
|
||||
await cdp.send('Network.setBypassServiceWorker', { bypass: true });
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
await cdp.send('Performance.enable');
|
||||
chrome = await Chrome.create(label, { scenarioTimeoutMs });
|
||||
await chrome.enableNetworkTracking();
|
||||
|
||||
const startedAt = Date.now();
|
||||
await runSignupAndPostScenario(cdp);
|
||||
await runSignupAndPostScenario(chrome);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const performance = await collectPerformance(cdp);
|
||||
const heapSnapshot = await takeHeapSnapshot(cdp, heapSnapshotSavePath);
|
||||
const performance = await chrome.collectPerformance();
|
||||
const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath);
|
||||
const heapSnapshot = summarizeHeapSnapshot(heapSnapshotRaw);
|
||||
const measurement: BrowserMeasurementSample = {
|
||||
label,
|
||||
round,
|
||||
@@ -1006,15 +435,14 @@ async function measureSample(label: 'base' | 'head', round: number, heapSnapshot
|
||||
url: baseUrl,
|
||||
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
|
||||
durationMs,
|
||||
network: summarizeNetwork(networkRequests),
|
||||
network: summarizeNetwork(chrome.networkRequests, baseUrl),
|
||||
performance,
|
||||
heapSnapshot,
|
||||
};
|
||||
|
||||
return measurement;
|
||||
} finally {
|
||||
cdp?.close();
|
||||
if (chrome != null) await closeChrome(chrome);
|
||||
if (chrome != null) await chrome.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,10 +492,6 @@ async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath:
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) {
|
||||
throw new Error('Usage: node measure-frontend-browser-comparison.mts <base-dir> <head-dir> <base-output.json> <head-output.json> [head-heap-snapshot.heapsnapshot]');
|
||||
}
|
||||
|
||||
await measureRepo('base', resolve(baseDirArg), resolve(baseOutputArg));
|
||||
await measureRepo('head', resolve(headDirArg), resolve(headOutputArg), headHeapSnapshotOutputArg == null ? undefined : resolve(headHeapSnapshotOutputArg));
|
||||
}
|
||||
|
||||
4
.github/scripts/utility.mts
vendored
4
.github/scripts/utility.mts
vendored
@@ -9,6 +9,10 @@ import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
||||
}
|
||||
|
||||
export function median(values: number[]) {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const center = Math.floor(sorted.length / 2);
|
||||
|
||||
Reference in New Issue
Block a user