diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts new file mode 100644 index 0000000000..5a70e6f43f --- /dev/null +++ b/.github/scripts/frontend-browser-report.mts @@ -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; + 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; + 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', '
'); +} + +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 = [ + `
${title}`, + '', + '| 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('', '
'); + return lines.join('\n'); +} + +function renderFailedRequests(report: BrowserMeasurement, title: string) { + if (report.network.failedRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| 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('', '
'); + 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._', + '', + '
', + 'Requests by resource type', + '', + renderResourceTypeTable(base, head), + '', + '
', + '', + '
', + 'V8 heap snapshot statistics', + '', + renderHeapSnapshotTable(base, head), + '', + ...(headHeapSnapshotUrl != null && headHeapSnapshotUrl !== '' ? [`[Download head heap snapshot](${headHeapSnapshotUrl})`, ''] : []), + '
', + '', + ]; + + 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'; +} diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 7bbc19ce0b..581fe48d4a 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -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 = ''; @@ -477,11 +478,17 @@ function renderFrontendBundleReport(before: ReturnType '); +} + 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'); diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts new file mode 100644 index 0000000000..06ccd42254 --- /dev/null +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -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 = { + 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; + 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; + 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 void; + reject: (error: Error) => void; + }>(); + private eventHandlers = new Map 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((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(method: string, params: Record = {}): Promise { + const id = this.nextId++; + this.ws.send(JSON.stringify({ id, method, params })); + + return new Promise((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(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 { + 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(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(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) { + 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(); + 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(cdp: CdpClient, expression: string, timeoutMs = 30_000): Promise { + 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(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(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(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(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(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 { + 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(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) { + 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>; + + 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][]) { + 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 [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(); diff --git a/.github/workflows/frontend-bundle-report-comment.yml b/.github/workflows/frontend-bundle-report-comment.yml index b93f8a320f..a024d635aa 100644 --- a/.github/workflows/frontend-bundle-report-comment.yml +++ b/.github/workflows/frontend-bundle-report-comment.yml @@ -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 = ''; - const visualizerMarker = ''; - 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; diff --git a/.github/workflows/frontend-bundle-report.yml b/.github/workflows/frontend-bundle-report.yml index 7625f4696d..324e8aca34 100644 --- a/.github/workflows/frontend-bundle-report.yml +++ b/.github/workflows/frontend-bundle-report.yml @@ -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