mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-31 04:15:51 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8e959c64c |
@@ -0,0 +1,271 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as util from './utility.mts';
|
||||||
|
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
|
||||||
|
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||||
|
|
||||||
|
export type BrowserMeasurement = {
|
||||||
|
label: string;
|
||||||
|
timestamp: string;
|
||||||
|
url: string;
|
||||||
|
scenario: string;
|
||||||
|
durationMs: number;
|
||||||
|
network: {
|
||||||
|
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;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeCell(value: string) {
|
||||||
|
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(value: string, maxLength = 140) {
|
||||||
|
if (value.length <= maxLength) return value;
|
||||||
|
return `${value.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMs(value: number | null | undefined) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '-';
|
||||||
|
if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`;
|
||||||
|
return `${util.formatNumber(value)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSecondsAsMs(value: number | null | undefined) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '-';
|
||||||
|
return formatMs(value * 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDelta(value: number, formatter: (value: number) => string) {
|
||||||
|
if (value === 0) return formatter(0);
|
||||||
|
return util.formatColoredDelta(formatter(Math.abs(value)), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricRow(label: string, baseValue: number | null | undefined, headValue: number | null | undefined, formatter: (value: number) => string) {
|
||||||
|
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
|
||||||
|
const delta = headValue - baseValue;
|
||||||
|
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${formatDelta(delta, formatter)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} |`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
|
||||||
|
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetric(report: BrowserMeasurement, key: string) {
|
||||||
|
return report.performance.cdpMetrics[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryTable(base: BrowserMeasurement, head: BrowserMeasurement) {
|
||||||
|
const rows = [
|
||||||
|
metricRow('Scenario duration', base.durationMs, head.durationMs, formatMs),
|
||||||
|
metricRow('Requests', base.network.requestCount, head.network.requestCount, util.formatNumber),
|
||||||
|
metricRow('Failed requests', base.network.failedRequestCount, head.network.failedRequestCount, util.formatNumber),
|
||||||
|
metricRow('Encoded network', base.network.totalEncodedBytes, head.network.totalEncodedBytes, util.formatBytes),
|
||||||
|
metricRow('Decoded body', base.network.totalDecodedBodyBytes, head.network.totalDecodedBodyBytes, util.formatBytes),
|
||||||
|
metricRow('Same-origin encoded', base.network.sameOriginEncodedBytes, head.network.sameOriginEncodedBytes, util.formatBytes),
|
||||||
|
metricRow('Third-party encoded', base.network.thirdPartyEncodedBytes, head.network.thirdPartyEncodedBytes, util.formatBytes),
|
||||||
|
metricRow('Script encoded', resourceTypeBytes(base, ['Script']), resourceTypeBytes(head, ['Script']), util.formatBytes),
|
||||||
|
metricRow('Stylesheet encoded', resourceTypeBytes(base, ['Stylesheet']), resourceTypeBytes(head, ['Stylesheet']), util.formatBytes),
|
||||||
|
metricRow('Fetch/XHR encoded', resourceTypeBytes(base, ['Fetch', 'XHR']), resourceTypeBytes(head, ['Fetch', 'XHR']), util.formatBytes),
|
||||||
|
metricRow('Image encoded', resourceTypeBytes(base, ['Image']), resourceTypeBytes(head, ['Image']), util.formatBytes),
|
||||||
|
metricRow('Font encoded', resourceTypeBytes(base, ['Font']), resourceTypeBytes(head, ['Font']), util.formatBytes),
|
||||||
|
metricRow('First contentful paint', base.performance.webVitals.firstContentfulPaintMs, head.performance.webVitals.firstContentfulPaintMs, formatMs),
|
||||||
|
metricRow('Load event end', base.performance.webVitals.loadEventEndMs, head.performance.webVitals.loadEventEndMs, formatMs),
|
||||||
|
metricRow('Long tasks', base.performance.webVitals.longTaskCount, head.performance.webVitals.longTaskCount, util.formatNumber),
|
||||||
|
metricRow('Long task duration', base.performance.webVitals.longTaskDurationMs, head.performance.webVitals.longTaskDurationMs, formatMs),
|
||||||
|
metricRow('Max long task', base.performance.webVitals.maxLongTaskDurationMs, head.performance.webVitals.maxLongTaskDurationMs, formatMs),
|
||||||
|
metricRow('JS heap used', base.performance.runtimeHeap?.usedSize ?? getMetric(base, 'JSHeapUsedSize'), head.performance.runtimeHeap?.usedSize ?? getMetric(head, 'JSHeapUsedSize'), util.formatBytes),
|
||||||
|
metricRow('JS heap total', base.performance.runtimeHeap?.totalSize ?? getMetric(base, 'JSHeapTotalSize'), head.performance.runtimeHeap?.totalSize ?? getMetric(head, 'JSHeapTotalSize'), util.formatBytes),
|
||||||
|
metricRow('V8 heap snapshot total', base.heapSnapshot.categories.total, head.heapSnapshot.categories.total, util.formatBytes),
|
||||||
|
metricRow('DOM elements', base.performance.webVitals.domElements, head.performance.webVitals.domElements, util.formatNumber),
|
||||||
|
metricRow('CDP nodes', getMetric(base, 'Nodes'), getMetric(head, 'Nodes'), util.formatNumber),
|
||||||
|
metricRow('JS event listeners', getMetric(base, 'JSEventListeners'), getMetric(head, 'JSEventListeners'), util.formatNumber),
|
||||||
|
metricRow('Layout count', getMetric(base, 'LayoutCount'), getMetric(head, 'LayoutCount'), util.formatNumber),
|
||||||
|
metricRow('Recalc style count', getMetric(base, 'RecalcStyleCount'), getMetric(head, 'RecalcStyleCount'), util.formatNumber),
|
||||||
|
metricRow('Script duration', getMetric(base, 'ScriptDuration'), getMetric(head, 'ScriptDuration'), formatSecondsAsMs),
|
||||||
|
metricRow('Task duration', getMetric(base, 'TaskDuration'), getMetric(head, 'TaskDuration'), formatSecondsAsMs),
|
||||||
|
].filter(row => row != null);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'| Metric | Base | Head | Δ | Δ (%) |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: |',
|
||||||
|
...rows,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResourceTypeTable(base: BrowserMeasurement, head: BrowserMeasurement) {
|
||||||
|
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
|
||||||
|
const keys = [...new Set([
|
||||||
|
...preferredOrder,
|
||||||
|
...Object.keys(base.network.byResourceType),
|
||||||
|
...Object.keys(head.network.byResourceType),
|
||||||
|
])].filter(key => base.network.byResourceType[key] != null || head.network.byResourceType[key] != null);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'| Type | Base reqs | Head reqs | Δ reqs | Base encoded | Head encoded | Δ encoded |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const baseRow = base.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
|
||||||
|
const headRow = head.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
|
||||||
|
lines.push(`| **${key}** | ${util.formatNumber(baseRow.requests)} | ${util.formatNumber(headRow.requests)} | ${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)} | ${util.formatBytes(baseRow.encodedBytes)} | ${util.formatBytes(headRow.encodedBytes)} | ${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeapSnapshotTable(base: BrowserMeasurement, head: BrowserMeasurement) {
|
||||||
|
const lines = [
|
||||||
|
'| Category | Base | Head | Δ | Δ (%) | Base nodes | Head nodes |',
|
||||||
|
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
|
||||||
|
const baseValue = base.heapSnapshot.categories[category];
|
||||||
|
const headValue = head.heapSnapshot.categories[category];
|
||||||
|
const baseNodeCount = base.heapSnapshot.nodeCounts[category];
|
||||||
|
const headNodeCount = head.heapSnapshot.nodeCounts[category];
|
||||||
|
const categoryInfo = heapSnapshotUtil.heapSnapshotCategory[category];
|
||||||
|
const metricText = `$\\color{${categoryInfo.color}}{\\rule{8pt}{8pt}}$ **${categoryInfo.label}**`;
|
||||||
|
lines.push(`| ${metricText} | ${util.formatBytes(baseValue)} | ${util.formatBytes(headValue)} | ${formatDelta(headValue - baseValue, util.formatBytes)} | ${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')} | ${util.formatNumber(baseNodeCount)} | ${util.formatNumber(headNodeCount)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLargestRequests(report: BrowserMeasurement, title: string) {
|
||||||
|
if (report.network.largestRequests.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`<details><summary>${title}</summary>`,
|
||||||
|
'',
|
||||||
|
'| Resource | Type | Status | Encoded | Decoded |',
|
||||||
|
'| --- | --- | ---: | ---: | ---: |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const request of report.network.largestRequests.slice(0, 10)) {
|
||||||
|
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '</details>');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFailedRequests(report: BrowserMeasurement, title: string) {
|
||||||
|
if (report.network.failedRequests.length === 0) return null;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`<details><summary>${title}</summary>`,
|
||||||
|
'',
|
||||||
|
'| Resource | Type | Status | Error |',
|
||||||
|
'| --- | --- | ---: | --- |',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const request of report.network.failedRequests.slice(0, 20)) {
|
||||||
|
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', '</details>');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeadHeapSankey(head: BrowserMeasurement) {
|
||||||
|
return heapSnapshotUtil.renderHeapSnapshotSankey({
|
||||||
|
summary: head.heapSnapshot,
|
||||||
|
samples: [{
|
||||||
|
round: 1,
|
||||||
|
data: head.heapSnapshot,
|
||||||
|
}],
|
||||||
|
}, 'Head browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderFrontendBrowserReport(base: BrowserMeasurement, head: BrowserMeasurement, options: {
|
||||||
|
headHeapSnapshotUrl?: string;
|
||||||
|
} = {}) {
|
||||||
|
const headHeapSnapshotUrl = options.headHeapSnapshotUrl;
|
||||||
|
const lines = [
|
||||||
|
'## Frontend Browser Metrics',
|
||||||
|
'',
|
||||||
|
renderSummaryTable(base, head),
|
||||||
|
'',
|
||||||
|
'_Measured once per side with a fresh headless Chrome profile, browser cache disabled, service workers bypassed, and a forced V8 GC before the heap snapshot. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible._',
|
||||||
|
'',
|
||||||
|
'<details>',
|
||||||
|
'<summary>Requests by resource type</summary>',
|
||||||
|
'',
|
||||||
|
renderResourceTypeTable(base, head),
|
||||||
|
'',
|
||||||
|
'</details>',
|
||||||
|
'',
|
||||||
|
'<details>',
|
||||||
|
'<summary>V8 heap snapshot statistics</summary>',
|
||||||
|
'',
|
||||||
|
renderHeapSnapshotTable(base, head),
|
||||||
|
'',
|
||||||
|
...(headHeapSnapshotUrl != null && headHeapSnapshotUrl !== '' ? [`[Download head heap snapshot](${headHeapSnapshotUrl})`, ''] : []),
|
||||||
|
'</details>',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const section of [
|
||||||
|
renderHeadHeapSankey(head),
|
||||||
|
renderLargestRequests(head, 'Largest head requests'),
|
||||||
|
renderFailedRequests(base, 'Failed base requests'),
|
||||||
|
renderFailedRequests(head, 'Failed head requests'),
|
||||||
|
]) {
|
||||||
|
if (section == null) continue;
|
||||||
|
lines.push(section, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n').trimEnd() + '\n';
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as util from './utility.mts';
|
import * as util from './utility.mts';
|
||||||
|
import { renderFrontendBrowserReport, type BrowserMeasurement } from './frontend-browser-report.mts';
|
||||||
|
|
||||||
const marker = '<!-- misskey-frontend-js-size -->';
|
const marker = '<!-- misskey-frontend-js-size -->';
|
||||||
|
|
||||||
@@ -477,11 +478,17 @@ function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerR
|
|||||||
}
|
}
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args;
|
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, beforeBrowserMetricsFile, afterBrowserMetricsFile, outFile] = args;
|
||||||
|
if (beforeDir == null || afterDir == null || beforeStatsFile == null || afterStatsFile == null || beforeBrowserMetricsFile == null || afterBrowserMetricsFile == null || outFile == null) {
|
||||||
|
throw new Error('Usage: node frontend-js-size.mts <before-dir> <after-dir> <before-stats.json> <after-stats.json> <before-browser.json> <after-browser.json> <output.md>');
|
||||||
|
}
|
||||||
|
|
||||||
const before = await collectReport(beforeDir);
|
const before = await collectReport(beforeDir);
|
||||||
const after = await collectReport(afterDir);
|
const after = await collectReport(afterDir);
|
||||||
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
|
||||||
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
|
||||||
|
const beforeBrowserMetrics = JSON.parse(await fs.readFile(beforeBrowserMetricsFile, 'utf8')) as BrowserMeasurement;
|
||||||
|
const afterBrowserMetrics = JSON.parse(await fs.readFile(afterBrowserMetricsFile, 'utf8')) as BrowserMeasurement;
|
||||||
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
const beforeVisualizerReport = collectVisualizerReport(beforeStats);
|
||||||
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
const afterVisualizerReport = collectVisualizerReport(afterStats);
|
||||||
const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
|
||||||
@@ -497,6 +504,10 @@ const body = [
|
|||||||
'',
|
'',
|
||||||
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
|
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
|
||||||
'',
|
'',
|
||||||
|
renderFrontendBrowserReport(beforeBrowserMetrics, afterBrowserMetrics, {
|
||||||
|
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL,
|
||||||
|
}),
|
||||||
|
'',
|
||||||
visualizerArtifactLink,
|
visualizerArtifactLink,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,841 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import { 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, type HeapSnapshotData } from './heap-snapshot-util.mts';
|
||||||
|
|
||||||
|
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2);
|
||||||
|
|
||||||
|
const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812';
|
||||||
|
const serverReadyTimeoutMs = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SERVER_READY_TIMEOUT_MS', 120_000, 1);
|
||||||
|
const scenarioTimeoutMs = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SCENARIO_TIMEOUT_MS', 90_000, 1);
|
||||||
|
const settleMs = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SETTLE_MS', 1_000, 0);
|
||||||
|
const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 8, 1);
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 closeChrome(handle: ChromeHandle) {
|
||||||
|
handle.process.kill();
|
||||||
|
await new Promise<void>(resolvePromise => {
|
||||||
|
if (handle.process.exitCode != null) {
|
||||||
|
resolvePromise();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle.process.once('exit', () => resolvePromise());
|
||||||
|
setTimeout(() => {
|
||||||
|
handle.process.kill('SIGKILL');
|
||||||
|
resolvePromise();
|
||||||
|
}, 5_000).unref();
|
||||||
|
});
|
||||||
|
await rm(handle.userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
env: process.env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: process.platform !== 'win32',
|
||||||
|
});
|
||||||
|
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||||
|
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopServer(child: ChildProcessWithoutNullStreams) {
|
||||||
|
if (child.exitCode != null) return;
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||||
|
} else if (child.pid != null) {
|
||||||
|
try {
|
||||||
|
process.kill(-child.pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>(resolvePromise => {
|
||||||
|
if (child.exitCode != null) {
|
||||||
|
resolvePromise();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
child.once('exit', () => resolvePromise());
|
||||||
|
setTimeout(() => {
|
||||||
|
if (child.pid != null) {
|
||||||
|
try {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
||||||
|
} else {
|
||||||
|
process.kill(-child.pid, 'SIGKILL');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolvePromise();
|
||||||
|
}, 10_000).unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForServer(child: ChildProcessWithoutNullStreams) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < serverReadyTimeoutMs) {
|
||||||
|
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/`, { redirect: 'manual' });
|
||||||
|
if (response.status < 500) return;
|
||||||
|
} catch {
|
||||||
|
// retry
|
||||||
|
}
|
||||||
|
await sleep(1_000);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${baseUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(endpoint: string, body: Record<string, unknown>) {
|
||||||
|
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`/api/${endpoint} returned ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareInstance() {
|
||||||
|
await api('reset-db', {});
|
||||||
|
await api('admin/accounts/create', {
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin1234',
|
||||||
|
setupPassword: 'example_password_please_change_this_or_you_will_get_hacked',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 });
|
||||||
|
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 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]');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 click(cdp, '[data-cy-signup-submit]');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstReadySelector = await waitForAnySelector(cdp, [
|
||||||
|
'[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 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]');
|
||||||
|
|
||||||
|
if (!await waitForText(cdp, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyHeapSnapshotData(): HeapSnapshotData {
|
||||||
|
const categories = {} as HeapSnapshotData['categories'];
|
||||||
|
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
|
||||||
|
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||||
|
categories[category] = 0;
|
||||||
|
nodeCounts[category] = 0;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
nodeCounts,
|
||||||
|
breakdowns: {} as HeapSnapshotData['breakdowns'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.toSorted((a, b) => b[1] - a[1]);
|
||||||
|
const topEntries = sorted.slice(0, heapSnapshotBreakdownTopN);
|
||||||
|
const otherValue = sorted
|
||||||
|
.slice(heapSnapshotBreakdownTopN)
|
||||||
|
.reduce((sum, [, value]) => sum + value, 0);
|
||||||
|
const collapsed = Object.fromEntries(topEntries);
|
||||||
|
if (otherValue > 0) collapsed.Other = otherValue;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) {
|
||||||
|
let server: ChildProcessWithoutNullStreams | null = null;
|
||||||
|
let chrome: ChromeHandle | null = null;
|
||||||
|
let cdp: CdpClient | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
server = startServer(label, repoDir);
|
||||||
|
await waitForServer(server);
|
||||||
|
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');
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await runSignupAndPostScenario(cdp);
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
const performance = await collectPerformance(cdp);
|
||||||
|
const heapSnapshot = await takeHeapSnapshot(cdp, heapSnapshotSavePath);
|
||||||
|
const measurement: BrowserMeasurement = {
|
||||||
|
label,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: baseUrl,
|
||||||
|
scenario: 'fresh browser signup, first timeline note, after the note becomes visible',
|
||||||
|
durationMs,
|
||||||
|
network: summarizeNetwork(networkRequests),
|
||||||
|
performance,
|
||||||
|
heapSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
await writeFile(outputPath, JSON.stringify(measurement, null, '\t'));
|
||||||
|
process.stderr.write(`[${label}] Wrote browser measurement to ${outputPath}\n`);
|
||||||
|
} finally {
|
||||||
|
cdp?.close();
|
||||||
|
if (chrome != null) await closeChrome(chrome);
|
||||||
|
if (server != null) await stopServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -27,6 +27,9 @@ on:
|
|||||||
- .node-version
|
- .node-version
|
||||||
- .github/scripts/utility.mts
|
- .github/scripts/utility.mts
|
||||||
- .github/scripts/frontend-js-size.mts
|
- .github/scripts/frontend-js-size.mts
|
||||||
|
- .github/scripts/frontend-browser-report.mts
|
||||||
|
- .github/scripts/heap-snapshot-util.mts
|
||||||
|
- .github/scripts/measure-frontend-browser-comparison.mts
|
||||||
- .github/workflows/frontend-bundle-report.yml
|
- .github/workflows/frontend-bundle-report.yml
|
||||||
- .github/workflows/frontend-bundle-report-comment.yml
|
- .github/workflows/frontend-bundle-report-comment.yml
|
||||||
|
|
||||||
@@ -181,8 +184,6 @@ jobs:
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const jsSizeMarker = '<!-- misskey-frontend-js-size -->';
|
const jsSizeMarker = '<!-- misskey-frontend-js-size -->';
|
||||||
const visualizerMarker = '<!-- misskey-frontend-bundle-visualizer -->';
|
|
||||||
const reportMarkers = [jsSizeMarker, visualizerMarker];
|
|
||||||
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
|
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-report');
|
||||||
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
|
const jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
|
||||||
const prNumberPath = path.join(reportDir, 'pr-number.txt');
|
const prNumberPath = path.join(reportDir, 'pr-number.txt');
|
||||||
@@ -291,7 +292,7 @@ jobs:
|
|||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
const previousReports = comments.filter((comment) =>
|
const previousReports = comments.filter((comment) =>
|
||||||
comment.user?.type === 'Bot' && reportMarkers.some((reportMarker) => comment.body?.includes(reportMarker)));
|
comment.user?.type === 'Bot' && comment.body?.includes(jsSizeMarker));
|
||||||
|
|
||||||
if (previousReports.length > 0) {
|
if (previousReports.length > 0) {
|
||||||
const [previous, ...duplicates] = previousReports;
|
const [previous, ...duplicates] = previousReports;
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ on:
|
|||||||
- .node-version
|
- .node-version
|
||||||
- .github/scripts/utility.mts
|
- .github/scripts/utility.mts
|
||||||
- .github/scripts/frontend-js-size.mts
|
- .github/scripts/frontend-js-size.mts
|
||||||
|
- .github/scripts/frontend-browser-report.mts
|
||||||
|
- .github/scripts/heap-snapshot-util.mts
|
||||||
|
- .github/scripts/measure-frontend-browser-comparison.mts
|
||||||
- .github/workflows/frontend-bundle-report.yml
|
- .github/workflows/frontend-bundle-report.yml
|
||||||
- .github/workflows/frontend-bundle-report-comment.yml
|
- .github/workflows/frontend-bundle-report-comment.yml
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: read
|
pull-requests: read
|
||||||
|
|
||||||
@@ -36,7 +40,11 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
report:
|
report:
|
||||||
name: Build frontend bundle report
|
name: Build frontend bundle report
|
||||||
|
needs:
|
||||||
|
- browser-report
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
supported: ${{ steps.check-base-visualizer.outputs.supported }}
|
||||||
env:
|
env:
|
||||||
FRONTEND_JS_SIZE_LOCALE: ja-JP
|
FRONTEND_JS_SIZE_LOCALE: ja-JP
|
||||||
steps:
|
steps:
|
||||||
@@ -135,6 +143,13 @@ jobs:
|
|||||||
archive: false
|
archive: false
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Download browser metrics
|
||||||
|
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: frontend-browser-report-part
|
||||||
|
path: ${{ runner.temp }}/frontend-browser-report
|
||||||
|
|
||||||
- name: Generate report markdown
|
- name: Generate report markdown
|
||||||
if: steps.check-base-visualizer.outputs.supported == 'true'
|
if: steps.check-base-visualizer.outputs.supported == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -143,9 +158,13 @@ jobs:
|
|||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||||
|
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ needs.browser-report.outputs.head-heap-snapshot-url }}
|
||||||
run: |
|
run: |
|
||||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||||
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-js-size-report.md"
|
BROWSER_REPORT_DIR="$RUNNER_TEMP/frontend-browser-report"
|
||||||
|
test -s "$BROWSER_REPORT_DIR/before-browser.json"
|
||||||
|
test -s "$BROWSER_REPORT_DIR/after-browser.json"
|
||||||
|
node after/.github/scripts/frontend-js-size.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$BROWSER_REPORT_DIR/before-browser.json" "$BROWSER_REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-js-size-report.md"
|
||||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||||
@@ -168,3 +187,114 @@ jobs:
|
|||||||
path: ${{ runner.temp }}/frontend-bundle-report/
|
path: ${{ runner.temp }}/frontend-bundle-report/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
browser-report:
|
||||||
|
name: Measure frontend browser metrics
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 75
|
||||||
|
outputs:
|
||||||
|
head-heap-snapshot-url: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
ports:
|
||||||
|
- 54312:5432
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: test-misskey
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
ports:
|
||||||
|
- 56312:6379
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout base
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||||
|
ref: ${{ github.event.pull_request.base.sha }}
|
||||||
|
path: before
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Checkout pull request
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
path: after
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v6.0.3
|
||||||
|
with:
|
||||||
|
package_json_file: after/package.json
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: after/.node-version
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: |
|
||||||
|
before/pnpm-lock.yaml
|
||||||
|
after/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies for base
|
||||||
|
working-directory: before
|
||||||
|
run: pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Configure base
|
||||||
|
working-directory: before
|
||||||
|
run: cp .github/misskey/test.yml .config
|
||||||
|
|
||||||
|
- name: Build base
|
||||||
|
working-directory: before
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Install dependencies for pull request
|
||||||
|
working-directory: after
|
||||||
|
run: pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Configure pull request
|
||||||
|
working-directory: after
|
||||||
|
run: cp .github/misskey/test.yml .config
|
||||||
|
|
||||||
|
- name: Build pull request
|
||||||
|
working-directory: after
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Measure frontend browser metrics
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
FRONTEND_BROWSER_METRICS_SCENARIO_TIMEOUT_MS: 120000
|
||||||
|
FRONTEND_BROWSER_METRICS_SETTLE_MS: 1000
|
||||||
|
run: |
|
||||||
|
REPORT_DIR="$RUNNER_TEMP/frontend-browser-report"
|
||||||
|
mkdir -p "$REPORT_DIR"
|
||||||
|
node after/.github/scripts/measure-frontend-browser-comparison.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/head-heap-snapshot.heapsnapshot"
|
||||||
|
|
||||||
|
- name: Upload browser head heap snapshot
|
||||||
|
id: upload-browser-head-heap-snapshot
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: frontend-browser-head-heap-snapshot
|
||||||
|
path: ${{ runner.temp }}/frontend-browser-report/head-heap-snapshot.heapsnapshot
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Check browser metrics
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
REPORT_DIR="$RUNNER_TEMP/frontend-browser-report"
|
||||||
|
test -s "$REPORT_DIR/before-browser.json"
|
||||||
|
test -s "$REPORT_DIR/after-browser.json"
|
||||||
|
|
||||||
|
- name: Upload browser report part
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: frontend-browser-report-part
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/frontend-browser-report/before-browser.json
|
||||||
|
${{ runner.temp }}/frontend-browser-report/after-browser.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|||||||
Reference in New Issue
Block a user