From ab369784fbeedf5fb0fdfd0a02ddfade1d56dcaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:09:22 +0900 Subject: [PATCH] =?UTF-8?q?refactor(gh):=20CI=E7=94=A8=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=83=97=E3=83=88=E3=82=92package=E3=81=A8=E3=81=97?= =?UTF-8?q?=E3=81=A6=E6=95=B4=E7=90=86=20(#17727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(gh): CI用スクリプトをpackageとして整理 * fix * fix * fix * fix * fix * fix * fix * remove old scripts * migrate * refactor 1 * refactor 2 * fix comment * fix * fix * fix * fix * remove vite-node from changelog-checker * fix lint * fix * refactor * update deps * fix * spec: rename packages --- .../scripts/backend-diagnostics.render-md.mts | 183 -- .github/scripts/chrome.mts | 570 ---- .../frontend-browser-diagnostics.inspect.mts | 279 -- ...ser-diagnostics.render-additional-html.mts | 446 --- ...frontend-browser-diagnostics.render-md.mts | 297 -- .../frontend-bundle-diagnostics.render-md.mts | 527 ---- .github/scripts/heap-snapshot-util.mts | 520 ---- .github/scripts/utility.mts | 311 -- .../workflows/backend-diagnostics.comment.yml | 16 +- .../workflows/backend-diagnostics.inspect.yml | 14 +- .github/workflows/changelog-check.yml | 22 +- .../frontend-browser-diagnostics.inspect.yml | 27 +- .../frontend-bundle-diagnostics.inspect.yml | 10 +- .github/workflows/packages-private.yml | 53 + ...frontend-bundle-generated-chunks-design.md | 176 -- package.json | 18 +- packages-private/README.md | 32 + .../changelog-checker/eslint.config.js | 8 +- .../changelog-checker/package.json | 24 + .../changelog-checker/src/checker.ts | 20 +- .../changelog-checker/src/index.ts | 2 +- .../changelog-checker/src/parser.ts | 3 + .../changelog-checker/test/checker.test.ts | 300 +- .../changelog-checker/tsconfig.json | 9 +- .../diagnostics-backend/eslint.config.js | 25 + .../diagnostics-backend/package.json | 27 + .../diagnostics-backend/src/compare.ts | 150 +- .../diagnostics-backend/src/db.ts | 35 + .../diagnostics-backend/src/inspect.ts | 27 + .../diagnostics-backend/src/measure-once.ts | 29 + .../diagnostics-backend/src/measure/index.ts | 129 + .../diagnostics-backend/src/measure/proc.ts | 43 + .../diagnostics-backend/src/measure/server.ts | 199 ++ .../src/measure/stability.ts | 4 + .../diagnostics-backend/src/render-md.ts | 30 + .../src/report/markdown.ts | 182 ++ .../diagnostics-backend/src/types.ts | 49 + .../test/__snapshots__/render-md.md | 29 + .../test/fixtures/base.json | 200 ++ .../test/fixtures/head.json | 200 ++ .../test/render-md.test.ts | 29 + .../diagnostics-backend/test/server.test.ts | 77 + .../test/stability.test.ts | 29 +- .../diagnostics-backend/tsconfig.json | 20 + .../eslint.config.js | 25 + .../diagnostics-frontend-browser/package.json | 25 + .../src/browser/controller.ts | 211 ++ .../src/browser/diagnostics.ts | 21 + .../src/browser/network.ts | 265 ++ .../src/inspect.ts | 128 + .../src/render-html.ts | 26 + .../src/render-md.ts | 31 + .../src/report/html-styles.ts | 177 ++ .../src/report/html.ts | 255 ++ .../src/report/markdown.ts | 189 ++ .../src/scenario.ts | 31 + .../src/server.ts | 99 + .../src/summarize.ts | 158 + .../diagnostics-frontend-browser/src/types.ts | 139 + .../test/__snapshots__/render-html.html | 351 +++ .../test/__snapshots__/render-md.md | 83 + .../test/fixtures/base.json | 679 ++++ .../test/fixtures/head.json | 742 +++++ .../test/render.test.ts | 69 + .../tsconfig.json | 20 + .../eslint.config.js | 25 + .../diagnostics-frontend-bundle/package.json | 22 + .../src/chunk-report.ts | 217 ++ .../src/fs-utils.ts | 40 + .../src/manifest.ts | 171 + .../src/render-md.ts | 32 + .../diagnostics-frontend-bundle/src/report.ts | 33 + .../src/visualizer.ts | 204 ++ .../test/__snapshots__/render-md.md | 100 + .../test/fixtures/after-stats.json | 1 + .../test/fixtures/before-stats.json | 1 + .../test/render-md.test.ts | 105 + .../diagnostics-frontend-bundle/tsconfig.json | 20 + .../diagnostics-shared/eslint.config.js | 25 + .../diagnostics-shared/package.json | 23 + .../diagnostics-shared/src/env.ts | 40 + .../diagnostics-shared/src/format.ts | 116 + .../src/heap-snapshot/analyze.ts | 198 ++ .../src/heap-snapshot/breakdown.ts | 94 + .../src/heap-snapshot/categories.ts | 54 + .../src/heap-snapshot/index.ts | 10 + .../src/heap-snapshot/render.ts | 177 ++ .../src/heap-snapshot/summarize.ts | 63 + .../diagnostics-shared/src/html.ts | 58 + .../diagnostics-shared/src/stats.ts | 84 + .../diagnostics-shared/test/format.test.ts | 102 + .../diagnostics-shared/test/html.test.ts | 63 + .../diagnostics-shared/test/stats.test.ts | 109 + .../diagnostics-shared/tsconfig.json | 20 + packages/backend/scripts/measure-memory.mts | 303 -- pnpm-lock.yaml | 397 ++- pnpm-workspace.yaml | 1 + scripts/changelog-checker/.gitignore | 3 - scripts/changelog-checker/package-lock.json | 2773 ----------------- scripts/changelog-checker/package.json | 24 - scripts/changelog-checker/vite.config.ts | 6 - 101 files changed, 8111 insertions(+), 6707 deletions(-) delete mode 100644 .github/scripts/backend-diagnostics.render-md.mts delete mode 100644 .github/scripts/chrome.mts delete mode 100644 .github/scripts/frontend-browser-diagnostics.inspect.mts delete mode 100644 .github/scripts/frontend-browser-diagnostics.render-additional-html.mts delete mode 100644 .github/scripts/frontend-browser-diagnostics.render-md.mts delete mode 100644 .github/scripts/frontend-bundle-diagnostics.render-md.mts delete mode 100644 .github/scripts/heap-snapshot-util.mts delete mode 100644 .github/scripts/utility.mts create mode 100644 .github/workflows/packages-private.yml delete mode 100644 docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md create mode 100644 packages-private/README.md rename {scripts => packages-private}/changelog-checker/eslint.config.js (71%) create mode 100644 packages-private/changelog-checker/package.json rename {scripts => packages-private}/changelog-checker/src/checker.ts (77%) rename {scripts => packages-private}/changelog-checker/src/index.ts (91%) rename {scripts => packages-private}/changelog-checker/src/parser.ts (90%) rename {scripts => packages-private}/changelog-checker/test/checker.test.ts (56%) rename {scripts => packages-private}/changelog-checker/tsconfig.json (89%) create mode 100644 packages-private/diagnostics-backend/eslint.config.js create mode 100644 packages-private/diagnostics-backend/package.json rename .github/scripts/backend-diagnostics.inspect.mts => packages-private/diagnostics-backend/src/compare.ts (51%) create mode 100644 packages-private/diagnostics-backend/src/db.ts create mode 100644 packages-private/diagnostics-backend/src/inspect.ts create mode 100644 packages-private/diagnostics-backend/src/measure-once.ts create mode 100644 packages-private/diagnostics-backend/src/measure/index.ts create mode 100644 packages-private/diagnostics-backend/src/measure/proc.ts create mode 100644 packages-private/diagnostics-backend/src/measure/server.ts rename .github/scripts/memory-stability-util.mts => packages-private/diagnostics-backend/src/measure/stability.ts (91%) create mode 100644 packages-private/diagnostics-backend/src/render-md.ts create mode 100644 packages-private/diagnostics-backend/src/report/markdown.ts create mode 100644 packages-private/diagnostics-backend/src/types.ts create mode 100644 packages-private/diagnostics-backend/test/__snapshots__/render-md.md create mode 100644 packages-private/diagnostics-backend/test/fixtures/base.json create mode 100644 packages-private/diagnostics-backend/test/fixtures/head.json create mode 100644 packages-private/diagnostics-backend/test/render-md.test.ts create mode 100644 packages-private/diagnostics-backend/test/server.test.ts rename .github/scripts/memory-stability-util.test.mts => packages-private/diagnostics-backend/test/stability.test.ts (77%) create mode 100644 packages-private/diagnostics-backend/tsconfig.json create mode 100644 packages-private/diagnostics-frontend-browser/eslint.config.js create mode 100644 packages-private/diagnostics-frontend-browser/package.json create mode 100644 packages-private/diagnostics-frontend-browser/src/browser/controller.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/browser/network.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/inspect.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/render-html.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/render-md.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/report/html-styles.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/report/html.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/report/markdown.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/scenario.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/server.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/summarize.ts create mode 100644 packages-private/diagnostics-frontend-browser/src/types.ts create mode 100644 packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html create mode 100644 packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md create mode 100644 packages-private/diagnostics-frontend-browser/test/fixtures/base.json create mode 100644 packages-private/diagnostics-frontend-browser/test/fixtures/head.json create mode 100644 packages-private/diagnostics-frontend-browser/test/render.test.ts create mode 100644 packages-private/diagnostics-frontend-browser/tsconfig.json create mode 100644 packages-private/diagnostics-frontend-bundle/eslint.config.js create mode 100644 packages-private/diagnostics-frontend-bundle/package.json create mode 100644 packages-private/diagnostics-frontend-bundle/src/chunk-report.ts create mode 100644 packages-private/diagnostics-frontend-bundle/src/fs-utils.ts create mode 100644 packages-private/diagnostics-frontend-bundle/src/manifest.ts create mode 100644 packages-private/diagnostics-frontend-bundle/src/render-md.ts create mode 100644 packages-private/diagnostics-frontend-bundle/src/report.ts create mode 100644 packages-private/diagnostics-frontend-bundle/src/visualizer.ts create mode 100644 packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md create mode 100644 packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json create mode 100644 packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json create mode 100644 packages-private/diagnostics-frontend-bundle/test/render-md.test.ts create mode 100644 packages-private/diagnostics-frontend-bundle/tsconfig.json create mode 100644 packages-private/diagnostics-shared/eslint.config.js create mode 100644 packages-private/diagnostics-shared/package.json create mode 100644 packages-private/diagnostics-shared/src/env.ts create mode 100644 packages-private/diagnostics-shared/src/format.ts create mode 100644 packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts create mode 100644 packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts create mode 100644 packages-private/diagnostics-shared/src/heap-snapshot/categories.ts create mode 100644 packages-private/diagnostics-shared/src/heap-snapshot/index.ts create mode 100644 packages-private/diagnostics-shared/src/heap-snapshot/render.ts create mode 100644 packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts create mode 100644 packages-private/diagnostics-shared/src/html.ts create mode 100644 packages-private/diagnostics-shared/src/stats.ts create mode 100644 packages-private/diagnostics-shared/test/format.test.ts create mode 100644 packages-private/diagnostics-shared/test/html.test.ts create mode 100644 packages-private/diagnostics-shared/test/stats.test.ts create mode 100644 packages-private/diagnostics-shared/tsconfig.json delete mode 100644 packages/backend/scripts/measure-memory.mts delete mode 100644 scripts/changelog-checker/.gitignore delete mode 100644 scripts/changelog-checker/package-lock.json delete mode 100644 scripts/changelog-checker/package.json delete mode 100644 scripts/changelog-checker/vite.config.ts diff --git a/.github/scripts/backend-diagnostics.render-md.mts b/.github/scripts/backend-diagnostics.render-md.mts deleted file mode 100644 index 5a748584e1..0000000000 --- a/.github/scripts/backend-diagnostics.render-md.mts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { readFile, writeFile } from 'node:fs/promises'; -import * as util from './utility.mts'; -import * as heapSnapshotUtil from './heap-snapshot-util.mts'; -import type { MemoryReport } from './backend-diagnostics.inspect.mts'; - -const [baseFile, headFile, outputFile] = process.argv.slice(2); - -const memoryReportPhases = [ - { - key: 'afterGc', - title: 'After GC', - }, -] as const; - -const memoryMetrics = [ - 'HeapUsed', - 'Pss', - 'USS', - 'External', -] as const; - -function formatMemoryMb(valueKiB: number | null | undefined) { - if (valueKiB == null) return '-'; - return `${util.formatNumber(valueKiB / 1000)} MB`; -} - -function formatMemoryMetricName(metric: typeof memoryMetrics[number]) { - return metric === 'Pss' ? 'PSS' : metric; -} - -function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const memoryUsage = sample.phases[phase].memoryUsage; - if (metric !== 'USS') return memoryUsage[metric]; - return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; -} - -function getSampleValues(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); -} - -function getMemoryValue(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; - return util.median(getSampleValues(report, phase, metric)); -} - -function getSampleSpread(report: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const values = getSampleValues(report, phase, metric); - if (values.length < 2) return null; - - const center = util.median(values); - return util.median(values.map(value => Math.abs(value - center))); -} - -function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key']) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - - function formatDeltaMemory(deltaKiB: number) { - return util.formatColoredDelta(deltaKiB, v => formatMemoryMb(v), 100); // 0.1 MB threshold - } - - for (const metric of memoryMetrics) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - - const baseSpread = getSampleSpread(base, phase, metric); - const headSpread = getSampleSpread(head, phase, metric); - const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); - const percent = summary.median * 100 / baseValue; - const deltaMedian = `${formatDeltaMemory(summary.median)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - - lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)}
± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)}
± ${formatMemoryMb(headSpread)} | ${deltaMedian} | ${formatMemoryMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); - } - - return lines.join('\n'); -} - -function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { - const baseHeapSnapshotReport = { - summary: base.summary.afterGc.heapSnapshot!, - samples: base.samples.map(sample => ({ - round: sample.round, - data: sample.phases.afterGc.heapSnapshot!, - })), - }; - - const headHeapSnapshotReport = { - summary: head.summary.afterGc.heapSnapshot!, - samples: head.samples.map(sample => ({ - round: sample.round, - data: sample.phases.afterGc.heapSnapshot!, - })), - }; - - const table = heapSnapshotUtil.renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); - if (table == null) return null; - - const lines = [ - '### V8 Heap Snapshot Statistics', - '', - table, - '', - ]; - - for (const graph of [ - //heapSnapshotUtil.renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), - //heapSnapshotUtil.renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), - ]) { - if (graph == null) continue; - lines.push(graph); - lines.push(''); - } - - return lines.join('\n'); -} - -const base = JSON.parse(await readFile(baseFile, 'utf8')) as MemoryReport; -const head = JSON.parse(await readFile(headFile, 'utf8')) as MemoryReport; - -const lines = [ - '## ⚙️ Backend Diagnostics Report', - '', -]; - -//const summary = measurementSummary(base, head); -//if (summary != null) { -// lines.push(summary); -// lines.push(''); -//} - -for (const phase of memoryReportPhases) { - lines.push(`### Memory: ${phase.title}`); - lines.push(renderMainTableForPhase(base, head, phase.key)); - lines.push(''); -} - -const heapSnapshotSection = renderHeapSnapshotSection(base, head); -if (heapSnapshotSection != null) { - lines.push(heapSnapshotSection); - lines.push(''); -} - -const baseHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE!.trim(); -const headHeapSnapshotArtifactUrl = process.env.MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD!.trim(); -lines.push(`Download representative heap snapshot: [base](${baseHeapSnapshotArtifactUrl}) / [head](${headHeapSnapshotArtifactUrl})`); -lines.push(''); - -function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - return ((headValue - baseValue) * 100) / baseValue; -} - -function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: typeof memoryReportPhases[number]['key'], metric: typeof memoryMetrics[number]) { - const baseValue = getMemoryValue(base, phase, metric); - const headValue = getMemoryValue(head, phase, metric); - - const delta = headValue - baseValue; - if (delta <= 0) return false; - - const baseSpread = getSampleSpread(base, phase, metric); - const headSpread = getSampleSpread(head, phase, metric); - if (baseSpread == null || headSpread == null) return true; - - const combinedSpread = Math.hypot(baseSpread, headSpread); - return delta > combinedSpread * 3; -} - -const warningMetric = 'Pss'; -const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); -if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { - lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); - lines.push(''); -} - -await writeFile(outputFile, `${lines.join('\n')}\n`); diff --git a/.github/scripts/chrome.mts b/.github/scripts/chrome.mts deleted file mode 100644 index b40c82a263..0000000000 --- a/.github/scripts/chrome.mts +++ /dev/null @@ -1,570 +0,0 @@ -/* - * 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'; -import * as util from './utility.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 BrowserDiagnostics = { - pageErrorCount: number; - console: Record<'log' | 'warning' | 'error' | 'info', number>; -}; - -export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { - const median = (select: (sample: BrowserDiagnostics) => number) => util.median(samples.map(select)); - - return { - pageErrorCount: median(sample => sample.pageErrorCount), - console: { - log: median(sample => sample.console.log), - warning: median(sample => sample.console.warning), - error: median(sample => sample.console.error), - info: median(sample => sample.console.info), - }, - }; -} - -export type BrowserMeasurement = { - label: string; - timestamp: string; - url: string; - scenario: string; - diagnostics: BrowserDiagnostics; - 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 diagnostics = { - pageErrorCount: 0, - console: {} as Record, - }; - 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); - this.page.on('pageerror', () => { - this.diagnostics.pageErrorCount++; - }); - this.page.on('console', message => { - if (this.diagnostics.console[message.type()] == null) { - this.diagnostics.console[message.type()] = 1; - } else { - this.diagnostics.console[message.type()]++; - } - }); - } - - 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 collectDiagnostics(): BrowserDiagnostics { - return { - pageErrorCount: this.diagnostics.pageErrorCount, - console: { - log: this.diagnostics.console.log ?? 0, - warning: this.diagnostics.console.warning ?? 0, - error: this.diagnostics.console.error ?? 0, - info: this.diagnostics.console.info ?? 0, - }, - }; - } - - 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-diagnostics.inspect.mts b/.github/scripts/frontend-browser-diagnostics.inspect.mts deleted file mode 100644 index 1f0d74875f..0000000000 --- a/.github/scripts/frontend-browser-diagnostics.inspect.mts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * 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, summarizeBrowserDiagnostics, 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] = 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 heapSnapshotWorkDirs = { - base: resolve('frontend-browser-base-heap-snapshots'), - head: resolve('frontend-browser-head-heap-snapshots'), -} as const; -const heapSnapshotOutputPaths = { - base: resolve('base-heap-snapshot.heapsnapshot'), - head: resolve('head-heap-snapshot.heapsnapshot'), -} as const; - -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, - diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)), - 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', - diagnostics: chrome.collectDiagnostics(), - durationMs, - network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), - networkRequests: chrome.networkRequests, - performance, - heapSnapshot, - }; - - return measurement; - }); -} - -function heapSnapshotPath(label: 'base' | 'head', round: number) { - return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`); -} - -async function saveRepresentativeHeapSnapshot(label: 'base' | 'head', report: BrowserMetricsReport) { - const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); - await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]); - process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`); - await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); -} - -async function genReport(label: 'base' | 'head', repoDir: string, outputPath: string) { - let server: ReturnType | null = null; - - try { - server = util.startServer(label, repoDir); - await util.waitForServer(baseUrl, server); - - await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); - await mkdir(heapSnapshotWorkDirs[label], { 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, - heapSnapshotPath(label, round), - )); - } - - const report = summarizeSamples(label, samples); - await writeFile(outputPath, JSON.stringify(report, null, '\t')); - process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); - - await saveRepresentativeHeapSnapshot(label, report); - } finally { - if (server != null) await util.stopServer(server); - } -} - -async function main() { - await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); - await genReport('head', resolve(headDirArg), resolve(headOutputArg)); -} - -await main(); diff --git a/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts b/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts deleted file mode 100644 index 568f4257e2..0000000000 --- a/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts +++ /dev/null @@ -1,446 +0,0 @@ -/* - * 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-diagnostics.render-md.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); - - 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)); -} - -// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) -if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} diff --git a/.github/scripts/frontend-browser-diagnostics.render-md.mts b/.github/scripts/frontend-browser-diagnostics.render-md.mts deleted file mode 100644 index d90c3d32c1..0000000000 --- a/.github/scripts/frontend-browser-diagnostics.render-md.mts +++ /dev/null @@ -1,297 +0,0 @@ -/* - * 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 { BrowserDiagnostics, NetworkRequest } from './chrome.mts'; - -export type BrowserMeasurement = { - label: string; - timestamp: string; - url: string; - scenario: string; - diagnostics: BrowserDiagnostics; - 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 formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) { - const values = report.samples.map(sample => getSampleValue(sample)).filter(v => Number.isFinite(v)) as number[]; - const spread = values.length < 2 ? null : util.median(values.map(value => Math.abs(value - util.median(values)))); - if (spread == null) return formatter(value); - return `${formatter(value)}
± ${formatter(spread)}`; -} - -function renderMetricRow( - 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 = `${util.formatColoredDelta(summary.median, formatter, colorThreshold)}
${percent == null ? '-' : util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - const deltaMedian = util.formatColoredDelta(summary.median, formatter, significantThreshold); - - //return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter)} | ${summary == null ? '-' : util.formatColoredDelta(summary.max, formatter)} |`; - return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : util.formatColoredDelta(summary.min, formatter, significantThreshold)} | ${summary == null ? '-' : util.formatColoredDelta(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 renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { - //function getMetric(report: BrowserMeasurement, key: string) { - // return report.performance.cdpMetrics[key]; - //} - - const rows = [ - //metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, util.formatMs), - renderMetricRow('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), - renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes, 10000, !all), - renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes, 10000, !all), - renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes, 10000, !all), - renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes, 10000, !all), - renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes, 10000, !all), - renderMetricRow('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, util.formatMs), - //metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, util.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, util.formatMs), - //metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, util.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'), util.formatSecondsAsMs), - //metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), util.formatSecondsAsMs), - renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, util.formatNumber, 1, !all), - renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, util.formatBytes, 10000, !all), - renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, util.formatBytes, 10000, !all), - renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, util.formatNumber, 1, !all), - renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, util.formatNumber, 1, !all), - renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, util.formatNumber, 1, !all), - renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, util.formatNumber, 1, !all), - renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, util.formatNumber, 1, !all), - renderMetricRow('Page-attributed 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)}${util.formatColoredDelta(headRow.requests - baseRow.requests, util.formatNumber)}${util.formatBytes(baseRow.encodedBytes)}${util.formatBytes(headRow.encodedBytes)}${util.formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}
'); - - 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, - })), - }; -} - -function renderMd(base: BrowserMetricsReport, head: BrowserMetricsReport, options: { - baseHeapSnapshotUrl: string; - headHeapSnapshotUrl: string; - detailedHtmlUrl?: string; -}) { - const detailedHtmlUrl = options.detailedHtmlUrl; - const heapSnapshotTable = heapSnapshotUtil.renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); - const lines = [ - '## 🖥 Frontend Browser Diagnostics Report', - '', - renderSummaryTable(base, head), - '', - 'Only metrics showing significant changes are displayed.', - '', - 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 heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, - '
', - '', - ]; - - return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; -} - -async function main() { - const [baseFile, headFile, outputFile] = process.argv.slice(2); - - const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport; - const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport; - await writeFile(outputFile, renderMd(base, head, { - baseHeapSnapshotUrl: process.env.FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL!, - headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL!, - detailedHtmlUrl: process.env.FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL, - })); -} - -// 直接実行されたときだけ呼ぶための判定(テストなどでこのファイル内の関数をimportするだけのとき用) -if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} diff --git a/.github/scripts/frontend-bundle-diagnostics.render-md.mts b/.github/scripts/frontend-bundle-diagnostics.render-md.mts deleted file mode 100644 index 6049c08d6b..0000000000 --- a/.github/scripts/frontend-bundle-diagnostics.render-md.mts +++ /dev/null @@ -1,527 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import * as util from './utility.mts'; - -const locale = 'ja-JP'; - -type Manifest = Record; - -const stableNamedChunks = new Set(['vue', 'i18n']); - -type FileEntry = { - comparisonKey: string | null; - displayName: string; - file: string; - manifestKeys: string[]; - size: number; -}; - -type CollectedReport = { - manifest: Manifest; - chunks: FileEntry[]; - comparableChunks: Record; - chunksByManifestKey: Record; - startupFiles: string[]; -}; - -function entryDisplayName(entry: FileEntry) { - if (!entry) return ''; - return entry.displayName || entry.file; -} - -function findEntryKey(manifest: Manifest) { - const entries = Object.entries(manifest); - return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] - ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] - ?? entries.find(([, chunk]) => chunk.isEntry)?.[0] - ?? null; -} - -function stableChunkKey(chunk: Manifest[string]) { - if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; - if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; - return null; -} - -function collectStartupManifestKeys(manifest: Manifest) { - const entryKey = findEntryKey(manifest); - const keys = new Set(); - if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); - - function visit(key: string, importedBy?: string) { - if (keys.has(key)) return; - const chunk = manifest[key]; - const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; - if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); - if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); - if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); - keys.add(key); - for (const importKey of chunk.imports ?? []) visit(importKey, key); - } - - visit(entryKey); - return keys; -} - -async function resolveBuiltFile(outDir: string, file: string) { - if (file.startsWith('scripts/')) { - const localizedFile = file.slice('scripts/'.length); - const localizedPath = path.join(outDir, locale, localizedFile); - if (await util.fileExists(localizedPath)) { - return { - absolutePath: localizedPath, - relativePath: `${locale}/${localizedFile}`, - }; - } - - throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`); - } - return { - absolutePath: path.join(outDir, file), - relativePath: file, - }; -} - -async function collectReport(repoDir: string): Promise { - const outDir = path.join(repoDir, 'built/_frontend_vite_'); - const manifestPath = path.join(outDir, 'manifest.json'); - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const chunksByFile = new Map(); - const comparableChunks = new Map(); - const chunksByManifestKey = new Map(); - - for (const [manifestKey, chunk] of Object.entries(manifest)) { - if (!chunk.file?.endsWith('.js')) continue; - const builtFile = await resolveBuiltFile(outDir, chunk.file); - const comparisonKey = stableChunkKey(chunk); - let entry = chunksByFile.get(builtFile.relativePath); - if (entry == null) { - entry = { - comparisonKey, - displayName: chunk.src ?? chunk.name ?? manifestKey, - file: builtFile.relativePath, - manifestKeys: [manifestKey], - size: await util.fileSize(builtFile.absolutePath), - }; - chunksByFile.set(entry.file, entry); - } else if (entry.comparisonKey !== comparisonKey) { - throw new Error(`Conflicting identities for ${entry.file}`); - } else { - entry.manifestKeys.push(manifestKey); - } - chunksByManifestKey.set(manifestKey, entry); - if (comparisonKey != null) { - const existing = comparableChunks.get(comparisonKey); - if (existing != null && existing.file !== entry.file) { - throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); - } - comparableChunks.set(comparisonKey, entry); - } - } - - const localeDir = path.join(outDir, locale); - if (await util.fileExists(localeDir)) { - for await (const fullPath of util.traverseDirectory(localeDir)) { - if (!fullPath.endsWith('.js')) continue; - const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (chunksByFile.has(relativePath)) continue; - chunksByFile.set(relativePath, { - comparisonKey: null, - displayName: relativePath, - file: relativePath, - manifestKeys: [], - size: await util.fileSize(fullPath), - }); - } - } - - const startupFiles = new Set(); - for (const manifestKey of collectStartupManifestKeys(manifest)) { - const entry = chunksByManifestKey.get(manifestKey); - if (entry != null) startupFiles.add(entry.file); - } - - return { - manifest, - chunks: [...chunksByFile.values()], - comparableChunks: Object.fromEntries(comparableChunks), - chunksByManifestKey: Object.fromEntries(chunksByManifestKey), - startupFiles: [...startupFiles], - }; -} - -type VisualizerReport = { - nodeParts?: Record; - nodeMetas?: Record; - renderedLength: number; - gzipLength: number; - brotliLength: number; - }>; - options?: Record; -}; - - -function collectVisualizerReport(data: VisualizerReport) { - const nodeParts = data.nodeParts ?? {}; - const nodeMetas = Object.values(data.nodeMetas ?? {}); - const moduleRows = []; - const bundleMap = new Map(); - - for (const meta of nodeMetas) { - const row = { - id: meta.id, - bundles: 0, - renderedLength: 0, - gzipLength: 0, - brotliLength: 0, - importedByCount: meta.importedBy?.length ?? 0, - importedCount: meta.imported?.length ?? 0, - }; - - for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { - const part = nodeParts[partUid]; - if (part == null) continue; - - row.bundles += 1; - row.renderedLength += part.renderedLength; - row.gzipLength += part.gzipLength; - row.brotliLength += part.brotliLength; - - const bundle = bundleMap.get(bundleId) ?? { - id: bundleId, - modules: 0, - renderedLength: 0, - gzipLength: 0, - brotliLength: 0, - }; - bundle.modules += 1; - bundle.renderedLength += part.renderedLength; - bundle.gzipLength += part.gzipLength; - bundle.brotliLength += part.brotliLength; - bundleMap.set(bundleId, bundle); - } - - if (row.bundles > 0) { - moduleRows.push(row); - } - } - - let staticImports = 0; - let dynamicImports = 0; - for (const meta of nodeMetas) { - for (const imported of meta.imported ?? []) { - if (imported.dynamic) { - dynamicImports += 1; - } else { - staticImports += 1; - } - } - } - - const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); - const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); - const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); - const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); - const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); - - return { - options: data.options ?? {}, - summary: { - bundles: bundleRows.length, - modules: moduleRows.length, - entries: nodeMetas.filter((meta) => meta.isEntry).length, - externals: nodeMetas.filter((meta) => meta.isExternal).length, - staticImports, - dynamicImports, - }, - metrics: { - renderedLength: totalRendered, - gzipLength: totalGzip, - brotliLength: totalBrotli, - }, - hotModules, - }; -} - -function renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { - const summary = [ - 'bundles', - 'modules', - 'entries', - //'externals', - 'staticImports', - 'dynamicImports', - ] as const; - - const metrics = [ - 'renderedLength', - 'gzipLength', - 'brotliLength', - ] as const; - - return [ - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - ``, - ...summary.map((key) => ``), - ...metrics.map((key) => ``), - ``, - ``, - `
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before${util.formatNumber(before.summary[key])}${util.formatBytes(before.metrics[key])}
After${util.formatNumber(after.summary[key])}${util.formatBytes(after.metrics[key])}
Δ${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}
Δ (%)${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}
`, - ].join('\n'); -} - -function getChunkComparisonRows(keys: string[], before: Record, after: Record) { - return keys.map(key => { - const beforeEntry = before[key]; - const afterEntry = after[key]; - const beforeSize = beforeEntry?.size ?? 0; - const afterSize = afterEntry?.size ?? 0; - return { - key, - name: entryDisplayName(beforeEntry ?? afterEntry), - beforeFile: beforeEntry?.file, - afterFile: afterEntry?.file, - beforeSize, - afterSize, - changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', - sortSize: Math.max(beforeSize, afterSize), - }; - }); -} - -type ChunkComparisonRow = ReturnType[number]; - -type ChunkAggregate = { - beforeSize: number; - afterSize: number; - beforeCount: number; - afterCount: number; -}; - -const smallDeltaThreshold = 5; - -function sumChunkSizes(chunks: FileEntry[]) { - return chunks.reduce((sum, chunk) => sum + chunk.size, 0); -} - -function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { - const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); - const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); - return { - beforeSize: sumChunkSizes(beforeGenerated), - afterSize: sumChunkSizes(afterGenerated), - beforeCount: beforeGenerated.length, - afterCount: afterGenerated.length, - }; -} - -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) { - if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); - } - return Object.fromEntries(entries); -} - -function summarizeChunkChanges(rows: ReturnType) { - return { - updated: rows.filter((row) => row.changeType === 'updated').length, - added: rows.filter((row) => row.changeType === 'added').length, - removed: rows.filter((row) => row.changeType === 'removed').length, - }; -} - -function formatChunkChangeSummary(label: string, summary: ReturnType) { - return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; -} - -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: ChunkComparisonRow) { - if (row.beforeFile == null) return row.afterFile ?? ''; - if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; - return `${row.beforeFile} → ${row.afterFile}`; -} - -function chunkMarkdownTable( - rows: ReturnType, - total?: { beforeSize: number; afterSize: number }, - generated?: ChunkAggregate, - other?: ChunkAggregate, -) { - 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 | Δ | Δ (%) |', - '| --- | ---: | ---: | ---: | ---: |', - ]; - 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('\\%', '\\\\%')} |`); - lines.push('| | | | | |'); - } - - for (const row of rows) { - const chunkFile = chunkFileDisplay(row); - if (row.changeType === 'added') { - lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); - } else if (row.changeType === 'removed') { - lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); - } else { - lines.push(`|
\`${util.escapeMdTableCell(row.name)}\` \`${util.escapeMdTableCell(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 (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'); -} - -function renderFrontendChunkReport(before: Awaited>, after: Awaited>) { - const beforeComparable = before.comparableChunks; - const afterComparable = after.comparableChunks; - const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; - const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); - - const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); - const diffSummary = summarizeChunkChanges(changedRows); - const diffTotal = { - beforeSize: sumChunkSizes(before.chunks), - afterSize: sumChunkSizes(after.chunks), - }; - const diffGenerated = generatedAggregate(before.chunks, after.chunks); - const diffOther = comparisonRowsAggregate(changedRows.filter(hasSmallDelta)); - const diffRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする - - const beforeStartupFiles = new Set(before.startupFiles); - const afterStartupFiles = new Set(after.startupFiles); - const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); - const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); - const beforeStartupComparable = comparableMap(beforeStartupChunks); - const afterStartupComparable = comparableMap(afterStartupChunks); - const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; - const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); - const startupSummary = summarizeChunkChanges(startupComparisonRows); - const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); - const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); - const startupTotal = { - beforeSize: sumChunkSizes(beforeStartupChunks), - afterSize: sumChunkSizes(afterStartupChunks), - }; - const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); - - return [ - '
', - `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, - '', - chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), - '', - '
', - '', - '
', - `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, - '', - chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), - '', - `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, - '', - '
', - '', - ].join('\n'); -} - -const args = process.argv.slice(2); -const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = args; -const before = await collectReport(beforeDir); -const after = await collectReport(afterDir); -const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; -const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; -const beforeVisualizerReport = collectVisualizerReport(beforeStats); -const afterVisualizerReport = collectVisualizerReport(afterStats); -const visualizerArtifactLink = `[Open treemap HTML](${process.env.FRONTEND_BUNDLE_REPORT_ARTIFACT_URL})`; - -const body = [ - `## 📦 Frontend Bundle Report`, - '', - renderFrontendChunkReport(before, after), - '', - '## Bundle Stats', - '', - renderVisualizerSummaryTable(beforeVisualizerReport, afterVisualizerReport), - '', - visualizerArtifactLink, -].join('\n'); - -await fs.writeFile(outFile, body); diff --git a/.github/scripts/heap-snapshot-util.mts b/.github/scripts/heap-snapshot-util.mts deleted file mode 100644 index 92e36188c1..0000000000 --- a/.github/scripts/heap-snapshot-util.mts +++ /dev/null @@ -1,520 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない - -import * as util from './utility.mts'; - -export const heapSnapshotCategory = { - total: { label: 'Total', color: 'gray', colorHex: '#888888' }, - code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' }, - strings: { label: 'Strings', color: 'red', colorHex: '#e15759' }, - jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' }, - typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' }, - systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' }, - otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' }, - otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' }, -} as const satisfies Record; - -export type HeapSnapshotData = { - categories: Record; - nodeCounts: Record; - breakdowns?: Record>; -}; - -export type HeapSnapshotReport = { - summary: HeapSnapshotData; - samples: { - round: number; - data: HeapSnapshotData; - }[]; -}; - -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]; -} - -export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { - const lines = [ - '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', - ]; - const baseTotal = getHeapSnapshotCategoryValue(base, 'total'); - const headTotal = getHeapSnapshotCategoryValue(head, 'total'); - - function getHeapSnapshotSampleSpread(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) { - const values = report.samples - .map(sample => sample.data.categories[category]) - .filter(value => Number.isFinite(value)) as number[]; - if (values.length < 2) throw new Error(`Not enough samples for category ${category}`); - - const center = util.median(values); - return util.median(values.map(value => Math.abs(value - center))); - } - - for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) { - const baseValue = getHeapSnapshotCategoryValue(base, category); - const headValue = getHeapSnapshotCategoryValue(head, category); - const baseSpread = getHeapSnapshotSampleSpread(base, category); - const headSpread = getHeapSnapshotSampleSpread(head, category); - const summary = util.pairedDeltaSummary(base.samples, head.samples, (sample) => sample.data.categories[category]); - const percent = summary.median * 100 / baseValue; - - if (category === 'total') { - const deltaMedian = `${util.formatDeltaBytes(summary.median, 100000)}
${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`; - const baseText = `${util.formatBytes(baseValue)}
± ${util.formatBytes(baseSpread)}`; - const headText = `${util.formatBytes(headValue)}
± ${util.formatBytes(headSpread)}`; - const metricText = `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; - lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`); - lines.push('| | | | | | | |'); - } else { - const deltaMedian = util.formatDeltaBytes(summary.median, 100000); - const baseText = util.formatBytes(baseValue); - const headText = util.formatBytes(headValue); - const basePercent = util.formatPercent((baseValue * 100) / baseTotal); - const headPercent = util.formatPercent((headValue * 100) / headTotal); - const metricText = `
$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**${basePercent} → ${headPercent}
`; - lines.push(`| ${metricText} | ${baseText} | ${headText} | ${deltaMedian} | ${util.formatBytes(summary.mad)} | ${util.formatDeltaBytes(summary.min, 100000)} | ${util.formatDeltaBytes(summary.max, 100000)} |`); - } - } - - if (lines.length === 2) return null; - return lines.join('\n'); -} - -const heapSnapshotSankeyChildMinRatio = 0.3; -const heapSnapshotSankeyParentMinPercent = 10; - -function escapeCsvValue(value: string) { - return `"${String(value).replaceAll('"', '""')}"`; -} - -export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) { - const total = getHeapSnapshotCategoryValue(report, 'total'); - if (total == null || total <= 0) return null; - - function getHeapSnapshotBreakdownEntries(category: keyof typeof heapSnapshotCategory) { - const breakdown = report.summary.breakdowns?.[category]; - if (breakdown == null || typeof breakdown !== 'object') return []; - - return Object.entries(breakdown) - .filter(([, value]) => Number.isFinite(value) && value > 0) - .toSorted((a, b) => b[1] - a[1]); - } - - function formatHeapSnapshotSankeyChildLabel(label: string) { - return String(label).replace(/^[^:]+:\s*/, ''); - } - - function formatSankeyPercentValue(value: number) { - const rounded = Math.round(value * 100) / 100; - if (rounded === 0 && value > 0) return '0.01'; - if (Number.isInteger(rounded)) return String(rounded); - return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); - } - - const categories = (Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) - .filter(category => category !== 'total') - .map(category => { - const value = getHeapSnapshotCategoryValue(report, category); - if (value == null || value <= 0) return null; - const breakdownEntries = getHeapSnapshotBreakdownEntries(category); - const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); - const percent = (value * 100) / total; - const childEntries = []; - let otherPercent = 0; - - if (breakdownTotal > 0 && percent > heapSnapshotSankeyParentMinPercent) { - for (const [childName, childValue] of breakdownEntries) { - const childRatio = childValue / breakdownTotal; - const childPercent = percent * childRatio; - if (childRatio >= heapSnapshotSankeyChildMinRatio) { - childEntries.push([formatHeapSnapshotSankeyChildLabel(childName), childPercent]); - } else { - otherPercent += childPercent; - } - } - - if (childEntries.length > 0 && otherPercent > 0) { - childEntries.push(['Other', otherPercent]); - } - } - - return { - category, - percent, - childEntries, - }; - }) - .filter(value => value != null); - - if (categories.length === 0) return null; - - const nodeColors = { - [title]: heapSnapshotCategory.total.colorHex, - } as Record; - for (const { category, childEntries } of categories) { - const categoryColor = heapSnapshotCategory[category].colorHex; - nodeColors[category] = categoryColor; - - for (const [childName] of childEntries) { - nodeColors[childName] = categoryColor; - } - } - - const lines = [ - `
${title} heap snapshot composition`, - '', - '```mermaid', - `%%{init: ${JSON.stringify({ - sankey: { - showValues: false, - linkColor: 'target', - labelStyle: 'outlined', - nodeAlignment: 'center', - nodePadding: 10, - nodeColors: { - ...nodeColors, - 'Other': '#888888', - }, - }, - })}}%%`, - 'sankey-beta', - ]; - - for (const { category, percent, childEntries } of categories) { - lines.push(`${escapeCsvValue(title)},${escapeCsvValue(heapSnapshotCategory[category].label)},${formatSankeyPercentValue(percent)}`); - - for (const [childName, childPercent] of childEntries) { - lines.push(`${escapeCsvValue(heapSnapshotCategory[category].label)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`); - } - } - - lines.push('```'); - lines.push(''); - lines.push('
'); - - return lines.join('\n'); -} diff --git a/.github/scripts/utility.mts b/.github/scripts/utility.mts deleted file mode 100644 index c8d7d2914c..0000000000 --- a/.github/scripts/utility.mts +++ /dev/null @@ -1,311 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// NOTE: このファイルはworkflow上でバックエンドからも参照されるため、side effectがあってはならない - -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); - if (sorted.length % 2 === 1) return sorted[center]; - return Math.round((sorted[center - 1] + sorted[center]) / 2); -} - -export function mad(values: number[]) { - if (values.length < 2) throw new Error('Not enough samples to calculate MAD'); - - const center = median(values); - return median(values.map(value => Math.abs(value - center))); -} - -function getSamplesByRound(samples: T) { - const samplesByRound = new Map(); - for (const sample of samples) { - if (sample.round <= 0) continue; - samplesByRound.set(sample.round, sample); - } - return samplesByRound; -} - -export function pairedDeltaSummary(baseSamples: T, headSamples: T, getValue: (sample: T[number]) => number | null) { - const baseSamplesByRound = getSamplesByRound(baseSamples); - const headSamplesByRound = getSamplesByRound(headSamples); - const values = []; - - for (const [round, baseSample] of baseSamplesByRound) { - const headSample = headSamplesByRound.get(round); - if (headSample == null) continue; - - const baseValue = getValue(baseSample); - const headValue = getValue(headSample); - if (baseValue == null || headValue == null) continue; - - values.push(headValue - baseValue); - } - - return { - median: median(values), - mad: mad(values), - min: Math.min(...values), - max: Math.max(...values), - samples: values.length, - }; -} - -export function normalizePath(filePath: string) { - return filePath.split(path.sep).join('/'); -} - -export async function fileExists(filePath: string) { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -export async function fileSize(filePath: string) { - const stat = await fs.stat(filePath); - return stat.size; -} - -export async function* traverseDirectory(dir: string): AsyncGenerator { - for (const entry of await fs.readdir(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - yield* traverseDirectory(fullPath); - } else if (entry.isFile()) { - yield fullPath; - } - } -} - -export function escapeLatex(text: string) { - return text - .replaceAll('\\', '\\\\') - .replaceAll('{', '\\{') - .replaceAll('}', '\\}') - .replaceAll('%', '\\%'); -} - -export function escapeMdTableCell(value: string) { - return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); -} - -export function formatMs(value: number | null | undefined) { - if (value == null || !Number.isFinite(value)) return '-'; - if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; - return `${formatNumber(value)} ms`; -} - -export function formatSecondsAsMs(value: number | null | undefined) { - if (value == null || !Number.isFinite(value)) return '-'; - return formatMs(value * 1_000); -} - -export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { - if (delta === 0) return text(0); - const sign = delta > 0 ? '+' : '-'; - if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`; - const color = delta > 0 ? 'orange' : 'green'; - return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`; -} - -const numberFormatter = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 1, -}); - -export function formatNumber(value: number) { - return numberFormatter.format(value); -} - -export function formatBytes(value: number) { - if (value === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - let unitIndex = 0; - let size = value; - while (size >= 1000 && unitIndex < units.length - 1) { - size /= 1000; - unitIndex += 1; - } - - const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; - return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; -} - -export function calcAndFormatDeltaNumber(before: number, after: number, colorThreshold = 0) { - if (before == null || after == null) return '-'; - const delta = after - before; - return formatColoredDelta(delta, v => formatNumber(v), colorThreshold); -} - -export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { - return formatColoredDelta(deltaBytes, v => formatBytes(v), colorThreshold); -} - -export function calcAndFormatDeltaBytes(before: number, after: number, colorThreshold = 0) { - if (before == null || after == null) return '-'; - const delta = after - before; - return formatDeltaBytes(delta, colorThreshold); -} - -export function formatPercent(value: number) { - return `${formatNumber(value)}%`; -} - -export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { - return formatColoredDelta(deltaPercent, v => formatPercent(v), colorThreshold); -} - -export function calcAndFormatDeltaPercent(before: number, after: number, colorThreshold = 0) { - if (before == null || before === 0 || after == null || after === 0) return '-'; - const delta = after - before; - return formatDeltaPercent(delta / before * 100, colorThreshold); -} - -export function commandName(command: string) { - if (process.platform !== 'win32') return command; - if (command === 'pnpm') return 'pnpm.cmd'; - return command; -} - -export function readIntegerEnv(name: string, defaultValue: number, min: number) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); - - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); - return value; -} - -export function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; logStdout?: boolean } = {}) { - return new Promise((resolvePromise, reject) => { - const child = spawn(commandName(command), args, { - cwd: options.cwd, - env: options.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', data => { - stdout += data; - if (options.logStdout) process.stderr.write(data); - }); - - child.stderr.on('data', data => { - stderr += data; - process.stderr.write(data); - }); - - child.on('error', reject); - - child.on('close', code => { - if (code === 0) { - resolvePromise(stdout); - } else { - reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}\n${stderr}`)); - } - }); - }); -} - -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/backend-diagnostics.comment.yml b/.github/workflows/backend-diagnostics.comment.yml index 9519f89437..a6d914441c 100644 --- a/.github/workflows/backend-diagnostics.comment.yml +++ b/.github/workflows/backend-diagnostics.comment.yml @@ -19,6 +19,16 @@ jobs: - name: Checkout uses: actions/checkout@v6.0.3 + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.9 + - name: Use Node.js + uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - name: Install dependencies + run: pnpm --filter diagnostics-backend... install --frozen-lockfile + - name: Download artifacts uses: actions/download-artifact@v8 with: @@ -60,7 +70,11 @@ jobs: env: MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE: ${{ steps.find-heap-snapshot-artifacts.outputs.base-url }} MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD: ${{ steps.find-heap-snapshot-artifacts.outputs.head-url }} - run: node .github/scripts/backend-diagnostics.render-md.mts ./artifacts/memory-base.json ./artifacts/memory-head.json ./output.md + run: >- + pnpm --filter diagnostics-backend run render-md + "$GITHUB_WORKSPACE/artifacts/memory-base.json" + "$GITHUB_WORKSPACE/artifacts/memory-head.json" + "$GITHUB_WORKSPACE/output.md" - uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ steps.load-pr-num.outputs.pr-number }} diff --git a/.github/workflows/backend-diagnostics.inspect.yml b/.github/workflows/backend-diagnostics.inspect.yml index d5a47b804f..74469ded63 100644 --- a/.github/workflows/backend-diagnostics.inspect.yml +++ b/.github/workflows/backend-diagnostics.inspect.yml @@ -9,10 +9,8 @@ on: paths: - packages/backend/** - packages/misskey-js/** - - .github/scripts/utility.mts - - .github/scripts/backend-diagnostics.render-md.mts - - .github/scripts/backend-diagnostics.inspect.mts - - .github/scripts/memory-stability-util*.mts + - packages-private/diagnostics-shared/** + - packages-private/diagnostics-backend/** - .github/workflows/backend-diagnostics.inspect.yml - .github/workflows/backend-diagnostics.comment.yml @@ -89,11 +87,17 @@ jobs: working-directory: head run: pnpm build - name: Measure backend memory usage + working-directory: head env: MK_MEMORY_COMPARE_ROUNDS: 10 MK_MEMORY_COMPARE_WARMUP_ROUNDS: 1 MK_MEMORY_HEAP_SNAPSHOT: 1 - run: node head/.github/scripts/backend-diagnostics.inspect.mts base head memory-base.json memory-head.json + # 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す + MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} + run: >- + pnpm --filter diagnostics-backend run inspect + "$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" + "$GITHUB_WORKSPACE/memory-base.json" "$GITHUB_WORKSPACE/memory-head.json" - name: Upload base heap snapshot uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 318999f596..a6d222b6bd 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -13,10 +13,15 @@ jobs: steps: - name: Checkout head uses: actions/checkout@v6.0.3 - - name: Setup Node.js + + - name: setup pnpm + uses: pnpm/action-setup@v6 + + - name: setup node uses: actions/setup-node@v6.4.0 with: node-version-file: '.node-version' + cache: pnpm - name: Checkout base run: | @@ -27,17 +32,16 @@ jobs: git checkout origin/${{ github.base_ref }} CHANGELOG.md - name: Copy to Checker directory for CHANGELOG-base.md - run: cp _base/CHANGELOG.md scripts/changelog-checker/CHANGELOG-base.md + run: cp _base/CHANGELOG.md packages-private/changelog-checker/CHANGELOG-base.md - name: Copy to Checker directory for CHANGELOG-head.md - run: cp CHANGELOG.md scripts/changelog-checker/CHANGELOG-head.md + run: cp CHANGELOG.md packages-private/changelog-checker/CHANGELOG-head.md - name: diff continue-on-error: true run: diff -u CHANGELOG-base.md CHANGELOG-head.md - working-directory: scripts/changelog-checker + working-directory: packages-private/changelog-checker + + - name: Install dependencies + run: pnpm --filter changelog-checker... install --frozen-lockfile - - name: Setup Checker - run: npm install - working-directory: scripts/changelog-checker - name: Run Checker - run: npm run run - working-directory: scripts/changelog-checker + run: pnpm --filter changelog-checker check diff --git a/.github/workflows/frontend-browser-diagnostics.inspect.yml b/.github/workflows/frontend-browser-diagnostics.inspect.yml index 92f645ab26..7b6238c81d 100644 --- a/.github/workflows/frontend-browser-diagnostics.inspect.yml +++ b/.github/workflows/frontend-browser-diagnostics.inspect.yml @@ -22,12 +22,8 @@ on: - pnpm-workspace.yaml - .node-version - .github/misskey/test.yml - - .github/scripts/utility.mts - - .github/scripts/heap-snapshot-util.mts - - .github/scripts/chrome.mts - - .github/scripts/frontend-browser-diagnostics.render-additional-html.mts - - .github/scripts/frontend-browser-diagnostics.render-md.mts - - .github/scripts/frontend-browser-diagnostics.inspect.mts + - packages-private/diagnostics-shared/** + - packages-private/diagnostics-frontend-browser/** - .github/workflows/frontend-browser-diagnostics.inspect.yml - .github/workflows/frontend-browser-diagnostics.comment.yml @@ -116,18 +112,23 @@ jobs: run: pnpm build - name: Install Playwright browsers - working-directory: after/packages/frontend + working-directory: after/packages-private/diagnostics-frontend-browser run: pnpm exec playwright install --with-deps --no-shell chromium - name: Measure frontend browser metrics shell: bash + working-directory: after env: FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5 MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true" + # 計測ハーネスはafter側のものを両方に使うが、成果物はrunnerのworkspace直下に出す + FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }} run: | REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" mkdir -p "$REPORT_DIR" - node after/.github/scripts/frontend-browser-diagnostics.inspect.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" + pnpm --filter diagnostics-frontend-browser run inspect \ + "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \ + "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" - name: Upload browser base heap snapshot id: upload-browser-base-heap-snapshot @@ -149,11 +150,14 @@ jobs: - name: Generate browser detailed html shell: bash + working-directory: after run: | REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" test -s "$REPORT_DIR/before-browser.json" test -s "$REPORT_DIR/after-browser.json" - node after/.github/scripts/frontend-browser-diagnostics.render-additional-html.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-detailed-html.html" + pnpm --filter diagnostics-frontend-browser run render-html \ + "$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 @@ -167,6 +171,7 @@ jobs: - name: Generate browser metrics report shell: bash + working-directory: after env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -178,7 +183,9 @@ jobs: REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics" test -s "$REPORT_DIR/before-browser.json" test -s "$REPORT_DIR/after-browser.json" - node after/.github/scripts/frontend-browser-diagnostics.render-md.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-diagnostics.md" + pnpm --filter diagnostics-frontend-browser run render-md \ + "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" \ + "$REPORT_DIR/frontend-browser-diagnostics.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" diff --git a/.github/workflows/frontend-bundle-diagnostics.inspect.yml b/.github/workflows/frontend-bundle-diagnostics.inspect.yml index 6aa4f54fb4..0332ea6a3e 100644 --- a/.github/workflows/frontend-bundle-diagnostics.inspect.yml +++ b/.github/workflows/frontend-bundle-diagnostics.inspect.yml @@ -20,8 +20,8 @@ on: - pnpm-lock.yaml - pnpm-workspace.yaml - .node-version - - .github/scripts/utility.mts - - .github/scripts/frontend-bundle-diagnostics.render-md.mts + - packages-private/diagnostics-shared/** + - packages-private/diagnostics-frontend-bundle/** - .github/workflows/frontend-bundle-diagnostics.inspect.yml - .github/workflows/frontend-bundle-diagnostics.comment.yml @@ -114,6 +114,7 @@ jobs: - name: Generate report markdown shell: bash + working-directory: after env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -121,7 +122,10 @@ jobs: FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }} run: | REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report" - node after/.github/scripts/frontend-bundle-diagnostics.render-md.mts before after "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" "$REPORT_DIR/frontend-bundle-diagnostics-report.md" + pnpm --filter diagnostics-frontend-bundle run render-md \ + "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" \ + "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" \ + "$REPORT_DIR/frontend-bundle-diagnostics-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" diff --git a/.github/workflows/packages-private.yml b/.github/workflows/packages-private.yml new file mode 100644 index 0000000000..e6a72b68c9 --- /dev/null +++ b/.github/workflows/packages-private.yml @@ -0,0 +1,53 @@ +name: Lint and test packages-private + +on: + push: + branches: + - master + - develop + paths: + - packages-private/** + - packages/shared/eslint.config.js + - package.json + - pnpm-workspace.yaml + - pnpm-lock.yaml + - .github/workflows/packages-private.yml + pull_request: + paths: + - packages-private/** + - packages/shared/eslint.config.js + - package.json + - pnpm-workspace.yaml + - pnpm-lock.yaml + - .github/workflows/packages-private.yml + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + workspace: + - diagnostics-shared + - diagnostics-backend + - diagnostics-frontend-browser + - diagnostics-frontend-bundle + - changelog-checker + steps: + - uses: actions/checkout@v6.0.3 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@v6.0.9 + - uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - run: pnpm --filter ${{ matrix.workspace }}... install --frozen-lockfile + # lint = typecheck + eslint + - run: pnpm --filter ${{ matrix.workspace }} run lint + # 各パッケージは必ず test スクリプトを持たせる (無いと ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT で落ちる) + - run: pnpm --filter ${{ matrix.workspace }} run test 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 deleted file mode 100644 index 3a8c404a3e..0000000000 --- a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md +++ /dev/null @@ -1,176 +0,0 @@ -# Frontend bundle report: generated chunk handling - -## Background - -The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`. - -Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:`, so later entries overwrite earlier entries. This produces two problems: - -1. unrelated chunks can be compared as if they were the same chunk; and -2. overwritten chunks are omitted from the reported total. - -Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching. - -## Goals - -- Never compare unrelated generated chunks as the same chunk. -- Keep generated chunks out of the individual chunk-diff rows. -- Preserve the contribution of every physical JavaScript chunk in totals. -- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. -- Apply the same rules to the full chunk report and the startup chunk report. -- Continue comparing source-backed chunks and intentionally named chunks. - -## Non-goals - -- Inferring chunk identity from module-set similarity. -- Preserving an `updated` relationship for every shared chunk after code splitting changes. -- Changing the frontend's code-splitting strategy or output filenames. -- Suppressing specific names such as `esm` or `dist` with a denylist. - -## Approaches considered - -### Denylist generated-looking names - -Exclude names such as `esm`, `dist`, `lib`, and `index`. - -This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place. - -### Compare only stable identities and aggregate the rest - -Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate. - -This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible. - -### Match shared chunks by their module sets - -Use visualizer metadata to compare exact or similar sets of module IDs. - -This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient. - -## Design - -### Separate physical chunks from comparable identities - -`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection. - -Each physical chunk contains: - -- its resolved relative output path; -- its byte size; -- its manifest key, when present; -- its display name; and -- an optional comparison key. - -The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds. - -### Comparison-key classification - -A chunk receives a comparison key only in one of these cases: - -1. `chunk.src` is present: use `src:`. -2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:`. - -The initial stable-name allowlist is: - -- `vue` -- `i18n` - -These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`. - -All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key. - -If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison. - -### Full chunk report - -The report computes three independent values: - -1. **Total:** the sum of every unique physical JavaScript chunk. -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. 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. No additional generated-chunk count note is rendered below the table. - -### 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 - -Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one. - -Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table. - -### Displayed filenames - -For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file. - -The display can use one filename when unchanged and `before → after` when the content-hashed filename differs. - -The generated aggregate row does not display an arbitrary representative filename. - -## Data flow - -1. Read each build's Vite manifest. -2. Resolve localized output files and collect unique physical chunks. -3. Attach optional stable comparison keys using the classification rules. -4. Traverse startup imports using manifest identities and map them to physical chunks. -5. Calculate full and startup totals from physical chunks. -6. Calculate generated aggregates from chunks without comparison keys. -7. Compare only unique stable keys for individual rows. -8. Render totals, generated aggregates, and stable rows. - -## Error handling - -- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error. -- Duplicate physical output paths are de-duplicated and counted once. -- Duplicate stable comparison keys fail with an error that includes the key and conflicting files. -- Missing or malformed manifest data remains fatal rather than producing a partial report. - -## Testing - -Add focused fixtures or pure-function tests covering: - -- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate; -- `dist` and `esm` chunks never produce individual comparison rows; -- source-backed chunks with the same `src` are reported as updated; -- source-backed additions and removals remain visible; -- allowlisted `vue` and `i18n` chunks remain individually comparable; -- 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; -- 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. - -## Future extension - -If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity. diff --git a/package.json b/package.json index 37c33e355f..8bb49f141e 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,19 @@ }, "packageManager": "pnpm@11.11.0", "workspaces": [ - "packages/misskey-js", + "packages/backend", + "packages/frontend-shared", + "packages/frontend", + "packages/frontend-builder", + "packages/frontend-embed", "packages/i18n", + "packages/icons-subsetter", + "packages/sw", + "packages/misskey-js", + "packages/misskey-js/generator", "packages/misskey-reversi", "packages/misskey-bubble-game", - "packages/icons-subsetter", - "packages/frontend-shared", - "packages/frontend-builder", - "packages/sw", - "packages/backend", - "packages/frontend", - "packages/frontend-embed" + "packages-private/*" ], "private": true, "scripts": { diff --git a/packages-private/README.md b/packages-private/README.md new file mode 100644 index 0000000000..faba8fa046 --- /dev/null +++ b/packages-private/README.md @@ -0,0 +1,32 @@ +# packages-private + +`packages-private` は、CIなどで使用するためのユーティリティなどを格納する場所です。この配下にあるものは**プロダクションの `/packages`**の依存となるものではありません。 + +## パッケージ一覧 + +| パッケージ | 用途 | +| --- | --- | +| [`diagnostics-shared`](./diagnostics-shared) | 計測系パッケージが共有する統計・書式整形・V8 heap snapshot解析のユーティリティ | +| [`diagnostics-backend`](./diagnostics-backend) | バックエンドのメモリ使用量をbase/headで比較し、PRコメント用のMarkdownを生成する | +| [`diagnostics-frontend-browser`](./diagnostics-frontend-browser) | ヘッドレスChromeでフロントエンドを操作し、メモリ・ネットワーク等の指標をbase/headで比較する | +| [`diagnostics-frontend-bundle`](./diagnostics-frontend-bundle) | フロントエンドのビルド成果物のchunkサイズをbase/headで比較する | +| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する | + +## GitHub Actionsからの呼び出し方 + +ワークフロー側にロジックを持たせず、各パッケージのnpm scriptを呼ぶだけにしている。 +CLIに渡すパスはすべて呼び出し側のカレントディレクトリ基準で解決されるため、 +ワークフローからは `$GITHUB_WORKSPACE` / `$RUNNER_TEMP` を使った絶対パスを渡すこと。 + +```yaml +- name: Generate report + working-directory: after + run: >- + pnpm --filter diagnostics-frontend-bundle run render-md + "$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after" + "$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json" + "$REPORT_DIR/report.md" +``` + +base/head を比較する計測では、**head側のチェックアウトにあるハーネスで両方を計測する** +(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。 diff --git a/scripts/changelog-checker/eslint.config.js b/packages-private/changelog-checker/eslint.config.js similarity index 71% rename from scripts/changelog-checker/eslint.config.js rename to packages-private/changelog-checker/eslint.config.js index 813e96981a..a3b10c3e77 100644 --- a/scripts/changelog-checker/eslint.config.js +++ b/packages-private/changelog-checker/eslint.config.js @@ -1,10 +1,16 @@ import tsParser from '@typescript-eslint/parser'; import sharedConfig from '../../packages/shared/eslint.config.js'; +// eslint-disable-next-line import/no-default-export export default [ ...sharedConfig, { - files: ['src/**/*.ts', 'src/**/*.tsx'], + ignores: [ + '**/node_modules', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], languageOptions: { parserOptions: { parser: tsParser, diff --git a/packages-private/changelog-checker/package.json b/packages-private/changelog-checker/package.json new file mode 100644 index 0000000000..30a3af2dd9 --- /dev/null +++ b/packages-private/changelog-checker/package.json @@ -0,0 +1,24 @@ +{ + "name": "changelog-checker", + "private": true, + "type": "module", + "scripts": { + "check": "tsx src/index.ts", + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/mdast": "4.0.4", + "@types/node": "26.1.1", + "@vitest/coverage-v8": "4.1.10", + "mdast-util-to-string": "4.0.0", + "remark": "15.0.1", + "remark-parse": "11.0.0", + "tsx": "4.23.1", + "unified": "11.0.5", + "vitest": "4.1.10" + } +} diff --git a/scripts/changelog-checker/src/checker.ts b/packages-private/changelog-checker/src/checker.ts similarity index 77% rename from scripts/changelog-checker/src/checker.ts rename to packages-private/changelog-checker/src/checker.ts index 8fd6ff5629..01c2598a74 100644 --- a/scripts/changelog-checker/src/checker.ts +++ b/packages-private/changelog-checker/src/checker.ts @@ -33,16 +33,22 @@ export function checkNewRelease(base: Release[], head: Release[]): Result { return Result.ofFailed('Invalid release count.'); } - const baseLatest = base[0]; - const headPrevious = head[releaseCountDiff]; - - if (baseLatest.releaseName !== headPrevious.releaseName) { - return Result.ofFailed('Contains unexpected releases.'); + // 追加分を除いた残り (= base に既にあったリリース) が順序ごと一致することを確認する。 + // 先頭だけ比較していると、より古いリリースが書き換えられていても素通りしてしまう + const existingReleases = head.slice(releaseCountDiff); + for (let relIdx = 0; relIdx < base.length; relIdx++) { + if (base[relIdx].releaseName !== existingReleases[relIdx].releaseName) { + return Result.ofFailed(`Contains unexpected releases. base:${base[relIdx].releaseName}, head:${existingReleases[relIdx].releaseName}`); + } } return Result.ofSuccess(); } +function isSameItems(base: string[], head: string[]) { + return base.length === head.length && base.every((item, idx) => item === head[idx]); +} + /** * topic -> developまたはtopic -> masterを想定したパターン。 * head側の最新リリース配下に書き加えられているかをチェックする。 @@ -78,9 +84,9 @@ export function checkNewTopic(base: Release[], head: Release[]): Result { return Result.ofFailed(`Category is different. base:${baseCategory.categoryName}, head:${headCategory.categoryName}`); } - if (baseCategory.items.length !== headCategory.items.length) { + if (!isSameItems(baseCategory.items, headCategory.items)) { if (headLatest.releaseName !== headItem.releaseName) { - // 最新リリース以外に追記されていた場合 + // 最新リリース以外が変更されていた場合 return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`); } } diff --git a/scripts/changelog-checker/src/index.ts b/packages-private/changelog-checker/src/index.ts similarity index 91% rename from scripts/changelog-checker/src/index.ts rename to packages-private/changelog-checker/src/index.ts index 0e2c5ce057..868ca3f3b6 100644 --- a/scripts/changelog-checker/src/index.ts +++ b/packages-private/changelog-checker/src/index.ts @@ -18,7 +18,7 @@ function abort(message?: string) { function main() { if (!fs.existsSync('./CHANGELOG-base.md') || !fs.existsSync('./CHANGELOG-head.md')) { - console.error('CHANGELOG-base.md or CHANGELOG-head.md is missing.'); + abort('CHANGELOG-base.md or CHANGELOG-head.md is missing.'); return; } diff --git a/scripts/changelog-checker/src/parser.ts b/packages-private/changelog-checker/src/parser.ts similarity index 90% rename from scripts/changelog-checker/src/parser.ts rename to packages-private/changelog-checker/src/parser.ts index 894d60d6af..46b74a4ed2 100644 --- a/scripts/changelog-checker/src/parser.ts +++ b/packages-private/changelog-checker/src/parser.ts @@ -51,6 +51,9 @@ export function parseChangeLog(path: string): Release[] { // リリース release = new Release(toString(it)); releases.push(release); + // 直前のリリースのカテゴリを引き継ぐと、カテゴリ見出しの無いリスト項目が + // 前のリリースに混入するのでリセットする + category = null; } else if (isHeading(it) && it.depth === 3 && release) { // リリース配下のカテゴリ category = new ReleaseCategory(toString(it)); diff --git a/scripts/changelog-checker/test/checker.test.ts b/packages-private/changelog-checker/test/checker.test.ts similarity index 56% rename from scripts/changelog-checker/test/checker.test.ts rename to packages-private/changelog-checker/test/checker.test.ts index 9ca8fa85f2..459379f8db 100644 --- a/scripts/changelog-checker/test/checker.test.ts +++ b/packages-private/changelog-checker/test/checker.test.ts @@ -3,52 +3,62 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import {expect, suite, test} from "vitest"; -import {Release, ReleaseCategory} from "../src/parser"; -import {checkNewRelease, checkNewTopic} from "../src/checker"; +import { expect, describe, test } from 'vitest'; +import { Release, ReleaseCategory } from '../src/parser.js'; +import { checkNewRelease, checkNewTopic } from '../src/checker.js'; -suite('checkNewRelease', () => { +describe('checkNewRelease', () => { test('headに新しいリリースがある1', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.1'), new Release('2024.12.0')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.1'), new Release('2024.12.0')]; - const result = checkNewRelease(base, head) + const result = checkNewRelease(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('headに新しいリリースがある2', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.12.0')]; - const result = checkNewRelease(base, head) - - expect(result.success).toBe(true) - }) + const result = checkNewRelease(base, head); + expect(result.success).toBe(true); + }); test('リリースの数が同じ', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.0')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.0')]; - const result = checkNewRelease(base, head) + const result = checkNewRelease(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('baseにあるリリースがheadにない', () => { - const base = [new Release('2024.12.0')] - const head = [new Release('2024.12.2'), new Release('2024.12.1')] + const base = [new Release('2024.12.0')]; + const head = [new Release('2024.12.2'), new Release('2024.12.1')]; - const result = checkNewRelease(base, head) + const result = checkNewRelease(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) -}) + console.log(result.message); + expect(result.success).toBe(false); + }); -suite('checkNewTopic', () => { + // 先頭だけ比較していると、より古いリリースの書き換えを見逃す + test('追加分の直後は一致しているが、より古いリリースが書き換えられている', () => { + const base = [new Release('2024.12.1'), new Release('2024.12.0')]; + const head = [new Release('2024.12.2'), new Release('2024.12.1'), new Release('2024.11.0')]; + + const result = checkNewRelease(base, head); + + console.log(result.message); + expect(result.success).toBe(false); + }); +}); + +describe('checkNewTopic', () => { test('追記なし', () => { const base = [ new Release('2024.12.1', [ @@ -59,7 +69,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -69,9 +79,9 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -82,7 +92,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -92,14 +102,14 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンにカテゴリを追加したときはエラーにならない', () => { const base = [ @@ -117,9 +127,9 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -130,7 +140,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -140,14 +150,14 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンからカテゴリを削除したときはエラーにならない', () => { const base = [ @@ -159,7 +169,7 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) + ]), ]), new Release('2024.12.0', [ new ReleaseCategory('Server', [ @@ -169,9 +179,9 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -188,14 +198,14 @@ suite('checkNewTopic', () => { new ReleaseCategory('Client', [ 'feat3', 'feat4', - ]) - ]) - ] + ]), + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンに追記したときはエラーにならない', () => { const base = [ @@ -210,8 +220,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -226,13 +236,13 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('最新バージョンから削除したときはエラーにならない', () => { const base = [ @@ -247,8 +257,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -261,13 +271,13 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - expect(result.success).toBe(true) - }) + expect(result.success).toBe(true); + }); test('古いバージョンにカテゴリを追加したときはエラーになる', () => { const base = [ @@ -282,8 +292,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -301,14 +311,14 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('古いバージョンからカテゴリを削除したときはエラーになる', () => { const base = [ @@ -323,8 +333,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -334,14 +344,14 @@ suite('checkNewTopic', () => { ]), ]), new Release('2024.12.0', [ - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('古いバージョンに追記したときはエラーになる', () => { const base = [ @@ -356,8 +366,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -372,14 +382,14 @@ suite('checkNewTopic', () => { 'feat2', 'feat3', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) + console.log(result.message); + expect(result.success).toBe(false); + }); test('古いバージョンから削除したときはエラーになる', () => { const base = [ @@ -394,8 +404,8 @@ suite('checkNewTopic', () => { 'feat1', 'feat2', ]), - ]) - ] + ]), + ]; const head = [ new Release('2024.12.1', [ @@ -408,12 +418,88 @@ suite('checkNewTopic', () => { new ReleaseCategory('Server', [ 'feat1', ]), - ]) - ] + ]), + ]; - const result = checkNewTopic(base, head) + const result = checkNewTopic(base, head); - console.log(result.message) - expect(result.success).toBe(false) - }) -}) + console.log(result.message); + expect(result.success).toBe(false); + }); + + // 件数が同じでも内容が変わっていれば履歴の書き換えなのでエラーにする + test('古いバージョンの項目が書き換えられたときはエラーになる', () => { + const base = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1', 'feat2']), + ]), + ]; + + const head = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1', 'feat2-rewritten']), + ]), + ]; + + const result = checkNewTopic(base, head); + + console.log(result.message); + expect(result.success).toBe(false); + }); + + test('古いバージョンの項目が並べ替えられたときはエラーになる', () => { + const base = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1', 'feat2']), + ]), + ]; + + const head = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat2', 'feat1']), + ]), + ]; + + const result = checkNewTopic(base, head); + + console.log(result.message); + expect(result.success).toBe(false); + }); + + // 最新リリースの書き換えは通常の編集なので許容する + test('最新バージョンの項目を書き換えたときはエラーにならない', () => { + const base = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1']), + ]), + ]; + + const head = [ + new Release('2024.12.1', [ + new ReleaseCategory('Server', ['feat1-rewritten']), + ]), + new Release('2024.12.0', [ + new ReleaseCategory('Server', ['feat1']), + ]), + ]; + + const result = checkNewTopic(base, head); + + expect(result.success).toBe(true); + }); +}); diff --git a/scripts/changelog-checker/tsconfig.json b/packages-private/changelog-checker/tsconfig.json similarity index 89% rename from scripts/changelog-checker/tsconfig.json rename to packages-private/changelog-checker/tsconfig.json index 32f1547eb8..a728e49d4d 100644 --- a/scripts/changelog-checker/tsconfig.json +++ b/packages-private/changelog-checker/tsconfig.json @@ -14,18 +14,17 @@ "experimentalDecorators": true, "noImplicitReturns": true, "esModuleInterop": true, - "typeRoots": [ - "./node_modules/@types" - ], + "skipLibCheck": true, + "types": ["node"], "lib": [ "esnext" ] }, "include": [ - "src/**/*" + "src/**/*", + "test/**/*" ], "exclude": [ "node_modules", - "test/**/*" ] } diff --git a/packages-private/diagnostics-backend/eslint.config.js b/packages-private/diagnostics-backend/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-backend/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-backend/package.json b/packages-private/diagnostics-backend/package.json new file mode 100644 index 0000000000..20fd36e30a --- /dev/null +++ b/packages-private/diagnostics-backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "diagnostics-backend", + "private": true, + "type": "module", + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "inspect": "tsx src/inspect.ts", + "lint": "pnpm typecheck && pnpm eslint", + "measure": "tsx src/measure-once.ts", + "render-md": "tsx src/render-md.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "diagnostics-shared": "workspace:*", + "execa": "9.6.1" + }, + "devDependencies": { + "@types/node": "26.1.1", + "@types/pg": "8.20.0", + "ioredis": "5.11.1", + "pg": "8.22.0", + "tsx": "4.23.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/.github/scripts/backend-diagnostics.inspect.mts b/packages-private/diagnostics-backend/src/compare.ts similarity index 51% rename from .github/scripts/backend-diagnostics.inspect.mts rename to packages-private/diagnostics-backend/src/compare.ts index 5bf0f3ee99..8847b44d4a 100644 --- a/.github/scripts/backend-diagnostics.inspect.mts +++ b/packages-private/diagnostics-backend/src/compare.ts @@ -3,75 +3,43 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createRequire } from 'node:module'; 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 { MemorySample } from '../../packages/backend/scripts/measure-memory.mts'; +import { execa } from 'execa'; +import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; +import { median } from 'diagnostics-shared/stats'; +import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; +import { resetState } from './db'; +import { measureBackendMemory } from './measure'; +import { memoryPhases, type MemoryReport } from './types'; -const phases = ['afterGc'] as const; +const heapSnapshotLabels = ['base', 'head'] as const; -export type MemoryReport = { - timestamp: string; - sampleCount: number; - aggregation: string; - summary: Record; - heapSnapshot?: heapSnapshotUtil.HeapSnapshotData; - }>; - samples: (MemorySample & { - round: number; - })[]; +type HeapSnapshotLabel = typeof heapSnapshotLabels[number]; + +export type CompareOptions = { + baseDir: string; + headDir: string; + baseOutput: string; + headOutput: string; }; -const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); - -const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1); -const heapSnapshotLabels = ['base', 'head'] as const; +const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); +// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す +const HEAP_SNAPSHOT_OUTPUT_DIR = resolve(readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd()); const HEAP_SNAPSHOT_WORK_DIRS = { - base: resolve('base-heap-snapshots'), - head: resolve('head-heap-snapshots'), + base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshots'), + head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshots'), }; const HEAP_SNAPSHOT_OUTPUT_PATHS = { - base: resolve('base-heap-snapshot.heapsnapshot'), - head: resolve('head-heap-snapshot.heapsnapshot'), + base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshot.heapsnapshot'), + head: join(HEAP_SNAPSHOT_OUTPUT_DIR, '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')); - const pg = require('pg'); - const Redis = require('ioredis'); - - const postgres = new pg.Client({ - host: '127.0.0.1', - port: 54312, - database: 'postgres', - user: 'postgres', - }); - - await postgres.connect(); - try { - await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)'); - await postgres.query('CREATE DATABASE "test-misskey"'); - } finally { - await postgres.end(); - } - - const redis = new Redis({ host: '127.0.0.1', port: 56312 }); - try { - await redis.flushall(); - } finally { - redis.disconnect(); - } -} - -function summarizeSamples(samples: MemoryReport['samples']) { +export function summarizeSamples(samples: MemoryReport['samples']) { const summary = {} as MemoryReport['summary']; - for (const phase of phases) { + for (const phase of memoryPhases) { summary[phase] = { memoryUsage: {}, }; @@ -85,10 +53,10 @@ function summarizeSamples(samples: MemoryReport['samples']) { for (const key of metricKeys) { const values = samples.map(sample => sample.phases[phase].memoryUsage[key]); - summary[phase].memoryUsage[key] = util.median(values); + summary[phase].memoryUsage[key] = median(values); } - const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples( + const heapSnapshot = summarizeHeapSnapshotDataSamples( samples, sample => sample.phases[phase].heapSnapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }, @@ -101,42 +69,38 @@ function summarizeSamples(samples: MemoryReport['samples']) { async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) { process.stderr.write(`[${label}] Resetting database and Redis\n`); - await resetState(repoDir); + await resetState(); process.stderr.write(`[${label}] Running migrations\n`); - await util.run('pnpm', ['--filter', 'backend', 'migrate'], { + // 出力はログとして流しつつ手元にも残す (失敗時にexecaが例外メッセージへ含めてくれる) + await execa('pnpm', ['--filter', 'backend', 'migrate'], { cwd: repoDir, - env: process.env, - logStdout: true, + stdout: ['pipe', process.stderr], + stderr: ['pipe', process.stderr], }); process.stderr.write(`[${label}] Measuring memory\n`); - const measureEnv = { - ...process.env, - 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', [MEASURE_MEMORY_SCRIPT], { - cwd: repoDir, - env: measureEnv, + return await measureBackendMemory(resolve(repoDir, 'packages/backend'), { + // warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない + ...(round <= 0 ? { heapSnapshot: false } : {}), + heapSnapshotSavePath: options.heapSnapshotSavePath ?? null, }); - - return JSON.parse(stdout) as MemorySample; } -function heapSnapshotPath(label: typeof heapSnapshotLabels[number], round: number) { +function heapSnapshotPath(label: HeapSnapshotLabel, round: number) { return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`); } +/** + * 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。 + */ function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) { - const medianTotal = summary.afterGc.heapSnapshot?.categories?.total; + const medianTotal = summary.afterGc.heapSnapshot?.categories.total; if (medianTotal == null || !Number.isFinite(medianTotal)) return null; let selected: { round: number; distance: number } | null = null; for (const sample of samples) { - const total = sample.phases.afterGc.heapSnapshot?.categories?.total; + const total = sample.phases.afterGc.heapSnapshot?.categories.total; if (total == null || !Number.isFinite(total)) continue; const distance = Math.abs(total - medianTotal); @@ -151,7 +115,7 @@ function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], return selected?.round ?? null; } -async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) { +async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) { const round = selectRepresentativeHeapSnapshotRound(samples, summary); if (round == null) return; @@ -160,13 +124,13 @@ async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[n await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true }); } -async function main() { - const baseDir = resolve(baseDirArg); - const headDir = resolve(headDirArg); - const baseOutput = resolve(baseOutputArg); - const headOutput = resolve(headOutputArg); - const rounds = util.readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1); - const warmupRounds = util.readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0); +/** + * base / head を交互に計測してJSONレポートを書き出す。 + * 交互にするのは、実行順やマシンの状態による偏りを両者に均等に載せるため。 + */ +export async function compareBackendMemory(options: CompareOptions) { + const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1); + const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0); const startedAt = new Date().toISOString(); for (const label of heapSnapshotLabels) { @@ -176,11 +140,11 @@ async function main() { const reports = { base: { - dir: baseDir, + dir: options.baseDir, samples: [] as MemoryReport['samples'], }, head: { - dir: headDir, + dir: options.headDir, samples: [] as MemoryReport['samples'], }, }; @@ -197,8 +161,7 @@ async function main() { process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`); for (const label of order) { - const options = { heapSnapshotSavePath: heapSnapshotPath(label, round) }; - const sample = await genSample(label, reports[label].dir, round, options); + const sample = await genSample(label, reports[label].dir, round, { heapSnapshotSavePath: heapSnapshotPath(label, round) }); reports[label].samples.push({ ...sample, round, @@ -215,7 +178,7 @@ async function main() { } for (const label of heapSnapshotLabels) { - const report = { + const report: MemoryReport = { timestamp: new Date().toISOString(), sampleCount: reports[label].samples.length, aggregation: 'median', @@ -229,11 +192,6 @@ async function main() { samples: reports[label].samples, }; - await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(label === 'base' ? options.baseOutput : options.headOutput, `${JSON.stringify(report, null, 2)}\n`); } } - -main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/packages-private/diagnostics-backend/src/db.ts b/packages-private/diagnostics-backend/src/db.ts new file mode 100644 index 0000000000..13045b6b90 --- /dev/null +++ b/packages-private/diagnostics-backend/src/db.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import Redis from 'ioredis'; +import pg from 'pg'; + +/** + * 計測ラウンド間で状態を持ち越さないよう、テスト用DBを作り直しRedisを空にする。 + * 接続先はCIのテスト用サービス (.github/misskey/test.yml) と揃えてある。 + */ +export async function resetState() { + const postgres = new pg.Client({ + host: '127.0.0.1', + port: 54312, + database: 'postgres', + user: 'postgres', + }); + + await postgres.connect(); + try { + await postgres.query('DROP DATABASE IF EXISTS "test-misskey" WITH (FORCE)'); + await postgres.query('CREATE DATABASE "test-misskey"'); + } finally { + await postgres.end(); + } + + const redis = new Redis({ host: '127.0.0.1', port: 56312 }); + try { + await redis.flushall(); + } finally { + redis.disconnect(); + } +} diff --git a/packages-private/diagnostics-backend/src/inspect.ts b/packages-private/diagnostics-backend/src/inspect.ts new file mode 100644 index 0000000000..c98ea6b644 --- /dev/null +++ b/packages-private/diagnostics-backend/src/inspect.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { resolve } from 'node:path'; +import { compareBackendMemory } from './compare'; + +const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition +if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) { + console.error('Usage: inspect '); + process.exit(1); +} + +try { + await compareBackendMemory({ + baseDir: resolve(baseDirArg), + headDir: resolve(headDirArg), + baseOutput: resolve(baseOutputArg), + headOutput: resolve(headOutputArg), + }); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/packages-private/diagnostics-backend/src/measure-once.ts b/packages-private/diagnostics-backend/src/measure-once.ts new file mode 100644 index 0000000000..6efdbf09d0 --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure-once.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { resolve } from 'node:path'; +import { readOptionalEnv } from 'diagnostics-shared/env'; +import { measureBackendMemory } from './measure'; + +// ローカルデバッグ用: バックエンド1回分の計測結果をJSONで出力する +const [backendDirArg] = process.argv.slice(2); + +if (backendDirArg == null) { + console.error('Usage: measure '); + process.exit(1); +} + +try { + const sample = await measureBackendMemory(resolve(backendDirArg), { + heapSnapshotSavePath: readOptionalEnv('MK_MEMORY_HEAP_SNAPSHOT_SAVE_PATH'), + }); + console.log(JSON.stringify(sample, null, 2)); +} catch (err) { + console.error(JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + timestamp: new Date().toISOString(), + })); + process.exit(1); +} diff --git a/packages-private/diagnostics-backend/src/measure/index.ts b/packages-private/diagnostics-backend/src/measure/index.ts new file mode 100644 index 0000000000..3acf1b0e66 --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure/index.ts @@ -0,0 +1,129 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { readBooleanEnv, readIntegerEnv } from 'diagnostics-shared/env'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; +import { getMemoryUsage, getSmapsRollupMemoryUsage } from './proc'; +import { + forkBackendServer, + getRuntimeMemoryUsage, + requestHeapSnapshot, + shutdownBackendServer, + triggerGc, + waitForServerReady, +} from './server'; +import { measureMemoryUntilStable } from './stability'; +import type { MemorySample } from '../types'; + +export type MeasureBackendMemoryOptions = { + /** heap snapshotを取得するか (既定: MK_MEMORY_HEAP_SNAPSHOT) */ + heapSnapshot?: boolean; + /** 取得したheap snapshotの保存先。未指定なら解析後に破棄する */ + heapSnapshotSavePath?: string | null; + heapSnapshotBreakdownTopN?: number; + heapSnapshotTimeoutMs?: number; + startupTimeoutMs?: number; + ipcTimeoutMs?: number; +}; + +function resolveOptions(options: MeasureBackendMemoryOptions) { + return { + heapSnapshot: options.heapSnapshot ?? readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false), + heapSnapshotSavePath: options.heapSnapshotSavePath ?? null, + heapSnapshotBreakdownTopN: options.heapSnapshotBreakdownTopN ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1), + heapSnapshotTimeoutMs: options.heapSnapshotTimeoutMs ?? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_TIMEOUT_MS', 120000, 1), + startupTimeoutMs: options.startupTimeoutMs ?? readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1), + ipcTimeoutMs: options.ipcTimeoutMs ?? readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1), + }; +} + +/** + * バックエンドを1回起動し、GC後のメモリ使用量を計測して1サンプル分の結果を返す。 + */ +export async function measureBackendMemory(backendDir: string, options: MeasureBackendMemoryOptions = {}): Promise { + const settings = resolveOptions(options); + const serverProcess = forkBackendServer(backendDir); + + // 起動完了メッセージを取りこぼさないよう、他のハンドラより先に待ち受ける + const serverReady = waitForServerReady(serverProcess, settings.startupTimeoutMs); + + serverProcess.stdout?.on('data', (data) => { + process.stderr.write(`[server stdout] ${data}`); + }); + + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(`[server stderr] ${data}`); + }); + + serverProcess.on('error', (err) => { + process.stderr.write(`[server error] ${err}\n`); + }); + + // 途中で失敗しても子プロセスを残さない。残すと次のラウンドがポート衝突で落ちる + try { + const startupStartTime = Date.now(); + await serverReady; + + const startupTime = Date.now() - startupStartTime; + process.stderr.write(`Server started in ${startupTime}ms\n`); + + await triggerGc(serverProcess, settings.ipcTimeoutMs); + + const pid = serverProcess.pid!; + const stableSmapsRollup = await measureMemoryUntilStable(() => getSmapsRollupMemoryUsage(pid)); + const afterGc = { + memoryUsage: { + ...await getMemoryUsage(pid), + ...stableSmapsRollup.memoryUsage, + ...await getRuntimeMemoryUsage(serverProcess, settings.ipcTimeoutMs), + }, + stability: stableSmapsRollup.stability, + }; + 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, settings); + + return { + timestamp: new Date().toISOString(), + phases: { + afterGc: { + memoryUsage: afterGc.memoryUsage, + memoryStability: afterGc.stability, + heapSnapshot: heapSnapshotAfterGc, + }, + }, + }; + } finally { + await shutdownBackendServer(serverProcess); + } +} + +async function getHeapSnapshotStatistics( + serverProcess: ReturnType, + settings: ReturnType, +): Promise { + if (!settings.heapSnapshot) return null; + + const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); + const writtenPath = await requestHeapSnapshot(serverProcess, snapshotPath, settings.heapSnapshotTimeoutMs); + + try { + if (settings.heapSnapshotSavePath != null && settings.heapSnapshotSavePath !== '') { + await fs.mkdir(dirname(settings.heapSnapshotSavePath), { recursive: true }); + await fs.copyFile(writtenPath, settings.heapSnapshotSavePath); + } + + const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); + return analyzeHeapSnapshot(snapshot, { breakdownTopN: settings.heapSnapshotBreakdownTopN }); + } finally { + // 数百MBになることがあるため、解析後は必ず消す + await fs.unlink(writtenPath).catch(err => { + process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); + }); + } +} diff --git a/packages-private/diagnostics-backend/src/measure/proc.ts b/packages-private/diagnostics-backend/src/measure/proc.ts new file mode 100644 index 0000000000..435c3ac54c --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure/proc.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs/promises'; + +export const procStatusKeys = ['VmPeak', 'VmSize', 'VmHWM', 'VmRSS', 'VmData', 'VmStk', 'VmExe', 'VmLib', 'VmPTE', 'VmSwap'] as const; +export const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; + +/** + * `/proc` 配下の `Key: 1234 kB` 形式のファイルから指定キーを取り出す。 + * 1つでも欠けていると以降の集計が静かに壊れるため、見つからなければ例外にする。 + */ +export function parseMemoryFile(content: string, keys: KS, path: string): Record { + const result = {} as Record; + for (const _key of keys) { + const key = _key as KS[number]; + const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); + if (match) { + result[key] = parseInt(match[1], 10); + } else { + throw new Error(`Failed to parse ${key} from ${path}`); + } + } + return result; +} + +export function bytesToKiB(value: number) { + return Math.round(value / 1024); +} + +export async function getMemoryUsage(pid: number) { + const path = `/proc/${pid}/status`; + const status = await fs.readFile(path, 'utf-8'); + return parseMemoryFile(status, procStatusKeys, path); +} + +export async function getSmapsRollupMemoryUsage(pid: number) { + const path = `/proc/${pid}/smaps_rollup`; + const smapsRollup = await fs.readFile(path, 'utf-8'); + return parseMemoryFile(smapsRollup, smapsRollupKeys, path); +} diff --git a/packages-private/diagnostics-backend/src/measure/server.ts b/packages-private/diagnostics-backend/src/measure/server.ts new file mode 100644 index 0000000000..ee3d74ba9f --- /dev/null +++ b/packages-private/diagnostics-backend/src/measure/server.ts @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { fork, type ChildProcess } from 'node:child_process'; +import { join } from 'node:path'; +import { bytesToKiB } from './proc'; + +type GcMessage = 'gc ok' | 'gc unavailable'; +type RuntimeMemoryUsageMessage = { + type: 'memory usage'; + value: NodeJS.MemoryUsage; +}; +type HeapSnapshotMessage = { + type: 'heap snapshot'; + path?: string; +}; +type HeapSnapshotErrorMessage = { + type: 'heap snapshot error'; + message: string; +}; +type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object'; +} + +function isGcMessage(message: unknown): message is GcMessage { + return message === 'gc ok' || message === 'gc unavailable'; +} + +function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { + return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); +} + +function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { + if (!isRecord(message)) return false; + if (message.type === 'heap snapshot') return true; + return message.type === 'heap snapshot error' && typeof message.message === 'string'; +} + +export function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout: number) { + return new Promise((resolve, reject) => { + const cleanup = () => { + globalThis.clearTimeout(timer); + serverProcess.off('message', onMessage); + serverProcess.off('exit', onExit); + serverProcess.off('error', onError); + serverProcess.off('disconnect', onDisconnect); + }; + + const timer = globalThis.setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ${description}`)); + }, timeout); + + const onMessage = (message: unknown) => { + if (!predicate(message)) return; + cleanup(); + resolve(message); + }; + + // 子が死んだ場合、待ち続けてもメッセージは来ない。 + // タイムアウトまで待って誤解を招くエラーを出すより、理由を添えて即座に失敗させる + const onExit = (code: number | null, signal: string | null) => { + cleanup(); + reject(new Error(`Server exited (code=${code}, signal=${signal}) while waiting for ${description}`)); + }; + + const onError = (err: Error) => { + cleanup(); + reject(new Error(`Server errored while waiting for ${description}: ${err.message}`)); + }; + + const onDisconnect = () => { + cleanup(); + reject(new Error(`Server IPC channel closed while waiting for ${description}`)); + }; + + serverProcess.on('message', onMessage); + serverProcess.once('exit', onExit); + serverProcess.once('error', onError); + serverProcess.once('disconnect', onDisconnect); + }); +} + +/** + * ビルド済みバックエンドを子プロセスとして起動する。 + * execArgv は親から引き継がず `--expose-gc` のみを渡す: 親は tsx 経由で動くため、 + * 引き継ぐと計測対象プロセスにTSローダーが載ってしまいメモリ量が歪む。 + */ +export function forkBackendServer(backendDir: string) { + return fork(join(backendDir, 'built/entry.js'), [], { + cwd: backendDir, + env: { + ...process.env, + NODE_ENV: 'production', + MK_DISABLE_CLUSTERING: '1', + MK_ONLY_SERVER: '1', + MK_NO_DAEMONS: '1', + }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + execArgv: ['--expose-gc'], + }); +} + +export function waitForServerReady(serverProcess: ChildProcess, timeout: number) { + return waitForMessage( + serverProcess, + (message): message is 'ok' => message === 'ok', + 'server startup', + timeout, + ); +} + +export async function triggerGc(serverProcess: ChildProcess, timeout: number) { + // 送信前にlistenerを張らないと、応答を取りこぼす可能性がある + const ok = waitForMessage(serverProcess, isGcMessage, 'GC completion', timeout); + + serverProcess.send('gc'); + + const message = await ok; + if (message === 'gc unavailable') { + throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); + } +} + +export async function getRuntimeMemoryUsage(serverProcess: ChildProcess, timeout: number) { + const response = waitForMessage( + serverProcess, + isRuntimeMemoryUsageMessage, + 'memory usage', + timeout, + ); + + serverProcess.send('memory usage'); + + const message = await response; + const memoryUsage = message.value; + + // /proc 由来の値と単位を揃える + return { + HeapTotal: bytesToKiB(memoryUsage.heapTotal), + HeapUsed: bytesToKiB(memoryUsage.heapUsed), + External: bytesToKiB(memoryUsage.external), + ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers), + }; +} + +/** + * heap snapshotの書き出しを依頼し、実際に書かれたパスを返す。 + */ +export async function requestHeapSnapshot(serverProcess: ChildProcess, snapshotPath: string, timeout: number) { + const response = waitForMessage( + serverProcess, + isHeapSnapshotResponseMessage, + 'heap snapshot', + timeout, + ); + + serverProcess.send({ + type: 'heap snapshot', + path: snapshotPath, + }); + + const message = await response; + if (message.type === 'heap snapshot error') { + throw new Error(`Failed to write heap snapshot: ${message.message}`); + } + + return typeof message.path === 'string' ? message.path : snapshotPath; +} + +/** + * SIGTERMで終了を促し、一定時間で落ちなければSIGKILLする。 + */ +export async function shutdownBackendServer(serverProcess: ChildProcess) { + // 既に終了しているなら 'exit' はもう発火しないので、待つと無駄に10秒止まる + if (serverProcess.exitCode != null || serverProcess.signalCode != null) return; + + await new Promise(resolve => { + let forceTimer: NodeJS.Timeout | undefined; + const termTimer = globalThis.setTimeout(() => { + serverProcess.kill('SIGKILL'); + // SIGKILLは無視できないので通常はここで 'exit' が来る。 + // D状態などで落ちない場合に計測全体を止めないよう、待ち時間には上限を設ける + forceTimer = globalThis.setTimeout(resolve, 5000); + }, 10000); + + serverProcess.once('exit', () => { + globalThis.clearTimeout(termTimer); + if (forceTimer != null) globalThis.clearTimeout(forceTimer); + resolve(); + }); + + serverProcess.kill('SIGTERM'); + }); +} diff --git a/.github/scripts/memory-stability-util.mts b/packages-private/diagnostics-backend/src/measure/stability.ts similarity index 91% rename from .github/scripts/memory-stability-util.mts rename to packages-private/diagnostics-backend/src/measure/stability.ts index 0a9a8d4f52..0c390088c2 100644 --- a/.github/scripts/memory-stability-util.mts +++ b/packages-private/diagnostics-backend/src/measure/stability.ts @@ -38,6 +38,10 @@ function getMaxAbsoluteSlopes>(readings: { elap return result; } +/** + * メモリ使用量が落ち着くまで繰り返し読み取る。 + * 起動直後は遅延初期化でじわじわ増え続けるため、直近 `windowSize` 件の傾きが十分小さくなるまで待つ。 + */ export async function measureMemoryUntilStable>( readMemoryUsage: () => Promise, timer: MemoryStabilityTimer = defaultTimer, diff --git a/packages-private/diagnostics-backend/src/render-md.ts b/packages-private/diagnostics-backend/src/render-md.ts new file mode 100644 index 0000000000..991ac0fe53 --- /dev/null +++ b/packages-private/diagnostics-backend/src/render-md.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readRequiredEnv } from 'diagnostics-shared/env'; +import { renderMemoryReportMarkdown } from './report/markdown'; +import type { MemoryReport } from './types'; + +async function main() { + const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); + if (baseFileArg == null || headFileArg == null || outputFileArg == null) { + throw new Error('Usage: render-md '); + } + + const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as MemoryReport; + const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as MemoryReport; + + await writeFile(resolve(outputFileArg), renderMemoryReportMarkdown(base, head, { + baseHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_BASE'), + headHeapSnapshotUrl: readRequiredEnv('MK_MEMORY_HEAP_SNAPSHOT_ARTIFACT_URL_HEAD'), + })); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-backend/src/report/markdown.ts b/packages-private/diagnostics-backend/src/report/markdown.ts new file mode 100644 index 0000000000..bd4655dbe2 --- /dev/null +++ b/packages-private/diagnostics-backend/src/report/markdown.ts @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatColoredDelta, formatDeltaPercentInMdTable, formatKiBAsMb } from 'diagnostics-shared/format'; +import { median, pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats'; +import { renderHeapSnapshotTable } from 'diagnostics-shared/heap-snapshot'; +import type { MemoryPhase, MemoryReport } from '../types'; + +export type RenderMemoryReportOptions = { + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; +}; + +const memoryReportPhases = [ + { + key: 'afterGc', + title: 'After GC', + }, +] as const satisfies readonly { key: MemoryPhase; title: string }[]; + +const memoryMetrics = [ + 'HeapUsed', + 'Pss', + 'USS', + 'External', +] as const; + +type MemoryMetric = typeof memoryMetrics[number]; + +function formatMemoryMetricName(metric: MemoryMetric) { + return metric === 'Pss' ? 'PSS' : metric; +} + +function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: MemoryPhase, metric: MemoryMetric) { + const memoryUsage = sample.phases[phase].memoryUsage; + // USSは直接取れないのでPrivateの合算で近似する + if (metric !== 'USS') return memoryUsage[metric]; + return memoryUsage.Private_Clean + memoryUsage.Private_Dirty; +} + +function getSampleValues(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric)); +} + +function getMemoryValue(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + if (metric !== 'USS') return report.summary[phase].memoryUsage[metric]; + return median(getSampleValues(report, phase, metric)); +} + +function getSampleSpread(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + return sampleSpread(getSampleValues(report, phase, metric)); +} + +function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) { + const lines = [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + + function formatDeltaMemory(deltaKiB: number) { + return formatColoredDelta(deltaKiB, v => formatKiBAsMb(v), 100); // 0.1 MB threshold + } + + for (const metric of memoryMetrics) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + + const baseSpread = getSampleSpread(base, phase, metric); + const headSpread = getSampleSpread(head, phase, metric); + const summary = pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric)); + const percent = summary.median * 100 / baseValue; + const deltaMedian = `${formatDeltaMemory(summary.median)}
${formatDeltaPercentInMdTable(percent, 0.1)}`; + + lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatKiBAsMb(baseValue)}
± ${formatKiBAsMb(baseSpread)} | ${formatKiBAsMb(headValue)}
± ${formatKiBAsMb(headSpread)} | ${deltaMedian} | ${formatKiBAsMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`); + } + + return lines.join('\n'); +} + +function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) { + const baseHeapSnapshotReport = { + summary: base.summary.afterGc.heapSnapshot!, + samples: base.samples.map(sample => ({ + round: sample.round, + data: sample.phases.afterGc.heapSnapshot!, + })), + }; + + const headHeapSnapshotReport = { + summary: head.summary.afterGc.heapSnapshot!, + samples: head.samples.map(sample => ({ + round: sample.round, + data: sample.phases.afterGc.heapSnapshot!, + })), + }; + + const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport); + if (table == null) return null; + + const lines = [ + '### V8 Heap Snapshot Statistics', + '', + table, + '', + ]; + + // Sankeyはノイズが多く読み取りづらかったため現在は無効。復活させる余地を残して残置する + for (const graph of [ + //renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'), + //renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'), + ] as (string | null)[]) { + if (graph == null) continue; + lines.push(graph); + lines.push(''); + } + + return lines.join('\n'); +} + +function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + return ((headValue - baseValue) * 100) / baseValue; +} + +/** + * 増加分がサンプルのばらつきを明確に超えているかを見る。 + * 測定ノイズで警告が出続けるのを避けるため、合成ばらつきの3倍を閾値にする。 + */ +function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) { + const baseValue = getMemoryValue(base, phase, metric); + const headValue = getMemoryValue(head, phase, metric); + + const delta = headValue - baseValue; + if (delta <= 0) return false; + + const baseSpread = getSampleSpread(base, phase, metric); + const headSpread = getSampleSpread(head, phase, metric); + if (baseSpread == null || headSpread == null) return true; + + const combinedSpread = Math.hypot(baseSpread, headSpread); + return delta > combinedSpread * 3; +} + +export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) { + const lines = [ + '## ⚙️ Backend Diagnostics Report', + '', + ]; + + //const summary = measurementSummary(base, head); + //if (summary != null) { + // lines.push(summary); + // lines.push(''); + //} + + for (const phase of memoryReportPhases) { + lines.push(`### Memory: ${phase.title}`); + lines.push(renderMainTableForPhase(base, head, phase.key)); + lines.push(''); + } + + const heapSnapshotSection = renderHeapSnapshotSection(base, head); + if (heapSnapshotSection != null) { + lines.push(heapSnapshotSection); + lines.push(''); + } + + lines.push(`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`); + lines.push(''); + + const warningMetric = 'Pss'; + const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric); + if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) { + lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`); + lines.push(''); + } + + return `${lines.join('\n')}\n`; +} diff --git a/packages-private/diagnostics-backend/src/types.ts b/packages-private/diagnostics-backend/src/types.ts new file mode 100644 index 0000000000..2dff718a5c --- /dev/null +++ b/packages-private/diagnostics-backend/src/types.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; + +/** 計測フェーズ。将来的に増やせるようリスト化してある */ +export const memoryPhases = ['afterGc'] as const; + +export type MemoryPhase = typeof memoryPhases[number]; + +export type MemoryStability = { + converged: boolean; + readingCount: number; + elapsedMs: number; + maxAbsoluteSlopesKiBPerSecond: Record | null; +}; + +/** バックエンドを1回起動して得られる計測結果 */ +export type MemorySample = { + timestamp: string; + phases: Record; + memoryStability: MemoryStability; + heapSnapshot: HeapSnapshotData | null; + }>; +}; + +/** base / head それぞれについて出力されるJSONレポート */ +export type MemoryReport = { + timestamp: string; + sampleCount: number; + aggregation: string; + comparison?: { + strategy: string; + rounds: number; + warmupRounds: number; + startedAt: string; + }; + summary: Record; + heapSnapshot?: HeapSnapshotData; + }>; + samples: (MemorySample & { + round: number; + })[]; +}; diff --git a/packages-private/diagnostics-backend/test/__snapshots__/render-md.md b/packages-private/diagnostics-backend/test/__snapshots__/render-md.md new file mode 100644 index 0000000000..f2b05b3918 --- /dev/null +++ b/packages-private/diagnostics-backend/test/__snapshots__/render-md.md @@ -0,0 +1,29 @@ +## ⚙️ Backend Diagnostics Report + +### Memory: After GC +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| **HeapUsed** | 152 MB
± 1 MB | 168 MB
± 1 MB | $\color{orange}{\text{+16 MB}}$
$\color{orange}{\text{+10.5\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | +| **PSS** | 202 MB
± 1 MB | 218 MB
± 1 MB | $\color{orange}{\text{+16 MB}}$
$\color{orange}{\text{+7.9\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | +| **USS** | 184 MB
± 1 MB | 200 MB
± 1 MB | $\color{orange}{\text{+16 MB}}$
$\color{orange}{\text{+8.7\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ | +| **External** | 8.2 MB
± 0.1 MB | 8.2 MB
± 0.1 MB | 0 MB
0% | 0 MB | 0 MB | 0 MB | + +### V8 Heap Snapshot Statistics + +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB
± 200 KB | 44 MB
± 200 KB | $\color{orange}{\text{+3.2 MB}}$
$\color{orange}{\text{+7.9\\%}}$ | 0 B | $\color{orange}{\text{+3.2 MB}}$ | $\color{orange}{\text{+3.2 MB}}$ | +| | | | | | | | +|
$\color{orange}{\rule{8pt}{8pt}}$ **Code**11.8% → 11.8%
| 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 0 B | $\color{orange}{\text{+376 KB}}$ | $\color{orange}{\text{+376 KB}}$ | +|
$\color{red}{\rule{8pt}{8pt}}$ **Strings**11% → 11%
| 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 0 B | $\color{orange}{\text{+352 KB}}$ | $\color{orange}{\text{+352 KB}}$ | +|
$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**10.3% → 10.3%
| 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 0 B | $\color{orange}{\text{+328 KB}}$ | $\color{orange}{\text{+328 KB}}$ | +|
$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**9.5% → 9.5%
| 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 0 B | $\color{orange}{\text{+304 KB}}$ | $\color{orange}{\text{+304 KB}}$ | +|
$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**8.8% → 8.8%
| 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 0 B | $\color{orange}{\text{+280 KB}}$ | $\color{orange}{\text{+280 KB}}$ | +|
$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**8% → 8%
| 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 0 B | $\color{orange}{\text{+256 KB}}$ | $\color{orange}{\text{+256 KB}}$ | +|
$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**7.3% → 7.3%
| 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 0 B | $\color{orange}{\text{+232 KB}}$ | $\color{orange}{\text{+232 KB}}$ | + + +Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head) + +⚠️ **Warning**: Memory usage (PSS) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change. + diff --git a/packages-private/diagnostics-backend/test/fixtures/base.json b/packages-private/diagnostics-backend/test/fixtures/base.json new file mode 100644 index 0000000000..d9022b9b3c --- /dev/null +++ b/packages-private/diagnostics-backend/test/fixtures/base.json @@ -0,0 +1,200 @@ +{ + "timestamp": "2026-07-18T07:57:17.653Z", + "sampleCount": 3, + "aggregation": "median", + "comparison": { + "strategy": "interleaved-pairs", + "rounds": 3, + "warmupRounds": 1, + "startedAt": "2026-07-18T07:57:17.653Z" + }, + "summary": { + "afterGc": { + "memoryUsage": { + "VmRSS": 202000, + "Pss": 202000, + "Private_Clean": 2020, + "Private_Dirty": 182000, + "HeapUsed": 152000, + "HeapTotal": 170000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "heapSnapshot": { + "categories": { + "total": 40400000, + "code": 4747000, + "strings": 4444000, + "jsArrays": 4141000, + "typedArrays": 3838000, + "systemObjects": 3535000, + "otherJsObjects": 3232000, + "otherNonJsObjects": 2929000 + }, + "nodeCounts": { + "total": 404000, + "code": 47470, + "strings": 44440, + "jsArrays": 41410, + "typedArrays": 38380, + "systemObjects": 35350, + "otherJsObjects": 32320, + "otherNonJsObjects": 29290 + }, + "breakdowns": {} + } + } + }, + "samples": [ + { + "round": 1, + "timestamp": "2026-07-18T07:57:17.652Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 201000, + "Pss": 201000, + "Private_Clean": 2010, + "Private_Dirty": 181000, + "HeapUsed": 151000, + "HeapTotal": 170000, + "External": 8100, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 40200000, + "code": 4723500, + "strings": 4422000, + "jsArrays": 4120500, + "typedArrays": 3819000, + "systemObjects": 3517500, + "otherJsObjects": 3216000, + "otherNonJsObjects": 2914500 + }, + "nodeCounts": { + "total": 402000, + "code": 47235, + "strings": 44220, + "jsArrays": 41205, + "typedArrays": 38190, + "systemObjects": 35175, + "otherJsObjects": 32160, + "otherNonJsObjects": 29145 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 2, + "timestamp": "2026-07-18T07:57:17.653Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 202000, + "Pss": 202000, + "Private_Clean": 2020, + "Private_Dirty": 182000, + "HeapUsed": 152000, + "HeapTotal": 170000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 40400000, + "code": 4747000, + "strings": 4444000, + "jsArrays": 4141000, + "typedArrays": 3838000, + "systemObjects": 3535000, + "otherJsObjects": 3232000, + "otherNonJsObjects": 2929000 + }, + "nodeCounts": { + "total": 404000, + "code": 47470, + "strings": 44440, + "jsArrays": 41410, + "typedArrays": 38380, + "systemObjects": 35350, + "otherJsObjects": 32320, + "otherNonJsObjects": 29290 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 3, + "timestamp": "2026-07-18T07:57:17.653Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 203000, + "Pss": 203000, + "Private_Clean": 2030, + "Private_Dirty": 183000, + "HeapUsed": 153000, + "HeapTotal": 170000, + "External": 8300, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 40600000, + "code": 4770500, + "strings": 4466000, + "jsArrays": 4161500, + "typedArrays": 3857000, + "systemObjects": 3552500, + "otherJsObjects": 3248000, + "otherNonJsObjects": 2943500 + }, + "nodeCounts": { + "total": 406000, + "code": 47705, + "strings": 44660, + "jsArrays": 41615, + "typedArrays": 38570, + "systemObjects": 35525, + "otherJsObjects": 32480, + "otherNonJsObjects": 29435 + }, + "breakdowns": {} + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-backend/test/fixtures/head.json b/packages-private/diagnostics-backend/test/fixtures/head.json new file mode 100644 index 0000000000..3f9040ec93 --- /dev/null +++ b/packages-private/diagnostics-backend/test/fixtures/head.json @@ -0,0 +1,200 @@ +{ + "timestamp": "2026-07-18T07:57:17.654Z", + "sampleCount": 3, + "aggregation": "median", + "comparison": { + "strategy": "interleaved-pairs", + "rounds": 3, + "warmupRounds": 1, + "startedAt": "2026-07-18T07:57:17.654Z" + }, + "summary": { + "afterGc": { + "memoryUsage": { + "VmRSS": 218000, + "Pss": 218000, + "Private_Clean": 2020, + "Private_Dirty": 198000, + "HeapUsed": 168000, + "HeapTotal": 186000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "heapSnapshot": { + "categories": { + "total": 43600000, + "code": 5123000, + "strings": 4796000, + "jsArrays": 4469000, + "typedArrays": 4142000, + "systemObjects": 3815000, + "otherJsObjects": 3488000, + "otherNonJsObjects": 3161000 + }, + "nodeCounts": { + "total": 436000, + "code": 51230, + "strings": 47960, + "jsArrays": 44690, + "typedArrays": 41420, + "systemObjects": 38150, + "otherJsObjects": 34880, + "otherNonJsObjects": 31610 + }, + "breakdowns": {} + } + } + }, + "samples": [ + { + "round": 1, + "timestamp": "2026-07-18T07:57:17.654Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 217000, + "Pss": 217000, + "Private_Clean": 2010, + "Private_Dirty": 197000, + "HeapUsed": 167000, + "HeapTotal": 186000, + "External": 8100, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 43400000, + "code": 5099500, + "strings": 4774000, + "jsArrays": 4448500, + "typedArrays": 4123000, + "systemObjects": 3797500, + "otherJsObjects": 3472000, + "otherNonJsObjects": 3146500 + }, + "nodeCounts": { + "total": 434000, + "code": 50995, + "strings": 47740, + "jsArrays": 44485, + "typedArrays": 41230, + "systemObjects": 37975, + "otherJsObjects": 34720, + "otherNonJsObjects": 31465 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 2, + "timestamp": "2026-07-18T07:57:17.654Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 218000, + "Pss": 218000, + "Private_Clean": 2020, + "Private_Dirty": 198000, + "HeapUsed": 168000, + "HeapTotal": 186000, + "External": 8200, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 43600000, + "code": 5123000, + "strings": 4796000, + "jsArrays": 4469000, + "typedArrays": 4142000, + "systemObjects": 3815000, + "otherJsObjects": 3488000, + "otherNonJsObjects": 3161000 + }, + "nodeCounts": { + "total": 436000, + "code": 51230, + "strings": 47960, + "jsArrays": 44690, + "typedArrays": 41420, + "systemObjects": 38150, + "otherJsObjects": 34880, + "otherNonJsObjects": 31610 + }, + "breakdowns": {} + } + } + } + }, + { + "round": 3, + "timestamp": "2026-07-18T07:57:17.654Z", + "phases": { + "afterGc": { + "memoryUsage": { + "VmRSS": 219000, + "Pss": 219000, + "Private_Clean": 2030, + "Private_Dirty": 199000, + "HeapUsed": 169000, + "HeapTotal": 186000, + "External": 8300, + "ArrayBuffers": 2000 + }, + "memoryStability": { + "converged": true, + "readingCount": 3, + "elapsedMs": 4000, + "maxAbsoluteSlopesKiBPerSecond": { + "Pss": 10, + "Private_Dirty": 5 + } + }, + "heapSnapshot": { + "categories": { + "total": 43800000, + "code": 5146500, + "strings": 4818000, + "jsArrays": 4489500, + "typedArrays": 4161000, + "systemObjects": 3832500, + "otherJsObjects": 3504000, + "otherNonJsObjects": 3175500 + }, + "nodeCounts": { + "total": 438000, + "code": 51465, + "strings": 48180, + "jsArrays": 44895, + "typedArrays": 41610, + "systemObjects": 38325, + "otherJsObjects": 35040, + "otherNonJsObjects": 31755 + }, + "breakdowns": {} + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-backend/test/render-md.test.ts b/packages-private/diagnostics-backend/test/render-md.test.ts new file mode 100644 index 0000000000..468822014f --- /dev/null +++ b/packages-private/diagnostics-backend/test/render-md.test.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { expect, test } from 'vitest'; +import { renderMemoryReportMarkdown } from '../src/report/markdown'; +import type { MemoryReport } from '../src/types'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +async function loadFixture(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport; +} + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders the backend memory report', async () => { + const markdown = renderMemoryReportMarkdown(await loadFixture('base'), await loadFixture('head'), { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + }); + + await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); +}); diff --git a/packages-private/diagnostics-backend/test/server.test.ts b/packages-private/diagnostics-backend/test/server.test.ts new file mode 100644 index 0000000000..b99c6f9151 --- /dev/null +++ b/packages-private/diagnostics-backend/test/server.test.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; +import { describe, expect, test } from 'vitest'; +import { waitForMessage } from '../src/measure/server'; + +/** waitForMessage が使うのは message/exit/error/disconnect の購読だけなので EventEmitter で足りる */ +function createFakeServer() { + return new EventEmitter() as unknown as ChildProcess; +} + +function isPing(message: unknown): message is 'ping' { + return message === 'ping'; +} + +describe('waitForMessage', () => { + test('resolves with the first matching message', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 1_000); + + server.emit('message', 'noise'); + server.emit('message', 'ping'); + + await expect(received).resolves.toBe('ping'); + }); + + // 子が死んだあとメッセージは来ないので、タイムアウトまで待たずに理由を添えて失敗させる + test('rejects immediately when the server exits', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 60_000); + + server.emit('exit', 1, null); + + await expect(received).rejects.toThrow(/Server exited \(code=1, signal=null\) while waiting for ping/); + }); + + test('rejects immediately when the server errors', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 60_000); + + server.emit('error', new Error('spawn failed')); + + await expect(received).rejects.toThrow(/spawn failed/); + }); + + test('rejects immediately when the IPC channel closes', async () => { + const server = createFakeServer(); + const received = waitForMessage(server, isPing, 'ping', 60_000); + + server.emit('disconnect'); + + await expect(received).rejects.toThrow(/IPC channel closed/); + }); + + test('rejects on timeout', async () => { + const server = createFakeServer(); + await expect(waitForMessage(server, isPing, 'ping', 1)).rejects.toThrow(/Timed out waiting for ping/); + }); + + // 待機が終わった後もリスナーが残っていると、ラウンドを重ねるごとに積み上がる + test('removes every listener once settled', async () => { + const server = createFakeServer(); + const emitter = server as unknown as EventEmitter; + const received = waitForMessage(server, isPing, 'ping', 1_000); + + server.emit('message', 'ping'); + await received; + + for (const event of ['message', 'exit', 'error', 'disconnect']) { + expect(emitter.listenerCount(event)).toBe(0); + } + }); +}); diff --git a/.github/scripts/memory-stability-util.test.mts b/packages-private/diagnostics-backend/test/stability.test.ts similarity index 77% rename from .github/scripts/memory-stability-util.test.mts rename to packages-private/diagnostics-backend/test/stability.test.ts index 70bbb3924b..e27332b279 100644 --- a/.github/scripts/memory-stability-util.test.mts +++ b/packages-private/diagnostics-backend/test/stability.test.ts @@ -3,9 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { measureMemoryUntilStable } from './memory-stability-util.mts'; +import { expect, test } from 'vitest'; +import { measureMemoryUntilStable } from '../src/measure/stability'; function createTimer() { let elapsedMs = 0; @@ -35,9 +34,9 @@ test('adopts the latest reading once Pss and Private_Dirty slopes converge', asy ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 3); - assert.deepEqual(result.memoryUsage, readings[2]); - assert.deepEqual(result.stability, { + expect(readCount).toBe(3); + expect(result.memoryUsage).toStrictEqual(readings[2]); + expect(result.stability).toStrictEqual({ converged: true, readingCount: 3, elapsedMs: 4000, @@ -58,9 +57,9 @@ test('uses only the latest readings when determining convergence', async () => { ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 5); - assert.equal(result.stability.converged, true); - assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, { + expect(readCount).toBe(5); + expect(result.stability.converged).toBe(true); + expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({ Pss: 20, Private_Dirty: 10, }); @@ -77,9 +76,9 @@ test('bounds the wait and reports the latest slopes when memory does not converg ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 6); - assert.deepEqual(result.memoryUsage, readings[5]); - assert.deepEqual(result.stability, { + expect(readCount).toBe(6); + expect(result.memoryUsage).toStrictEqual(readings[5]); + expect(result.stability).toStrictEqual({ converged: false, readingCount: 6, elapsedMs: 10000, @@ -101,9 +100,9 @@ test('does not treat opposing adjacent slopes as convergence', async () => { ]; const { result, readCount } = await measure(readings); - assert.equal(readCount, 6); - assert.equal(result.stability.converged, false); - assert.deepEqual(result.stability.maxAbsoluteSlopesKiBPerSecond, { + expect(readCount).toBe(6); + expect(result.stability.converged).toBe(false); + expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({ Pss: 300, Private_Dirty: 0, }); diff --git a/packages-private/diagnostics-backend/tsconfig.json b/packages-private/diagnostics-backend/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-backend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-frontend-browser/eslint.config.js b/packages-private/diagnostics-frontend-browser/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-frontend-browser/package.json b/packages-private/diagnostics-frontend-browser/package.json new file mode 100644 index 0000000000..9264dc49f9 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/package.json @@ -0,0 +1,25 @@ +{ + "name": "diagnostics-frontend-browser", + "private": true, + "type": "module", + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "inspect": "tsx src/inspect.ts", + "lint": "pnpm typecheck && pnpm eslint", + "render-html": "tsx src/render-html.ts", + "render-md": "tsx src/render-md.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "diagnostics-shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "26.1.1", + "frontend": "workspace:*", + "playwright": "1.61.1", + "tsx": "4.23.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-frontend-browser/src/browser/controller.ts b/packages-private/diagnostics-frontend-browser/src/browser/controller.ts new file mode 100644 index 0000000000..756245c636 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/browser/controller.ts @@ -0,0 +1,211 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { writeFile } from 'node:fs/promises'; +import { chromium } from 'playwright'; +import type { Browser, BrowserContext, CDPSession, Page } from 'playwright'; +import { enableNetworkTracking, type NetworkTracker } from './network'; +import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from '../types'; + +export type HeadlessChromeOptions = { + scenarioTimeoutMs: number; + baseUrl: string; +}; + +export class HeadlessChromeController { + private readonly diagnostics = { + pageErrorCount: 0, + console: {} as Record, + }; + private readonly browser: Browser; + private readonly context: BrowserContext; + public readonly page: Page; + private readonly cdp: CDPSession; + private networkTracker: NetworkTracker | null = null; + + private constructor( + browser: Browser, + context: BrowserContext, + page: Page, + cdp: CDPSession, + options: HeadlessChromeOptions, + ) { + this.browser = browser; + this.context = context; + this.page = page; + this.cdp = cdp; + this.page.setDefaultTimeout(options.scenarioTimeoutMs); + this.page.setDefaultNavigationTimeout(options.scenarioTimeoutMs); + this.page.on('pageerror', () => { + this.diagnostics.pageErrorCount++; + }); + this.page.on('console', message => { + const type = message.type(); + this.diagnostics.console[type] = (this.diagnostics.console[type] ?? 0) + 1; + }); + } + + public get networkRequests(): NetworkRequest[] { + return this.networkTracker?.networkRequests ?? []; + } + + public get webSocketConnections(): WebSocketConnection[] { + return this.networkTracker?.webSocketConnections ?? []; + } + + static async create(label: string, options: HeadlessChromeOptions): Promise { + process.stderr.write(`[${label}] Launching Playwright Chromium\n`); + 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: HeadlessChromeOptions, 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() { + this.networkTracker = await enableNetworkTracking(this.cdp); + } + + public async waitForNetworkDetails() { + await this.networkTracker?.waitForDetails(); + } + + 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 collectDiagnostics(): BrowserDiagnostics { + return { + pageErrorCount: this.diagnostics.pageErrorCount, + console: { + log: this.diagnostics.console.log ?? 0, + warning: this.diagnostics.console.warning ?? 0, + error: this.diagnostics.console.error ?? 0, + info: this.diagnostics.console.info ?? 0, + }, + }; + } + + public async collectPerformance(): Promise { + const cdpMetricsResult = await this.cdp.send('Performance.getMetrics'); + const cdpMetrics = Object.fromEntries(cdpMetricsResult.metrics.map(metric => [metric.name, metric.value])); + const runtimeHeap = await this.cdp.send('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[] = []; + const onChunk = (params: { chunk: string }) => { + chunks.push(params.chunk); + }; + this.cdp.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + + let content: string; + try { + await this.cdp.send('HeapProfiler.enable'); + await this.cdp.send('HeapProfiler.collectGarbage'); + await this.cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + content = chunks.join(''); + } finally { + // 外さないとラウンドごとに積み上がり、古い配列にチャンクを流し続ける + this.cdp.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + } + + 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); + } +} diff --git a/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts b/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts new file mode 100644 index 0000000000..8365038bb2 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/browser/diagnostics.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { median } from 'diagnostics-shared/stats'; +import type { BrowserDiagnostics } from '../types'; + +export function summarizeBrowserDiagnostics(samples: BrowserDiagnostics[]): BrowserDiagnostics { + const medianOf = (select: (sample: BrowserDiagnostics) => number) => median(samples.map(select)); + + return { + pageErrorCount: medianOf(sample => sample.pageErrorCount), + console: { + log: medianOf(sample => sample.console.log), + warning: medianOf(sample => sample.console.warning), + error: medianOf(sample => sample.console.error), + info: medianOf(sample => sample.console.info), + }, + }; +} diff --git a/packages-private/diagnostics-frontend-browser/src/browser/network.ts b/packages-private/diagnostics-frontend-browser/src/browser/network.ts new file mode 100644 index 0000000000..538294ec3d --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/browser/network.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CDPSession } from 'playwright'; +import type { NetworkRequest, NetworkSummary, WebSocketConnection } from '../types'; + +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; + // opcode 1 はテキストフレームで、それ以外はbase64エンコードされたバイナリとして届く + if (frame.opcode === 1) return Buffer.byteLength(frame.payloadData, 'utf8'); + return Buffer.byteLength(frame.payloadData, 'base64'); +} + +export type NetworkTracker = { + networkRequests: NetworkRequest[]; + webSocketConnections: WebSocketConnection[]; + /** CDPへの追加問い合わせ (postDataの取得) が全て決着するまで待つ */ + waitForDetails: () => Promise; +}; + +/** + * CDPのNetworkドメインを有効化し、リクエスト/WebSocketの生ログを収集し続けるトラッカーを返す。 + */ +export async function enableNetworkTracking(cdp: CDPSession): Promise { + const networkRequests: NetworkRequest[] = []; + const webSocketConnections: WebSocketConnection[] = []; + const requests = new Map(); + const webSockets = new Map(); + const pendingDetailReads: Promise[] = []; + + const readRequestBody = (row: NetworkRequest) => { + if (!row.hasRequestBody || row.requestBody != null) return; + const pending = cdp.send('Network.getRequestPostData', { + requestId: row.requestId, + }).then(result => { + row.requestBody = result.postData; + }).catch(() => { + // Some requests expose hasPostData but no longer have retrievable body data. + }); + pendingDetailReads.push(pending); + }; + + 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); + networkRequests.push(row); + }); + + cdp.on('Network.webSocketCreated', params => { + if (params.requestId == null || params.url == null) return; + const row: WebSocketConnection = { + requestId: params.requestId, + url: params.url, + // Network.webSocketCreated はtimestampを持たないので、closedAt との差分は取れない + createdAt: 0, + sentFrameCount: 0, + receivedFrameCount: 0, + sentBytes: 0, + receivedBytes: 0, + errorCount: 0, + }; + webSockets.set(params.requestId, row); + webSocketConnections.push(row); + }); + + cdp.on('Network.webSocketWillSendHandshakeRequest', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.handshakeRequestHeaders = normalizeHeaders(params.request?.headers); + }); + + 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); + }); + + cdp.on('Network.webSocketFrameSent', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.sentFrameCount += 1; + row.sentBytes += webSocketFramePayloadBytes(params.response); + }); + + cdp.on('Network.webSocketFrameReceived', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.receivedFrameCount += 1; + row.receivedBytes += webSocketFramePayloadBytes(params.response); + }); + + cdp.on('Network.webSocketFrameError', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.errorCount += 1; + }); + + cdp.on('Network.webSocketClosed', params => { + const row = webSockets.get(params.requestId); + if (row == null) return; + row.closedAt = params.timestamp ?? 0; + }); + + 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; + }); + + cdp.on('Network.dataReceived', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.decodedBodyLength += params.dataLength ?? 0; + row.encodedDataLength += params.encodedDataLength ?? 0; + }); + + cdp.on('Network.loadingFinished', params => { + const row = requests.get(params.requestId); + if (row == null) return; + row.finished = true; + row.encodedDataLength = Math.max(row.encodedDataLength, params.encodedDataLength ?? 0); + readRequestBody(row); + }); + + 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 cdp.send('Network.enable'); + await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); + await cdp.send('Network.setBypassServiceWorker', { bypass: true }); + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Performance.enable'); + + return { + networkRequests, + webSocketConnections, + waitForDetails: async () => { + // 待っている最中にさらにpendingが増えることがあるので、増えなくなるまで繰り返す + let settledCount = 0; + while (settledCount < pendingDetailReads.length) { + const pending = pendingDetailReads.slice(settledCount); + settledCount = pendingDetailReads.length; + await Promise.allSettled(pending); + } + }, + }; +} + +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/packages-private/diagnostics-frontend-browser/src/inspect.ts b/packages-private/diagnostics-frontend-browser/src/inspect.ts new file mode 100644 index 0000000000..9cf08664e9 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/inspect.ts @@ -0,0 +1,128 @@ +/* + * 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 { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env'; +import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot'; +import { HeadlessChromeController } from './browser/controller'; +import { summarizeNetwork } from './browser/network'; +import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './scenario'; +import { startServer, stopServer, waitForServer } from './server'; +import { selectRepresentativeSample, summarizeSamples } from './summarize'; +import type { BrowserMeasurementSample, BrowserMetricsReport } from './types'; + +type Label = 'base' | 'head'; + +const labels = ['base', 'head'] as const satisfies readonly Label[]; + +const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812'; +const sampleCount = readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1); +const heapSnapshotBreakdownTopN = readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1); + +// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す +const heapSnapshotOutputDir = resolve(readOptionalEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd()); +const heapSnapshotWorkDirs = { + base: join(heapSnapshotOutputDir, 'frontend-browser-base-heap-snapshots'), + head: join(heapSnapshotOutputDir, 'frontend-browser-head-heap-snapshots'), +} as const; +const heapSnapshotOutputPaths = { + base: join(heapSnapshotOutputDir, 'base-heap-snapshot.heapsnapshot'), + head: join(heapSnapshotOutputDir, 'head-heap-snapshot.heapsnapshot'), +} as const; + +function heapSnapshotPath(label: Label, round: number) { + return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`); +} + +/** + * ブラウザを毎ラウンド立ち上げ直して1サンプル分を計測する。 + * ブラウザを使い回すとキャッシュや前ラウンドのGC残渣が乗るため、必ず作り直す。 + */ +async function measureSample(label: Label, round: number, heapSnapshotSavePath: string): Promise { + await prepareInstance(baseUrl); + + return await HeadlessChromeController.with(label, { scenarioTimeoutMs: 120000, baseUrl }, async chrome => { + await chrome.enableNetworkTracking(); + + const startedAt = Date.now(); + await runSignupAndPostScenario(chrome, baseUrl); + const durationMs = Date.now() - startedAt; + + await chrome.waitForNetworkDetails(); + const performance = await chrome.collectPerformance(); + const heapSnapshotRaw = await chrome.takeHeapSnapshot(heapSnapshotSavePath); + + return { + label, + round, + timestamp: new Date().toISOString(), + url: baseUrl, + scenario: scenarioDescription, + diagnostics: chrome.collectDiagnostics(), + durationMs, + network: summarizeNetwork(chrome.networkRequests, baseUrl, chrome.webSocketConnections), + networkRequests: chrome.networkRequests, + performance, + heapSnapshot: analyzeHeapSnapshot(heapSnapshotRaw, { breakdownTopN: heapSnapshotBreakdownTopN }), + }; + }); +} + +/** + * 中央値に最も近いラウンドのスナップショットだけを成果物として残し、残りは捨てる。 + */ +async function saveRepresentativeHeapSnapshot(label: Label, report: BrowserMetricsReport) { + const representative = selectRepresentativeSample(report.samples, sample => sample.heapSnapshot.categories.total); + await copyFile(heapSnapshotPath(label, representative.round), heapSnapshotOutputPaths[label]); + process.stderr.write(`[${label}] Selected round ${representative.round} heap snapshot for artifact\n`); + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); +} + +async function genReport(label: Label, repoDir: string, outputPath: string) { + let server: ReturnType | null = null; + + try { + server = startServer(label, repoDir); + await waitForServer(baseUrl, server); + + await rm(heapSnapshotWorkDirs[label], { recursive: true, force: true }); + await mkdir(heapSnapshotWorkDirs[label], { 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, heapSnapshotPath(label, round))); + } + + const report = summarizeSamples(label, samples, { url: baseUrl, heapSnapshotBreakdownTopN }); + await writeFile(outputPath, JSON.stringify(report, null, '\t')); + process.stderr.write(`[${label}] Wrote browser metrics report to ${outputPath}\n`); + + await saveRepresentativeHeapSnapshot(label, report); + } finally { + if (server != null) await stopServer(server); + } +} + +async function main() { + const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2); + if (baseDirArg == null || headDirArg == null || baseOutputArg == null || headOutputArg == null) { + throw new Error('Usage: inspect '); + } + + for (const label of labels) { + await rm(heapSnapshotOutputPaths[label], { force: true }); + } + + // base / head は同じポートでサーバーを立てるため、並行させず順番に計測する + await genReport('base', resolve(baseDirArg), resolve(baseOutputArg)); + await genReport('head', resolve(headDirArg), resolve(headOutputArg)); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend-browser/src/render-html.ts b/packages-private/diagnostics-frontend-browser/src/render-html.ts new file mode 100644 index 0000000000..d3ed90927a --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/render-html.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { renderHtml } from './report/html'; +import type { BrowserMetricsReport } from './types'; + +async function main() { + const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); + if (baseFileArg == null || headFileArg == null || outputFileArg == null) { + throw new Error('Usage: render-html '); + } + + const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; + + await writeFile(resolve(outputFileArg), renderHtml(base, head)); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend-browser/src/render-md.ts b/packages-private/diagnostics-frontend-browser/src/render-md.ts new file mode 100644 index 0000000000..15c4924808 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/render-md.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env'; +import { renderMarkdown } from './report/markdown'; +import type { BrowserMetricsReport } from './types'; + +async function main() { + const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2); + if (baseFileArg == null || headFileArg == null || outputFileArg == null) { + throw new Error('Usage: render-md '); + } + + const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport; + const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport; + + await writeFile(resolve(outputFileArg), renderMarkdown(base, head, { + baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'), + headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'), + detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'), + })); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend-browser/src/report/html-styles.ts b/packages-private/diagnostics-frontend-browser/src/report/html-styles.ts new file mode 100644 index 0000000000..8e8603ba51 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/report/html-styles.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** 差分HTMLは単体のファイルとして配布されるので、CSSも埋め込みで持つ */ +export const networkDiffHtmlStyles = ` :root { + color-scheme: light dark; + --bg: #f7f7f8; + --fg: #202124; + --muted: #5f6368; + --card: #ffffff; + --border: #dfe1e5; + --added: #137333; + --added-bg: #e6f4ea; + --removed: #a50e0e; + --removed-bg: #fce8e6; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #111315; + --fg: #e8eaed; + --muted: #bdc1c6; + --card: #1b1d20; + --border: #3c4043; + --added-bg: #17351f; + --removed-bg: #3c1f1d; + } + } + body { + margin: 0; + font: 14px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--fg); + } + main { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + } + h1 { + font-size: 24px; + margin: 0 0 8px; + } + h2 { + font-size: 18px; + margin: 32px 0 8px; + } + .meta { + color: var(--muted); + margin: 0 0 24px; + } + .summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin: 24px 0; + } + .summary > div, .request, table { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + } + .summary > div { + padding: 14px; + } + .label { + display: block; + color: var(--muted); + font-size: 12px; + } + .summary strong { + display: block; + font-size: 24px; + margin-top: 4px; + } + .added-text { + color: var(--added); + } + .removed-text { + color: var(--removed); + } + table { + border-collapse: collapse; + width: 100%; + overflow: hidden; + } + th, td { + border-bottom: 1px solid var(--border); + padding: 8px 10px; + text-align: left; + } + th { + color: var(--muted); + font-weight: 600; + } + .num { + text-align: right; + } + .requests { + display: grid; + gap: 12px; + } + .request { + padding: 14px; + overflow-wrap: anywhere; + } + .request.added { + border-left: 4px solid var(--added); + } + .request.removed { + border-left: 4px solid var(--removed); + } + .request header { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; + } + .badge, .method, .type, .status { + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + } + .added .badge { + background: var(--added-bg); + color: var(--added); + } + .removed .badge { + background: var(--removed-bg); + color: var(--removed); + } + .method, .type, .status { + background: color-mix(in srgb, var(--muted) 14%, transparent); + color: var(--fg); + } + .url { + display: block; + margin: 8px 0 12px; + color: inherit; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + } + dl { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px 16px; + margin: 0 0 12px; + } + dl div { + min-width: 0; + } + dt { + color: var(--muted); + font-size: 12px; + } + dd { + margin: 0; + } + details { + margin-top: 8px; + } + summary { + cursor: pointer; + color: var(--muted); + } + pre { + white-space: pre-wrap; + overflow-x: auto; + background: color-mix(in srgb, var(--muted) 10%, transparent); + border-radius: 6px; + padding: 10px; + } + .empty { + color: var(--muted); + }`; diff --git a/packages-private/diagnostics-frontend-browser/src/report/html.ts b/packages-private/diagnostics-frontend-browser/src/report/html.ts new file mode 100644 index 0000000000..7627f99729 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/report/html.ts @@ -0,0 +1,255 @@ +/* + * 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 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 + ? 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} +
+ + +`); +} diff --git a/packages-private/diagnostics-frontend-browser/src/report/markdown.ts b/packages-private/diagnostics-frontend-browser/src/report/markdown.ts new file mode 100644 index 0000000000..370cb86892 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/report/markdown.ts @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format'; +import { pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats'; +import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot'; +import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from '../types'; + +export type RenderMarkdownOptions = { + baseHeapSnapshotUrl: string; + headHeapSnapshotUrl: string; + detailedHtmlUrl?: string | null; +}; + +function renderMetricRow( + 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 = pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample)); + // 有意な閾値に満たない場合はそもそもrowとして出力しない + if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null; + + const deltaMedian = formatColoredDelta(summary.median, formatter, significantThreshold); + + return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${formatter(summary.mad)} | ${formatColoredDelta(summary.min, formatter, significantThreshold)} | ${formatColoredDelta(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 renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) { + //function getMetric(report: BrowserMeasurement, key: string) { + // return report.performance.cdpMetrics[key]; + //} + + const rows = [ + //renderMetricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs), + renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, formatNumber, 1, !all), + //renderMetricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, formatNumber), + renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, formatBytes, 10000, !all), + renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, formatBytes, 10000, !all), + renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, formatBytes, 10000, !all), + renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, formatBytes, 10000, !all), + renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), formatBytes, 10000, !all), + renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), formatBytes, 10000, !all), + renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), formatBytes, 10000, !all), + renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), formatBytes, 10000, !all), + renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), formatBytes, 10000, !all), + //renderMetricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs), + //renderMetricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs), + //renderMetricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, formatNumber), + //renderMetricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs), + //renderMetricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs), + //renderMetricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), formatBytes), + //renderMetricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), formatBytes), + //renderMetricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, formatBytes, 10000), + //renderMetricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, formatNumber), + //renderMetricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), formatNumber), + //renderMetricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), formatNumber), + //renderMetricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), formatNumber), + //renderMetricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), formatNumber), + //renderMetricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs), + //renderMetricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs), + renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, formatNumber, 1, !all), + renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, formatBytes, 10000, !all), + renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, formatBytes, 10000, !all), + renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, formatNumber, 1, !all), + renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, formatNumber, 1, !all), + renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, formatNumber, 1, !all), + renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, formatNumber, 1, !all), + renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, formatNumber, 1, !all), + renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, 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}${formatNumber(baseRow.requests)}${formatNumber(headRow.requests)}${formatColoredDelta(headRow.requests - baseRow.requests, formatNumber)}${formatBytes(baseRow.encodedBytes)}${formatBytes(headRow.encodedBytes)}${formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, formatBytes)}
'); + + 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 renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsReport, options: RenderMarkdownOptions) { + const detailedHtmlUrl = options.detailedHtmlUrl; + const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head)); + const lines = [ + '## 🖥 Frontend Browser Diagnostics Report', + '', + renderSummaryTable(base, head), + '', + 'Only metrics showing significant changes are displayed.', + '', + 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._', + '', + //renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'), + //'', + `Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`, + '
', + '', + ]; + + return lines.filter(line => line != null).join('\n').trimEnd() + '\n'; +} diff --git a/packages-private/diagnostics-frontend-browser/src/scenario.ts b/packages-private/diagnostics-frontend-browser/src/scenario.ts new file mode 100644 index 0000000000..1ad513e93e --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/scenario.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { closeUserSetupDialog, postNote, registerUser, resetState, signupThroughUi, visitHome } from '../../../packages/frontend/test/e2e/shared'; +import { sleep } from './server'; +import type { HeadlessChromeController } from './browser/controller'; + +export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible'; + +/** + * 各ラウンドを同じ初期状態から始めるため、DBを消して管理者だけ作り直す。 + */ +export async function prepareInstance(baseUrl: string) { + await resetState(baseUrl); + await registerUser(baseUrl, 'admin', 'admin1234', true); +} + +export async function runSignupAndPostScenario(chrome: HeadlessChromeController, baseUrl: string) { + 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 sleep(1000); +} diff --git a/packages-private/diagnostics-frontend-browser/src/server.ts b/packages-private/diagnostics-frontend-browser/src/server.ts new file mode 100644 index 0000000000..c2690c5777 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/server.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; + +export function sleep(ms: number) { + return new Promise(resolvePromise => setTimeout(resolvePromise, ms)); +} + +function commandName(command: string) { + if (process.platform !== 'win32') return command; + if (command === 'pnpm') return 'pnpm.cmd'; + return command; +} + +/** + * 計測対象のリポジトリで Misskey テストサーバーを起動する。 + * POSIXでは detached にして、後で子孫プロセスごとまとめて落とせるようにする。 + */ +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; +} + +const serverStartupTimeoutMs = 120_000; + +export async function waitForServer(baseUrl: string, child: ChildProcess) { + const startedAt = Date.now(); + while (Date.now() - startedAt < serverStartupTimeoutMs) { + if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`); + try { + // 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう + const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt); + const response = await fetch(`${baseUrl}/`, { + redirect: 'manual', + signal: AbortSignal.timeout(remainingMs), + }); + if (response.status < 500) return; + } catch { + // 中断・接続拒否いずれもまだ起動中とみなしてリトライする + } + await sleep(1_000); + } + throw new Error(`Timed out waiting for ${baseUrl}`); +} + +export async function stopServer(child: ChildProcess) { + 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 { + // プロセスグループごと落とさないと pnpm 配下の node が残る + process.kill(-child.pid, 'SIGTERM'); + } catch { + child.kill('SIGTERM'); + } + } + + await new Promise(resolvePromise => { + if (child.exitCode != null) { + resolvePromise(); + return; + } + + const forceKillTimer = setTimeout(() => { + // 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある + if (child.exitCode == null && 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); + forceKillTimer.unref(); + + child.once('exit', () => { + clearTimeout(forceKillTimer); + resolvePromise(); + }); + }); +} diff --git a/packages-private/diagnostics-frontend-browser/src/summarize.ts b/packages-private/diagnostics-frontend-browser/src/summarize.ts new file mode 100644 index 0000000000..825ae188f5 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/summarize.ts @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { finiteMedian } from 'diagnostics-shared/stats'; +import { summarizeHeapSnapshotDataSamples } from 'diagnostics-shared/heap-snapshot'; +import { summarizeBrowserDiagnostics } from './browser/diagnostics'; +import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types'; + +export type SummarizeOptions = { + url: string; + heapSnapshotBreakdownTopN: number; +}; + +/** + * 中央値に最も近いサンプルを1つ選ぶ。 + * largestRequests のように中央値を取れない項目は、代表サンプルの値をそのまま採用する。 + */ +export function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) { + const medianValue = finiteMedian(samples.map(getValue), 0); + 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), 0), + encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes), 0), + decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes), 0), + }; +} + +export 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), 0), + webSocketConnectionCount: finiteMedian(samples.map(sample => sample.network.webSocketConnectionCount), 0), + webSocketSentBytes: finiteMedian(samples.map(sample => sample.network.webSocketSentBytes), 0), + webSocketReceivedBytes: finiteMedian(samples.map(sample => sample.network.webSocketReceivedBytes), 0), + finishedRequestCount: finiteMedian(samples.map(sample => sample.network.finishedRequestCount), 0), + failedRequestCount: finiteMedian(samples.map(sample => sample.network.failedRequestCount), 0), + cachedRequestCount: finiteMedian(samples.map(sample => sample.network.cachedRequestCount), 0), + serviceWorkerRequestCount: finiteMedian(samples.map(sample => sample.network.serviceWorkerRequestCount), 0), + totalEncodedBytes: finiteMedian(samples.map(sample => sample.network.totalEncodedBytes), 0), + totalDecodedBodyBytes: finiteMedian(samples.map(sample => sample.network.totalDecodedBodyBytes), 0), + sameOriginEncodedBytes: finiteMedian(samples.map(sample => sample.network.sameOriginEncodedBytes), 0), + thirdPartyEncodedBytes: finiteMedian(samples.map(sample => sample.network.thirdPartyEncodedBytes), 0), + byResourceType, + largestRequests: representative.network.largestRequests, + failedRequests: representative.network.failedRequests, + }; +} + +export 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]), 0); + } + + 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]), 0); + } + + return { + cdpMetrics, + runtimeHeap: { + usedSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.usedSize), 0), + totalSize: finiteMedian(samples.map(sample => sample.performance.runtimeHeap?.totalSize), 0), + }, + tabMemory: { + totalBytes: finiteMedian(samples.map(sample => sample.performance.tabMemory.totalBytes), 0), + }, + webVitals, + }; +} + +export function summarizeHeapSnapshotSamples(samples: BrowserMeasurementSample[], breakdownTopN: number) { + const summary = summarizeHeapSnapshotDataSamples( + samples, + sample => sample.heapSnapshot, + { breakdownTopN }, + ); + if (summary == null) throw new Error('No heap snapshot samples'); + return summary; +} + +export function summarizeSamples(label: 'base' | 'head', samples: BrowserMeasurementSample[], options: SummarizeOptions): 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: options.url, + scenario: representative.scenario, + diagnostics: summarizeBrowserDiagnostics(samples.map(sample => sample.diagnostics)), + durationMs: finiteMedian(samples.map(sample => sample.durationMs), 0), + network: summarizeNetworkSamples(samples), + performance: summarizePerformanceSamples(samples), + heapSnapshot: summarizeHeapSnapshotSamples(samples, options.heapSnapshotBreakdownTopN), + }; + + return { + label, + timestamp: new Date().toISOString(), + url: options.url, + scenario: representative.scenario, + sampleCount: samples.length, + aggregation: 'median', + summary, + samples, + }; +} diff --git a/packages-private/diagnostics-frontend-browser/src/types.ts b/packages-private/diagnostics-frontend-browser/src/types.ts new file mode 100644 index 0000000000..df91071c13 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/src/types.ts @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot'; + +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 BrowserDiagnostics = { + pageErrorCount: number; + console: Record<'log' | 'warning' | 'error' | 'info', number>; +}; + +export type BrowserMeasurement = { + label: string; + timestamp: string; + url: string; + scenario: string; + diagnostics: BrowserDiagnostics; + 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; +}; + +export type BrowserMeasurementSample = BrowserMeasurement & { + round: number; + /** ラウンド単位のリクエスト差分HTMLを描くために生ログを丸ごと保持する */ + networkRequests: NetworkRequest[]; +}; + +export type BrowserMetricsReport = { + label: string; + timestamp: string; + url: string; + scenario: string; + sampleCount: number; + aggregation: 'median'; + summary: BrowserMeasurement; + samples: BrowserMeasurementSample[]; +}; diff --git a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html b/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html new file mode 100644 index 0000000000..3e96d7cca6 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-html.html @@ -0,0 +1,351 @@ + + + + + + Frontend Browser Network Request Diff + + + +
+

Frontend Browser Network Request Diff

+

Generated at 2026-07-18T00:00:00.000Z. 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.

+ +
+
+ Base samples + 3 +
+
+ Head samples + 3 +
+
+ Added in Head + 3 +
+
+ Removed in Head + 0 +
+
+ +
+

Diffs by Resource Type

+ + + + + + + + +
TypeDiff requests
Fetch3
+
+ +
+

Round 1

+

1 added, 0 removed

+
+ +
+
+ Added in Head + POST + Fetch + 200 +
+ http://127.0.0.1:61812/vite/new-chunk.js +
+
Round
1
+
Base count
0
+
Head count
1
+
Encoded
50 KB
+
Decoded body
120 KB
+
MIME
text/javascript
+
Protocol
h2
+
Remote
-
+
Failed
no
+
+ + +
+ Request body +
{
+	"i": 1
+}
+
+ + +
+ Response headers +
{
+	"content-type": "text/javascript"
+}
+
+
+
+
+ +
+

Round 2

+

1 added, 0 removed

+
+ +
+
+ Added in Head + POST + Fetch + 200 +
+ http://127.0.0.1:61812/vite/new-chunk.js +
+
Round
2
+
Base count
0
+
Head count
1
+
Encoded
50 KB
+
Decoded body
120 KB
+
MIME
text/javascript
+
Protocol
h2
+
Remote
-
+
Failed
no
+
+ + +
+ Request body +
{
+	"i": 2
+}
+
+ + +
+ Response headers +
{
+	"content-type": "text/javascript"
+}
+
+
+
+
+ +
+

Round 3

+

1 added, 0 removed

+
+ +
+
+ Added in Head + POST + Fetch + 200 +
+ http://127.0.0.1:61812/vite/new-chunk.js +
+
Round
3
+
Base count
0
+
Head count
1
+
Encoded
50 KB
+
Decoded body
120 KB
+
MIME
text/javascript
+
Protocol
h2
+
Remote
-
+
Failed
no
+
+ + +
+ Request body +
{
+	"i": 3
+}
+
+ + +
+ Response headers +
{
+	"content-type": "text/javascript"
+}
+
+
+
+
+
+ + diff --git a/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md new file mode 100644 index 0000000000..0f791057c2 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/test/__snapshots__/render-md.md @@ -0,0 +1,83 @@ +## 🖥 Frontend Browser Diagnostics Report + +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| **Encoded network** | 1 MB | 1.1 MB | $\color{orange}{\text{+83 KB}}$ | 816 B | $\color{orange}{\text{+82 KB}}$ | $\color{orange}{\text{+84 KB}}$ | +| **Decoded body** | 3.5 MB | 3.7 MB | $\color{orange}{\text{+277 KB}}$ | 2.7 KB | $\color{orange}{\text{+274 KB}}$ | $\color{orange}{\text{+279 KB}}$ | +| **Same-origin encoded** | 1 MB | 1.1 MB | $\color{orange}{\text{+82 KB}}$ | 800 B | $\color{orange}{\text{+81 KB}}$ | $\color{orange}{\text{+82 KB}}$ | +| **Script encoded** | 918 KB | 991 KB | $\color{orange}{\text{+73 KB}}$ | 720 B | $\color{orange}{\text{+73 KB}}$ | $\color{orange}{\text{+74 KB}}$ | +| **Page-attributed memory** | 92 MB | 99 MB | $\color{orange}{\text{+7.3 MB}}$ | 72 KB | $\color{orange}{\text{+7.3 MB}}$ | $\color{orange}{\text{+7.4 MB}}$ | + +Only metrics showing significant changes are displayed. + +[View details](https://example.invalid/html) + +
+Requests by resource type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeRequestsEncoded bytes
BaseHeadΔBaseHeadΔ
Script10100918 KB991 KB$\color{orange}{\text{+73 KB}}$
Stylesheet22082 KB88 KB$\color{orange}{\text{+6.5 KB}}$
Fetch66041 KB44 KB$\color{orange}{\text{+3.3 KB}}$
+ +
+ +
+V8 heap snapshot statistics + +| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB
± 10 KB | 1.1 MB
± 11 KB | $\text{+82 KB}$
$\color{orange}{\text{+8\\%}}$ | 800 B | $\text{+81 KB}$ | $\text{+82 KB}$ | +| | | | | | | | +|
$\color{orange}{\rule{8pt}{8pt}}$ **Code**200% → 200%
| 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 1.6 KB | $\color{orange}{\text{+162 KB}}$ | $\color{orange}{\text{+165 KB}}$ | +|
$\color{red}{\rule{8pt}{8pt}}$ **Strings**300% → 300%
| 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 2.4 KB | $\color{orange}{\text{+242 KB}}$ | $\color{orange}{\text{+247 KB}}$ | +|
$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**400% → 400%
| 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 3.2 KB | $\color{orange}{\text{+323 KB}}$ | $\color{orange}{\text{+330 KB}}$ | +|
$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**500% → 500%
| 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 4 KB | $\color{orange}{\text{+404 KB}}$ | $\color{orange}{\text{+412 KB}}$ | +|
$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**600% → 600%
| 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 4.8 KB | $\color{orange}{\text{+485 KB}}$ | $\color{orange}{\text{+494 KB}}$ | +|
$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**700% → 700%
| 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 5.6 KB | $\color{orange}{\text{+566 KB}}$ | $\color{orange}{\text{+577 KB}}$ | +|
$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**800% → 800%
| 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 6.4 KB | $\color{orange}{\text{+646 KB}}$ | $\color{orange}{\text{+659 KB}}$ | + +Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head) +
diff --git a/packages-private/diagnostics-frontend-browser/test/fixtures/base.json b/packages-private/diagnostics-frontend-browser/test/fixtures/base.json new file mode 100644 index 0000000000..af591f4615 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/test/fixtures/base.json @@ -0,0 +1,679 @@ +{ + "label": "base", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "sampleCount": 3, + "aggregation": "median", + "summary": { + "label": "base", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 20400, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2040, + "webSocketReceivedBytes": 9180, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1040400, + "totalDecodedBodyBytes": 3457800, + "sameOriginEncodedBytes": 1020000, + "thirdPartyEncodedBytes": 20400, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 918000, + "decodedBodyBytes": 3060000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 81600, + "decodedBodyBytes": 306000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 40800, + "decodedBodyBytes": 91800 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30600000, + "totalSize": 51000000 + }, + "tabMemory": { + "totalBytes": 91800000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1020000, + "code": 2040000, + "strings": 3060000, + "jsArrays": 4080000, + "typedArrays": 5100000, + "systemObjects": 6120000, + "otherJsObjects": 7140000, + "otherNonJsObjects": 8160000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1224000, + "Other": 816000 + }, + "strings": { + "strings: alpha": 1836000, + "Other": 1224000 + }, + "jsArrays": { + "jsArrays: alpha": 2448000, + "Other": 1632000 + }, + "typedArrays": { + "typedArrays: alpha": 3060000, + "Other": 2040000 + }, + "systemObjects": { + "systemObjects: alpha": 3672000, + "Other": 2448000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4284000, + "Other": 2856000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4896000, + "Other": 3264000 + } + } + } + }, + "samples": [ + { + "label": "base", + "round": 1, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 2, + "error": 0, + "info": 2 + } + }, + "durationMs": 20200, + "network": { + "requestCount": 19, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2020, + "webSocketReceivedBytes": 9090, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1030200, + "totalDecodedBodyBytes": 3423900, + "sameOriginEncodedBytes": 1010000, + "thirdPartyEncodedBytes": 20200, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 909000, + "decodedBodyBytes": 3030000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 80800, + "decodedBodyBytes": 303000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 40400, + "decodedBodyBytes": 90900 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "1-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "1-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30300000, + "totalSize": 50500000 + }, + "tabMemory": { + "totalBytes": 90900000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1010000, + "code": 2020000, + "strings": 3030000, + "jsArrays": 4040000, + "typedArrays": 5050000, + "systemObjects": 6060000, + "otherJsObjects": 7070000, + "otherNonJsObjects": 8080000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1212000, + "Other": 808000 + }, + "strings": { + "strings: alpha": 1818000, + "Other": 1212000 + }, + "jsArrays": { + "jsArrays: alpha": 2424000, + "Other": 1616000 + }, + "typedArrays": { + "typedArrays: alpha": 3030000, + "Other": 2020000 + }, + "systemObjects": { + "systemObjects: alpha": 3636000, + "Other": 2424000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4242000, + "Other": 2828000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4848000, + "Other": 3232000 + } + } + } + }, + { + "label": "base", + "round": 2, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 20400, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2040, + "webSocketReceivedBytes": 9180, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1040400, + "totalDecodedBodyBytes": 3457800, + "sameOriginEncodedBytes": 1020000, + "thirdPartyEncodedBytes": 20400, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 918000, + "decodedBodyBytes": 3060000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 81600, + "decodedBodyBytes": 306000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 40800, + "decodedBodyBytes": 91800 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "2-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "2-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30600000, + "totalSize": 51000000 + }, + "tabMemory": { + "totalBytes": 91800000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1020000, + "code": 2040000, + "strings": 3060000, + "jsArrays": 4080000, + "typedArrays": 5100000, + "systemObjects": 6120000, + "otherJsObjects": 7140000, + "otherNonJsObjects": 8160000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1224000, + "Other": 816000 + }, + "strings": { + "strings: alpha": 1836000, + "Other": 1224000 + }, + "jsArrays": { + "jsArrays: alpha": 2448000, + "Other": 1632000 + }, + "typedArrays": { + "typedArrays: alpha": 3060000, + "Other": 2040000 + }, + "systemObjects": { + "systemObjects: alpha": 3672000, + "Other": 2448000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4284000, + "Other": 2856000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4896000, + "Other": 3264000 + } + } + } + }, + { + "label": "base", + "round": 3, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 4, + "error": 0, + "info": 2 + } + }, + "durationMs": 20600, + "network": { + "requestCount": 21, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2060, + "webSocketReceivedBytes": 9270, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1050600, + "totalDecodedBodyBytes": 3491700, + "sameOriginEncodedBytes": 1030000, + "thirdPartyEncodedBytes": 20600, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 927000, + "decodedBodyBytes": 3090000 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 82400, + "decodedBodyBytes": 309000 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 41200, + "decodedBodyBytes": 92700 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "3-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "3-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 30900000, + "totalSize": 51500000 + }, + "tabMemory": { + "totalBytes": 92700000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1030000, + "code": 2060000, + "strings": 3090000, + "jsArrays": 4120000, + "typedArrays": 5150000, + "systemObjects": 6180000, + "otherJsObjects": 7210000, + "otherNonJsObjects": 8240000 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1236000, + "Other": 824000 + }, + "strings": { + "strings: alpha": 1854000, + "Other": 1236000 + }, + "jsArrays": { + "jsArrays: alpha": 2472000, + "Other": 1648000 + }, + "typedArrays": { + "typedArrays: alpha": 3090000, + "Other": 2060000 + }, + "systemObjects": { + "systemObjects: alpha": 3708000, + "Other": 2472000 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4326000, + "Other": 2884000 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 4944000, + "Other": 3296000 + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-browser/test/fixtures/head.json b/packages-private/diagnostics-frontend-browser/test/fixtures/head.json new file mode 100644 index 0000000000..9832e65b31 --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/test/fixtures/head.json @@ -0,0 +1,742 @@ +{ + "label": "head", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "sampleCount": 3, + "aggregation": "median", + "summary": { + "label": "head", + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 22032, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2203, + "webSocketReceivedBytes": 9914, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1123632, + "totalDecodedBodyBytes": 3734424, + "sameOriginEncodedBytes": 1101600, + "thirdPartyEncodedBytes": 22032, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 991440, + "decodedBodyBytes": 3304800 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 88128, + "decodedBodyBytes": 330480 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 44064, + "decodedBodyBytes": 99144 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 33048000, + "totalSize": 55080000 + }, + "tabMemory": { + "totalBytes": 99144000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1101600, + "code": 2203200, + "strings": 3304800, + "jsArrays": 4406400, + "typedArrays": 5508000, + "systemObjects": 6609600, + "otherJsObjects": 7711200, + "otherNonJsObjects": 8812800 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1321920, + "Other": 881280 + }, + "strings": { + "strings: alpha": 1982880, + "Other": 1321920 + }, + "jsArrays": { + "jsArrays: alpha": 2643840, + "Other": 1762560 + }, + "typedArrays": { + "typedArrays: alpha": 3304800, + "Other": 2203200 + }, + "systemObjects": { + "systemObjects: alpha": 3965760, + "Other": 2643840 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4626720, + "Other": 3084480 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5287680, + "Other": 3525120 + } + } + } + }, + "samples": [ + { + "label": "head", + "round": 1, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 2, + "error": 0, + "info": 2 + } + }, + "durationMs": 21816, + "network": { + "requestCount": 19, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2182, + "webSocketReceivedBytes": 9817, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1112616, + "totalDecodedBodyBytes": 3697812, + "sameOriginEncodedBytes": 1090800, + "thirdPartyEncodedBytes": 21816, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 981720, + "decodedBodyBytes": 3272400 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 87264, + "decodedBodyBytes": 327240 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 43632, + "decodedBodyBytes": 98172 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "1-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "1-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "1-http://127.0.0.1:61812/vite/new-chunk.js", + "url": "http://127.0.0.1:61812/vite/new-chunk.js", + "method": "POST", + "resourceType": "Fetch", + "startedAt": 1, + "hasRequestBody": true, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200, + "requestBody": "{\"i\":1}" + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 32724000, + "totalSize": 54540000 + }, + "tabMemory": { + "totalBytes": 98172000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1090800, + "code": 2181600, + "strings": 3272400, + "jsArrays": 4363200, + "typedArrays": 5454000, + "systemObjects": 6544800, + "otherJsObjects": 7635600, + "otherNonJsObjects": 8726400 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1308960, + "Other": 872640 + }, + "strings": { + "strings: alpha": 1963440, + "Other": 1308960 + }, + "jsArrays": { + "jsArrays: alpha": 2617920, + "Other": 1745280 + }, + "typedArrays": { + "typedArrays: alpha": 3272400, + "Other": 2181600 + }, + "systemObjects": { + "systemObjects: alpha": 3926880, + "Other": 2617920 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4581360, + "Other": 3054240 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5235840, + "Other": 3490560 + } + } + } + }, + { + "label": "head", + "round": 2, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 3, + "error": 0, + "info": 2 + } + }, + "durationMs": 22032, + "network": { + "requestCount": 20, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2203, + "webSocketReceivedBytes": 9914, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1123632, + "totalDecodedBodyBytes": 3734424, + "sameOriginEncodedBytes": 1101600, + "thirdPartyEncodedBytes": 22032, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 991440, + "decodedBodyBytes": 3304800 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 88128, + "decodedBodyBytes": 330480 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 44064, + "decodedBodyBytes": 99144 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "2-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "2-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "2-http://127.0.0.1:61812/vite/new-chunk.js", + "url": "http://127.0.0.1:61812/vite/new-chunk.js", + "method": "POST", + "resourceType": "Fetch", + "startedAt": 1, + "hasRequestBody": true, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200, + "requestBody": "{\"i\":2}" + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 33048000, + "totalSize": 55080000 + }, + "tabMemory": { + "totalBytes": 99144000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1101600, + "code": 2203200, + "strings": 3304800, + "jsArrays": 4406400, + "typedArrays": 5508000, + "systemObjects": 6609600, + "otherJsObjects": 7711200, + "otherNonJsObjects": 8812800 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1321920, + "Other": 881280 + }, + "strings": { + "strings: alpha": 1982880, + "Other": 1321920 + }, + "jsArrays": { + "jsArrays: alpha": 2643840, + "Other": 1762560 + }, + "typedArrays": { + "typedArrays: alpha": 3304800, + "Other": 2203200 + }, + "systemObjects": { + "systemObjects: alpha": 3965760, + "Other": 2643840 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4626720, + "Other": 3084480 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5287680, + "Other": 3525120 + } + } + } + }, + { + "label": "head", + "round": 3, + "timestamp": "2026-07-18T00:00:00.000Z", + "url": "http://127.0.0.1:61812", + "scenario": "fresh browser signup, first timeline note, after the note becomes visible", + "diagnostics": { + "pageErrorCount": 0, + "console": { + "log": 5, + "warning": 4, + "error": 0, + "info": 2 + } + }, + "durationMs": 22248, + "network": { + "requestCount": 21, + "webSocketConnectionCount": 1, + "webSocketSentBytes": 2225, + "webSocketReceivedBytes": 10012, + "finishedRequestCount": 18, + "failedRequestCount": 0, + "cachedRequestCount": 0, + "serviceWorkerRequestCount": 0, + "totalEncodedBytes": 1134648, + "totalDecodedBodyBytes": 3771036, + "sameOriginEncodedBytes": 1112400, + "thirdPartyEncodedBytes": 22248, + "byResourceType": { + "Script": { + "requests": 10, + "encodedBytes": 1001160, + "decodedBodyBytes": 3337200 + }, + "Stylesheet": { + "requests": 2, + "encodedBytes": 88992, + "decodedBodyBytes": 333720 + }, + "Fetch": { + "requests": 6, + "encodedBytes": 44496, + "decodedBodyBytes": 100116 + } + }, + "largestRequests": [ + { + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "status": 200, + "encodedBytes": 300000, + "decodedBodyBytes": 900000 + } + ], + "failedRequests": [] + }, + "networkRequests": [ + { + "requestId": "3-http://127.0.0.1:61812/vite/a.js", + "url": "http://127.0.0.1:61812/vite/a.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "3-http://127.0.0.1:61812/vite/b.js", + "url": "http://127.0.0.1:61812/vite/b.js", + "method": "GET", + "resourceType": "Script", + "startedAt": 1, + "hasRequestBody": false, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200 + }, + { + "requestId": "3-http://127.0.0.1:61812/vite/new-chunk.js", + "url": "http://127.0.0.1:61812/vite/new-chunk.js", + "method": "POST", + "resourceType": "Fetch", + "startedAt": 1, + "hasRequestBody": true, + "encodedDataLength": 50000, + "decodedBodyLength": 120000, + "fromDiskCache": false, + "fromServiceWorker": false, + "finished": true, + "failed": false, + "mimeType": "text/javascript", + "protocol": "h2", + "responseHeaders": { + "content-type": "text/javascript" + }, + "status": 200, + "requestBody": "{\"i\":3}" + } + ], + "performance": { + "cdpMetrics": { + "Nodes": 5000, + "JSEventListeners": 400, + "LayoutCount": 30 + }, + "runtimeHeap": { + "usedSize": 33372000, + "totalSize": 55620000 + }, + "tabMemory": { + "totalBytes": 100116000 + }, + "webVitals": { + "firstPaintMs": 400, + "firstContentfulPaintMs": 450, + "domContentLoadedEventEndMs": 800, + "loadEventEndMs": 1200, + "longTaskCount": 3, + "longTaskDurationMs": 300, + "maxLongTaskDurationMs": 150, + "resourceEntryCount": 18, + "domElements": 2500 + } + }, + "heapSnapshot": { + "categories": { + "total": 1112400, + "code": 2224800, + "strings": 3337200, + "jsArrays": 4449600, + "typedArrays": 5562000, + "systemObjects": 6674400, + "otherJsObjects": 7786800, + "otherNonJsObjects": 8899200 + }, + "nodeCounts": { + "total": 100, + "code": 200, + "strings": 300, + "jsArrays": 400, + "typedArrays": 500, + "systemObjects": 600, + "otherJsObjects": 700, + "otherNonJsObjects": 800 + }, + "breakdowns": { + "code": { + "code: alpha": 1334880, + "Other": 889920 + }, + "strings": { + "strings: alpha": 2002320, + "Other": 1334880 + }, + "jsArrays": { + "jsArrays: alpha": 2669760, + "Other": 1779840 + }, + "typedArrays": { + "typedArrays: alpha": 3337200, + "Other": 2224800 + }, + "systemObjects": { + "systemObjects: alpha": 4004640, + "Other": 2669760 + }, + "otherJsObjects": { + "otherJsObjects: alpha": 4672080, + "Other": 3114720 + }, + "otherNonJsObjects": { + "otherNonJsObjects: alpha": 5339520, + "Other": 3559680 + } + } + } + } + ] +} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-browser/test/render.test.ts b/packages-private/diagnostics-frontend-browser/test/render.test.ts new file mode 100644 index 0000000000..29e38f7b3e --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/test/render.test.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { renderHtml } from '../src/report/html'; +import { renderMarkdown } from '../src/report/markdown'; +import type { BrowserMetricsReport } from '../src/types'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +async function loadFixture(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport; +} + +beforeEach(() => { + // renderHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z')); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders the browser diagnostics markdown report', async () => { + const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + detailedHtmlUrl: 'https://example.invalid/html', + }); + + await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); +}); + +test('omits the details link when no detailed html artifact was uploaded', async () => { + const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), { + baseHeapSnapshotUrl: 'https://example.invalid/base', + headHeapSnapshotUrl: 'https://example.invalid/head', + detailedHtmlUrl: null, + }); + + expect(markdown).not.toContain('View details'); +}); + +test('renders the network request diff html report', async () => { + const html = renderHtml(await loadFixture('base'), await loadFixture('head')); + + await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html'); +}); + +test('escapes html metacharacters coming from the browser session', async () => { + const base = await loadFixture('base'); + const head = await loadFixture('head'); + // CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる + head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/">'; + + const html = renderHtml(base, head); + + expect(html).not.toContain(''); + expect(html).toContain('<script>alert(1)</script>'); +}); diff --git a/packages-private/diagnostics-frontend-browser/tsconfig.json b/packages-private/diagnostics-frontend-browser/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-frontend-browser/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-frontend-bundle/eslint.config.js b/packages-private/diagnostics-frontend-bundle/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-frontend-bundle/package.json b/packages-private/diagnostics-frontend-bundle/package.json new file mode 100644 index 0000000000..b0271a8101 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/package.json @@ -0,0 +1,22 @@ +{ + "name": "diagnostics-frontend-bundle", + "private": true, + "type": "module", + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "render-md": "tsx src/render-md.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "diagnostics-shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "26.1.1", + "tsx": "4.23.1", + "typescript": "5.9.3", + "vite": "8.1.4", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts b/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts new file mode 100644 index 0000000000..2b2492377c --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/chunk-report.ts @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + calcAndFormatDeltaBytes, + calcAndFormatDeltaPercentInMdTable, + escapeMdTableCell, + formatBytes, +} from 'diagnostics-shared/format'; +import type { CollectedReport, FileEntry } from './manifest'; + +/** + * この差分以下のチャンクは個別に出さず `(other)` にまとめる。 + * ハッシュ文字列の揺れ等でサイズが数バイト動くだけの行がノイズになるため。 + */ +const smallDeltaThreshold = 5; + +/** diff表に個別行として出す上限。これを超えた分は `(other)` に集約する */ +const diffRowLimit = 30; + +function entryDisplayName(entry: FileEntry | undefined) { + if (entry == null) return ''; + return entry.displayName || entry.file; +} + +export function getChunkComparisonRows(keys: string[], before: Partial>, after: Partial>) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; + const beforeSize = beforeEntry?.size ?? 0; + const afterSize = afterEntry?.size ?? 0; + return { + key, + name: entryDisplayName(beforeEntry ?? afterEntry), + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, + beforeSize, + afterSize, + changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', + sortSize: Math.max(beforeSize, afterSize), + }; + }); +} + +export type ChunkComparisonRow = ReturnType[number]; + +export type ChunkAggregate = { + beforeSize: number; + afterSize: number; + beforeCount: number; + afterCount: number; +}; + +export function sumChunkSizes(chunks: FileEntry[]) { + return chunks.reduce((sum, chunk) => sum + chunk.size, 0); +} + +/** + * 比較キーを持たない (= before/after で対応付けできない) チャンクの合計。 + */ +export function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +export function hasSmallDelta(row: ChunkComparisonRow) { + return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold; +} + +export 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, + }; +} + +export function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} + +export function summarizeChunkChanges(rows: ChunkComparisonRow[]) { + return { + updated: rows.filter((row) => row.changeType === 'updated').length, + added: rows.filter((row) => row.changeType === 'added').length, + removed: rows.filter((row) => row.changeType === 'removed').length, + }; +} + +export function formatChunkChangeSummary(label: string, summary: ReturnType) { + return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`; +} + +/** + * 差分の絶対値が大きい順。同着は増加側・元サイズ・名前の順で決定的に並べる。 + */ +export 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); +} + +export 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}`; +} + +export function chunkMarkdownTable( + rows: ChunkComparisonRow[], + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, + other?: ChunkAggregate, +) { + 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 | Δ | Δ (%) |', + '| --- | ---: | ---: | ---: | ---: |', + ]; + if (total != null) { + lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.beforeSize, total.afterSize, 0.1)} |`); + lines.push('| | | | | |'); + } + + for (const row of rows) { + const chunkFile = chunkFileDisplay(row); + if (row.changeType === 'added') { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + } else if (row.changeType === 'removed') { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + } else { + lines.push(`|
\`${escapeMdTableCell(row.name)}\` \`${escapeMdTableCell(chunkFile)}\`
| ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.beforeSize, row.afterSize, 0.1)} |`); + } + } + if (hasGenerated) { + lines.push(`| (other generated chunks) | ${formatBytes(generated.beforeSize)} | ${formatBytes(generated.afterSize)} | ${calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.beforeSize, generated.afterSize, 0.1)} |`); + } + if (hasOther) { + lines.push(`| (other) | ${formatBytes(other.beforeSize)} | ${formatBytes(other.afterSize)} | ${calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.beforeSize, other.afterSize, 0.1)} |`); + } + return lines.join('\n'); +} + +export function renderFrontendChunkReport(before: CollectedReport, after: CollectedReport) { + const beforeComparable = before.comparableChunks; + const afterComparable = after.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); + + const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); + const diffSummary = summarizeChunkChanges(changedRows); + const diffTotal = { + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), + }; + const diffGenerated = generatedAggregate(before.chunks, after.chunks); + const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); + const diffRows = largeDeltaRows.slice(0, diffRowLimit); + // 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる + const diffOther = comparisonRowsAggregate([ + ...changedRows.filter(hasSmallDelta), + ...largeDeltaRows.slice(diffRowLimit), + ]); + + const beforeStartupFiles = new Set(before.startupFiles); + const afterStartupFiles = new Set(after.startupFiles); + const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); + const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); + const beforeStartupComparable = comparableMap(beforeStartupChunks); + const afterStartupComparable = comparableMap(afterStartupChunks); + const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); + const startupSummary = summarizeChunkChanges(startupComparisonRows); + const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta)); + const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows); + const startupTotal = { + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), + }; + const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); + + return [ + '
', + `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, + '', + chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther), + '', + '
', + '', + '
', + `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, + '', + chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther), + '', + '_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._', + '', + '
', + '', + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts b/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts new file mode 100644 index 0000000000..74ed757b53 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/fs-utils.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * Windows の `\` 区切りを `/` に揃える。manifest 側のキーが常に `/` 区切りのため、 + * 実ファイルパスと突き合わせる前に正規化する必要がある。 + */ +export function normalizePath(filePath: string) { + return filePath.split(path.sep).join('/'); +} + +export async function fileExists(filePath: string) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function fileSize(filePath: string) { + const stat = await fs.stat(filePath); + return stat.size; +} + +export async function* traverseDirectory(dir: string): AsyncGenerator { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* traverseDirectory(fullPath); + } else if (entry.isFile()) { + yield fullPath; + } + } +} diff --git a/packages-private/diagnostics-frontend-bundle/src/manifest.ts b/packages-private/diagnostics-frontend-bundle/src/manifest.ts new file mode 100644 index 0000000000..d35a5cfbfb --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/manifest.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils'; +import type { Manifest, ManifestChunk } from 'vite'; + +/** + * 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、 + * 代表として ja-JP のみを見る。 + */ +const locale = 'ja-JP'; + +/** + * `src` を持たないチャンクのうち、名前がビルド間で安定していて比較可能なもの。 + */ +const stableNamedChunks = new Set(['vue', 'i18n']); + +export type FileEntry = { + comparisonKey: string | null; + displayName: string; + file: string; + manifestKeys: string[]; + size: number; +}; + +export type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + +export function findEntryKey(manifest: Manifest) { + const entries = Object.entries(manifest); + return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0] + ?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0] + ?? entries.find(([, chunk]) => chunk.isEntry)?.[0] + ?? null; +} + +/** + * ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、 + * これが取れないチャンクは before/after の対応付けができない。 + */ +export function stableChunkKey(chunk: ManifestChunk) { + if (chunk.src != null) return `src:${normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; +} + +/** + * 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。 + */ +export function collectStartupManifestKeys(manifest: Manifest) { + const entryKey = findEntryKey(manifest); + const keys = new Set(); + if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); + + function visit(key: string, importedBy?: string) { + if (keys.has(key)) return; + const chunk = manifest[key]; + const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); + if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey, key); + } + + visit(entryKey); + return keys; +} + +/** + * manifest 上の出力パスを実ファイルへ解決する。`scripts/` 配下はロケール別に + * 複製されて出力されるため、代表ロケールのものへ読み替える。 + */ +export async function resolveBuiltFile(outDir: string, file: string) { + if (file.startsWith('scripts/')) { + const localizedFile = file.slice('scripts/'.length); + const localizedPath = path.join(outDir, locale, localizedFile); + if (await fileExists(localizedPath)) { + return { + absolutePath: localizedPath, + relativePath: `${locale}/${localizedFile}`, + }; + } + + throw new Error(`Expected ${locale} localized chunk for ${file}, but ${localizedPath} was not found.`); + } + return { + absolutePath: path.join(outDir, file), + relativePath: file, + }; +} + +export async function collectReport(repoDir: string): Promise { + const outDir = path.join(repoDir, 'built/_frontend_vite_'); + const manifestPath = path.join(outDir, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); + + for (const [manifestKey, chunk] of Object.entries(manifest)) { + if (!chunk.file.endsWith('.js')) continue; + const builtFile = await resolveBuiltFile(outDir, chunk.file); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } + } + + // manifest に載らないロケール別チャンクも合計サイズには含めたいので拾っておく + const localeDir = path.join(outDir, locale); + if (await fileExists(localeDir)) { + for await (const fullPath of traverseDirectory(localeDir)) { + if (!fullPath.endsWith('.js')) continue; + const relativePath = normalizePath(path.relative(outDir, fullPath)); + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, + displayName: relativePath, + file: relativePath, + manifestKeys: [], + size: await fileSize(fullPath), + }); + } + } + + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + + return { + manifest, + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], + }; +} diff --git a/packages-private/diagnostics-frontend-bundle/src/render-md.ts b/packages-private/diagnostics-frontend-bundle/src/render-md.ts new file mode 100644 index 0000000000..d8fe0997dd --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/render-md.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { readRequiredEnv } from 'diagnostics-shared/env'; +import { collectReport } from './manifest'; +import { renderBundleReportMarkdown } from './report'; +import type { VisualizerReport } from './visualizer'; + +async function main() { + const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = process.argv.slice(2).map(arg => path.resolve(arg)); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (outFile == null) throw new Error('Usage: render-md '); + + // 未設定のまま `undefined` という文字列をコメントに埋め込まないよう、ここで落とす + const visualizerArtifactUrl = readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'); + + const before = await collectReport(beforeDir); + const after = await collectReport(afterDir); + const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport; + const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport; + + await fs.writeFile(outFile, renderBundleReportMarkdown(before, after, beforeStats, afterStats, { visualizerArtifactUrl })); +} + +await main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages-private/diagnostics-frontend-bundle/src/report.ts b/packages-private/diagnostics-frontend-bundle/src/report.ts new file mode 100644 index 0000000000..db2e1cf6e3 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/report.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { renderFrontendChunkReport } from './chunk-report'; +import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './visualizer'; +import type { CollectedReport } from './manifest'; + +export type RenderBundleReportOptions = { + /** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */ + visualizerArtifactUrl: string; +}; + +export function renderBundleReportMarkdown( + before: CollectedReport, + after: CollectedReport, + beforeStats: VisualizerReport, + afterStats: VisualizerReport, + options: RenderBundleReportOptions, +) { + return [ + '## 📦 Frontend Bundle Report', + '', + renderFrontendChunkReport(before, after), + '', + '## Bundle Stats', + '', + renderVisualizerSummaryTable(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)), + '', + `[Open treemap HTML](${options.visualizerArtifactUrl})`, + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend-bundle/src/visualizer.ts b/packages-private/diagnostics-frontend-bundle/src/visualizer.ts new file mode 100644 index 0000000000..17e4f190b6 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/src/visualizer.ts @@ -0,0 +1,204 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + calcAndFormatDeltaBytes, + calcAndFormatDeltaNumber, + calcAndFormatDeltaPercent, + formatBytes, + formatNumber, +} from 'diagnostics-shared/format'; + +/** + * rollup-plugin-visualizer が出力する `stats.json` のうち、ここで使う部分のみの型。 + */ +export type VisualizerReport = { + nodeParts?: Record; + nodeMetas?: Record; + renderedLength: number; + gzipLength: number; + brotliLength: number; + }>; + options?: Record; +}; + +type ModuleRow = { + id: string; + bundles: number; + renderedLength: number; + gzipLength: number; + brotliLength: number; + importedByCount: number; + importedCount: number; +}; + +type BundleRow = { + id: string; + modules: number; + renderedLength: number; + gzipLength: number; + brotliLength: number; +}; + +export function collectVisualizerReport(data: VisualizerReport) { + const nodeParts = data.nodeParts ?? {}; + const nodeMetas = Object.values(data.nodeMetas ?? {}); + const moduleRows: ModuleRow[] = []; + const bundleMap = new Map(); + + for (const meta of nodeMetas) { + const row: ModuleRow = { + id: meta.id, + bundles: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + importedByCount: meta.importedBy?.length ?? 0, + importedCount: meta.imported?.length ?? 0, + }; + + for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) { + const part = nodeParts[partUid]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (part == null) continue; + + row.bundles += 1; + row.renderedLength += part.renderedLength; + row.gzipLength += part.gzipLength; + row.brotliLength += part.brotliLength; + + const bundle = bundleMap.get(bundleId) ?? { + id: bundleId, + modules: 0, + renderedLength: 0, + gzipLength: 0, + brotliLength: 0, + }; + bundle.modules += 1; + bundle.renderedLength += part.renderedLength; + bundle.gzipLength += part.gzipLength; + bundle.brotliLength += part.brotliLength; + bundleMap.set(bundleId, bundle); + } + + // どのバンドルにも含まれないモジュール (tree-shake 済み等) は集計対象外 + if (row.bundles > 0) { + moduleRows.push(row); + } + } + + let staticImports = 0; + let dynamicImports = 0; + for (const meta of nodeMetas) { + for (const imported of meta.imported ?? []) { + if (imported.dynamic) { + dynamicImports += 1; + } else { + staticImports += 1; + } + } + } + + const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength); + const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength); + const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0); + const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0); + const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0); + + return { + options: data.options ?? {}, + summary: { + bundles: bundleRows.length, + modules: moduleRows.length, + entries: nodeMetas.filter((meta) => meta.isEntry).length, + externals: nodeMetas.filter((meta) => meta.isExternal).length, + staticImports, + dynamicImports, + }, + metrics: { + renderedLength: totalRendered, + gzipLength: totalGzip, + brotliLength: totalBrotli, + }, + hotModules, + }; +} + +/** + * NOTE: 以前はこの関数が `string[]` を返しており、呼び出し側の `[...].join('\n')` の + * 要素として配列のまま埋め込まれていたため、テーブルがカンマ区切りの1行に潰れていた。 + * 呼び出し側で意識しなくて済むよう、ここで文字列にして返す。 + */ +export function renderVisualizerSummaryTable(before: ReturnType, after: ReturnType) { + const summary = [ + 'bundles', + 'modules', + 'entries', + //'externals', + 'staticImports', + 'dynamicImports', + ] as const; + + const metrics = [ + 'renderedLength', + 'gzipLength', + 'brotliLength', + ] as const; + + return [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '', + ...summary.map((key) => ``), + ...metrics.map((key) => ``), + '', + '', + '
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before${formatNumber(before.summary[key])}${formatBytes(before.metrics[key])}
After${formatNumber(after.summary[key])}${formatBytes(after.metrics[key])}
Δ${calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}${calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}
Δ (%)${calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}${calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}
', + ].join('\n'); +} diff --git a/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md b/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md new file mode 100644 index 0000000000..867cbf3b75 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/__snapshots__/render-md.md @@ -0,0 +1,100 @@ +## 📦 Frontend Bundle Report + +
+Chunk size diff (2 updated, 0 added, 0 removed) + +| Chunk | Before | After | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other generated chunks) | 1.2 KB | 1.5 KB | $\text{+300 B}$ | $\color{orange}{\text{+25\\%}}$ | +| (other) | 20 KB | 20 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +
+ +
+Startup chunk size (2 updated, 0 added, 0 removed) + +| Chunk | Before | After | Δ | Δ (%) | +| --- | ---: | ---: | ---: | ---: | +| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ | +| | | | | | +|
`vue` `assets/vue-b2.js`
| 90 KB | 96 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+6.7\\%}}$ | +| (other) | 24 KB | 24 KB | $\text{+3 B}$ | $\text{+0\\%}$ | + +_Startup chunks are the Vite entry for `src/_boot_.ts` and its static imports._ + +
+ + +## Bundle Stats + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BundlesModulesEntriesImportsSize
StaticDynamicRenderedGzipBrotli
Before2612321 KB6.3 KB5.3 KB
After2713331 KB8.4 KB7 KB
Δ0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+1}}$0$\color{orange}{\text{+9.8 KB}}$$\color{orange}{\text{+2.1 KB}}$$\color{orange}{\text{+1.8 KB}}$
Δ (%)0%$\color{orange}{\text{+16.7\%}}$0%$\color{orange}{\text{+50\%}}$0%$\color{orange}{\text{+46.7\%}}$$\color{orange}{\text{+33.3\%}}$$\color{orange}{\text{+33.3\%}}$
+ +[Open treemap HTML](https://example.invalid/treemap) \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json b/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json new file mode 100644 index 0000000000..b69e3f4cea --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/fixtures/after-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json b/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json new file mode 100644 index 0000000000..0a54b93148 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/fixtures/before-stats.json @@ -0,0 +1 @@ +{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}} \ No newline at end of file diff --git a/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts b/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts new file mode 100644 index 0000000000..17adcef883 --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/test/render-md.test.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, expect, test } from 'vitest'; +import { collectReport } from '../src/manifest'; +import { renderBundleReportMarkdown } from '../src/report'; +import type { VisualizerReport } from '../src/visualizer'; + +const fixturesDir = join(import.meta.dirname, 'fixtures'); + +/** + * ビルド成果物のfixture。 + * + * `collectReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の + * 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず + * (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。 + */ +const manifest = { + 'src/_boot_.ts': { file: 'assets/boot-a1.js', src: 'src/_boot_.ts', name: 'boot', isEntry: true, imports: ['_vue.js', '_i18n.js'] }, + '_vue.js': { file: 'assets/vue-b2.js', name: 'vue' }, + // `scripts/` 配下はロケール別に出力されるので ja-JP/ に解決される + '_i18n.js': { file: 'scripts/i18n-c3.js', name: 'i18n' }, + 'src/pages/foo.vue': { file: 'assets/foo-d4.js', src: 'src/pages/foo.vue', name: 'foo' }, + // .js 以外はチャンクとして数えない + 'src/pages/style.css': { file: 'assets/style-e5.css', src: 'src/pages/style.css' }, +}; + +const fileSizes = { + before: { + 'assets/boot-a1.js': 20_000, + 'assets/vue-b2.js': 90_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + 'ja-JP/orphan.js': 1_200, + }, + after: { + // 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される + 'assets/boot-a1.js': 20_003, + // 明確に増えるので diff表に行として出る + 'assets/vue-b2.js': 96_000, + 'assets/foo-d4.js': 5_000, + 'assets/style-e5.css': 100, + 'ja-JP/i18n-c3.js': 4_000, + // manifestに載らない出力なので「(other generated chunks)」に集約される + 'ja-JP/orphan.js': 1_500, + }, +} as const satisfies Record<'before' | 'after', Record>; + +let repoDirs: { before: string; after: string }; +let workDir: string; + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-bundle-')); + + for (const label of ['before', 'after'] as const) { + const outDir = join(workDir, label, 'built/_frontend_vite_'); + await mkdir(outDir, { recursive: true }); + await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest)); + + for (const [file, size] of Object.entries(fileSizes[label])) { + const path = join(outDir, file); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, 'x'.repeat(size)); + } + } + + repoDirs = { + before: join(workDir, 'before'), + after: join(workDir, 'after'), + }; +}); + +afterAll(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +async function loadStats(name: string) { + return JSON.parse(await readFile(join(fixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport; +} + +/** + * 出力をゴールデンファイルで固定する。 + * 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。 + */ +test('renders the frontend bundle report', async () => { + const markdown = renderBundleReportMarkdown( + await collectReport(repoDirs.before), + await collectReport(repoDirs.after), + await loadStats('before'), + await loadStats('after'), + { visualizerArtifactUrl: 'https://example.invalid/treemap' }, + ); + + await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md'); +}); + +test('fails loudly when the built output is missing', async () => { + await expect(collectReport(join(workDir, 'nonexistent'))).rejects.toThrow(); +}); diff --git a/packages-private/diagnostics-frontend-bundle/tsconfig.json b/packages-private/diagnostics-frontend-bundle/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-frontend-bundle/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages-private/diagnostics-shared/eslint.config.js b/packages-private/diagnostics-shared/eslint.config.js new file mode 100644 index 0000000000..11bc648243 --- /dev/null +++ b/packages-private/diagnostics-shared/eslint.config.js @@ -0,0 +1,25 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../packages/shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + ignores: [ + '**/node_modules', + '**/__snapshots__', + 'test/fixtures', + ], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages-private/diagnostics-shared/package.json b/packages-private/diagnostics-shared/package.json new file mode 100644 index 0000000000..1fd0a14682 --- /dev/null +++ b/packages-private/diagnostics-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "diagnostics-shared", + "private": true, + "type": "module", + "exports": { + "./env": "./src/env.ts", + "./format": "./src/format.ts", + "./html": "./src/html.ts", + "./stats": "./src/stats.ts", + "./heap-snapshot": "./src/heap-snapshot/index.ts" + }, + "scripts": { + "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", + "lint": "pnpm typecheck && pnpm eslint", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "26.1.1", + "typescript": "5.9.3", + "vitest": "4.1.10" + } +} diff --git a/packages-private/diagnostics-shared/src/env.ts b/packages-private/diagnostics-shared/src/env.ts new file mode 100644 index 0000000000..0875cb2373 --- /dev/null +++ b/packages-private/diagnostics-shared/src/env.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function readIntegerEnv(name: string, defaultValue: number, min: number) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); + + const value = Number(rawValue); + if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); + return value; +} + +export function readBooleanEnv(name: string, defaultValue: boolean) { + const rawValue = process.env[name]; + if (rawValue == null || rawValue === '') return defaultValue; + if (rawValue === '1' || rawValue === 'true') return true; + if (rawValue === '0' || rawValue === 'false') return false; + throw new Error(`${name} must be one of: 1, 0, true, false`); +} + +/** + * 必須の環境変数を読む。未設定・空文字ならエラーにする。 + */ +export function readRequiredEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') throw new Error(`${name} must be set`); + return rawValue; +} + +/** + * 任意の環境変数を読む。未設定・空文字なら null を返す。 + */ +export function readOptionalEnv(name: string) { + const rawValue = process.env[name]?.trim(); + if (rawValue == null || rawValue === '') return null; + return rawValue; +} diff --git a/packages-private/diagnostics-shared/src/format.ts b/packages-private/diagnostics-shared/src/format.ts new file mode 100644 index 0000000000..75dc835e7b --- /dev/null +++ b/packages-private/diagnostics-shared/src/format.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const numberFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, +}); + +export function escapeLatex(text: string) { + return text + .replaceAll('\\', '\\\\') + .replaceAll('{', '\\{') + .replaceAll('}', '\\}') + .replaceAll('%', '\\%'); +} + +export function escapeMdTableCell(value: string) { + return String(value).replaceAll('|', '\\|').replaceAll('\n', '
'); +} + +export function escapeHtml(value: unknown) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('\'', '''); +} + +export function formatNumber(value: number) { + return numberFormatter.format(value); +} + +export function formatBytes(value: number) { + if (value === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let unitIndex = 0; + let size = value; + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000; + unitIndex += 1; + } + + const maximumFractionDigits = size >= 10 || unitIndex === 0 ? 0 : 1; + return `${numberFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`; +} + +/** + * KiB単位の値をMB表記にする。/proc 由来の値の表示に使う。 + */ +export function formatKiBAsMb(valueKiB: number | null | undefined) { + if (valueKiB == null || !Number.isFinite(valueKiB)) return '-'; + return `${formatNumber(valueKiB / 1000)} MB`; +} + +export function formatPercent(value: number) { + return `${formatNumber(value)}%`; +} + +export function formatMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + if (value >= 1_000) return `${formatNumber(value / 1_000)} s`; + return `${formatNumber(value)} ms`; +} + +export function formatSecondsAsMs(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return '-'; + return formatMs(value * 1_000); +} + +/** + * 差分値を符号付きで整形する。`colorThreshold` 以上の変化があるときだけ色を付ける。 + */ +export function formatColoredDelta(delta: number, text: (value: number) => string, colorThreshold = 0) { + if (delta === 0) return text(0); + const sign = delta > 0 ? '+' : '-'; + if (Math.abs(delta) < colorThreshold) return `$\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}$`; + const color = delta > 0 ? 'orange' : 'green'; + return `$\\color{${color}}{\\text{${sign}${escapeLatex(text(Math.abs(delta)))}}}$`; +} + +export function formatDeltaBytes(deltaBytes: number, colorThreshold = 0) { + return formatColoredDelta(deltaBytes, formatBytes, colorThreshold); +} + +export function formatDeltaPercent(deltaPercent: number, colorThreshold = 0) { + return formatColoredDelta(deltaPercent, formatPercent, colorThreshold); +} + +/** + * Markdownのテーブルセル内に置く差分パーセント。 + * LaTeX由来の `\%` がMarkdownのエスケープで食われるため二重エスケープする。 + */ +export function formatDeltaPercentInMdTable(deltaPercent: number, colorThreshold = 0) { + return formatDeltaPercent(deltaPercent, colorThreshold).replaceAll('\\%', '\\\\%'); +} + +export function calcAndFormatDeltaNumber(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatColoredDelta(after - before, formatNumber, colorThreshold); +} + +export function calcAndFormatDeltaBytes(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || after == null) return '-'; + return formatDeltaBytes(after - before, colorThreshold); +} + +export function calcAndFormatDeltaPercent(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + if (before == null || before === 0 || after == null) return '-'; + return formatDeltaPercent((after - before) / before * 100, colorThreshold); +} + +export function calcAndFormatDeltaPercentInMdTable(before: number | null | undefined, after: number | null | undefined, colorThreshold = 0) { + return calcAndFormatDeltaPercent(before, after, colorThreshold).replaceAll('\\%', '\\\\%'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts new file mode 100644 index 0000000000..b3443e1889 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { classifyHeapSnapshotBreakdown, collapseHeapSnapshotBreakdowns } from './breakdown'; +import { + createEmptyHeapSnapshotData, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +/** + * `.heapsnapshot` のJSONを、フィールドオフセットを解決した状態で扱うためのビュー。 + */ +type HeapSnapshotView = { + nodes: number[]; + edges: number[]; + strings: string[]; + nodeFieldCount: number; + edgeFieldCount: number; + nodeTypeNames: string[]; + edgeTypeNames: string[]; + typeOffset: number; + nameOffset: number; + selfSizeOffset: number; + edgeCountOffset: number; + edgeTypeOffset: number; + edgeNameOffset: number; + edgeToNodeOffset: number; + extraNativeBytes: number; +}; + +function requireOffsets(fields: string[], names: string[], what: string) { + const offsets = names.map(name => fields.indexOf(name)); + if (offsets.some(offset => offset < 0)) throw new Error(`Heap snapshot is missing required ${what} fields`); + return offsets; +} + +function parseHeapSnapshot(snapshot: any): HeapSnapshotView { + const meta = snapshot?.snapshot?.meta; + const { nodes, edges, strings } = snapshot ?? {}; + 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, nameOffset, selfSizeOffset, edgeCountOffset] = requireOffsets(nodeFields, ['type', 'name', 'self_size', 'edge_count'], 'node'); + const [edgeTypeOffset, edgeNameOffset, edgeToNodeOffset] = requireOffsets(edgeFields, ['type', 'name_or_index', 'to_node'], 'edge'); + + 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'); + + return { + nodes, + edges, + strings, + nodeFieldCount: nodeFields.length, + edgeFieldCount: edgeFields.length, + nodeTypeNames, + edgeTypeNames, + typeOffset, + nameOffset, + selfSizeOffset, + edgeCountOffset, + edgeTypeOffset, + edgeNameOffset, + edgeToNodeOffset, + extraNativeBytes: Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0, + }; +} + +/** + * ノードごとのedge開始位置と、各ノードが何本のedgeから参照されているかを求める。 + * JS配列のelementsストアが専有されているか判定するために使う。 + */ +function indexEdges(view: HeapSnapshotView) { + const edgeStartIndexes = new Map(); + const retainerCounts = new Map(); + let edgeIndex = 0; + + for (let nodeIndex = 0; nodeIndex < view.nodes.length; nodeIndex += view.nodeFieldCount) { + edgeStartIndexes.set(nodeIndex, edgeIndex); + const edgeCount = view.nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0; i < edgeCount; i++, edgeIndex += view.edgeFieldCount) { + const toNodeIndex = view.edges[edgeIndex + view.edgeToNodeOffset]; + retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1); + } + } + + return { edgeStartIndexes, retainerCounts }; +} + +export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData { + const view = parseHeapSnapshot(snapshot); + const { nodes, edges, strings, nodeFieldCount, edgeFieldCount, nodeTypeNames, edgeTypeNames } = view; + + 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 { categories, nodeCounts } = createEmptyHeapSnapshotData(); + const breakdowns = {} as Record>; + for (const category of heapSnapshotBreakdownCategories) { + breakdowns[category] = {}; + } + + const { edgeStartIndexes, retainerCounts } = indexEdges(view); + const jsArrayElementNodeIndexes = new Set(); + + function addCategoryValue(category: HeapSnapshotCategory, value: number, type: string, name: string, counted = true) { + if (value <= 0) return; + categories[category] += value; + const label = classifyHeapSnapshotBreakdown(category, type, name); + breakdowns[category][label] = (breakdowns[category][label] ?? 0) + value; + if (counted) nodeCounts[category]++; + } + + /** + * 配列オブジェクト自身のself_sizeには要素ストアが含まれないため、 + * その配列だけが参照しているelementsノードの分を JS arrays に加算する。 + */ + function addJsArrayElementSize(nodeIndex: number) { + const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0; + const edgeCount = nodes[nodeIndex + view.edgeCountOffset] ?? 0; + for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) { + if (edges[currentEdgeIndex + view.edgeTypeOffset] !== internalEdgeType) continue; + if (strings[edges[currentEdgeIndex + view.edgeNameOffset]] !== 'elements') continue; + + const elementsNodeIndex = edges[currentEdgeIndex + view.edgeToNodeOffset]; + if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) { + addCategoryValue('jsArrays', nodes[elementsNodeIndex + view.selfSizeOffset] ?? 0, 'array elements', 'Array elements'); + jsArrayElementNodeIndexes.add(elementsNodeIndex); + } + break; + } + } + + if (view.extraNativeBytes > 0) { + addCategoryValue('otherNonJsObjects', view.extraNativeBytes, 'extra native bytes', 'extra native bytes', false); + } + + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + const typeId = nodes[nodeIndex + view.typeOffset]; + const type = nodeTypeNames[typeId] ?? 'unknown'; + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + const selfSize = nodes[nodeIndex + view.selfSizeOffset] ?? 0; + categories.total += selfSize; + nodeCounts.total++; + + if (typeId === hiddenType) { + addCategoryValue('systemObjects', selfSize, type, name); + } else if (typeId === nativeType) { + addCategoryValue(name === 'system / JSArrayBufferData' ? 'typedArrays' : 'otherNonJsObjects', selfSize, type, name); + } else if (typeId === codeType) { + addCategoryValue('code', selfSize, type, name); + } else if (stringTypes.has(typeId)) { + addCategoryValue('strings', selfSize, type, name); + } else if (name === 'Array') { + addCategoryValue('jsArrays', selfSize, type, name); + addJsArrayElementSize(nodeIndex); + } + } + + categories.total += view.extraNativeBytes; + + // 上のループで JS arrays に計上したelementsノードが確定してから、残りを Other JS objects に振り分ける + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) { + if (jsArrayElementNodeIndexes.has(nodeIndex)) continue; + + const typeId = nodes[nodeIndex + view.typeOffset]; + if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue; + + const name = strings[nodes[nodeIndex + view.nameOffset]] ?? ''; + if (name === 'Array') continue; + + addCategoryValue('otherJsObjects', nodes[nodeIndex + view.selfSizeOffset] ?? 0, nodeTypeNames[typeId] ?? 'unknown', name); + } + + return { + categories, + nodeCounts, + breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN), + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts new file mode 100644 index 0000000000..0dd5ffe324 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/breakdown.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + defaultHeapSnapshotBreakdownTopN, + heapSnapshotBreakdownCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function sanitizeLabel(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)}...`; +} + +/** + * ノードの type / name から、内訳テーブルに出す粒度のラベルを決める。 + */ +export function classifyHeapSnapshotBreakdown(category: HeapSnapshotCategory, type: string, name: string) { + switch (category) { + case 'strings': + return type; + + case 'jsArrays': + if (type === 'array elements') return 'Array elements'; + if (type === 'object' && name === 'Array') return 'Array objects'; + return sanitizeLabel(`${type}: ${name}`); + + case 'typedArrays': + if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data'; + return sanitizeLabel(`${type}: ${name}`); + + case 'systemObjects': + if (name.startsWith('system /') || name.startsWith('(system ')) return sanitizeLabel(name); + return sanitizeLabel(`${type}: ${name}`, type); + + case 'otherJsObjects': + if (type === 'object') return sanitizeLabel(`object: ${name}`, 'object: unknown'); + return type; + + case 'otherNonJsObjects': + if (type === 'extra native bytes') return 'Extra native bytes'; + if (type === 'native') return sanitizeLabel(`native: ${name}`, 'native: unknown'); + return sanitizeLabel(`${type}: ${name}`, type); + + case '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 sanitizeLabel(`code: ${name}`, 'code: unknown'); + } + + default: + return sanitizeLabel(`${type}: ${name}`, type); + } +} + +/** + * 内訳を上位N件に丸め、残りを `Other` にまとめる。 + */ +export function collapseHeapSnapshotBreakdown(breakdown: Record, topN = defaultHeapSnapshotBreakdownTopN) { + const entries = Object.entries(breakdown) + .filter(([, value]) => value > 0) + .toSorted((a, b) => b[1] - a[1]); + + const collapsed = Object.fromEntries(entries.slice(0, topN)); + const otherValue = entries.slice(topN).reduce((sum, [, value]) => sum + value, 0); + if (otherValue > 0) collapsed.Other = otherValue; + return collapsed; +} + +export function collapseHeapSnapshotBreakdowns( + breakdowns: Partial>>, + topN = defaultHeapSnapshotBreakdownTopN, +) { + const collapsed: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const categoryBreakdown = breakdowns[category]; + if (categoryBreakdown == null) continue; + + const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN); + if (Object.keys(collapsedCategory).length > 0) { + collapsed[category] = collapsedCategory; + } + } + + return collapsed; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts new file mode 100644 index 0000000000..4e7caed6d8 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/categories.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// Chrome DevTools の heap snapshot Statistics ビューと同じ分類になるように保つこと。 +export const heapSnapshotCategory = { + total: { label: 'Total', color: 'gray', colorHex: '#888888' }, + code: { label: 'Code', color: 'orange', colorHex: '#f28e2c' }, + strings: { label: 'Strings', color: 'red', colorHex: '#e15759' }, + jsArrays: { label: 'JS arrays', color: 'cyan', colorHex: '#76b7b2' }, + typedArrays: { label: 'Typed arrays', color: 'green', colorHex: '#59a14f' }, + systemObjects: { label: 'System objects', color: 'yellow', colorHex: '#edc949' }, + otherJsObjects: { label: 'Other JS objs', color: 'violet', colorHex: '#af7aa1' }, + otherNonJsObjects: { label: 'Other non-JS objs', color: 'pink', colorHex: '#ff9da7' }, +} as const satisfies Record; + +export type HeapSnapshotCategory = keyof typeof heapSnapshotCategory; + +export const heapSnapshotCategories = Object.keys(heapSnapshotCategory) as HeapSnapshotCategory[]; + +/** `total` は他カテゴリの合算ではなく全体量なので、内訳を扱うときは除外する */ +export const heapSnapshotBreakdownCategories = heapSnapshotCategories.filter(category => category !== 'total'); + +export type HeapSnapshotData = { + categories: Record; + nodeCounts: Record; + /** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */ + breakdowns?: Partial>>; +}; + +export type HeapSnapshotReport = { + summary: HeapSnapshotData; + samples: { + round: number; + data: HeapSnapshotData; + }[]; +}; + +export const defaultHeapSnapshotBreakdownTopN = 6; + +export function createEmptyHeapSnapshotData(): HeapSnapshotData { + const categories = {} as HeapSnapshotData['categories']; + const nodeCounts = {} as HeapSnapshotData['nodeCounts']; + for (const category of heapSnapshotCategories) { + categories[category] = 0; + nodeCounts[category] = 0; + } + return { + categories, + nodeCounts, + breakdowns: {}, + }; +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/index.ts b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts new file mode 100644 index 0000000000..0634efac87 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export * from './analyze'; +export * from './breakdown'; +export * from './categories'; +export * from './render'; +export * from './summarize'; diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/render.ts b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts new file mode 100644 index 0000000000..7690c834c5 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/render.ts @@ -0,0 +1,177 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + formatBytes, + formatDeltaBytes, + formatDeltaPercentInMdTable, + formatPercent, +} from '../format'; +import { mad, pairedDeltaSummary } from '../stats'; +import { + heapSnapshotCategories, + heapSnapshotCategory, + type HeapSnapshotCategory, + type HeapSnapshotReport, +} from './categories'; + +/** これ未満のバイト差分には色を付けない (0.1 MB) */ +const byteColorThreshold = 100_000; + +function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + return report.summary.categories[category]; +} + +function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) { + const values = report.samples + .map(sample => sample.data.categories[category]) + .filter(value => Number.isFinite(value)); + if (values.length < 2) throw new Error(`Not enough samples for category ${category}`); + + return mad(values); +} + +function swatch(category: HeapSnapshotCategory) { + return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`; +} + +/** + * base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。 + */ +export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) { + const lines = [ + '| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + const baseTotal = categoryValue(base, 'total'); + const headTotal = categoryValue(head, 'total'); + + for (const category of heapSnapshotCategories) { + const baseValue = categoryValue(base, category); + const headValue = categoryValue(head, category); + const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]); + const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`; + + if (category === 'total') { + // Totalだけはばらつきと変化率も併記する + const percent = summary.median * 100 / baseValue; + const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}
${formatDeltaPercentInMdTable(percent, 0.1)}`; + const baseText = `${formatBytes(baseValue)}
± ${formatBytes(categorySampleSpread(base, category))}`; + const headText = `${formatBytes(headValue)}
± ${formatBytes(categorySampleSpread(head, category))}`; + lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`); + lines.push('| | | | | | | |'); + } else { + // 各カテゴリはTotalに占める割合の推移をdetailsで見せる + const basePercent = formatPercent((baseValue * 100) / baseTotal); + const headPercent = formatPercent((headValue * 100) / headTotal); + const metricText = `
${swatch(category)}${basePercent} → ${headPercent}
`; + lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`); + } + } + + if (lines.length === 2) return null; + return lines.join('\n'); +} + +const sankeyChildMinRatio = 0.3; +const sankeyParentMinPercent = 10; + +function escapeCsvValue(value: string) { + return `"${String(value).replaceAll('"', '""')}"`; +} + +function formatSankeyPercentValue(value: number) { + const rounded = Math.round(value * 100) / 100; + if (rounded === 0 && value > 0) return '0.01'; + if (Number.isInteger(rounded)) return String(rounded); + return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +/** + * heap snapshotの構成比をmermaidのsankey図として描画する。 + * 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。 + */ +export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) { + const total = categoryValue(report, 'total'); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (total == null || total <= 0) return null; + + const categories = heapSnapshotCategories + .filter(category => category !== 'total') + .map(category => { + const value = categoryValue(report, category); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value == null || value <= 0) return null; + + const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {}) + .filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0) + .toSorted((a, b) => b[1] - a[1]); + const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0); + const percent = (value * 100) / total; + const childEntries: [string, number][] = []; + let otherPercent = 0; + + if (breakdownTotal > 0 && percent > sankeyParentMinPercent) { + for (const [childName, childValue] of breakdownEntries) { + const childRatio = childValue / breakdownTotal; + if (childRatio >= sankeyChildMinRatio) { + childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]); + } else { + otherPercent += percent * childRatio; + } + } + + if (childEntries.length > 0 && otherPercent > 0) { + childEntries.push(['Other', otherPercent]); + } + } + + return { category, percent, childEntries }; + }) + .filter(value => value != null); + + if (categories.length === 0) return null; + + const nodeColors: Record = { + [title]: heapSnapshotCategory.total.colorHex, + Other: '#888888', + }; + for (const { category, childEntries } of categories) { + nodeColors[category] = heapSnapshotCategory[category].colorHex; + for (const [childName] of childEntries) { + nodeColors[childName] = heapSnapshotCategory[category].colorHex; + } + } + + const lines = [ + `
${title} heap snapshot composition`, + '', + '```mermaid', + `%%{init: ${JSON.stringify({ + sankey: { + showValues: false, + linkColor: 'target', + labelStyle: 'outlined', + nodeAlignment: 'center', + nodePadding: 10, + nodeColors, + }, + })}}%%`, + 'sankey-beta', + ]; + + for (const { category, percent, childEntries } of categories) { + const categoryLabel = heapSnapshotCategory[category].label; + lines.push(`${escapeCsvValue(title)},${escapeCsvValue(categoryLabel)},${formatSankeyPercentValue(percent)}`); + + for (const [childName, childPercent] of childEntries) { + lines.push(`${escapeCsvValue(categoryLabel)},${escapeCsvValue(childName)},${formatSankeyPercentValue(childPercent)}`); + } + } + + lines.push('```', '', '
'); + + return lines.join('\n'); +} diff --git a/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts new file mode 100644 index 0000000000..bbc2a6d203 --- /dev/null +++ b/packages-private/diagnostics-shared/src/heap-snapshot/summarize.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { finiteMedian } from '../stats'; +import { collapseHeapSnapshotBreakdown } from './breakdown'; +import { + heapSnapshotBreakdownCategories, + heapSnapshotCategories, + type HeapSnapshotCategory, + type HeapSnapshotData, +} from './categories'; + +function isComplete(values: Partial>): values is Record { + return heapSnapshotCategories.every(category => values[category] != null); +} + +/** + * 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。 + * 全カテゴリ分の値が揃わなければ null を返す。 + */ +export function summarizeHeapSnapshotDataSamples( + samples: T[], + getData: (sample: T) => HeapSnapshotData | null | undefined, + options: { breakdownTopN?: number } = {}, +) { + const data = samples.map(getData); + + const categories: Partial = {}; + const nodeCounts: Partial = {}; + for (const category of heapSnapshotCategories) { + const categoryValue = finiteMedian(data.map(snapshot => snapshot?.categories?.[category])); + if (categoryValue != null) categories[category] = categoryValue; + + const nodeCountValue = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category])); + if (nodeCountValue != null) nodeCounts[category] = nodeCountValue; + } + + // 一部のカテゴリだけ欠けた状態で返すと、呼び出し側が完全な値として扱って + // undefined を描画してしまう。全カテゴリ揃っていなければサマリ自体を無しとする + if (!isComplete(categories) || !isComplete(nodeCounts)) return null; + + const breakdowns: NonNullable = {}; + for (const category of heapSnapshotBreakdownCategories) { + const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {}))); + + 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 } : {}), + } satisfies HeapSnapshotData; +} diff --git a/packages-private/diagnostics-shared/src/html.ts b/packages-private/diagnostics-shared/src/html.ts new file mode 100644 index 0000000000..907c089656 --- /dev/null +++ b/packages-private/diagnostics-shared/src/html.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { escapeHtml } from './format'; + +/** + * エスケープ済み、あるいは意図的にエスケープしない生のHTML断片。 + * + * ただの文字列と型で区別するためだけの存在ではなく、`html` が実行時に + * 「この値はもうエスケープしなくてよい」と判定するためのマーカーでもある。 + * 型だけのブランドにすると実行時に判定できず、結局エスケープ漏れを防げない。 + */ +export class Raw { + constructor(private readonly value: string) {} + + toString() { + return this.value; + } +} + +/** + * 文字列をエスケープせずそのまま埋め込む。 + * 呼び出しが差分に残るので、レビューで「なぜ生で入れてよいのか」を確認できる。 + */ +export function raw(value: string) { + return new Raw(value); +} + +function interpolate(value: unknown): string { + if (value instanceof Raw) return value.toString(); + // 配列をそのまま文字列化するとカンマ区切りで潰れるので、要素ごとに処理する + if (Array.isArray(value)) return value.map(interpolate).join(''); + if (value == null) return ''; + return escapeHtml(value); +} + +/** + * HTMLを組み立てるタグ付きテンプレート。補間値は既定でエスケープされる。 + * エスケープしたくない場合は `raw()` で包む必要があるため、 + * 「うっかり生のまま埋め込む」ことが起きない。 + */ +export function html(strings: TemplateStringsArray, ...values: unknown[]) { + let result = strings[0]; + for (let i = 0; i < values.length; i++) { + result += interpolate(values[i]) + strings[i + 1]; + } + return new Raw(result); +} + +/** + * 断片を指定の区切り文字で連結する。 + * `html` の配列補間は区切り無しで繋ぐので、改行などを挟みたいときはこちらを使う。 + */ +export function joinHtml(parts: readonly Raw[], separator: string) { + return new Raw(parts.map(part => part.toString()).join(separator)); +} diff --git a/packages-private/diagnostics-shared/src/stats.ts b/packages-private/diagnostics-shared/src/stats.ts new file mode 100644 index 0000000000..7516242c32 --- /dev/null +++ b/packages-private/diagnostics-shared/src/stats.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function median(values: number[]) { + const sorted = values.toSorted((a, b) => a - b); + const center = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) return sorted[center]; + return Math.round((sorted[center - 1] + sorted[center]) / 2); +} + +export function mad(values: number[]) { + if (values.length < 2) throw new Error('Not enough samples to calculate MAD'); + + const center = median(values); + return median(values.map(value => Math.abs(value - center))); +} + +/** + * 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。 + */ +export function finiteMedian(values: (number | null | undefined)[]): number | null; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number; +export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length === 0) return defaultValue; + return median(finiteValues); +} + +/** + * サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。 + */ +export function sampleSpread(values: (number | null | undefined)[]) { + const finiteValues = values.filter(value => Number.isFinite(value)) as number[]; + if (finiteValues.length < 2) return null; + return mad(finiteValues); +} + +type RoundedSample = { round: number }; + +function indexByRound(samples: T[]) { + const samplesByRound = new Map(); + for (const sample of samples) { + // 負のroundはwarmupを表すため対象外 + if (sample.round <= 0) continue; + samplesByRound.set(sample.round, sample); + } + return samplesByRound; +} + +/** + * base / head を同じroundどうしで突き合わせ、その差分の分布を要約する。 + * 実行順による揺らぎの影響を抑えるため、単純な集計値どうしの引き算ではなくペア差分を使う。 + */ +export function pairedDeltaSummary(baseSamples: T[], headSamples: T[], getValue: (sample: T) => number | null | undefined) { + const baseSamplesByRound = indexByRound(baseSamples); + const headSamplesByRound = indexByRound(headSamples); + const values: number[] = []; + + for (const [round, baseSample] of baseSamplesByRound) { + const headSample = headSamplesByRound.get(round); + if (headSample == null) continue; + + const baseValue = getValue(baseSample); + const headValue = getValue(headSample); + if (baseValue == null || headValue == null) continue; + + values.push(headValue - baseValue); + } + + // 対応するroundが1つも無いと中央値も最小/最大も定義できない。 + // 静かにNaNやInfinityをレポートに載せるより、比較が成立していないと分かる形で落とす + if (values.length === 0) throw new Error('No paired samples to compare: base and head have no rounds in common'); + + return { + median: median(values), + // 1サンプルでは中央値からの偏差が常に0になる (mad() は統計として無意味なので拒否する) + mad: values.length < 2 ? 0 : mad(values), + min: Math.min(...values), + max: Math.max(...values), + samples: values.length, + }; +} diff --git a/packages-private/diagnostics-shared/test/format.test.ts b/packages-private/diagnostics-shared/test/format.test.ts new file mode 100644 index 0000000000..e391d86283 --- /dev/null +++ b/packages-private/diagnostics-shared/test/format.test.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { + calcAndFormatDeltaPercent, + calcAndFormatDeltaPercentInMdTable, + escapeHtml, + escapeLatex, + escapeMdTableCell, + formatBytes, + formatColoredDelta, + formatKiBAsMb, + formatMs, + formatNumber, +} from '../src/format'; + +describe('formatBytes', () => { + // 1024ではなく1000区切り。単位が2桁以上なら小数を落とす + test.each([ + [0, '0 B'], + [999, '999 B'], + [1_000, '1 KB'], + [1_500, '1.5 KB'], + [15_000, '15 KB'], + [1_234_567, '1.2 MB'], + [12_345_678, '12 MB'], + [1_500_000_000, '1.5 GB'], + [1_500_000_000_000, '1,500 GB'], + ])('formats %i as %s', (input, expected) => { + expect(formatBytes(input)).toBe(expected); + }); +}); + +describe('formatColoredDelta', () => { + test('leaves zero uncoloured and unsigned', () => { + expect(formatColoredDelta(0, formatNumber)).toBe('0'); + }); + + test('colours growth orange and shrinkage green', () => { + expect(formatColoredDelta(5, formatNumber)).toBe('$\\color{orange}{\\text{+5}}$'); + expect(formatColoredDelta(-5, formatNumber)).toBe('$\\color{green}{\\text{-5}}$'); + }); + + test('omits colour below the threshold but keeps the sign', () => { + expect(formatColoredDelta(5, formatNumber, 10)).toBe('$\\text{+5}$'); + }); +}); + +describe('escaping', () => { + test('escapeHtml covers the five metacharacters', () => { + expect(escapeHtml(`&<>"'`)).toBe('&<>"''); + }); + + test('escapeHtml maps nullish to an empty string', () => { + expect(escapeHtml(null)).toBe(''); + expect(escapeHtml(undefined)).toBe(''); + }); + + test('escapeLatex escapes braces and percent', () => { + expect(escapeLatex('100% {x}')).toBe('100\\% \\{x\\}'); + }); + + test('escapeMdTableCell keeps pipes and newlines from breaking the table', () => { + expect(escapeMdTableCell('a|b\nc')).toBe('a\\|b
c'); + }); +}); + +describe('percent helpers', () => { + // before が0だと変化率そのものが定義できない + test('returns a placeholder when the baseline is zero or missing', () => { + expect(calcAndFormatDeltaPercent(0, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(null, 10)).toBe('-'); + expect(calcAndFormatDeltaPercent(10, null)).toBe('-'); + }); + + // 0になったのは「消えた」という有効な結果なので、隠さず -100% として出す + test('formats a drop to zero as -100%', () => { + expect(calcAndFormatDeltaPercent(10, 0)).toBe('$\\color{green}{\\text{-100\\%}}$'); + }); + + // Markdownのテーブルセル内ではLaTeXの \% がさらに食われるため二重にする + test('doubles the percent escape for markdown table cells', () => { + expect(calcAndFormatDeltaPercentInMdTable(100, 110)).toContain('\\\\%'); + expect(calcAndFormatDeltaPercent(100, 110)).not.toContain('\\\\%'); + }); +}); + +describe('nullish handling', () => { + test('formatKiBAsMb and formatMs render a dash for missing values', () => { + expect(formatKiBAsMb(null)).toBe('-'); + expect(formatKiBAsMb(Number.NaN)).toBe('-'); + expect(formatMs(undefined)).toBe('-'); + }); + + test('formatMs switches to seconds past 1000ms', () => { + expect(formatMs(999)).toBe('999 ms'); + expect(formatMs(1_500)).toBe('1.5 s'); + }); +}); diff --git a/packages-private/diagnostics-shared/test/html.test.ts b/packages-private/diagnostics-shared/test/html.test.ts new file mode 100644 index 0000000000..6e9dbc48b0 --- /dev/null +++ b/packages-private/diagnostics-shared/test/html.test.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { html, joinHtml, raw, Raw } from '../src/html'; + +describe('html', () => { + test('escapes interpolated values by default', () => { + const value = ''; + expect(String(html`

${value}

`)).toBe('

<script>alert(1)</script>

'); + }); + + test('escapes values used in attribute position', () => { + const url = 'x">'; + expect(String(html``)).toBe(''); + }); + + test('leaves the static parts of the template untouched', () => { + expect(String(html`

x

`)).toBe('

x

'); + }); + + test('renders nullish values as an empty string', () => { + expect(String(html`

${null}${undefined}

`)).toBe('

'); + }); + + test('does not double-escape nested fragments', () => { + const inner = html`${'a&b'}`; + expect(String(html`

${inner}

`)).toBe('

a&b

'); + }); + + // 配列をそのまま文字列化するとカンマ区切りで潰れる。実際に過去これで表が壊れた + test('joins arrays without separators instead of stringifying them', () => { + const items = [html`
  • 1
  • `, html`
  • 2
  • `]; + expect(String(html`
      ${items}
    `)).toBe('
    • 1
    • 2
    '); + }); + + test('escapes array elements that are not fragments', () => { + expect(String(html`

    ${['', '']}

    `)).toBe('

    <a><b>

    '); + }); +}); + +describe('raw', () => { + test('embeds trusted markup without escaping', () => { + expect(String(html``)).toBe(''); + }); + + test('produces a Raw fragment', () => { + expect(raw('x')).toBeInstanceOf(Raw); + expect(html`x`).toBeInstanceOf(Raw); + }); +}); + +describe('joinHtml', () => { + test('joins fragments with the given separator', () => { + expect(String(joinHtml([html`
  • 1
  • `, html`
  • 2
  • `], '\n'))).toBe('
  • 1
  • \n
  • 2
  • '); + }); + + test('returns an empty fragment for an empty list', () => { + expect(String(joinHtml([], '\n'))).toBe(''); + }); +}); diff --git a/packages-private/diagnostics-shared/test/stats.test.ts b/packages-private/diagnostics-shared/test/stats.test.ts new file mode 100644 index 0000000000..1d57f0d67d --- /dev/null +++ b/packages-private/diagnostics-shared/test/stats.test.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test } from 'vitest'; +import { finiteMedian, mad, median, pairedDeltaSummary, sampleSpread } from '../src/stats'; + +describe('median', () => { + test('takes the middle value of an odd-length sample', () => { + expect(median([3, 1, 2])).toBe(2); + }); + + // 偶数長では平均を整数に丸める。KiB単位の整数値を扱う前提のため + test('rounds the average of an even-length sample', () => { + expect(median([1, 2])).toBe(2); + expect(median([1, 4])).toBe(3); + }); +}); + +describe('mad', () => { + test('measures the median absolute deviation', () => { + expect(mad([1, 1, 1])).toBe(0); + expect(mad([1, 2, 3])).toBe(1); + }); + + test('refuses to compute from a single sample', () => { + expect(() => mad([1])).toThrow(); + }); +}); + +describe('finiteMedian', () => { + test('ignores non-finite entries', () => { + expect(finiteMedian([1, null, undefined, Number.NaN, 3])).toBe(2); + }); + + test('returns null by default when nothing is finite', () => { + expect(finiteMedian([null, undefined])).toBeNull(); + }); + + test('returns the supplied default when nothing is finite', () => { + expect(finiteMedian([null, undefined], 0)).toBe(0); + }); +}); + +describe('sampleSpread', () => { + test('needs at least two finite samples', () => { + expect(sampleSpread([1])).toBeNull(); + expect(sampleSpread([1, null])).toBeNull(); + expect(sampleSpread([1, 3])).toBe(1); + }); +}); + +describe('pairedDeltaSummary', () => { + const base = [ + { round: 1, value: 100 }, + { round: 2, value: 200 }, + { round: 3, value: 300 }, + ]; + + test('compares base and head within the same round', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 3, value: 320 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value)).toStrictEqual({ + median: 20, + mad: 10, + min: 10, + max: 30, + samples: 3, + }); + }); + + test('drops rounds that only one side has', () => { + const head = [ + { round: 1, value: 110 }, + { round: 2, value: 230 }, + { round: 9, value: 999 }, + ]; + + expect(pairedDeltaSummary(base, head, sample => sample.value).samples).toBe(2); + }); + + // 1サンプルでも中央値・最小・最大は定まる (偏差は常に0) + test('summarizes a single paired round without treating MAD as an error', () => { + expect(pairedDeltaSummary([base[0]], [{ round: 1, value: 130 }], sample => sample.value)).toStrictEqual({ + median: 30, + mad: 0, + min: 30, + max: 30, + samples: 1, + }); + }); + + test('fails loudly when no round is shared', () => { + expect(() => pairedDeltaSummary(base, [{ round: 9, value: 1 }], sample => sample.value)).toThrow(/no rounds in common/); + }); + + // 負のroundはwarmupを表すので集計に混ぜない + test('ignores warmup rounds', () => { + const warmupBase = [{ round: -1, value: 0 }, ...base]; + const warmupHead = [{ round: -1, value: 9999 }, { round: 1, value: 110 }, { round: 2, value: 230 }, { round: 3, value: 320 }]; + + expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3); + }); +}); diff --git a/packages-private/diagnostics-shared/tsconfig.json b/packages-private/diagnostics-shared/tsconfig.json new file mode 100644 index 0000000000..6672c40acb --- /dev/null +++ b/packages-private/diagnostics-shared/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [] +} diff --git a/packages/backend/scripts/measure-memory.mts b/packages/backend/scripts/measure-memory.mts deleted file mode 100644 index a95eb3075c..0000000000 --- a/packages/backend/scripts/measure-memory.mts +++ /dev/null @@ -1,303 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ChildProcess, fork } from 'node:child_process'; -import { dirname, join, resolve } from 'node:path'; -import { tmpdir } from 'node:os'; -import * as fs from 'node:fs/promises'; -import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN, type HeapSnapshotData } from '../../../.github/scripts/heap-snapshot-util.mts'; -import { measureMemoryUntilStable } from '../../../.github/scripts/memory-stability-util.mts'; - -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]; - if (rawValue == null || rawValue === '') return defaultValue; - if (!/^\d+$/.test(rawValue)) throw new Error(`${name} must be an integer`); - - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < min) throw new Error(`${name} must be >= ${min}`); - return value; -} - -function readBooleanEnv(name, defaultValue) { - const rawValue = process.env[name]; - if (rawValue == null || rawValue === '') return defaultValue; - if (rawValue === '1' || rawValue === 'true') return true; - if (rawValue === '0' || rawValue === 'false') return false; - throw new Error(`${name} must be one of: 1, 0, true, false`); -} - -const STARTUP_TIMEOUT = readIntegerEnv('MK_MEMORY_STARTUP_TIMEOUT_MS', 120000, 1); // Timeout for server startup -const IPC_TIMEOUT = readIntegerEnv('MK_MEMORY_IPC_TIMEOUT_MS', 30000, 1); // Timeout for IPC responses -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', 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; -const smapsRollupKeys = ['Pss', 'Shared_Clean', 'Shared_Dirty', 'Private_Clean', 'Private_Dirty', 'Swap', 'SwapPss'] as const; - -type GcMessage = 'gc ok' | 'gc unavailable'; -type RuntimeMemoryUsageMessage = { - type: 'memory usage'; - value: NodeJS.MemoryUsage; -}; -type HeapSnapshotMessage = { - type: 'heap snapshot'; - path?: string; -}; -type HeapSnapshotErrorMessage = { - type: 'heap snapshot error'; - message: string; -}; -type HeapSnapshotResponseMessage = HeapSnapshotMessage | HeapSnapshotErrorMessage; - -function parseMemoryFile(content: string, keys: KS, path: string): Record { - const result = {} as Record; - for (const _key of keys) { - const key = _key as KS[number]; - const match = content.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); - if (match) { - result[key] = parseInt(match[1], 10); - } else { - throw new Error(`Failed to parse ${key} from ${path}`); - } - } - return result; -} - -function bytesToKiB(value: number) { - return Math.round(value / 1024); -} - -async function getMemoryUsage(pid: number) { - const path = `/proc/${pid}/status`; - const status = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(status, procStatusKeys, path); -} - -async function getSmapsRollupMemoryUsage(pid: number) { - const path = `/proc/${pid}/smaps_rollup`; - const smapsRollup = await fs.readFile(path, 'utf-8'); - return parseMemoryFile(smapsRollup, smapsRollupKeys, path); -} - -function isRecord(value: unknown): value is Record { - return value != null && typeof value === 'object'; -} - -function isGcMessage(message: unknown): message is GcMessage { - return message === 'gc ok' || message === 'gc unavailable'; -} - -function isRuntimeMemoryUsageMessage(message: unknown): message is RuntimeMemoryUsageMessage { - return isRecord(message) && message.type === 'memory usage' && isRecord(message.value); -} - -function isHeapSnapshotResponseMessage(message: unknown): message is HeapSnapshotResponseMessage { - if (!isRecord(message)) return false; - if (message.type === 'heap snapshot') return true; - return message.type === 'heap snapshot error' && typeof message.message === 'string'; -} - -function waitForMessage(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout = IPC_TIMEOUT) { - return new Promise((resolve, reject) => { - const timer = globalThis.setTimeout(() => { - serverProcess.off('message', onMessage); - reject(new Error(`Timed out waiting for ${description}`)); - }, timeout); - - const onMessage = (message: unknown) => { - if (!predicate(message)) return; - globalThis.clearTimeout(timer); - serverProcess.off('message', onMessage); - resolve(message); - }; - - serverProcess.on('message', onMessage); - }); -} - -async function getRuntimeMemoryUsage(serverProcess: ChildProcess) { - const response = waitForMessage( - serverProcess, - isRuntimeMemoryUsageMessage, - 'memory usage', - ); - - serverProcess.send('memory usage'); - - const message = await response; - const memoryUsage = message.value; - - return { - HeapTotal: bytesToKiB(memoryUsage.heapTotal), - HeapUsed: bytesToKiB(memoryUsage.heapUsed), - External: bytesToKiB(memoryUsage.external), - ArrayBuffers: bytesToKiB(memoryUsage.arrayBuffers), - }; -} - -async function getHeapSnapshotStatistics(serverProcess: ChildProcess): Promise { - if (!HEAP_SNAPSHOT) return null; - - const snapshotPath = join(tmpdir(), `misskey-backend-heap-${process.pid}-${serverProcess.pid}-${Date.now()}.heapsnapshot`); - const response = waitForMessage( - serverProcess, - isHeapSnapshotResponseMessage, - 'heap snapshot', - HEAP_SNAPSHOT_TIMEOUT, - ); - - serverProcess.send({ - type: 'heap snapshot', - path: snapshotPath, - }); - - const message = await response; - if (message.type === 'heap snapshot error') { - throw new Error(`Failed to write heap snapshot: ${message.message}`); - } - - const writtenPath = typeof message.path === 'string' ? message.path : snapshotPath; - - try { - if (HEAP_SNAPSHOT_SAVE_PATH != null && HEAP_SNAPSHOT_SAVE_PATH !== '') { - await fs.mkdir(dirname(HEAP_SNAPSHOT_SAVE_PATH), { recursive: true }); - await fs.copyFile(writtenPath, HEAP_SNAPSHOT_SAVE_PATH); - } - - const snapshot = JSON.parse(await fs.readFile(writtenPath, 'utf-8')); - return analyzeHeapSnapshot(snapshot, { breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N }); - } finally { - await fs.unlink(writtenPath).catch(err => { - process.stderr.write(`Failed to delete heap snapshot ${writtenPath}: ${err.message}\n`); - }); - } -} - -async function getAllMemoryUsage(serverProcess: ChildProcess) { - const pid = serverProcess.pid!; - const stableSmapsRollup = await measureMemoryUntilStable( - () => getSmapsRollupMemoryUsage(pid), - ); - - return { - memoryUsage: { - ...await getMemoryUsage(pid), - ...stableSmapsRollup.memoryUsage, - ...await getRuntimeMemoryUsage(serverProcess), - }, - stability: stableSmapsRollup.stability, - }; -} - -async function measureMemory() { - const serverProcess = fork(join(backendDir, 'built/entry.js'), [], { - cwd: backendDir, - env: { - ...process.env, - NODE_ENV: 'production', - MK_DISABLE_CLUSTERING: '1', - MK_ONLY_SERVER: '1', - MK_NO_DAEMONS: '1', - }, - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - execArgv: [...process.execArgv, '--expose-gc'], - }); - - const serverReady = waitForMessage( - serverProcess, - (message): message is 'ok' => message === 'ok', - 'server startup', - STARTUP_TIMEOUT, - ); - - serverProcess.stdout?.on('data', (data) => { - process.stderr.write(`[server stdout] ${data}`); - }); - - serverProcess.stderr?.on('data', (data) => { - process.stderr.write(`[server stderr] ${data}`); - }); - - serverProcess.on('error', (err) => { - process.stderr.write(`[server error] ${err}\n`); - }); - - async function triggerGc() { - const ok = waitForMessage( - serverProcess, - isGcMessage, - 'GC completion', - ); - - serverProcess.send('gc'); - - const message = await ok; - if (message === 'gc unavailable') { - throw new Error('GC is unavailable. Start the process with --expose-gc to enable this feature.'); - } - } - - const startupStartTime = Date.now(); - try { - await serverReady; - } catch (err) { - serverProcess.kill('SIGTERM'); - throw err; - } - - const startupTime = Date.now() - startupStartTime; - process.stderr.write(`Server started in ${startupTime}ms\n`); - - await triggerGc(); - - 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); - - 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; - - return { - timestamp: new Date().toISOString(), - phases: { - afterGc: { - memoryUsage: afterGc.memoryUsage, - memoryStability: afterGc.stability, - heapSnapshot: heapSnapshotAfterGc, - }, - }, - }; -} - -export type MemorySample = Awaited>; - -async function main() { - console.log(JSON.stringify(await measureMemory(), null, 2)); -} - -main().catch((err) => { - console.error(JSON.stringify({ - error: err.message, - timestamp: new Date().toISOString(), - })); - process.exit(1); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f82d6b63f..53a344d0e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,126 @@ importers: specifier: npm:@typescript/typescript6@6.0.2 version: '@typescript/typescript6@6.0.2' + packages-private/changelog-checker: + devDependencies: + '@types/mdast': + specifier: 4.0.4 + version: 4.0.4 + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + mdast-util-to-string: + specifier: 4.0.0 + version: 4.0.0 + remark: + specifier: 15.0.1 + version: 15.0.1 + remark-parse: + specifier: 11.0.0 + version: 11.0.0 + tsx: + specifier: 4.23.1 + version: 4.23.1 + unified: + specifier: 11.0.5 + version: 11.0.5 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-backend: + dependencies: + diagnostics-shared: + specifier: workspace:* + version: link:../diagnostics-shared + execa: + specifier: 9.6.1 + version: 9.6.1 + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@types/pg': + specifier: 8.20.0 + version: 8.20.0 + ioredis: + specifier: 5.11.1 + version: 5.11.1 + pg: + specifier: 8.22.0 + version: 8.22.0 + tsx: + specifier: 4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-frontend-browser: + dependencies: + diagnostics-shared: + specifier: workspace:* + version: link:../diagnostics-shared + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + frontend: + specifier: workspace:* + version: link:../../packages/frontend + playwright: + specifier: 1.61.1 + version: 1.61.1 + tsx: + specifier: 4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-frontend-bundle: + dependencies: + diagnostics-shared: + specifier: workspace:* + version: link:../diagnostics-shared + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + tsx: + specifier: 4.23.1 + version: 4.23.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 8.1.4 + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + + packages-private/diagnostics-shared: + devDependencies: + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + packages/backend: dependencies: '@aws-sdk/client-s3': @@ -504,10 +624,10 @@ importers: version: 5.1.0 vite: specifier: 8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) vitest-mock-extended: specifier: 4.0.0 version: 4.0.0(@typescript/typescript6@6.0.2)(vitest@4.1.10) @@ -944,7 +1064,7 @@ importers: version: 1.1.5 vite: specifier: 8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) devDependencies: '@types/estree': specifier: 1.0.9 @@ -1263,7 +1383,7 @@ importers: version: 0.33.0 vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) vitest-websocket-mock: specifier: 0.7.0 version: 0.7.0(vitest@4.1.10) @@ -7772,6 +7892,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + rename@1.0.4: resolution: {integrity: sha512-YMM6Fn3lrFOCjhORKjj+z/yizj8WSzv3F3YUlpJA20fteWCb0HbJU19nvuRBPUM5dWgxJcHP+kix3M+5NowJyA==} @@ -8674,6 +8797,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + tsyringe@4.10.0: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} @@ -12239,13 +12367,27 @@ snapshots: vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) vue: 3.5.39(typescript@6.0.2) - '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: - '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) playwright: 1.61.1 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) transitivePeerDependencies: - bufferutil - msw @@ -12266,16 +12408,48 @@ snapshots: - utf-8-validate - vite - '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + playwright: 1.61.1 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -12301,6 +12475,24 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -12313,9 +12505,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) optionalDependencies: - '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10) + '@vitest/browser': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/expect@2.0.5': dependencies: @@ -12341,14 +12533,23 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))': + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2) - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@26.1.1)(typescript@5.9.3) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))': dependencies: @@ -12359,6 +12560,15 @@ snapshots: msw: 2.15.0(@types/node@26.1.1)(typescript@6.0.2) vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@26.1.1)(typescript@7.0.2) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -15563,6 +15773,32 @@ snapshots: - '@types/node' optional: true + msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.0.12(@types/node@26.1.1) + '@mswjs/interceptors': 0.41.8 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.15.0(@types/node@26.1.1)(typescript@6.0.2): dependencies: '@inquirer/confirm': 6.0.12(@types/node@26.1.1) @@ -15588,6 +15824,32 @@ snapshots: transitivePeerDependencies: - '@types/node' + msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2): + dependencies: + '@inquirer/confirm': 6.0.12(@types/node@26.1.1) + '@mswjs/interceptors': 0.41.8 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.0 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - '@types/node' + optional: true + muggle-string@0.4.1: {} multer@2.2.0: @@ -16494,6 +16756,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + rename@1.0.4: dependencies: debug: 2.6.9 @@ -17482,6 +17753,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + tsyringe@4.10.0: dependencies: tslib: 1.14.1 @@ -17773,6 +18050,22 @@ snapshots: terser: 5.48.0 tsx: 4.23.0 + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.18 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + sass: 1.100.0 + sass-embedded: 1.100.0 + terser: 5.48.0 + tsx: 4.23.1 + vitest-fetch-mock@0.4.5(vitest@4.1.10): dependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) @@ -17781,17 +18074,17 @@ snapshots: dependencies: ts-essentials: 10.2.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) vitest-websocket-mock@0.7.0(vitest@4.1.10): dependencies: mock-socket: 9.3.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0)) + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -17808,12 +18101,43 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 26.1.1 - '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.0))(vitest@4.1.10) + '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(@typescript/typescript6@6.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + happy-dom: 20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - msw + + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.1 + '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) happy-dom: 20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -17850,6 +18174,37 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 26.1.1 + '@vitest/browser-playwright': 4.1.10(bufferutil@4.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@7.0.2))(playwright@1.61.1)(utf-8-validate@6.0.6)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + happy-dom: 20.10.6(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b6b2b67cf5..c280df00f2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,7 @@ packages: - packages/misskey-js/generator - packages/misskey-reversi - packages/misskey-bubble-game + - packages-private/* allowBuilds: '@nestjs/core': true '@parcel/watcher': true diff --git a/scripts/changelog-checker/.gitignore b/scripts/changelog-checker/.gitignore deleted file mode 100644 index 882936f9bd..0000000000 --- a/scripts/changelog-checker/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -coverage -.idea \ No newline at end of file diff --git a/scripts/changelog-checker/package-lock.json b/scripts/changelog-checker/package-lock.json deleted file mode 100644 index f2f48d92f7..0000000000 --- a/scripts/changelog-checker/package-lock.json +++ /dev/null @@ -1,2773 +0,0 @@ -{ - "name": "changelog-checker", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "changelog-checker", - "version": "1.0.0", - "devDependencies": { - "@types/mdast": "4.0.4", - "@types/node": "26.1.1", - "@vitest/coverage-v8": "4.1.10", - "mdast-util-to-string": "4.0.0", - "remark": "15.0.1", - "remark-parse": "11.0.0", - "typescript": "7.0.2", - "unified": "11.0.5", - "vite": "8.1.4", - "vite-node": "6.0.0", - "vitest": "4.1.10" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", - "dev": true - }, - "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==", - "dev": true - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/cac": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz", - "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dev": true, - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", - "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.0.0.tgz", - "integrity": "sha512-xadSsJayQIucJ9n053dfQwVu1kuXg7jCTdYsMK8rqzKZh52nLfSH/k0sAxE0u+pj/zKZX+o5wB+ML5mRayOxFA==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", - "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", - "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz", - "integrity": "sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", - "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", - "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", - "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", - "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.0.1.tgz", - "integrity": "sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", - "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", - "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", - "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", - "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", - "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", - "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", - "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", - "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz", - "integrity": "sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/remark": { - "version": "15.0.1", - "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", - "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dev": true, - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/trough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.1.0.tgz", - "integrity": "sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", - "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-6.0.0.tgz", - "integrity": "sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^7.0.0", - "es-module-lexer": "^2.0.0", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "vite": "^8.0.0" - }, - "bin": { - "vite-node": "dist/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://opencollective.com/antfu" - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/scripts/changelog-checker/package.json b/scripts/changelog-checker/package.json deleted file mode 100644 index f557a8c072..0000000000 --- a/scripts/changelog-checker/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "changelog-checker", - "version": "1.0.0", - "description": "", - "type": "module", - "scripts": { - "run": "vite-node src/index.ts", - "test": "vitest run", - "test:coverage": "vitest run --coverage" - }, - "devDependencies": { - "@types/mdast": "4.0.4", - "@types/node": "26.1.1", - "@vitest/coverage-v8": "4.1.10", - "mdast-util-to-string": "4.0.0", - "remark": "15.0.1", - "remark-parse": "11.0.0", - "typescript": "7.0.2", - "unified": "11.0.5", - "vite": "8.1.4", - "vite-node": "6.0.0", - "vitest": "4.1.10" - } -} diff --git a/scripts/changelog-checker/vite.config.ts b/scripts/changelog-checker/vite.config.ts deleted file mode 100644 index 46db02c806..0000000000 --- a/scripts/changelog-checker/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {defineConfig} from 'vite'; - - -const config = defineConfig({}); - -export default config;