/* * 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(); }