/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { formatBytes, formatNumber } from 'diagnostics-shared/format'; import { type Raw, html, joinHtml, raw } from 'diagnostics-shared/html'; import { networkDiffHtmlStyles } from './html-styles'; import type { BrowserMeasurementSample, BrowserMetricsReport, NetworkRequest } from '../types'; type DiffDirection = 'added' | 'removed'; type RequestDiff = { direction: DiffDirection; round: number; baseCount: number; headCount: number; request: NetworkRequest; }; function isHttpRequest(request: NetworkRequest) { try { const { protocol } = new URL(request.url); return protocol === 'http:' || protocol === 'https:'; } catch { return false; } } function requestKey(request: NetworkRequest) { // URLに現れない文字で区切らないと、区切り文字を含むURLが別のキーと衝突しうる 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])); } /** * 同じラウンドどうしで、同一 (method, resourceType, URL) のリクエスト本数を突き合わせる。 * 本数が増えていればhead側の増分を added、減っていればbase側の余りを removed として扱う。 */ 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[]): Raw { const added = diffs.filter(diff => diff.direction === 'added').length; const removed = diffs.filter(diff => diff.direction === 'removed').length; const typeCounts = countBy(diffs, diff => diff.request.resourceType); const typeRows = joinHtml(typeCounts.map(([type, count]) => html` ${type} ${formatNumber(count)} `), ''); return html`
Base samples ${formatNumber(base.sampleCount)}
Head samples ${formatNumber(head.sampleCount)}
Added in Head ${formatNumber(added)}
Removed in Head ${formatNumber(removed)}
${typeCounts.length === 0 ? raw('') : html`

Diffs by Resource Type

${typeRows}
TypeDiff requests
`}`; } function renderDetails(title: string, content: string | null, open = false): Raw { if (content == null || content === '') return raw(''); return html` ${title}
${content}
`; } function renderRequest(diff: RequestDiff): Raw { 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 ? html`

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

` : raw(''); return html`
${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'} ${request.method} ${request.resourceType} ${request.status ?? '-'}
${request.url}
Round
${formatNumber(diff.round)}
Base count
${formatNumber(diff.baseCount)}
Head count
${formatNumber(diff.headCount)}
Encoded
${formatBytes(request.encodedDataLength ?? 0)}
Decoded body
${formatBytes(request.decodedBodyLength ?? 0)}
MIME
${request.mimeType ?? '-'}
Protocol
${request.protocol ?? '-'}
Remote
${request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`}
Failed
${request.failed ? (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[]): Raw { const added = diffs.filter(diff => diff.direction === 'added').length; const removed = diffs.filter(diff => diff.direction === 'removed').length; return html`

Round ${formatNumber(round)}

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

${joinHtml(diffs.map(renderRequest), '\n')}
`; } export function renderBrowserDiagnosticsHtml(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 ? html`

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

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

Frontend Browser Network Request Diff

Generated at ${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}
`); }