diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ce726dd641..b10b001171 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,3 +1,5 @@ +language: "ja-JP" +tone_instructions: "常に丁寧語(です・ます調)を用い、敬意のある表現で応答してください。人格や能力への評価、皮肉、威圧的・高圧的・命令的な表現は避けてください。指摘では問題点と影響を明確にし、理由を簡潔に説明したうえで、具体的な改善案を提案してください。不確実な事項は推測と明示し、確認を促してください。重大なバグやセキュリティ上の懸念は重要度と根拠を明確に伝えてください。" reviews: profile: "chill" high_level_summary: false diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts new file mode 100644 index 0000000000..5d07ac6efe --- /dev/null +++ b/.github/scripts/chrome.mts @@ -0,0 +1,523 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createRequire } from 'node:module'; +import { writeFile } from 'node:fs/promises'; +import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; +import type { HeapSnapshotData } from './heap-snapshot-util.mts'; + +export type NetworkRequest = { + requestId: string; + url: string; + method: string; + resourceType: string; + startedAt: number; + documentUrl?: string; + requestHeaders?: Record; + requestBody?: string; + hasRequestBody: boolean; + status?: number; + statusText?: string; + mimeType?: string; + responseHeaders?: Record; + protocol?: string; + remoteIPAddress?: string; + remotePort?: number; + encodedDataLength: number; + decodedBodyLength: number; + fromDiskCache: boolean; + fromServiceWorker: boolean; + finished: boolean; + failed: boolean; + errorText?: string; +}; + +export type WebSocketConnection = { + requestId: string; + url: string; + createdAt: number; + handshakeRequestHeaders?: Record; + handshakeResponseStatus?: number; + handshakeResponseStatusText?: string; + handshakeResponseHeaders?: Record; + closedAt?: number; + sentFrameCount: number; + receivedFrameCount: number; + sentBytes: number; + receivedBytes: number; + errorCount: number; +}; + +export type NetworkSummary = { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: 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; + }[]; +}; + +export type TabMemory = { + totalBytes: number; +}; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + durationMs: number; + network: NetworkSummary; + performance: { + cdpMetrics: Record; + runtimeHeap?: { + usedSize: number; + totalSize: number; + }; + tabMemory: TabMemory; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +type PlaywrightModule = typeof import('playwright'); + +const requireFromFrontend = createRequire(new URL('../../packages/frontend/package.json', import.meta.url)); + +function loadPlaywright(): PlaywrightModule { + return requireFromFrontend('playwright') as PlaywrightModule; +} + +function normalizeHeaders(headers: Record | undefined) { + if (headers == null) return undefined; + const normalized = {} as Record; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = String(value); + } + return normalized; +} + +function webSocketFramePayloadBytes(frame: { opcode?: number; payloadData?: string } | undefined) { + if (frame?.payloadData == null) return 0; + if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8'); + return Buffer.byteLength(frame.payloadData, 'base64'); +} + +type PlaywrightBrowserOptions = { + scenarioTimeoutMs: number; + baseUrl: string; +}; + +export class HeadlessChromeController { + public networkRequests: NetworkRequest[] = []; + public webSocketConnections: WebSocketConnection[] = []; + private readonly browser: Browser; + private readonly context: BrowserContext; + public readonly page: Page; + private readonly cdp: CDPSession; + private pendingNetworkDetailReads: Promise[] = []; + + private constructor( + browser: Browser, + context: BrowserContext, + page: Page, + cdp: CDPSession, + options: PlaywrightBrowserOptions, + ) { + this.browser = browser; + this.context = context; + this.page = page; + this.cdp = cdp; + this.page.setDefaultTimeout(options.scenarioTimeoutMs); + this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); + } + + static async create(label: string, options: PlaywrightBrowserOptions): Promise { + process.stderr.write(`[${label}] Launching Playwright Chromium\n`); + const { chromium } = loadPlaywright(); + const browser = await chromium.launch({ + channel: 'chromium', + headless: true, + args: [ + '--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', + ], + }); + + try { + const context = await browser.newContext({ + baseURL: options.baseUrl, + locale: 'en-US', + }); + await context.addInitScript(() => { + // @ts-expect-error Test-only runtime hint consumed by Misskey frontend code. + window.isPlaywright = true; + }); + + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + return new HeadlessChromeController(browser, context, page, cdp, options); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; + } + } + + static async with(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise): Promise { + const browser = await HeadlessChromeController.create(label, options); + try { + return await callback(browser); + } finally { + await browser.close(); + } + } + + public async enableNetworkTracking() { + const requests = new Map(); + const webSockets = new Map(); + + const readRequestBody = (row: NetworkRequest) => { + if (!row.hasRequestBody || row.requestBody != null) return; + const pending = this.cdp.send<{ postData: string }>('Network.getRequestPostData', { + requestId: row.requestId, + }).then(result => { + row.requestBody = result.postData; + }).catch(() => { + // Some requests expose hasPostData but no longer have retrievable body data. + }); + this.pendingNetworkDetailReads.push(pending); + }; + + this.cdp.on('Network.requestWillBeSent', params => { + if (params.request?.url == null) return; + const row: NetworkRequest = { + requestId: params.requestId, + url: params.request.url, + method: params.request.method ?? 'GET', + resourceType: params.type ?? 'Other', + startedAt: params.timestamp ?? 0, + documentUrl: params.documentURL, + requestHeaders: normalizeHeaders(params.request.headers), + requestBody: typeof params.request.postData === 'string' ? params.request.postData : undefined, + hasRequestBody: params.request.hasPostData === true || typeof params.request.postData === 'string', + encodedDataLength: 0, + decodedBodyLength: 0, + fromDiskCache: false, + fromServiceWorker: false, + finished: false, + failed: false, + }; + requests.set(params.requestId, row); + this.networkRequests.push(row); + }); + + this.cdp.on('Network.webSocketCreated', params => { + if (params.requestId == null || params.url == null) return; + const row: WebSocketConnection = { + requestId: params.requestId, + url: params.url, + createdAt: params.timestamp ?? 0, + sentFrameCount: 0, + receivedFrameCount: 0, + sentBytes: 0, + receivedBytes: 0, + errorCount: 0, + }; + webSockets.set(params.requestId, row); + this.webSocketConnections.push(row); + }); + + this.cdp.on('Network.webSocketWillSendHandshakeRequest', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers); + }); + + this.cdp.on('Network.webSocketHandshakeResponseReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeResponseStatus = params.response?.status; + row.handshakeResponseStatusText = params.response?.statusText; + row.handshakeResponseHeaders = normalizeHeaders(params.response?.headers); + }); + + this.cdp.on('Network.webSocketFrameSent', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.sentFrameCount += 1; + row.sentBytes += webSocketFramePayloadBytes(params.response); + }); + + this.cdp.on('Network.webSocketFrameReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.receivedFrameCount += 1; + row.receivedBytes += webSocketFramePayloadBytes(params.response); + }); + + this.cdp.on('Network.webSocketFrameError', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.errorCount += 1; + }); + + this.cdp.on('Network.webSocketClosed', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.closedAt = params.timestamp ?? 0; + }); + + this.cdp.on('Network.responseReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.status = params.response?.status; + row.statusText = params.response?.statusText; + row.mimeType = params.response?.mimeType; + row.responseHeaders = normalizeHeaders(params.response?.headers); + row.protocol = params.response?.protocol; + row.remoteIPAddress = params.response?.remoteIPAddress; + row.remotePort = params.response?.remotePort; + row.requestHeaders ??= normalizeHeaders(params.response?.requestHeaders); + row.fromDiskCache = params.response?.fromDiskCache === true; + row.fromServiceWorker = params.response?.fromServiceWorker === true; + }); + + this.cdp.on('Network.dataReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.decodedBodyLength += params.dataLength ?? 0; + row.encodedDataLength += params.encodedDataLength ?? 0; + }); + + this.cdp.on('Network.loadingFinished', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.finished = true; + row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); + readRequestBody(row); + }); + + this.cdp.on('Network.loadingFailed', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.failed = true; + row.finished = true; + row.errorText = params.errorText; + readRequestBody(row); + }); + + await this.cdp.send('Network.enable'); + await this.cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); + await this.cdp.send('Network.setBypassServiceWorker', { bypass: true }); + await this.cdp.send('Page.enable'); + await this.cdp.send('Runtime.enable'); + await this.cdp.send('Performance.enable'); + } + + public async waitForNetworkDetails() { + let settledCount = 0; + while (settledCount < this.pendingNetworkDetailReads.length) { + const pending = this.pendingNetworkDetailReads.slice(settledCount); + settledCount = this.pendingNetworkDetailReads.length; + await Promise.allSettled(pending); + } + } + + public async evaluate(expression: string, timeoutMs = 30_000): Promise { + return await Promise.race([ + this.page.evaluate(expression), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Playwright evaluate timed out after ${timeoutMs}ms`)), timeoutMs).unref()), + ]) as T; + } + + public async collectPerformance(): Promise { + const cdpMetricsResult = await this.cdp.send<{ metrics: { name: string; value: number }[] }>('Performance.getMetrics'); + const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); + const runtimeHeap = await this.cdp.send<{ usedSize: number; totalSize: number }>('Runtime.getHeapUsage').catch(() => undefined); + const tabMemory = await this.collectTabMemory(); + const webVitals = await this.evaluate(`(() => { + 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, + tabMemory, + webVitals, + }; + } + + public async collectTabMemory(): Promise { + const userAgentSpecificMemory = await this.evaluate<{ bytes?: number }>(`(async () => { + const measureMemory = performance.measureUserAgentSpecificMemory; + if (typeof measureMemory !== 'function') return {}; + const result = await measureMemory.call(performance); + return { bytes: result.bytes }; + })()`, 60_000); + + const userAgentSpecificBytes = userAgentSpecificMemory?.bytes; + if (!Number.isFinite(userAgentSpecificBytes)) { + throw new Error('performance.measureUserAgentSpecificMemory() did not return finite bytes'); + } + + return { + totalBytes: userAgentSpecificBytes as number, + }; + } + + public async takeHeapSnapshot(savePath?: string) { + const chunks: string[] = []; + this.cdp.on('HeapProfiler.addHeapSnapshotChunk', params => { + chunks.push(params.chunk); + }); + + await this.cdp.send('HeapProfiler.enable'); + await this.cdp.send('HeapProfiler.collectGarbage'); + await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + + const content = chunks.join(''); + if (savePath != null) { + await writeFile(savePath, content); + } + + return JSON.parse(content); + } + + public async close() { + await this.cdp.detach().catch(() => undefined); + await this.context.close().catch(() => undefined); + await this.browser.close().catch(() => undefined); + } +} + +function isMeasurableRequest(row: NetworkRequest) { + return !row.url.startsWith('data:') && !row.url.startsWith('blob:') && !row.url.startsWith('devtools:'); +} + +export function summarizeNetwork(requestRows: NetworkRequest[], baseUrl: string, webSocketRows?: WebSocketConnection[]): 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, + webSocketConnectionCount: webSocketRows == null + ? rows.filter(row => row.resourceType === 'WebSocket').length + : webSocketRows.length, + webSocketSentBytes: webSocketRows?.reduce((sum, row) => sum + row.sentBytes, 0) ?? 0, + webSocketReceivedBytes: webSocketRows?.reduce((sum, row) => sum + row.receivedBytes, 0) ?? 0, + 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, + })), + }; +} diff --git a/.github/scripts/frontend-browser-detailed-html.mts b/.github/scripts/frontend-browser-detailed-html.mts new file mode 100644 index 0000000000..34240eefc6 --- /dev/null +++ b/.github/scripts/frontend-browser-detailed-html.mts @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import * as util from './utility.mts'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './frontend-browser-report.mts'; +import type { NetworkRequest } from './chrome.mts'; + +type DiffDirection = 'added' | 'removed'; + +type RequestDiff = { + direction: DiffDirection; + round: number; + baseCount: number; + headCount: number; + request: NetworkRequest; +}; + +function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeAttribute(value: unknown) { + return escapeHtml(value); +} + +function isHttpRequest(request: NetworkRequest) { + try { + const { protocol } = new URL(request.url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +function requestKey(request: NetworkRequest) { + return [ + request.method, + request.resourceType, + request.url, + ].join('\u0000'); +} + +function groupRequests(requests: NetworkRequest[] | undefined) { + const grouped = new Map(); + for (const request of requests ?? []) { + if (!isHttpRequest(request)) continue; + const key = requestKey(request); + const rows = grouped.get(key) ?? []; + rows.push(request); + grouped.set(key, rows); + } + return grouped; +} + +function byRound(samples: BrowserMeasurementSample[]) { + return new Map(samples.map(sample => [sample.round, sample])); +} + +function diffRound(round: number, baseSample: BrowserMeasurementSample | undefined, headSample: BrowserMeasurementSample | undefined) { + const baseRequests = groupRequests(baseSample?.networkRequests); + const headRequests = groupRequests(headSample?.networkRequests); + const keys = [...new Set([ + ...baseRequests.keys(), + ...headRequests.keys(), + ])].toSorted(); + const diffs: RequestDiff[] = []; + + for (const key of keys) { + const baseRows = baseRequests.get(key) ?? []; + const headRows = headRequests.get(key) ?? []; + if (headRows.length > baseRows.length) { + for (const request of headRows.slice(baseRows.length)) { + diffs.push({ + direction: 'added', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } else if (baseRows.length > headRows.length) { + for (const request of baseRows.slice(headRows.length)) { + diffs.push({ + direction: 'removed', + round, + baseCount: baseRows.length, + headCount: headRows.length, + request, + }); + } + } + } + + return diffs; +} + +function diffReports(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const baseSamples = byRound(base.samples); + const headSamples = byRound(head.samples); + const rounds = [...new Set([ + ...baseSamples.keys(), + ...headSamples.keys(), + ])].toSorted((a, b) => a - b); + return rounds.flatMap(round => diffRound(round, baseSamples.get(round), headSamples.get(round))); +} + +function formatMaybeJson(value: string | undefined) { + if (value == null || value === '') return null; + try { + return JSON.stringify(JSON.parse(value), null, '\t'); + } catch { + return value; + } +} + +function formatHeaders(headers: Record | undefined) { + if (headers == null || Object.keys(headers).length === 0) return null; + return JSON.stringify(headers, null, '\t'); +} + +function countBy(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) { + const counts = new Map(); + for (const diff of diffs) { + counts.set(getKey(diff), (counts.get(getKey(diff)) ?? 0) + 1); + } + return [...counts].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); +} + +function renderSummary(base: BrowserMetricsReport, head: BrowserMetricsReport, diffs: RequestDiff[]) { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + const typeRows = countBy(diffs, diff => diff.request.resourceType).map(([type, count]) => ` + + ${escapeHtml(type)} + ${util.formatNumber(count)} + `).join(''); + + return ` +
+
+ Base samples + ${util.formatNumber(base.sampleCount)} +
+
+ Head samples + ${util.formatNumber(head.sampleCount)} +
+
+ Added in Head + ${util.formatNumber(added)} +
+
+ Removed in Head + ${util.formatNumber(removed)} +
+
+ ${typeRows === '' ? '' : ` +
+

Diffs by Resource Type

+ + + ${typeRows} + +
TypeDiff requests
+
`}`; +} + +function renderDetails(title: string, content: string | null, open = false) { + if (content == null || content === '') return ''; + return ` + + ${escapeHtml(title)} +
${escapeHtml(content)}
+ `; +} + +function renderRequest(diff: RequestDiff) { + const { request } = diff; + const requestBody = formatMaybeJson(request.requestBody); + const requestHeaders = formatHeaders(request.requestHeaders); + const responseHeaders = formatHeaders(request.responseHeaders); + const bodyNote = requestBody == null && request.hasRequestBody === true + ? '

Request body was present but could not be retrieved from CDP.

' + : ''; + + return ` +
+
+ ${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'} + ${escapeHtml(request.method)} + ${escapeHtml(request.resourceType)} + ${escapeHtml(request.status ?? '-')} +
+ ${escapeHtml(request.url)} +
+
Round
${util.formatNumber(diff.round)}
+
Base count
${util.formatNumber(diff.baseCount)}
+
Head count
${util.formatNumber(diff.headCount)}
+
Encoded
${util.formatBytes(request.encodedDataLength ?? 0)}
+
Decoded body
${util.formatBytes(request.decodedBodyLength ?? 0)}
+
MIME
${escapeHtml(request.mimeType ?? '-')}
+
Protocol
${escapeHtml(request.protocol ?? '-')}
+
Remote
${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}
+
Failed
${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}
+
+ ${bodyNote} + ${renderDetails('Request body', requestBody, requestBody != null)} + ${renderDetails('Request headers', requestHeaders)} + ${renderDetails('Response headers', responseHeaders)} +
`; +} + +function renderRound(round: number, diffs: RequestDiff[]) { + const added = diffs.filter(diff => diff.direction === 'added').length; + const removed = diffs.filter(diff => diff.direction === 'removed').length; + return ` +
+

Round ${util.formatNumber(round)}

+

${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed

+
+ ${diffs.map(renderRequest).join('\n')} +
+
`; +} + +function renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const diffs = diffReports(base, head); + const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b); + const generatedAt = new Date().toISOString(); + const content = diffs.length === 0 + ? '

No added or removed HTTP(S) requests were found in paired samples.

' + : rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n'); + + return ` + + + + + Frontend Browser Network Request Diff + + + +
+

Frontend Browser Network Request Diff

+

Generated at ${escapeHtml(generatedAt)}. Requests are compared per paired round by method, resource type, and exact URL. Bodies are shown for added/removed request instances when CDP exposes them.

+ ${renderSummary(base, head, diffs)} + ${content} +
+ + +`; +} + +async function main() { + const [baseFile, headFile, outputFile] = process.argv.slice(2); + if (baseFile == null || headFile == null || outputFile == null) { + throw new Error('Usage: node frontend-browser-detailed-html.mts '); + } + + const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; + await writeFile(outputFile, renderHtml(base, head)); +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/frontend-browser-report.mts b/.github/scripts/frontend-browser-report.mts new file mode 100644 index 0000000000..755b29bc88 --- /dev/null +++ b/.github/scripts/frontend-browser-report.mts @@ -0,0 +1,378 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import * as util from './utility.mts'; +import * as heapSnapshotUtil from './heap-snapshot-util.mts'; +import type { HeapSnapshotData, HeapSnapshotReport } from './heap-snapshot-util.mts'; +import type { NetworkRequest } from './chrome.mts'; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + durationMs: number; + network: { + requestCount: number; + webSocketConnectionCount: number; + webSocketSentBytes: number; + webSocketReceivedBytes: 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; + }; + tabMemory: { + totalBytes: number; + }; + webVitals: { + firstPaintMs?: number; + firstContentfulPaintMs?: number; + domContentLoadedEventEndMs?: number; + loadEventEndMs?: number; + longTaskCount: number; + longTaskDurationMs: number; + maxLongTaskDurationMs: number; + resourceEntryCount: number; + domElements: number; + }; + }; + heapSnapshot: HeapSnapshotData; +}; + +export type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + networkRequests?: NetworkRequest[]; +}; + +export type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; + +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(delta: number, formatter: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return formatter(0); + return util.formatColoredDelta(delta, v => formatter(v), colorThreshold); +} + +function finiteValues(values: (number | null | undefined)[]) { + return values.filter(value => Number.isFinite(value)) as number[]; +} + +function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) { + const values = finiteValues(report.samples.map(sample => getValue(sample))); + if (values.length < 2) return null; + + const center = util.median(values); + return util.median(values.map(value => Math.abs(value - center))); +} + +function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { + const spread = sampleSpread(report, getSampleValue); + if (spread == null) return formatter(value); + return `${formatter(value)}
± ${formatter(spread)}`; +} + +function metricRow( + label: string, + base: BrowserMetricsReport, + head: BrowserMetricsReport, + getSummaryValue: (summary: BrowserMeasurement) => number, + getSampleValue: (sample: BrowserMeasurementSample) => number, + formatter: (value: number) => string, + significantThreshold = 0, + skipIfNotSignificant = true +) { + const baseValue = getSummaryValue(base.summary); + const headValue = getSummaryValue(head.summary); + if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null; + + const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); + // 有意な閾値に満たない場合はそもそもrowとして出力しない + if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; + + const percent = baseValue === 0 ? null : summary.median * 100 / baseValue; + //const deltaMedian = `${formatDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = formatDelta(summary.median, formatter, significantThreshold); + + //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`; + return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : formatDelta(summary.max, formatter, significantThreshold)} |`; +} + +function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { + return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0); +} + +function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) { + return resourceTypeBytes(sample, resourceTypes); +} + +function getMetric(report: BrowserMeasurement, key: string) { + return report.performance.cdpMetrics[key]; +} + +function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { + const rows = [ + //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), + metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber, 1, !all), + //metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber), + metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), + metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all), + metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all), + metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all), + metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all), + metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all), + metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), + metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), + metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes, 10000, !all), + //metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), + //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), + //metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber), + //metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), + //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), + //metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes), + //metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes), + //metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes, 10000), + //metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber), + //metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber), + //metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber), + //metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber), + //metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber), + //metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), + //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), + metricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all), + metricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all), + metricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all), + metricRow('Tab memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, util.formatBytes, 10000, !all), + ].filter(row => row != null); + + return [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + ].join('\n'); +} + +function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) { + const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other']; + const keys = [...new Set([ + ...preferredOrder, + ...Object.keys(base.summary.network.byResourceType), + ...Object.keys(head.summary.network.byResourceType), + ])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null); + + const lines = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + for (const key of keys) { + const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 }; + lines.push(''); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(``); + lines.push(''); + } + + lines.push(''); + lines.push('
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
${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 renderLargestRequests(report: BrowserMetricsReport, title: string) { + if (report.summary.network.largestRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| Resource | Type | Status | Encoded | Decoded |', + '| --- | --- | ---: | ---: | ---: |', + ]; + + for (const request of report.summary.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: BrowserMetricsReport, title: string) { + if (report.summary.network.failedRequests.length === 0) return null; + + const lines = [ + `
${title}`, + '', + '| Resource | Type | Status | Error |', + '| --- | --- | ---: | --- |', + ]; + + for (const request of report.summary.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 toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport { + return { + summary: report.summary.heapSnapshot, + samples: report.samples.map(sample => ({ + round: sample.round, + data: sample.heapSnapshot, + })), + }; +} + +export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { + headHeapSnapshotUrl?: string; + detailedHtmlUrl?: string; +} = {}) { + const headHeapSnapshotUrl = options.headHeapSnapshotUrl; + const detailedHtmlUrl = options.detailedHtmlUrl; + const sampleSummary = base.sampleCount === head.sampleCount + ? `${base.sampleCount} samples per side` + : `${base.sampleCount} base sample(s), ${head.sampleCount} head sample(s)`; + const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); + const lines = [ + '## 🖥 Frontend Browser Metrics', + '', + 'Only metrics showing significant changes are displayed.', + '', + renderSummaryTable(base, head), + '', + //`> Measured ${sampleSummary} with fresh headless Chrome profiles, browser cache disabled, service workers bypassed, and forced V8 GC before each heap snapshot. Base/Head values are medians; Δ median is the median of paired Head - Base sample deltas; percent uses Δ median / Base median; ± and Δ MAD are median absolute deviations. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible.`, + //'', + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`, + detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '', + '
', + 'Requests by resource type', + '', + renderResourceTypeTable(base, head), + '', + '
', + '', + '
', + 'V8 heap snapshot statistics', + '', + heapSnapshotTable ?? '_No V8 heap snapshot data._', + '', + heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + '', + `[Download representative head heap snapshot](${headHeapSnapshotUrl})`, + '
', + '', + ]; + + for (const section of [ + //renderLargestRequests(head, 'Largest representative head requests'), + //renderFailedRequests(base, 'Failed representative base requests'), + //renderFailedRequests(head, 'Failed representative head requests'), + ]) { + if (section == null) continue; + lines.push(section, ''); + } + + return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; +} + +async function main() { + const [baseFile, headFile, outputFile] = process.argv.slice(2); + if (baseFile == null || headFile == null || outputFile == null) { + throw new Error('Usage: node frontend-browser-report.mts '); + } + + const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; + await writeFile(outputFile, renderFrontendBrowserReport(base, head, { + headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL, + detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, + })); +} + +if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 04a92679c7..db134bab79 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -368,6 +368,8 @@ function getChunkComparisonRows(keys: string[], before: Record[number]; + type ChunkAggregate = { beforeSize: number; afterSize: number; @@ -375,6 +377,8 @@ type ChunkAggregate = { afterCount: number; }; +const smallDeltaThreshold = 5; + function sumChunkSizes(chunks: FileEntry[]) { return chunks.reduce((sum, chunk) => sum + chunk.size, 0); } @@ -390,6 +394,19 @@ function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggre }; } +function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; +} + +function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate { + return { + beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0), + afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0), + beforeCount: rows.filter(row => row.beforeFile != null).length, + afterCount: rows.filter(row => row.afterFile != null).length, + }; +} + function comparableMap(chunks: FileEntry[]) { const entries: [string, FileEntry][] = []; for (const chunk of chunks) { @@ -410,14 +427,14 @@ function formatChunkChangeSummary(label: string, summary: ReturnType[number], b: ReturnType[number]) { +function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) { return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize) || (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize) || b.sortSize - a.sortSize || a.name.localeCompare(b.name); } -function chunkFileDisplay(row: ReturnType[number]) { +function chunkFileDisplay(row: ChunkComparisonRow) { if (row.beforeFile == null) return row.afterFile ?? ''; if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; return `${row.beforeFile} → ${row.afterFile}`; @@ -427,23 +444,20 @@ function chunkMarkdownTable( rows: ReturnType, total?: { beforeSize: number; afterSize: number }, generated?: ChunkAggregate, + other?: ChunkAggregate, ) { - if (rows.length === 0 && total == null && generated == null) return '_No data_'; + const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0); + const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0); + if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_'; const lines = [ '| Chunk | Before | After | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; - let hasSummaryRow = false; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; + lines.push('| | | | | |'); } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { - lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - hasSummaryRow = true; - } - if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); for (const row of rows) { const chunkFile = chunkFileDisplay(row); @@ -455,9 +469,11 @@ function chunkMarkdownTable( lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } - if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { - lines.push(''); - lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); + if (hasGenerated) { + lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + } + if (hasOther) { + lines.push(`| (other) | ${util.formatBytes(other.beforeSize)} | ${util.formatBytes(other.afterSize)} | ${util.calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(other.beforeSize, other.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } return lines.join('\n'); } @@ -475,7 +491,8 @@ function renderFrontendChunkReport(before: Awaited !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする const beforeStartupFiles = new Set(before.startupFiles); const afterStartupFiles = new Set(after.startupFiles); @@ -485,8 +502,9 @@ function renderFrontendChunkReport(before: Awaited !hasSmallDelta(row)).sort(compareChunkComparisonRows); const startupTotal = { beforeSize: sumChunkSizes(beforeStartupChunks), afterSize: sumChunkSizes(afterStartupChunks), @@ -501,14 +519,14 @@ function renderFrontendChunkReport(before: Awaited', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', - chunkMarkdownTable(diffRows, diffTotal, diffGenerated), + chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), '', '', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', - chunkMarkdownTable(startupRows, startupTotal, startupGenerated), + chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), '', `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, '', diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts index 347d21548c..d3b3db9af6 100644 --- a/.github/scripts/frontend-js-size.test.mts +++ b/.github/scripts/frontend-js-size.test.mts @@ -86,17 +86,38 @@ test('groups generated chunks while preserving full and startup totals', async t const report = await runReport( t, fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), ); - assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.match(report, /\| \(total\) \| 220 B \| 285 B \|/); assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); - assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); + assert.doesNotMatch(report, /generated chunks are grouped/); assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); assert.match(report, /`src\/_boot_\.ts`<\/summary>/); assert.match(report, /`vue`<\/summary>/); }); +test('groups small deltas at the bottom while preserving all change counts', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._removedSmall = { file: 'scripts/removed-small-before.js', src: 'src/removed-small.ts' }; + before.manifest['src/_boot_.ts'].imports?.push('_removedSmall'); + before.sizes['scripts/removed-small-before.js'] = 5; + + const after = fixture('after', 'esm', { entry: 106, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); + after.manifest._addedSmall = { file: 'scripts/added-small-after.js', src: 'src/added-small.ts' }; + after.manifest['src/_boot_.ts'].imports?.push('_addedSmall'); + after.sizes['scripts/added-small-after.js'] = 5; + + const report = await runReport(t, before, after); + + assert.match(report, /Chunk size diff \(2 updated, 1 added, 1 removed\)<\/summary>/); + assert.match(report, /Startup chunk size \(2 updated, 1 added, 1 removed\)<\/summary>/); + assert.equal(report.match(/`src\/_boot_\.ts`<\/summary>/g)?.length, 2); + assert.doesNotMatch(report, /`(?:vue|i18n|src\/added-small\.ts|src\/removed-small\.ts)`<\/summary>/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 45 B \| 50 B \|/); + assert.match(report, /\| \(total\) \| 225 B \| 276 B \|[^\n]*\n\| \| \| \| \| \|\n\|
`src\/_boot_\.ts`<\/summary>[^\n]*\n\| \(other generated chunks\) \| 30 B \| 70 B \|[^\n]*\n\| \(other\) \| 95 B \| 100 B \|/); +}); + test('fails instead of overwriting duplicate stable chunk keys', async t => { const duplicateVue = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; @@ -115,7 +136,7 @@ test('shows both filenames for an updated stable chunk', async t => { const report = await runReport( t, fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), - fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 55, i18n: 50 }), ); assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); @@ -203,7 +224,6 @@ test('counts an unmanifested localized JavaScript file in totals and the generat assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); - assert.match(report, /_3 before \/ 2 after generated chunks are grouped\._/); }); test('counts duplicate manifest entries for one physical output only once', async t => { @@ -217,7 +237,6 @@ test('counts duplicate manifest entries for one physical output only once', asyn ); assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); - assert.match(report, /_2 before \/ 2 after generated chunks are grouped\._/); }); test('does not count generated-aggregate-only changes as individual chunk changes', async t => { diff --git a/.github/scripts/heap-snapshot-util.mts b/.github/scripts/heap-snapshot-util.mts index c99ce5f441..92e36188c1 100644 --- a/.github/scripts/heap-snapshot-util.mts +++ b/.github/scripts/heap-snapshot-util.mts @@ -32,6 +32,326 @@ export type HeapSnapshotReport = { }[]; }; +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): 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 sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') { + const label = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (label === '') return fallback; + if (label.length <= 80) return label; + return `${label.slice(0, 77)}...`; +} + +function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) { + if (category === 'strings') return type; + + if (category === 'jsArrays') { + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); + } + + if (category === 'typedArrays') { + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); + } + + if (category === 'systemObjects') { + if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); + if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); + } + + if (category === 'otherJsObjects') { + if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); + return type; + } + + if (category === 'otherNonJsObjects') { + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); + } + + if (category === 'code') { + const lowerName = name.toLowerCase(); + if (lowerName.includes('bytecode')) return 'bytecode'; + if (lowerName.includes('builtin')) return 'builtins'; + if (lowerName.includes('regexp')) return 'regexp code'; + if (lowerName.includes('stub')) return 'stubs'; + return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); + } + + return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); +} + +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const topEntries = entries.slice(0, topN); + const otherValue = entries + .slice(topN) + .reduce((sum, [, value]) => sum + value, 0); + + const collapsed = Object.fromEntries(topEntries); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed = {} as NonNullable; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category === 'total') continue; + + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} + +// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const meta = snapshot?.snapshot?.meta; + const nodes = snapshot?.nodes; + const edges = snapshot?.edges; + const strings = snapshot?.strings; + if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { + throw new Error('Invalid heap snapshot format'); + } + + const nodeFields = meta.node_fields; + if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); + const edgeFields = meta.edge_fields; + if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); + + const typeOffset = nodeFields.indexOf('type'); + const nameOffset = nodeFields.indexOf('name'); + const selfSizeOffset = nodeFields.indexOf('self_size'); + const edgeCountOffset = nodeFields.indexOf('edge_count'); + if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { + throw new Error('Heap snapshot is missing required node fields'); + } + const edgeTypeOffset = edgeFields.indexOf('type'); + const edgeNameOffset = edgeFields.indexOf('name_or_index'); + const edgeToNodeOffset = edgeFields.indexOf('to_node'); + if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { + throw new Error('Heap snapshot is missing required edge fields'); + } + + const nodeTypeNames = meta.node_types?.[typeOffset]; + if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); + const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; + if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); + + const nodeFieldCount = nodeFields.length; + const edgeFieldCount = edgeFields.length; + const nativeType = nodeTypeNames.indexOf('native'); + const codeType = nodeTypeNames.indexOf('code'); + const hiddenType = nodeTypeNames.indexOf('hidden'); + const stringTypes = new Set([ + nodeTypeNames.indexOf('string'), + nodeTypeNames.indexOf('concatenated string'), + nodeTypeNames.indexOf('sliced string'), + ]); + const internalEdgeType = edgeTypeNames.indexOf('internal'); + const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; + const { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category !== 'total') breakdowns[category] = {}; + } + + function addValue(map: Record, key: string, value: number) { + map[key] = (map[key] ?? 0) + value; + } + + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { + const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { + if (value <= 0) return; + categories[category] += value; + addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); + if (nodeIndex != null) nodeCounts[category]++; + } + + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; + if (edgeType !== internalEdgeType) continue; + + const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; + if (edgeName !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); + continue; + } + + if (typeId === nativeType) { + if (name === 'system / JSArrayBufferData') { + addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); + } else { + addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); + } + continue; + } + + if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name, nodeIndex); + continue; + } + + if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name, nodeIndex); + continue; + } + + if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); + addJsArrayElementSize(nodeIndex); + continue; + } + } + + categories.total += extraNativeBytes; + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; + if (name === 'Array') continue; + + const type = nodeTypeNames[typeId] ?? 'unknown'; + const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; + addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} + +function finiteMedian(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return null; + return util.median(finiteValues); +} + +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + const categories = {} as HeapSnapshotData['categories']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (value != null) categories[category] = value; + } + + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (value != null) nodeCounts[category] = value; + } + + if (Object.keys(categories).length === 0) return null; + + const breakdowns = {} as NonNullable; + for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { + if (category === 'total') continue; + + const childKeys = new Set(); + for (const snapshot of data) { + for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) { + childKeys.add(childKey); + } + } + + const categoryBreakdown = {} as Record; + for (const childKey of childKeys) { + const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey])); + if (value != null) categoryBreakdown[childKey] = value; + } + + const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN); + if (Object.keys(collapsed).length > 0) { + breakdowns[category] = collapsed; + } + } + + return { + categories, + nodeCounts, + ...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}), + }; +} + function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) { return report.summary.categories[category]; } diff --git a/.github/scripts/measure-backend-memory-comparison.mts b/.github/scripts/measure-backend-memory-comparison.mts index 3dce74dc0a..ee95c68165 100644 --- a/.github/scripts/measure-backend-memory-comparison.mts +++ b/.github/scripts/measure-backend-memory-comparison.mts @@ -8,39 +8,30 @@ import { copyFile, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; -import type { MemoryReportRaw } from '../../packages/backend/scripts/measure-memory.mts'; +import type { MemorySample } from '../../packages/backend/scripts/measure-memory.mts'; const phases = ['afterGc'] as const; export type MemoryReport = { timestamp: string; - sampleCount: any; + sampleCount: number; aggregation: string; - measurement: { - startupTimeoutMs: any; - memorySettleTimeMs: any; - ipcTimeoutMs: any; - requestCount: any; - heapSnapshot: { - enabled: any; - timeoutMs: any; - breakdownTopN: any; - }; - }; summary: Record; heapSnapshot?: heapSnapshotUtil.HeapSnapshotData; }>; - samples: (MemoryReportRaw['samples'][number] & { + samples: (MemorySample & { round: number; })[]; }; const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); const HEAD_HEAP_SNAPSHOT_WORK_DIR = resolve('head-heap-snapshots'); const HEAD_HEAP_SNAPSHOT_OUTPUT_PATH = resolve('head-heap-snapshot.heapsnapshot'); +// Use the head checkout's measurement harness for both targets so only the built backend differs. +const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts'); async function resetState(repoDir: string) { const require = createRequire(join(repoDir, 'packages/backend/package.json')); @@ -70,51 +61,6 @@ async function resetState(repoDir: string) { } } -function summarizeHeapSnapshotBreakdowns(samples: MemoryReport['samples'], phase: typeof phases[number]) { - const breakdowns = {} as Record>; - - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - if (category === 'total') continue; - - const childKeys = new Set(); - for (const sample of samples) { - for (const childKey of Object.keys(sample.phases[phase].heapSnapshot?.breakdowns?.[category] ?? {})) { - childKeys.add(childKey); - } - } - - const categoryBreakdown = {} as Record; - for (const childKey of childKeys) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.breakdowns?.[category]?.[childKey]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) categoryBreakdown[childKey] = util.median(values); - } - - if (Object.keys(categoryBreakdown).length > 0) { - breakdowns[category] = collapseHeapSnapshotBreakdown(categoryBreakdown); - } - } - - return breakdowns; -} - -function collapseHeapSnapshotBreakdown(breakdown: Record) { - const entries = Object.entries(breakdown) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const collapsed = Object.fromEntries(topEntries); - if (otherValue > 0) collapsed.Other = otherValue; - return collapsed; -} - function summarizeSamples(samples: MemoryReport['samples']) { const summary = {} as MemoryReport['summary']; @@ -135,33 +81,12 @@ function summarizeSamples(samples: MemoryReport['samples']) { summary[phase].memoryUsage[key] = util.median(values); } - const heapSnapshotCategoryValues = {} as Record; - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.categories?.[category]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) heapSnapshotCategoryValues[category] = util.median(values); - } - - const heapSnapshotNodeCountValues = {} as Record; - for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) { - const values = samples - .map(sample => sample.phases[phase].heapSnapshot?.nodeCounts?.[category]) - .filter(value => Number.isFinite(value)) as number[]; - - if (values.length > 0) heapSnapshotNodeCountValues[category] = util.median(values); - } - - if (Object.keys(heapSnapshotCategoryValues).length > 0) { - const heapSnapshotBreakdowns = summarizeHeapSnapshotBreakdowns(samples, phase); - - summary[phase].heapSnapshot = { - categories: heapSnapshotCategoryValues, - nodeCounts: heapSnapshotNodeCountValues, - ...(Object.keys(heapSnapshotBreakdowns).length > 0 ? { breakdowns: heapSnapshotBreakdowns } : {}), - }; - } + const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + samples, + sample => sample.phases[phase].heapSnapshot, + { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }, + ); + if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot; } return summary; @@ -181,20 +106,17 @@ async function measureRepo(label: string, repoDir: string, round: number, option process.stderr.write(`[${label}] Measuring memory\n`); const measureEnv = { ...process.env, - MK_MEMORY_SAMPLE_COUNT: '1', + MK_MEMORY_BACKEND_DIR: resolve(repoDir, 'packages/backend'), } as NodeJS.ProcessEnv; if (round <= 0) measureEnv.MK_MEMORY_HEAP_SNAPSHOT = '0'; if (options.heapSnapshotSavePath != null) measureEnv.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH = options.heapSnapshotSavePath; - const stdout = await util.run('node', ['packages/backend/scripts/measure-memory.mts'], { + const stdout = await util.run('node', [MEASURE_MEMORY_SCRIPT], { cwd: repoDir, env: measureEnv, }); - const report = JSON.parse(stdout) as MemoryReportRaw; - const sample = report.samples[0]; - - return sample; + return JSON.parse(stdout) as MemorySample; } function headHeapSnapshotPath(round: number) { diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts new file mode 100644 index 0000000000..0825133b1d --- /dev/null +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -0,0 +1,274 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import * as util from './utility.mts'; +import * as heapSnapshotUtil from './heap-snapshot-util.mts'; +import { HeadlessChromeController, summarizeNetwork } from './chrome.mts'; +import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts'; +import { closeUserSetupDialog, postNote, signupThroughUi, visitHome } from '../../packages/frontend/test/e2e/shared.ts'; + +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 sampleCount = util.readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1); +const heapSnapshotBreakdownTopN = util.readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); +const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots'); + +type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + networkRequests: NetworkRequest[]; +}; + +type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; + +async function runSignupAndPostScenario(chrome: HeadlessChromeController) { + const page = chrome.page; + const noteText = `Frontend browser metrics ${Date.now()}`; + + await visitHome(page, baseUrl); + await signupThroughUi(page, { username: 'alice', password: 'password' }); + await closeUserSetupDialog(page); + await postNote(page, noteText, 10_000); + + await util.sleep(1000); +} + +function finiteMedian(values: (number | null | undefined)[], defaultValue = 0) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return util.median(finiteValues); +} + +function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) { + const medianValue = finiteMedian(samples.map(getValue)); + let selected: { sample: BrowserMeasurementSample; distance: number } | null = null; + + for (const sample of samples) { + const value = getValue(sample); + if (!Number.isFinite(value)) continue; + const distance = Math.abs(value - medianValue); + if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) { + selected = { + sample, + distance, + }; + } + } + + return selected?.sample ?? samples[0]; +} + +function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) { + return { + requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests)), + encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes)), + decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes)), + }; +} + +function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary { + const resourceTypes = new Set(); + for (const sample of samples) { + for (const resourceType of Object.keys(sample.network.byResourceType)) { + resourceTypes.add(resourceType); + } + } + + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const byResourceType = {} as NetworkSummary['byResourceType']; + for (const resourceType of resourceTypes) { + byResourceType[resourceType] = summarizeResourceType(samples, resourceType); + } + + return { + requestCount: finiteMedian(samples.map(sample => sample.network.requestCount)), + webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount)), + webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes)), + webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes)), + finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount)), + failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount)), + cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount)), + serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount)), + totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes)), + totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes)), + sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes)), + thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes)), + byResourceType, + largestRequests: representative.network.largestRequests, + failedRequests: representative.network.failedRequests, + }; +} + +function summarizePerformanceSamples(samples: BrowserMeasurementSample[]): BrowserMeasurement['performance'] { + const cdpMetricKeys = new Set(); + for (const sample of samples) { + for (const key of Object.keys(sample.performance.cdpMetrics)) { + cdpMetricKeys.add(key); + } + } + + const cdpMetrics = {} as Record; + for (const key of cdpMetricKeys) { + cdpMetrics[key] = finiteMedian(samples.map(sample => sample.performance.cdpMetrics[key])); + } + + const webVitalKeys = [ + 'firstPaintMs', + 'firstContentfulPaintMs', + 'domContentLoadedEventEndMs', + 'loadEventEndMs', + 'longTaskCount', + 'longTaskDurationMs', + 'maxLongTaskDurationMs', + 'resourceEntryCount', + 'domElements', + ] as const satisfies (keyof BrowserMeasurement['performance']['webVitals'])[]; + + const webVitals = {} as BrowserMeasurement['performance']['webVitals']; + for (const key of webVitalKeys) { + webVitals[key] = finiteMedian(samples.map(sample => sample.performance.webVitals[key])); + } + + return { + cdpMetrics, + runtimeHeap: { + usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize)), + totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize)), + }, + tabMemory: { + totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes)), + }, + webVitals, + }; +} + +function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[]) { + const summary = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + samples, + sample => sample.heapSnapshot, + { breakdownTopN: heapSnapshotBreakdownTopN }, + ); + if (summary == null) throw new Error('No heap snapshot samples'); + return summary; +} + +function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[]): BrowserMetricsReport { + if (samples.length === 0) throw new Error(`No browser metric samples for ${label}`); + const representative = selectRepresentativeSample(samples, sample => sample.network.totalEncodedBytes); + const summary: BrowserMeasurement = { + label, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: representative.scenario, + durationMs: finiteMedian(samples.map(sample => sample.durationMs)), + network: summarizeNetworkSamples(samples), + performance: summarizePerformanceSamples(samples), + heapSnapshot: summarizeHeapSnapshotSamples(samples), + }; + + return { + label, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: representative.scenario, + sampleCount: samples.length, + aggregation: 'median', + summary, + samples, + }; +} + +async function measureSample(label: 'base' | 'head', round: number, heapSnapshotSavePath?: string) { + await util.prepareInstance(baseUrl); + + return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => { + await chrome.enableNetworkTracking(); + + const startedAt = Date.now(); + await runSignupAndPostScenario(chrome); + const durationMs = Date.now() - startedAt; + await chrome.waitForNetworkDetails(); + const performance = await chrome.collectPerformance(); + const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath); + const heapSnapshot = heapSnapshotUtil.analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN }); + const measurement: BrowserMeasurementSample = { + label, + round, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: 'fresh browser signup, first timeline note, after the note becomes visible', + durationMs, + network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), + networkRequests: chrome.networkRequests, + performance, + heapSnapshot, + }; + + return measurement; + }); +} + +function headHeapSnapshotPath(round: number) { + return join(headHeapSnapshotWorkDir, `round-${round}.heapsnapshot`); +} + +async function saveRepresentativeHeadHeapSnapshot(report: BrowserMetricsReport, outputPath: string) { + const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); + await copyFile(headHeapSnapshotPath(representative.round), outputPath); + process.stderr.write(`[head] Selected round ${representative.round} heap snapshot for artifact\n`); + await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); +} + +async function measureRepo(label: 'base' | 'head', repoDir: string, outputPath: string, heapSnapshotSavePath?: string) { + let server: ReturnType | null = null; + + try { + server = util.startServer(label, repoDir); + await util.waitForServer(baseUrl, server!); + + if (label === 'head' && heapSnapshotSavePath != null) { + await rm(headHeapSnapshotWorkDir, { recursive: true, force: true }); + await mkdir(headHeapSnapshotWorkDir, { recursive: true }); + } + + const samples: BrowserMeasurementSample[] = []; + for (let round = 1; round <= sampleCount; round++) { + process.stderr.write(`[${label}] Measuring browser metrics sample ${round}/${sampleCount}\n`); + samples.push(await measureSample( + label, + round, + label === 'head' && heapSnapshotSavePath != null ? headHeapSnapshotPath(round) : undefined, + )); + } + + const report = summarizeSamples(label, samples); + await writeFile(outputPath, JSON.stringify(report, null, '\t')); + process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); + + if (label === 'head' && heapSnapshotSavePath != null) { + await saveRepresentativeHeadHeapSnapshot(report, heapSnapshotSavePath); + } + } finally { + if (server != null) await util.stopServer(server); + } +} + +async function main() { + 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/scripts/memory-stability-util.mts b/.github/scripts/memory-stability-util.mts new file mode 100644 index 0000000000..0a9a8d4f52 --- /dev/null +++ b/.github/scripts/memory-stability-util.mts @@ -0,0 +1,75 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setTimeout } from 'node:timers/promises'; + +type MemoryStabilityTimer = { + now: () => number; + wait: (durationMs: number) => Promise; +}; + +const intervalMs = 2000; +const maxWaitMs = 10000; +const windowSize = 3; +const slopeThresholdKiBPerSecond = 256; +const stabilityMetrics = ['Pss', 'Private_Dirty'] as const; + +const defaultTimer: MemoryStabilityTimer = { + now: () => performance.now(), + wait: durationMs => setTimeout(durationMs), +}; + +function getMaxAbsoluteSlopes>(readings: { elapsedMs: number; memoryUsage: T }[]) { + const result = {} as Record; + + for (const metric of stabilityMetrics) { + let maxAbsoluteSlope = 0; + for (let i = 1; i < readings.length; i++) { + const previous = readings[i - 1]; + const current = readings[i]; + const durationSeconds = (current.elapsedMs - previous.elapsedMs) / 1000; + maxAbsoluteSlope = Math.max(maxAbsoluteSlope, Math.abs(current.memoryUsage[metric] - previous.memoryUsage[metric]) / durationSeconds); + } + result[metric] = maxAbsoluteSlope; + } + + return result; +} + +export async function measureMemoryUntilStable>( + readMemoryUsage: () => Promise, + timer: MemoryStabilityTimer = defaultTimer, +) { + const startedAt = timer.now(); + const readings: { elapsedMs: number; memoryUsage: T }[] = []; + let maxAbsoluteSlopesKiBPerSecond: Record | null = null; + + while (true) { + const memoryUsage = await readMemoryUsage(); + const elapsedMs = timer.now() - startedAt; + readings.push({ elapsedMs, memoryUsage }); + + let converged = false; + if (readings.length >= windowSize) { + const latestSlopes = getMaxAbsoluteSlopes(readings.slice(-windowSize)); + maxAbsoluteSlopesKiBPerSecond = latestSlopes; + converged = stabilityMetrics.every(metric => latestSlopes[metric] <= slopeThresholdKiBPerSecond); + } + + if (converged || elapsedMs >= maxWaitMs) { + return { + memoryUsage, + stability: { + converged, + readingCount: readings.length, + elapsedMs, + maxAbsoluteSlopesKiBPerSecond, + }, + }; + } + + await timer.wait(Math.min(intervalMs, maxWaitMs - elapsedMs)); + } +} diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts index fb65d9191d..caec5555ef 100644 --- a/.github/scripts/utility.mts +++ b/.github/scripts/utility.mts @@ -5,10 +5,14 @@ // NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない -import { spawn } from 'node:child_process'; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +export function sleep(ms: number) { + return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); +} + export function median(values: number[]) { const sorted = values.toSorted((a, b) => a - b); const center = Math.floor(sorted.length / 2); @@ -202,3 +206,91 @@ export function run(command: string, args: string[], options: { cwd?: string; en }); }); } + +export function startServer(label: string, repoDir: string) { + process.stderr.write(`[${label}] Starting Misskey test server\n`); + const child = spawn(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; +} + +export async function waitForServer(baseUrl: string, child: ChildProcessWithoutNullStreams) { + const startedAt = Date.now(); + while (Date.now() - startedAt < 120_000) { + 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}`); +} + +export async function api(baseUrl: string, 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(); +} + +export async function prepareInstance(baseUrl: string) { + await api(baseUrl, 'reset-db', {}); + await api(baseUrl, 'admin/accounts/create', { + username: 'admin', + password: 'admin1234', + setupPassword: 'example_password_please_change_this_or_you_will_get_hacked', + }); +} + +export 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(); + }); +} diff --git a/.github/workflows/frontend-browser-metrics-report-comment.yml b/.github/workflows/frontend-browser-metrics-report-comment.yml new file mode 100644 index 0000000000..0312092a5b --- /dev/null +++ b/.github/workflows/frontend-browser-metrics-report-comment.yml @@ -0,0 +1,44 @@ +name: frontend-browser-metrics-report-comment + +on: + workflow_run: + workflows: + - frontend-browser-metrics-report + types: + - completed + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + comment: + name: Comment frontend browser metrics report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + concurrency: + group: frontend-browser-metrics-report-comment-${{ github.event.workflow_run.id }} + cancel-in-progress: true + steps: + - name: Download browser metrics report + uses: actions/download-artifact@v8 + with: + name: frontend-browser-metrics-report + path: ${{ runner.temp }}/frontend-browser-metrics-report + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Load PR number + id: load-pr-number + shell: bash + run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-metrics-report/pr-number.txt")" >> "$GITHUB_OUTPUT" + + - name: Comment on pull request + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.load-pr-number.outputs.pr-number }} + comment-tag: frontend_browser_metrics_report + file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-metrics-report.yml new file mode 100644 index 0000000000..863c7bb155 --- /dev/null +++ b/.github/workflows/frontend-browser-metrics-report.yml @@ -0,0 +1,200 @@ +name: frontend-browser-metrics-report + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - packages/frontend/** + - packages/frontend-shared/** + - packages/frontend-builder/** + - packages/backend/** + - packages/i18n/** + - packages/icons-subsetter/** + - packages/misskey-js/** + - packages/misskey-reversi/** + - packages/misskey-bubble-game/** + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - .node-version + - .github/misskey/test.yml + - .github/scripts/utility.mts + - .github/scripts/frontend-browser-detailed-html.mts + - .github/scripts/frontend-browser-report.mts + - .github/scripts/heap-snapshot-util.mts + - .github/scripts/measure-frontend-browser-comparison.mts + - .github/scripts/chrome.mts + - .github/workflows/frontend-browser-metrics-report.yml + - .github/workflows/frontend-browser-metrics-report-comment.yml + +permissions: + contents: read + pull-requests: read + +concurrency: + group: frontend-browser-metrics-report-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + report: + name: Measure frontend browser metrics + runs-on: ubuntu-latest + timeout-minutes: 90 + + 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: + persist-credentials: false + 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: + persist-credentials: false + 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: Install Playwright browsers + working-directory: after/packages/frontend + run: pnpm exec playwright install --with-deps --no-shell chromium + + - name: Measure frontend browser metrics + shell: bash + env: + FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 + MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-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-metrics-head-heap-snapshot + path: ${{ runner.temp }}/frontend-browser-metrics-report/head-heap-snapshot.heapsnapshot + if-no-files-found: error + retention-days: 7 + + - name: Generate browser detailed html + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/before-browser.json" + test -s "$REPORT_DIR/after-browser.json" + node after/.github/scripts/frontend-browser-detailed-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html" + + - name: Upload browser detailed html + id: upload-browser-detailed-html + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-detailed-html + path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-detailed-html.html + if-no-files-found: error + archive: false + retention-days: 7 + + - name: Generate browser metrics report + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }} + FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }} + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/before-browser.json" + test -s "$REPORT_DIR/after-browser.json" + node after/.github/scripts/frontend-browser-report.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-metrics-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" + printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt" + + - name: Check browser metrics report + shell: bash + run: | + REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report" + test -s "$REPORT_DIR/frontend-browser-metrics-report.md" + test -s "$REPORT_DIR/frontend-browser-detailed-html.html" + test -s "$REPORT_DIR/pr-number.txt" + test -s "$REPORT_DIR/head-sha.txt" + cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload browser metrics report + uses: actions/upload-artifact@v7 + with: + name: frontend-browser-metrics-report + path: | + ${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json + ${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json + ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md + ${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt + ${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt + ${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt + ${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml index 400a81a7d9..0d0bec3307 100644 --- a/.github/workflows/get-backend-memory.yml +++ b/.github/workflows/get-backend-memory.yml @@ -12,6 +12,7 @@ on: - .github/scripts/utility.mts - .github/scripts/backend-memory-report.mts - .github/scripts/measure-backend-memory-comparison.mts + - .github/scripts/memory-stability-util*.mts - .github/scripts/backend-js-footprint.mjs - .github/scripts/backend-js-footprint-loader.mjs - .github/scripts/backend-js-footprint-require.cjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d3c745aea6..c8e24fb19b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,6 +19,7 @@ on: - packages/shared/eslint.config.js - scripts/check-dts*.mjs - .github/scripts/frontend-js-size*.mts + - .github/scripts/memory-stability-util*.mts - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json @@ -37,6 +38,7 @@ on: - packages/shared/eslint.config.js - scripts/check-dts*.mjs - .github/scripts/frontend-js-size*.mts + - .github/scripts/memory-stability-util*.mts - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md index 95e0de006b..3a8c404a3e 100644 --- a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md +++ b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md @@ -89,15 +89,33 @@ The report computes three independent values: 2. **Generated chunk aggregate:** the sum of chunks without a comparison key. 3. **Individual rows:** before/after comparisons for stable comparison keys only. -The table starts with the existing `(total)` row. When either build contains generated chunks, it then includes one aggregate row such as: +The table starts with the existing `(total)` row. Generated chunks are rendered at the bottom of the table as one aggregate row such as: ```text (other generated chunks) ``` -This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. +This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. No additional generated-chunk count note is rendered below the table. -The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames. +### Small-delta aggregation + +Stable comparison rows whose absolute byte delta is at most `5 B` are grouped into an `(other)` aggregate instead of being rendered individually. The threshold is inclusive: deltas from `-5 B` through `+5 B` are grouped, while a `6 B` absolute delta remains an individual row. Small additions and removals are handled by the same rule. + +The `(other)` row reports the sum of the grouped chunks' before sizes and the sum of their after sizes. It does not expose an arbitrary representative filename. In the full chunk report, only changed rows are candidates because unchanged rows are already omitted. In the startup report, all rows currently eligible for display are candidates, so unchanged rows are also grouped instead of being listed individually. + +The updated/added/removed counts are calculated before small-delta rows are grouped. Therefore the summary continues to include every stable changed chunk, including chunks represented only by `(other)`. + +Table rows are ordered as follows: + +1. `(total)`; +2. one empty separator row; +3. individual stable comparison rows whose absolute delta is greater than `5 B`; +4. `(other generated chunks)`, when generated chunks exist; and +5. `(other)`, when small-delta stable chunks exist. + +There is no additional separator row before the aggregate rows. If no individual stable row exists, the empty row after `(total)` is therefore immediately followed by the aggregate rows. + +The existing 30-row limit applies after small-delta rows have been removed from the individual-row candidates. ### Startup chunk report @@ -143,8 +161,13 @@ Add focused fixtures or pure-function tests covering: - duplicate stable keys produce a descriptive error instead of overwriting; - full totals equal the sum of all unique physical chunks; - startup totals include multiple same-name generated chunks exactly once each; -- generated aggregate changes do not affect the updated/added/removed summary counts; and -- differing before/after filenames are rendered without attributing both sizes to one file. +- generated aggregate changes do not affect the updated/added/removed summary counts; +- differing before/after filenames are rendered without attributing both sizes to one file; +- deltas of exactly `5 B` are grouped into `(other)`, while `6 B` deltas remain individual; +- small updated, added, and removed chunks remain included in the summary counts; +- an empty separator row appears immediately after `(total)`, with no additional separator before `(other generated chunks)` or `(other)`; +- no generated-chunk grouping note is rendered below the table; and +- the same small-delta and ordering rules apply to the full and startup tables. Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts index 74b84d998f..55aab61a84 100644 --- a/packages/backend/scripts/measure-memory.mts +++ b/packages/backend/scripts/measure-memory.mts @@ -4,16 +4,15 @@ */ import { ChildProcess, fork } from 'node:child_process'; -import { setTimeout } from 'node:timers/promises'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; -//import * as http from 'node:http'; import * as fs from 'node:fs/promises'; -import { heapSnapshotCategory, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; +import { measureMemoryUntilStable } from '../../../.github/scripts/memory-stability-util.mts'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const backendDir = process.env.MK_MEMORY_BACKEND_DIR == null || process.env.MK_MEMORY_BACKEND_DIR === '' + ? join(import.meta.dirname, '..') + : resolve(process.env.MK_MEMORY_BACKEND_DIR); function readIntegerEnv(name, defaultValue, min) { const rawValue = process.env[name]; @@ -33,14 +32,11 @@ function readBooleanEnv(name, defaultValue) { throw new Error(`${name} must be one of: 1, 0, true, false`); } -const SAMPLE_COUNT = readIntegerEnv('MK_MEMORY_SAMPLE_COUNT', 3, 1); // Number of samples to measure const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup -const MEMORY_SETTLE_TIME = readIntegerEnv('MK_MEMORY_SETTLE_TIME_MS', 10000, 0); // Wait after startup for memory to settle const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses -const REQUEST_COUNT = readIntegerEnv('MK_MEMORY_REQUEST_COUNT', 10, 0); const HEAP_SNAPSHOT = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false); const HEAP_SNAPSHOT_TIMEOUT = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1); -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', 6, 1); +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); const HEAP_SNAPSHOT_SAVE_PATH = process.env.MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH; const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; @@ -79,246 +75,6 @@ function bytesToKiB(value: number) { return Math.round(value / 1024); } -function sanitizeHeapSnapshotBreakdownLabel(value, fallback = 'unknown') { - const label = String(value ?? '').replace(/\s+/g, ' ').trim(); - if (label === '') return fallback; - if (label.length <= 80) return label; - return `${label.slice(0, 77)}...`; -} - -function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type, name) { - if (category === 'strings') return type; - - if (category === 'jsArrays') { - if (type === 'array elements') return 'Array elements'; - if (type === 'object' && name === 'Array') return 'Array objects'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'typedArrays') { - if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`); - } - - if (category === 'systemObjects') { - if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name); - if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'otherJsObjects') { - if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown'); - return type; - } - - if (category === 'otherNonJsObjects') { - if (type === 'extra native bytes') return 'Extra native bytes'; - if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown'); - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); - } - - if (category === 'code') { - const lowerName = name.toLowerCase(); - if (lowerName.includes('bytecode')) return 'bytecode'; - if (lowerName.includes('builtin')) return 'builtins'; - if (lowerName.includes('regexp')) return 'regexp code'; - if (lowerName.includes('stub')) return 'stubs'; - return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown'); - } - - return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type); -} - -function collapseHeapSnapshotBreakdown(breakdowns: Record>) { - const collapsed = {} as Record>; - - for (const [category, children] of Object.entries(breakdowns)) { - const entries = Object.entries(children) - .filter(([, value]) => value > 0) - .toSorted((a, b) => b[1] - a[1]); - - const topEntries = entries.slice(0, HEAP_SNAPSHOT_BREAKDOWN_TOP_N); - const otherValue = entries - .slice(HEAP_SNAPSHOT_BREAKDOWN_TOP_N) - .reduce((sum, [, value]) => sum + value, 0); - - const categoryBreakdown = Object.fromEntries(topEntries); - if (otherValue > 0) categoryBreakdown.Other = otherValue; - if (Object.keys(categoryBreakdown).length > 0) collapsed[category] = categoryBreakdown; - } - - return collapsed; -} - -// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view. -function analyzeHeapSnapshot(snapshot) { - const meta = snapshot?.snapshot?.meta; - const nodes = snapshot?.nodes; - const edges = snapshot?.edges; - const strings = snapshot?.strings; - if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) { - throw new Error('Invalid heap snapshot format'); - } - - const nodeFields = meta.node_fields; - if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields'); - const edgeFields = meta.edge_fields; - if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields'); - - const typeOffset = nodeFields.indexOf('type'); - const nameOffset = nodeFields.indexOf('name'); - const selfSizeOffset = nodeFields.indexOf('self_size'); - const edgeCountOffset = nodeFields.indexOf('edge_count'); - if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) { - throw new Error('Heap snapshot is missing required node fields'); - } - const edgeTypeOffset = edgeFields.indexOf('type'); - const edgeNameOffset = edgeFields.indexOf('name_or_index'); - const edgeToNodeOffset = edgeFields.indexOf('to_node'); - if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) { - throw new Error('Heap snapshot is missing required edge fields'); - } - - const nodeTypeNames = meta.node_types?.[typeOffset]; - if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types'); - const edgeTypeNames = meta.edge_types?.[edgeTypeOffset]; - if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types'); - - function createEmptyHeapSnapshotCategoryMap() { - return Object.fromEntries(Object.keys(heapSnapshotCategory).map(category => [category, 0])) as Record; - } - - const nodeFieldCount = nodeFields.length; - const edgeFieldCount = edgeFields.length; - const nativeType = nodeTypeNames.indexOf('native'); - const codeType = nodeTypeNames.indexOf('code'); - const hiddenType = nodeTypeNames.indexOf('hidden'); - const stringTypes = new Set([ - nodeTypeNames.indexOf('string'), - nodeTypeNames.indexOf('concatenated string'), - nodeTypeNames.indexOf('sliced string'), - ]); - const internalEdgeType = edgeTypeNames.indexOf('internal'); - const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0; - const categories = createEmptyHeapSnapshotCategoryMap(); - const nodeCounts = createEmptyHeapSnapshotCategoryMap(); - const breakdowns = Object.fromEntries( - (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) - .filter(category => category !== 'total') - .map(category => [category, {}]), - ); - - function addValue(map: Record, key: string, value: number) { - map[key] = (map[key] ?? 0) + value; - } - - const edgeStartIndexes = new Map(); - const retainerCounts = new Map(); - let edgeIndex = 0; - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - edgeStartIndexes.set(nodeIndex, edgeIndex); - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) { - const toNodeIndex = edges[edgeIndex + edgeToNodeOffset]; - retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); - } - } - - const jsArrayElementNodeIndexes = new Set(); - - function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) { - if (value <= 0) return; - categories[category] += value; - addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value); - if (nodeIndex != null) nodeCounts[category]++; - } - - function addJsArrayElementSize(nodeIndex: number) { - const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; - const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0; - for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { - const edgeType = edges[currentEdgeIndex + edgeTypeOffset]; - if (edgeType !== internalEdgeType) continue; - - const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]]; - if (edgeName !== 'elements') continue; - - const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset]; - if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { - const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex); - jsArrayElementNodeIndexes.add(elementsNodeIndex); - } - break; - } - } - - if (extraNativeBytes > 0) { - addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes'); - } - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - const typeId = nodes[nodeIndex + typeOffset]; - const type = nodeTypeNames[typeId] ?? 'unknown'; - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - categories.total += selfSize; - nodeCounts.total++; - - if (typeId === hiddenType) { - addCategoryValue('systemObjects', selfSize, type, name, nodeIndex); - continue; - } - - if (typeId === nativeType) { - if (name === 'system / JSArrayBufferData') { - addCategoryValue('typedArrays', selfSize, type, name, nodeIndex); - } else { - addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex); - } - continue; - } - - if (typeId === codeType) { - addCategoryValue('code', selfSize, type, name, nodeIndex); - continue; - } - - if (stringTypes.has(typeId)) { - addCategoryValue('strings', selfSize, type, name, nodeIndex); - continue; - } - - if (name === 'Array') { - addCategoryValue('jsArrays', selfSize, type, name, nodeIndex); - addJsArrayElementSize(nodeIndex); - continue; - } - } - - categories.total += extraNativeBytes; - - for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { - if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; - - const typeId = nodes[nodeIndex + typeOffset]; - if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; - - const name = strings[nodes[nodeIndex + nameOffset]] ?? ''; - if (name === 'Array') continue; - - const type = nodeTypeNames[typeId] ?? 'unknown'; - const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0; - addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex); - } - - return { - categories, - nodeCounts, - breakdowns: collapseHeapSnapshotBreakdown(breakdowns), - }; -} - async function getMemoryUsage(pid: number) { const path = `/proc/${pid}/status`; const status = await fs.readFile(path, 'utf-8'); @@ -417,7 +173,7 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); @@ -427,17 +183,23 @@ async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise getSmapsRollupMemoryUsage(pid), + ); + return { - ...await getMemoryUsage(pid), - ...await getSmapsRollupMemoryUsage(pid), - ...await getRuntimeMemoryUsage(serverProcess), + memoryUsage: { + ...await getMemoryUsage(pid), + ...stableSmapsRollup.memoryUsage, + ...await getRuntimeMemoryUsage(serverProcess), + }, + stability: stableSmapsRollup.stability, }; } async function measureMemory() { - // Start the Misskey backend server using fork to enable IPC - const serverProcess = fork(join(__dirname, '../built/entry.js'), [], { - cwd: join(__dirname, '..'), + const serverProcess = fork(join(backendDir, 'built/entry.js'), [], { + cwd: backendDir, env: { ...process.env, NODE_ENV: 'production', @@ -449,16 +211,13 @@ async function measureMemory() { execArgv: [...process.execArgv, '--expose-gc'], }); - let serverReady = false; + const serverReady = waitForMessage( + serverProcess, + (message): message is 'ok' => message === 'ok', + 'server startup', + STARTUP_TIMEOUT, + ); - // Listen for the 'ok' message from the server indicating it's ready - serverProcess.on('message', (message) => { - if (message === 'ok') { - serverReady = true; - } - }); - - // Handle server output serverProcess.stdout?.on('data', (data) => { process.stderr.write(`[server stdout] ${data}`); }); @@ -467,7 +226,6 @@ async function measureMemory() { process.stderr.write(`[server stderr] ${data}`); }); - // Handle server error serverProcess.on('error', (err) => { process.stderr.write(`[server error] ${err}\n`); }); @@ -485,141 +243,55 @@ async function measureMemory() { if (message === 'gc unavailable') { throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); } - - await setTimeout(1000); } - //function createRequest() { - // return new Promise((resolve, reject) => { - // const req = http.request({ - // host: 'localhost', - // port: 61812, - // path: '/api/meta', - // method: 'POST', - // }, (res) => { - // res.on('data', () => { }); - // res.on('end', () => { - // resolve(); - // }); - // }); - // req.on('error', (err) => { - // reject(err); - // }); - // req.end(); - // }); - //} - - // Wait for server to be ready or timeout const startupStartTime = Date.now(); - while (!serverReady) { - if (Date.now() - startupStartTime > STARTUP_TIMEOUT) { - serverProcess.kill('SIGTERM'); - throw new Error('Server startup timeout'); - } - await setTimeout(100); + try { + await serverReady; + } catch (err) { + serverProcess.kill('SIGTERM'); + throw err; } const startupTime = Date.now() - startupStartTime; process.stderr.write(`Server started in ${startupTime}ms\n`); - // Wait for memory to settle - await setTimeout(MEMORY_SETTLE_TIME); - - //const beforeGc = await getAllMemoryUsage(serverProcess); - await triggerGc(); - const memoryUsageAfterGC = await getAllMemoryUsage(serverProcess); - - //// create some http requests to simulate load - //await Promise.all( - // Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), - //); - - //await triggerGc(); - - //const afterRequest = await getAllMemoryUsage(serverProcess); + const afterGc = await getAllMemoryUsage(serverProcess); + process.stderr.write(`Memory ${afterGc.stability.converged ? 'stabilized' : 'did not stabilize'} after ${afterGc.stability.readingCount} readings over ${Math.round(afterGc.stability.elapsedMs)}ms\n`); const heapSnapshotAfterGc = await getHeapSnapshotStatistics(serverProcess); - // Stop the server - serverProcess.kill('SIGTERM'); - - // Wait for process to exit - let exited = false; - await new Promise((resolve) => { - serverProcess.on('exit', () => { - exited = true; - resolve(undefined); - }); - // Force kill after 10 seconds if not exited - setTimeout(10000).then(() => { - if (!exited) { - serverProcess.kill('SIGKILL'); - } - resolve(undefined); + const serverExited = new Promise(resolve => { + const timer = globalThis.setTimeout(() => { + serverProcess.kill('SIGKILL'); + resolve(); + }, 10000); + serverProcess.once('exit', () => { + globalThis.clearTimeout(timer); + resolve(); }); }); + serverProcess.kill('SIGTERM'); + await serverExited; - const result = { + return { timestamp: new Date().toISOString(), phases: { - //beforeGc, afterGc: { - memoryUsage: memoryUsageAfterGC, + memoryUsage: afterGc.memoryUsage, + memoryStability: afterGc.stability, heapSnapshot: heapSnapshotAfterGc, }, - //afterRequest, }, }; - - return result; } -export type MemoryReportRaw = { - timestamp: string; - sampleCount: number; - measurement: { - startupTimeoutMs: number; - memorySettleTimeMs: number; - ipcTimeoutMs: number; - requestCount: number; - heapSnapshot: { - enabled: boolean; - timeoutMs: number; - breakdownTopN: number; - }; - }; - samples: Awaited>[]; -}; +export type MemorySample = Awaited>; async function main() { - const results = []; - for (let i = 0; i < SAMPLE_COUNT; i++) { - process.stderr.write(`Starting sample ${i + 1}/${SAMPLE_COUNT}\n`); - const res = await measureMemory(); - results.push(res); - } - - const result: MemoryReportRaw = { - timestamp: new Date().toISOString(), - sampleCount: SAMPLE_COUNT, - measurement: { - startupTimeoutMs: STARTUP_TIMEOUT, - memorySettleTimeMs: MEMORY_SETTLE_TIME, - ipcTimeoutMs: IPC_TIMEOUT, - requestCount: REQUEST_COUNT, - heapSnapshot: { - enabled: HEAP_SNAPSHOT, - timeoutMs: HEAP_SNAPSHOT_TIMEOUT, - breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N, - }, - }, - samples: results, - }; - - // Output as JSON to stdout - console.log(JSON.stringify(result, null, 2)); + console.log(JSON.stringify(await measureMemory(), null, 2)); } main().catch((err) => { diff --git a/packages/frontend/test/e2e/basic.spec.ts b/packages/frontend/test/e2e/basic.spec.ts index ceae8c6c25..d432680e1d 100644 --- a/packages/frontend/test/e2e/basic.spec.ts +++ b/packages/frontend/test/e2e/basic.spec.ts @@ -10,7 +10,7 @@ import { // locator helper locateMkInput, locateMkSwitch, locateMkTextarea, // utils - registerUser, resetState, visitHome, + registerUser, resetState, visitHome, closeUserSetupDialog, postNote, // page utils waitApiResponse, signIn, } from './utils.js'; @@ -194,17 +194,11 @@ test.describe('After user setup', () => { await signIn(page, 'alice', 'alice1234'); // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout: 30000 }); - await page.getByTestId('modal-dialog-ok').click(); + await closeUserSetupDialog(page); }); test('note', async ({ page }) => { - await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); - await page.getByTestId('open-post-form').click(); - await page.getByTestId('post-form-text').fill('Hello, Misskey!'); - await page.getByTestId('post-form-submit').click(); - - await page.getByText('Hello, Misskey!').waitFor({ timeout: 15000 }); + await postNote(page, 'Hello, Misskey!'); }); test('open note form with hotkey', async ({ page }) => { diff --git a/packages/frontend/test/e2e/router.spec.ts b/packages/frontend/test/e2e/router.spec.ts index 83754d1fb9..41c057b510 100644 --- a/packages/frontend/test/e2e/router.spec.ts +++ b/packages/frontend/test/e2e/router.spec.ts @@ -10,7 +10,7 @@ import { // utils resetState, registerUser, // page utils - signIn, + signIn, closeUserSetupDialogIfVisible, } from './utils.js'; test.describe('Router transition', () => { @@ -26,10 +26,7 @@ test.describe('Router transition', () => { // 表示に時間がかかるのでデフォルト秒数だとタイムアウトする。少し待つ await page.waitForTimeout(1000); - if (await page.getByTestId('user-setup-dialog').isVisible()) { - await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click(); - await page.getByTestId('modal-dialog-ok').click(); - } + await closeUserSetupDialogIfVisible(page); }); test.describe('Redirect', () => { diff --git a/packages/frontend/test/e2e/shared.ts b/packages/frontend/test/e2e/shared.ts new file mode 100644 index 0000000000..2bff5bf136 --- /dev/null +++ b/packages/frontend/test/e2e/shared.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Locator, Page } from 'playwright'; + +export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; +export const DEFAULT_INVITATION_CODE = 'test-invitation-code'; + +export interface RegisteredUser { + id: string; + token: string; +} + +export function assertOk(status: number, route: string): void { + if (status < 200 || status >= 300) { + throw new Error(`${route} failed: status=${status}`); + } +} + +export async function api(baseUrl: string, endpoint: string, body: Record) { + const response = await fetch(`${baseUrl}/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + assertOk(response.status, `/api/${endpoint}`); + if (response.status === 204) return null; + return await response.json(); +} + +export async function resetState(baseUrl: string): Promise { + await api(baseUrl, 'reset-db', {}); +} + +export async function registerUser( + baseUrl: string, + username: string, + password: string, + isAdmin = false, +): Promise { + const route = isAdmin ? 'admin/accounts/create' : 'signup'; + const result = await api(baseUrl, route, { + username, + password, + ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), + }); + return result as RegisteredUser; +} + +export function locateMkInput(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] input`); +} + +export function locateMkTextarea(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] textarea`); +} + +export function locateMkSwitch(page: Page, testId: string): Locator { + return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); +} + +export async function visitHome(page: Page, baseUrl: string): Promise { + await page.goto(`${baseUrl}/`); + await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); +} + +export async function waitApiResponse(page: Page, path: string, timeout = 30_000): Promise { + await page.waitForResponse((response) => { + return response.url().endsWith(path) && response.request().method() === 'POST'; + }, { timeout }); +} + +export async function signIn(page: Page, baseUrl: string, username: string, password: string): Promise { + await visitHome(page, baseUrl); + await page.getByTestId('signin').click(); + await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 }); + await locateMkInput(page, 'signin-username').fill(username); + await page.keyboard.press('Enter'); + await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 }); + await locateMkInput(page, 'signin-password').fill(password); + const signinResponse = waitApiResponse(page, '/api/signin-flow'); + await page.keyboard.press('Enter'); + await signinResponse; +} + +export async function acceptSignupRules(page: Page): Promise { + await page.getByTestId('signup-rules-continue').waitFor({ state: 'visible' }); + await locateMkSwitch(page, 'signup-rules-notes-agree').click(); + await page.getByTestId('modal-dialog-ok').click(); + await page.getByTestId('signup-rules-continue').click(); +} + +export async function signupThroughUi( + page: Page, + options: { + username: string; + password: string; + invitationCode?: string; + }, +): Promise { + await page.getByTestId('signup').click(); + await acceptSignupRules(page); + + await locateMkInput(page, 'signup-username').fill(options.username); + await locateMkInput(page, 'signup-password').fill(options.password); + await locateMkInput(page, 'signup-password-retype').fill(options.password); + await locateMkInput(page, 'signup-invitation-code').fill(options.invitationCode ?? DEFAULT_INVITATION_CODE); + + const signupResponse = waitApiResponse(page, '/api/signup'); + await page.getByTestId('signup-submit').click(); + await signupResponse; +} + +export async function closeUserSetupDialog(page: Page, timeout = 30_000): Promise { + await page.locator('[data-testid="user-setup-dialog"] [data-testid="modal-window-close"]').click({ timeout }); + await page.getByTestId('modal-dialog-ok').click(); +} + +export async function closeUserSetupDialogIfVisible(page: Page): Promise { + if (await page.getByTestId('user-setup-dialog').isVisible()) { + await closeUserSetupDialog(page); + } +} + +export async function postNote(page: Page, noteText: string, timeout = 15_000): Promise { + await page.getByTestId('open-post-form').waitFor({ state: 'visible' }); + await page.getByTestId('open-post-form').click(); + await page.getByTestId('post-form-text').fill(noteText); + await page.getByTestId('post-form-submit').click(); + await page.getByText(noteText).waitFor({ timeout }); +} diff --git a/packages/frontend/test/e2e/utils.ts b/packages/frontend/test/e2e/utils.ts index 4a21076464..67bcfd10d1 100644 --- a/packages/frontend/test/e2e/utils.ts +++ b/packages/frontend/test/e2e/utils.ts @@ -3,92 +3,50 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Locator, Page } from 'playwright'; +import type { Page } from 'playwright'; +import { + registerUser as registerUserWithBaseUrl, + resetState as resetStateWithBaseUrl, + signIn as signInWithBaseUrl, + visitHome as visitHomeWithBaseUrl, +} from './shared.js'; +export type { RegisteredUser } from './shared.js'; +export { + ADMIN_SETUP_PASSWORD, + DEFAULT_INVITATION_CODE, + acceptSignupRules, + assertOk, + closeUserSetupDialog, + closeUserSetupDialogIfVisible, + locateMkInput, + locateMkSwitch, + locateMkTextarea, + postNote, + waitApiResponse, +} from './shared.js'; export const BASE_URL = 'http://localhost:61812'; -export const ADMIN_SETUP_PASSWORD = 'example_password_please_change_this_or_you_will_get_hacked'; - -export interface RegisteredUser { - id: string; - token: string; -} //#region Misc -export function assertOk(status: number, route: string): void { - if (status < 200 || status >= 300) { - throw new Error(`${route} failed: status=${status}`); - } -} - export async function resetState(): Promise { - const response = await fetch(`${BASE_URL}/api/reset-db`, { - method: 'POST', - body: '{}', - headers: { - 'Content-Type': 'application/json', - }, - }); - assertOk(response.status, '/api/reset-db'); + await resetStateWithBaseUrl(BASE_URL); } export async function registerUser( username: string, password: string, isAdmin = false, -): Promise { - const route = isAdmin ? '/api/admin/accounts/create' : '/api/signup'; - const response = await fetch(`${BASE_URL}${route}`, { - method: 'POST', - body: JSON.stringify({ - username, - password, - ...(isAdmin ? { setupPassword: ADMIN_SETUP_PASSWORD } : {}), - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - assertOk(response.status, route); - return await response.json() as RegisteredUser; -} -//#endregion - -//#region Locator Helpers -export function locateMkInput(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] input`); -} - -export function locateMkTextarea(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] textarea`); -} - -export function locateMkSwitch(page: Page, testId: string): Locator { - return page.locator(`[data-testid="${testId}"] [data-testid="switch-toggle"]`); +): ReturnType { + return registerUserWithBaseUrl(BASE_URL, username, password, isAdmin); } //#endregion //#region Page Helpers export async function visitHome(page: Page): Promise { - await page.goto(`${BASE_URL}/`); - await page.locator('button').first().waitFor({ state: 'visible', timeout: 30_000 }); -} - -export async function waitApiResponse(page: Page, path: string): Promise { - await page.waitForResponse((response) => { - return response.url().endsWith(path) && response.request().method() === 'POST'; - }, { timeout: 30_000 }); + await visitHomeWithBaseUrl(page, BASE_URL); } export async function signIn(page: Page, username: string, password: string): Promise { - await visitHome(page); - await page.getByTestId('signin').click(); - await page.getByTestId('signin-page-input').waitFor({ state: 'visible', timeout: 10_000 }); - await locateMkInput(page, 'signin-username').fill(username); - await page.keyboard.press('Enter'); - await page.getByTestId('signin-page-password').waitFor({ state: 'visible', timeout: 10_000 }); - await locateMkInput(page, 'signin-password').fill(password); - const signinResponse = waitApiResponse(page, '/api/signin-flow'); - await page.keyboard.press('Enter'); - await signinResponse; + await signInWithBaseUrl(page, BASE_URL, username, password); } //#endregion