1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-03 02:14:49 +02:00

Compare commits

...

1 Commits

Author SHA1 Message Date
syuilo
c8e959c64c wip 2026-06-27 15:59:52 +09:00
5 changed files with 1259 additions and 5 deletions

View File

@@ -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';
}

View File

@@ -6,6 +6,7 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import * as util from './utility.mts';
import { renderFrontendBrowserReport, type BrowserMeasurement } from './frontend-browser-report.mts';
const marker = '<!-- misskey-frontend-js-size -->';
@@ -477,11 +478,17 @@ function renderFrontendBundleReport(before: ReturnType<typeof collectVisualizerR
}
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 after = await collectReport(afterDir);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, '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 afterVisualizerReport = collectVisualizerReport(afterStats);
const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`;
@@ -497,6 +504,10 @@ const body = [
'',
renderFrontendBundleReport(beforeVisualizerReport, afterVisualizerReport),
'',
renderFrontendBrowserReport(beforeBrowserMetrics, afterBrowserMetrics, {
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL,
}),
'',
visualizerArtifactLink,
].join('\n');

View File

@@ -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();

View File

@@ -27,6 +27,9 @@ on:
- .node-version
- .github/scripts/utility.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-comment.yml
@@ -181,8 +184,6 @@ jobs:
const path = require('node:path');
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 jsSizeReportPath = path.join(reportDir, 'frontend-js-size-report.md');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
@@ -291,7 +292,7 @@ jobs:
per_page: 100,
});
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) {
const [previous, ...duplicates] = previousReports;

View File

@@ -22,10 +22,14 @@ on:
- .node-version
- .github/scripts/utility.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-comment.yml
permissions:
actions: read
contents: read
pull-requests: read
@@ -36,7 +40,11 @@ concurrency:
jobs:
report:
name: Build frontend bundle report
needs:
- browser-report
runs-on: ubuntu-latest
outputs:
supported: ${{ steps.check-base-visualizer.outputs.supported }}
env:
FRONTEND_JS_SIZE_LOCALE: ja-JP
steps:
@@ -135,6 +143,13 @@ jobs:
archive: false
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
if: steps.check-base-visualizer.outputs.supported == 'true'
shell: bash
@@ -143,9 +158,13 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
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: |
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' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
@@ -168,3 +187,114 @@ jobs:
path: ${{ runner.temp }}/frontend-bundle-report/
if-no-files-found: error
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