From 4a41b1461e7a37f1635dea37afab5e24d9f2d5f6 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:24:52 +0900 Subject: [PATCH] wip --- .github/scripts/chrome.mts | 54 ++- .../frontend-browser-detailed-html.mts | 448 ++++++++++++++++++ .github/scripts/frontend-browser-report.mts | 37 +- .../measure-frontend-browser-comparison.mts | 5 +- .../frontend-browser-metrics-report.yml | 20 + 5 files changed, 543 insertions(+), 21 deletions(-) create mode 100644 .github/scripts/frontend-browser-detailed-html.mts diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts index 0c117ba29d..ec0c625924 100644 --- a/.github/scripts/chrome.mts +++ b/.github/scripts/chrome.mts @@ -16,14 +16,23 @@ type ChromeHandle = { userDataDir: string; }; -type NetworkRequest = { +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; @@ -234,6 +243,15 @@ function selectorReadyExpression(selector: string, options: { visible?: boolean; })()`; } +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; +} + class CdpClient { private nextId = 1; private callbacks = new Map[] = []; constructor(handle: ChromeHandle, cdpClient: CdpClient, options: ChromeOptions) { this.handle = handle; @@ -351,6 +370,18 @@ export class Chrome { public async enableNetworkTracking() { const requests = 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 = { @@ -359,6 +390,10 @@ export class Chrome { 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, @@ -374,7 +409,13 @@ export class Chrome { 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; }); @@ -391,6 +432,7 @@ export class Chrome { if (row == null) return; row.finished = true; row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); + readRequestBody(row); }); this.cdp.on('Network.loadingFailed', params => { @@ -399,6 +441,7 @@ export class Chrome { row.failed = true; row.finished = true; row.errorText = params.errorText; + readRequestBody(row); }); await this.cdp.send('Network.enable'); @@ -409,6 +452,15 @@ export class Chrome { 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 { const result = await this.cdp.send<{ result: { value: T }; diff --git a/.github/scripts/frontend-browser-detailed-html.mts b/.github/scripts/frontend-browser-detailed-html.mts new file mode 100644 index 0000000000..d53509636e --- /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 { 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 index ef904b71e5..8c911439ba 100644 --- a/.github/scripts/frontend-browser-report.mts +++ b/.github/scripts/frontend-browser-report.mts @@ -8,6 +8,7 @@ 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 { NetworkRequest } from './chrome.mts'; export type BrowserMeasurement = { label: string; @@ -69,6 +70,7 @@ export type BrowserMeasurement = { export type BrowserMeasurementSample = BrowserMeasurement & { round: number; + networkRequests?: NetworkRequest[]; }; export type BrowserMetricsReport = { @@ -125,33 +127,25 @@ function formatValueWithSpread(report: BrowserMetricsReport, value: number, getS return `${formatter(value)}
± ${formatter(spread)}`; } -function pairedDelta(reportBase: BrowserMetricsReport, reportHead: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) { - try { - return util.pairedDeltaSummary(reportBase.samples, reportHead.samples, sample => getValue(sample) ?? null); - } catch { - return null; - } -} - function metricRow( label: string, base: BrowserMetricsReport, head: BrowserMetricsReport, - getSummaryValue: (summary: BrowserMeasurement) => number | null | undefined, - getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, + getSummaryValue: (summary: BrowserMeasurement) => number, + getSampleValue: (sample: BrowserMeasurementSample) => number, formatter: (value: number) => string, ) { 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 = pairedDelta(base, head, getSampleValue); - const percent = summary == null || baseValue === 0 ? null : summary.median * 100 / baseValue; - const deltaMedian = summary == null - ? '-' - : `${formatDelta(summary.median, formatter)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const summary = util.pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); + const percent = baseValue === 0 ? null : summary.median * 100 / baseValue; + //const deltaMedian = `${formatDelta(summary.median, formatter)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; + const deltaMedian = formatDelta(summary.median, formatter); - 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}** | ${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)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`; } function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) { @@ -300,8 +294,10 @@ function toHeapSnapshotReport(report: BrowserMetricsReport): HeapSnapshotReport 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)`; @@ -311,8 +307,10 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br '', 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.`, - '', + //`> 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', '', @@ -341,7 +339,7 @@ export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: Br lines.push(section, ''); } - return lines.join('\n').trimEnd() + '\n'; + return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; } async function main() { @@ -354,6 +352,7 @@ async function main() { 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, })); } diff --git a/.github/scripts/measure-frontend-browser-comparison.mts b/.github/scripts/measure-frontend-browser-comparison.mts index fd8e53b056..19be2737d9 100644 --- a/.github/scripts/measure-frontend-browser-comparison.mts +++ b/.github/scripts/measure-frontend-browser-comparison.mts @@ -9,7 +9,7 @@ import { join, resolve } from 'node:path'; import * as util from './utility.mts'; import * as heapSnapshotUtil from './heap-snapshot-util.mts'; import { Chrome, summarizeNetwork } from './chrome.mts'; -import type { BrowserMeasurement, NetworkSummary } from './chrome.mts'; +import type { BrowserMeasurement, NetworkRequest, NetworkSummary } from './chrome.mts'; const [baseDirArg, headDirArg, baseOutputArg, headOutputArg, headHeapSnapshotOutputArg] = process.argv.slice(2); @@ -23,6 +23,7 @@ const headHeapSnapshotWorkDir = resolve('frontend-browser-head-heap-snapshots'); type BrowserMeasurementSample = BrowserMeasurement & { round: number; + networkRequests: NetworkRequest[]; }; type BrowserMetricsReport = { @@ -319,6 +320,7 @@ async function measureSample(label: 'base' | 'head', round: number, heapSnapshot 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 }); @@ -330,6 +332,7 @@ async function measureSample(label: 'base' | 'head', round: number, heapSnapshot scenario: 'fresh browser signup, first timeline note, after the note becomes visible', durationMs, network: summarizeNetwork(chrome.networkRequests, baseUrl), + networkRequests: chrome.networkRequests, performance, heapSnapshot, }; diff --git a/.github/workflows/frontend-browser-metrics-report.yml b/.github/workflows/frontend-browser-metrics-report.yml index ab032d0256..22ef297cec 100644 --- a/.github/workflows/frontend-browser-metrics-report.yml +++ b/.github/workflows/frontend-browser-metrics-report.yml @@ -23,6 +23,7 @@ on: - .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 @@ -131,6 +132,23 @@ jobs: 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 + retention-days: 7 + - name: Generate browser metrics report shell: bash env: @@ -138,6 +156,7 @@ jobs: 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" @@ -153,6 +172,7 @@ jobs: 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"