mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-21 19:24:43 +02:00
Compare commits
63 Commits
2026.7.0-b
...
feature/ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d1510abc | ||
|
|
98ae62c432 | ||
|
|
41e730a763 | ||
|
|
41b11902a8 | ||
|
|
b51d5ba0a0 | ||
|
|
159b1a4484 | ||
|
|
f138a48419 | ||
|
|
1dcd6c287c | ||
|
|
ccd04fbcd9 | ||
|
|
b2d07ca6e3 | ||
|
|
ab369784fb | ||
|
|
7157f37011 | ||
|
|
bbd57c751c | ||
|
|
36c442a7b4 | ||
|
|
1cdfdfac31 | ||
|
|
981d327bf3 | ||
|
|
41cd2eff0c | ||
|
|
1b38954fcd | ||
|
|
bd6123ed5c | ||
|
|
3923e39810 | ||
|
|
1c61d7da51 | ||
|
|
a5b858b45b | ||
|
|
1cdd177478 | ||
|
|
0623cabc81 | ||
|
|
7fbb4828ce | ||
|
|
a4d2273682 | ||
|
|
25090b6bc0 | ||
|
|
4d987e8843 | ||
|
|
87b88d7347 | ||
|
|
eaac254498 | ||
|
|
3df1f0e780 | ||
|
|
23ca8b2286 | ||
|
|
f95f5691d3 | ||
|
|
eeae7a3c16 | ||
|
|
398f9a6b9b | ||
|
|
28044973e7 | ||
|
|
25aae46c59 | ||
|
|
9cc4c7d46b | ||
|
|
dfc313cadb | ||
|
|
17fa6cd13a | ||
|
|
91db5ad709 | ||
|
|
8f35f576e0 | ||
|
|
fcd0f0c8d2 | ||
|
|
da35321f28 | ||
|
|
640ddb2a58 | ||
|
|
ab7e9770b1 | ||
|
|
6f1c91d9c7 | ||
|
|
510952f861 | ||
|
|
e90f664922 | ||
|
|
4cca0c220e | ||
|
|
be9762c169 | ||
|
|
c1bd11eada | ||
|
|
2debf09bf2 | ||
|
|
a4a3606435 | ||
|
|
9f3d80a97a | ||
|
|
e57e88ebc6 | ||
|
|
d490936bf6 | ||
|
|
63a8520191 | ||
|
|
f88e647a83 | ||
|
|
4263f62e7b | ||
|
|
d0b8940723 | ||
|
|
f8e28df711 | ||
|
|
19605600d3 |
@@ -15,3 +15,14 @@ reviews:
|
||||
- "dependabot[bot]"
|
||||
- "renovate[bot]"
|
||||
- "github-actions[bot]"
|
||||
path_instructions:
|
||||
- path: "**/*"
|
||||
instructions: |
|
||||
【レビュー対象外】
|
||||
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別Workflow(CI/CD)でチェックされるため、レビュー対象外として無視してください。
|
||||
|
||||
【注力してほしい観点】
|
||||
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
|
||||
path_filters:
|
||||
- "!CHANGELOG.md"
|
||||
- "!**/__snapshots__/**"
|
||||
|
||||
239
.github/scripts/backend-diagnostics.inspect.mts
vendored
239
.github/scripts/backend-diagnostics.inspect.mts
vendored
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* 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';
|
||||
|
||||
const phases = ['afterGc'] as const;
|
||||
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
aggregation: string;
|
||||
summary: Record<typeof phases[number], {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: heapSnapshotUtil.HeapSnapshotData;
|
||||
}>;
|
||||
samples: (MemorySample & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
|
||||
const [baseDirArg, headDirArg, baseOutputArg, headOutputArg] = process.argv.slice(2);
|
||||
|
||||
const HEAP_SNAPSHOT_BREAKDOWN_TOP_N = util.readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', heapSnapshotUtil.defaultHeapSnapshotBreakdownTopN, 1);
|
||||
const heapSnapshotLabels = ['base', 'head'] as const;
|
||||
const HEAP_SNAPSHOT_WORK_DIRS = {
|
||||
base: resolve('base-heap-snapshots'),
|
||||
head: resolve('head-heap-snapshots'),
|
||||
};
|
||||
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
|
||||
base: resolve('base-heap-snapshot.heapsnapshot'),
|
||||
head: resolve('head-heap-snapshot.heapsnapshot'),
|
||||
};
|
||||
// Use the head checkout's measurement harness for both targets so only the built backend differs.
|
||||
const MEASURE_MEMORY_SCRIPT = resolve(import.meta.dirname, '../../packages/backend/scripts/measure-memory.mts');
|
||||
|
||||
async function resetState(repoDir: string) {
|
||||
const require = createRequire(join(repoDir, 'packages/backend/package.json'));
|
||||
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']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of phases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = util.median(values);
|
||||
}
|
||||
|
||||
const heapSnapshot = heapSnapshotUtil.summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.phases[phase].heapSnapshot,
|
||||
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
|
||||
);
|
||||
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState(repoDir);
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
await util.run('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
env: process.env,
|
||||
logStdout: true,
|
||||
});
|
||||
|
||||
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 JSON.parse(stdout) as MemorySample;
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: typeof heapSnapshotLabels[number], 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;
|
||||
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;
|
||||
if (total == null || !Number.isFinite(total)) continue;
|
||||
|
||||
const distance = Math.abs(total - medianTotal);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
|
||||
selected = {
|
||||
round: sample.round,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.round ?? null;
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeapSnapshot(label: typeof heapSnapshotLabels[number], samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const round = selectRepresentativeHeapSnapshotRound(samples, summary);
|
||||
if (round == null) return;
|
||||
|
||||
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
|
||||
process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\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);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true });
|
||||
}
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await genSample(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
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);
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = {
|
||||
base: summarizeSamples(reports.base.samples),
|
||||
head: summarizeSamples(reports.head.samples),
|
||||
};
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
|
||||
}
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summaries[label],
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? baseOutput : headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
183
.github/scripts/backend-diagnostics.render-md.mts
vendored
183
.github/scripts/backend-diagnostics.render-md.mts
vendored
@@ -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)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
|
||||
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatMemoryMb(baseValue)} <br> ± ${formatMemoryMb(baseSpread)} | ${formatMemoryMb(headValue)} <br> ± ${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`);
|
||||
570
.github/scripts/chrome.mts
vendored
570
.github/scripts/chrome.mts
vendored
@@ -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<string, string>;
|
||||
requestBody?: string;
|
||||
hasRequestBody: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
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<string, string>;
|
||||
handshakeResponseStatus?: number;
|
||||
handshakeResponseStatusText?: string;
|
||||
handshakeResponseHeaders?: Record<string, string>;
|
||||
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<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
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<string, number>;
|
||||
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<string, unknown> | undefined) {
|
||||
if (headers == null) return undefined;
|
||||
const normalized = {} as Record<string, string>;
|
||||
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<string, number | undefined>,
|
||||
};
|
||||
private readonly browser: Browser;
|
||||
private readonly context: BrowserContext;
|
||||
public readonly page: Page;
|
||||
private readonly cdp: CDPSession;
|
||||
private pendingNetworkDetailReads: Promise<void>[] = [];
|
||||
|
||||
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<HeadlessChromeController> {
|
||||
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<T>(label: string, options: PlaywrightBrowserOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
|
||||
const browser = await HeadlessChromeController.create(label, options);
|
||||
try {
|
||||
return await callback(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async enableNetworkTracking() {
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const webSockets = new Map<string, WebSocketConnection>();
|
||||
|
||||
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<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
return await Promise.race([
|
||||
this.page.evaluate(expression),
|
||||
new Promise<never>((_, 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<BrowserMeasurement['performance']> {
|
||||
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<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
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<TabMemory> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.performance.cdpMetrics)) {
|
||||
cdpMetricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cdpMetrics = {} as Record<string, number>;
|
||||
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<typeof util.startServer> | 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();
|
||||
@@ -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<string, NetworkRequest[]>();
|
||||
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<string, string> | undefined) {
|
||||
if (headers == null || Object.keys(headers).length === 0) return null;
|
||||
return JSON.stringify(headers, null, '\t');
|
||||
}
|
||||
|
||||
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
|
||||
const counts = new Map<T, number>();
|
||||
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]) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(type)}</td>
|
||||
<td class="num">${util.formatNumber(count)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
return `
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>${util.formatNumber(base.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>${util.formatNumber(head.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">${util.formatNumber(added)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">${util.formatNumber(removed)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
${typeRows === '' ? '' : `
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>${typeRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`}`;
|
||||
}
|
||||
|
||||
function renderDetails(title: string, content: string | null, open = false) {
|
||||
if (content == null || content === '') return '';
|
||||
return `
|
||||
<details${open ? ' open' : ''}>
|
||||
<summary>${escapeHtml(title)}</summary>
|
||||
<pre>${escapeHtml(content)}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
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
|
||||
? '<p class="empty">Request body was present but could not be retrieved from CDP.</p>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="request ${diff.direction}">
|
||||
<header>
|
||||
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
|
||||
<span class="method">${escapeHtml(request.method)}</span>
|
||||
<span class="type">${escapeHtml(request.resourceType)}</span>
|
||||
<span class="status">${escapeHtml(request.status ?? '-')}</span>
|
||||
</header>
|
||||
<a class="url" href="${escapeAttribute(request.url)}">${escapeHtml(request.url)}</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>${util.formatNumber(diff.round)}</dd></div>
|
||||
<div><dt>Base count</dt><dd>${util.formatNumber(diff.baseCount)}</dd></div>
|
||||
<div><dt>Head count</dt><dd>${util.formatNumber(diff.headCount)}</dd></div>
|
||||
<div><dt>Encoded</dt><dd>${util.formatBytes(request.encodedDataLength ?? 0)}</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>${util.formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
|
||||
<div><dt>MIME</dt><dd>${escapeHtml(request.mimeType ?? '-')}</dd></div>
|
||||
<div><dt>Protocol</dt><dd>${escapeHtml(request.protocol ?? '-')}</dd></div>
|
||||
<div><dt>Remote</dt><dd>${escapeHtml(request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`)}</dd></div>
|
||||
<div><dt>Failed</dt><dd>${request.failed ? escapeHtml(request.errorText ?? 'yes') : 'no'}</dd></div>
|
||||
</dl>
|
||||
${bodyNote}
|
||||
${renderDetails('Request body', requestBody, requestBody != null)}
|
||||
${renderDetails('Request headers', requestHeaders)}
|
||||
${renderDetails('Response headers', responseHeaders)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<section>
|
||||
<h2>Round ${util.formatNumber(round)}</h2>
|
||||
<p>${util.formatNumber(added)} added, ${util.formatNumber(removed)} removed</p>
|
||||
<div class="requests">
|
||||
${diffs.map(renderRequest).join('\n')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
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
|
||||
? '<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>'
|
||||
: rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))).join('\n');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
: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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">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.</p>
|
||||
${renderSummary(base, head, diffs)}
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
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<string, number>;
|
||||
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)}<br>± ${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)}<br>${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 = [
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<tr>',
|
||||
'<th rowspan="2">Type</th>',
|
||||
'<th colspan="3">Requests</th>',
|
||||
'<th colspan="3">Encoded bytes</th>',
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'</tr>',
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
];
|
||||
|
||||
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('<tr>');
|
||||
lines.push(`<td><b>${key}</b></td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(baseRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatNumber(headRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatColoredDelta(headRow.requests - baseRow.requests, util.formatNumber)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(baseRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatBytes(headRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${util.formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)}</td>`);
|
||||
lines.push('</tr>');
|
||||
}
|
||||
|
||||
lines.push('</tbody>');
|
||||
lines.push('</table>');
|
||||
|
||||
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),
|
||||
'',
|
||||
'<i>Only metrics showing significant changes are displayed.</i>',
|
||||
'',
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
|
||||
'<details>',
|
||||
'<summary>Requests by resource type</summary>',
|
||||
'',
|
||||
renderResourceTypeTable(base, head),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>V8 heap snapshot statistics</summary>',
|
||||
'',
|
||||
heapSnapshotTable ?? '_No V8 heap snapshot data._',
|
||||
'',
|
||||
//heapSnapshotUtil.renderHeapSnapshotSankey(toHeapSnapshotReport(head), 'Head'),
|
||||
//'',
|
||||
`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`,
|
||||
'</details>',
|
||||
'',
|
||||
];
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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<string, { file?: string; src?: string; name?: string; isEntry?: boolean; imports?: string[] }>;
|
||||
|
||||
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<string, FileEntry>;
|
||||
chunksByManifestKey: Record<string, FileEntry>;
|
||||
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<string>();
|
||||
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<CollectedReport> {
|
||||
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<string, FileEntry>();
|
||||
const comparableChunks = new Map<string, FileEntry>();
|
||||
const chunksByManifestKey = new Map<string, FileEntry>();
|
||||
|
||||
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<string>();
|
||||
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<string, {
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
nodeMetas?: Record<string, {
|
||||
id: string;
|
||||
isEntry?: boolean;
|
||||
isExternal?: boolean;
|
||||
importedBy?: string[];
|
||||
imported?: { id: string; dynamic?: boolean }[];
|
||||
moduleParts?: Record<string, string>;
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
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<typeof collectVisualizerReport>, after: ReturnType<typeof collectVisualizerReport>) {
|
||||
const summary = [
|
||||
'bundles',
|
||||
'modules',
|
||||
'entries',
|
||||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
] as const;
|
||||
|
||||
return [
|
||||
`<table>`,
|
||||
`<thead>`,
|
||||
`<tr>`,
|
||||
`<th rowspan="2"></th>`,
|
||||
`<th rowspan="2">Bundles</th>`,
|
||||
`<th rowspan="2">Modules</th>`,
|
||||
`<th rowspan="2">Entries</th>`,
|
||||
`<th colspan="2">Imports</th>`,
|
||||
`<th colspan="3">Size</th>`,
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th>Static</th>`,
|
||||
`<th>Dynamic</th>`,
|
||||
`<th>Rendered</th>`,
|
||||
`<th>Gzip</th>`,
|
||||
`<th>Brotli</th>`,
|
||||
`</tr>`,
|
||||
`</thead>`,
|
||||
`<tbody>`,
|
||||
`<tr>`,
|
||||
`<th><b>Before</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(before.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(before.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>After</b></th>`,
|
||||
...summary.map((key) => `<td>${util.formatNumber(after.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${util.formatBytes(after.metrics[key])}</td>`),
|
||||
`</tr>`,
|
||||
`<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</td>`),
|
||||
`</tr>`,
|
||||
`<tr>`,
|
||||
`<th><b>Δ (%)</b></th>`,
|
||||
...summary.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
|
||||
...metrics.map((key) => `<td>${util.calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
|
||||
`</tr>`,
|
||||
`</tbody>`,
|
||||
`</table>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getChunkComparisonRows(keys: string[], before: Record<string, FileEntry>, after: Record<string, FileEntry>) {
|
||||
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<typeof getChunkComparisonRows>[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<typeof getChunkComparisonRows>) {
|
||||
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<typeof summarizeChunkChanges>) {
|
||||
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<typeof getChunkComparisonRows>,
|
||||
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(`| <details><summary>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(chunkFile)}\` </details> | ${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(`| <details><summary>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(chunkFile)}\` </details> | ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${util.escapeMdTableCell(row.name)}\`</summary> \`${util.escapeMdTableCell(chunkFile)}\` </details> | ${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<ReturnType<typeof collectReport>>, after: Awaited<ReturnType<typeof collectReport>>) {
|
||||
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 [
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
|
||||
'',
|
||||
`_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].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);
|
||||
520
.github/scripts/heap-snapshot-util.mts
vendored
520
.github/scripts/heap-snapshot-util.mts
vendored
@@ -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<string, { label: string; color: string; colorHex: string }>;
|
||||
|
||||
export type HeapSnapshotData = {
|
||||
categories: Record<keyof typeof heapSnapshotCategory, number>;
|
||||
nodeCounts: Record<keyof typeof heapSnapshotCategory, number>;
|
||||
breakdowns?: Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
|
||||
};
|
||||
|
||||
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<string, number>, 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<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
|
||||
topN = defaultHeapSnapshotBreakdownTopN,
|
||||
) {
|
||||
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
|
||||
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<keyof typeof heapSnapshotCategory, Record<string, number>>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category !== 'total') breakdowns[category] = {};
|
||||
}
|
||||
|
||||
function addValue(map: Record<string, number>, key: string, value: number) {
|
||||
map[key] = (map[key] ?? 0) + value;
|
||||
}
|
||||
|
||||
const edgeStartIndexes = new Map<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
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<number>();
|
||||
|
||||
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<T>(
|
||||
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<HeapSnapshotData['breakdowns']>;
|
||||
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
|
||||
if (category === 'total') continue;
|
||||
|
||||
const childKeys = new Set<string>();
|
||||
for (const snapshot of data) {
|
||||
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
|
||||
childKeys.add(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
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)}<br>${util.formatDeltaPercent(percent, 0.1).replaceAll('\\%', '\\\\%')}`;
|
||||
const baseText = `${util.formatBytes(baseValue)} <br> ± ${util.formatBytes(baseSpread)}`;
|
||||
const headText = `${util.formatBytes(headValue)} <br> ± ${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 = `<details><summary>$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**</summary>${basePercent} → ${headPercent}</details>`;
|
||||
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<string, string>;
|
||||
for (const { category, childEntries } of categories) {
|
||||
const categoryColor = heapSnapshotCategory[category].colorHex;
|
||||
nodeColors[category] = categoryColor;
|
||||
|
||||
for (const [childName] of childEntries) {
|
||||
nodeColors[childName] = categoryColor;
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`<details><summary>${title} heap snapshot composition</summary>`,
|
||||
'',
|
||||
'```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('</details>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
311
.github/scripts/utility.mts
vendored
311
.github/scripts/utility.mts
vendored
@@ -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<T extends { round: number; }[]>(samples: T) {
|
||||
const samplesByRound = new Map<number, T[number]>();
|
||||
for (const sample of samples) {
|
||||
if (sample.round <= 0) continue;
|
||||
samplesByRound.set(sample.round, sample);
|
||||
}
|
||||
return samplesByRound;
|
||||
}
|
||||
|
||||
export function pairedDeltaSummary<T extends { round: number; }[]>(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<string> {
|
||||
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', '<br>');
|
||||
}
|
||||
|
||||
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<string>((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<string, unknown>) {
|
||||
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<void>(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();
|
||||
});
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,18 @@ jobs:
|
||||
working-directory: head
|
||||
run: pnpm build
|
||||
- name: Measure backend memory usage
|
||||
working-directory: head
|
||||
env:
|
||||
MK_MEMORY_COMPARE_ROUNDS: 10
|
||||
MK_MEMORY_COMPARE_ROUNDS: 15
|
||||
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
|
||||
MK_MEMORY_HEAP_SNAPSHOT_ROUNDS: 3
|
||||
# 計測ハーネスは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:
|
||||
|
||||
22
.github/workflows/changelog-check.yml
vendored
22
.github/workflows/changelog-check.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Frontend browser diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend browser diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend browser diagnostics report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-browser-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download browser metrics report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-browser-diagnostics
|
||||
path: ${{ runner.temp }}/frontend-browser-diagnostics
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Load PR number
|
||||
id: load-pr-number
|
||||
shell: bash
|
||||
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-diagnostics/pr-number.txt")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on pull request
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
|
||||
comment-tag: frontend-browser-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md
|
||||
@@ -1,210 +0,0 @@
|
||||
name: Frontend browser diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/backend/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/misskey/test.yml
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/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
|
||||
- .github/workflows/frontend-browser-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-browser-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-browser-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Measure frontend browser metrics
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
ports:
|
||||
- 54312:5432
|
||||
env:
|
||||
POSTGRES_DB: test-misskey
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: before
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: after
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
before/pnpm-lock.yaml
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure base
|
||||
working-directory: before
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build base
|
||||
working-directory: before
|
||||
run: pnpm build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure pull request
|
||||
working-directory: after
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build pull request
|
||||
working-directory: after
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: after/packages/frontend
|
||||
run: pnpm exec playwright install --with-deps --no-shell chromium
|
||||
|
||||
- name: Measure frontend browser metrics
|
||||
shell: bash
|
||||
env:
|
||||
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
|
||||
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-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"
|
||||
|
||||
- name: Upload browser base heap snapshot
|
||||
id: upload-browser-base-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: base-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload browser head heap snapshot
|
||||
id: upload-browser-head-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: head-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser detailed html
|
||||
shell: bash
|
||||
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"
|
||||
|
||||
- name: Upload browser detailed html
|
||||
id: upload-browser-detailed-html
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-detailed-html
|
||||
path: ${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-detailed-html.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser metrics report
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-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"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check browser metrics report
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-browser-diagnostics"
|
||||
test -s "$REPORT_DIR/frontend-browser-diagnostics.md"
|
||||
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
test -s "$REPORT_DIR/pr-number.txt"
|
||||
test -s "$REPORT_DIR/head-sha.txt"
|
||||
cat "$REPORT_DIR/frontend-browser-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload browser metrics report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-diagnostics
|
||||
path: |
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/before-browser.json
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/after-browser.json
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/frontend-browser-diagnostics.md
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/pr-number.txt
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/base-sha.txt
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/head-sha.txt
|
||||
${{ runner.temp }}/frontend-browser-diagnostics/pr-url.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Frontend bundle diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend bundle diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend bundle report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-bundle-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download bundle report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Load PR number
|
||||
id: load-pr-number
|
||||
shell: bash
|
||||
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-bundle-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on pull request
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
|
||||
comment-tag: frontend-bundle-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-diagnostics-report.md
|
||||
@@ -1,144 +0,0 @@
|
||||
name: Frontend bundle diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/scripts/utility.mts
|
||||
- .github/scripts/frontend-bundle-diagnostics.render-md.mts
|
||||
- .github/workflows/frontend-bundle-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-bundle-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-bundle-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Build frontend bundle report
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: before
|
||||
submodules: true
|
||||
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: after
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: after/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: after/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
before/pnpm-lock.yaml
|
||||
after/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for base
|
||||
working-directory: before
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Prepare report output
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-report"
|
||||
|
||||
- name: Build frontend report for base
|
||||
working-directory: before
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/before-stats.json
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Install dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Build frontend dependencies for pull request
|
||||
working-directory: after
|
||||
run: pnpm --filter "frontend^..." run build
|
||||
|
||||
- name: Build frontend report for pull request
|
||||
working-directory: after
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-report/after-stats.json
|
||||
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
run: pnpm --filter frontend run build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-visualizer
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/frontend-bundle-visualizer.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate report markdown
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_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"
|
||||
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
|
||||
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
|
||||
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check report
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-bundle-report"
|
||||
test -s "$REPORT_DIR/before-stats.json"
|
||||
test -s "$REPORT_DIR/after-stats.json"
|
||||
test -s "$REPORT_DIR/frontend-bundle-diagnostics-report.md"
|
||||
cat "$REPORT_DIR/frontend-bundle-diagnostics-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload bundle report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-report
|
||||
path: ${{ runner.temp }}/frontend-bundle-report/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
75
.github/workflows/frontend-diagnostics.comment.yml
vendored
Normal file
75
.github/workflows/frontend-diagnostics.comment.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Frontend diagnostics (comment)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Frontend diagnostics (inspect)
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment frontend diagnostics report
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: frontend-diagnostics-report-${{ github.event.workflow_run.id }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download frontend diagnostics report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-diagnostics
|
||||
path: ${{ runner.temp }}/frontend-diagnostics
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Resolve current pull request
|
||||
id: resolve-pr
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR_NUMBER_FILE: ${{ runner.temp }}/frontend-diagnostics/pr-number.txt
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs/promises');
|
||||
const { owner, repo } = context.repo;
|
||||
const workflowRun = context.payload.workflow_run;
|
||||
const pullRequestNumberText = (await fs.readFile(process.env.PR_NUMBER_FILE, 'utf8')).trim();
|
||||
if (!/^[1-9]\d*$/.test(pullRequestNumberText)) {
|
||||
throw new Error('Invalid pull request number in frontend diagnostics artifact.');
|
||||
}
|
||||
|
||||
const pullRequestNumber = Number(pullRequestNumberText);
|
||||
if (!Number.isSafeInteger(pullRequestNumber)) {
|
||||
throw new Error('Pull request number in frontend diagnostics artifact is not a safe integer.');
|
||||
}
|
||||
|
||||
const { data: currentPullRequest } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
|
||||
if (currentPullRequest.state !== 'open' || currentPullRequest.head.sha !== workflowRun.head_sha) {
|
||||
core.notice(`Skipping stale frontend diagnostics report for pull request #${pullRequestNumber}.`);
|
||||
core.setOutput('is-current', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('is-current', 'true');
|
||||
core.setOutput('pr-number', String(pullRequestNumber));
|
||||
|
||||
- name: Comment on pull request
|
||||
if: steps.resolve-pr.outputs.is-current == 'true'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
pr-number: ${{ steps.resolve-pr.outputs.pr-number }}
|
||||
comment-tag: frontend-diagnostics
|
||||
file-path: ${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
|
||||
246
.github/workflows/frontend-diagnostics.inspect.yml
vendored
Normal file
246
.github/workflows/frontend-diagnostics.inspect.yml
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
name: Frontend diagnostics (inspect)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- packages/frontend/**
|
||||
- packages/frontend-shared/**
|
||||
- packages/frontend-builder/**
|
||||
- packages/backend/**
|
||||
- packages/i18n/**
|
||||
- packages/icons-subsetter/**
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
- .node-version
|
||||
- .github/misskey/test.yml
|
||||
- packages-private/diagnostics-shared/**
|
||||
- packages-private/diagnostics-frontend/**
|
||||
- .github/workflows/frontend-diagnostics.inspect.yml
|
||||
- .github/workflows/frontend-diagnostics.comment.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-diagnostics-inspect-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Measure frontend diagnostics
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
ports:
|
||||
- 54312:5432
|
||||
env:
|
||||
POSTGRES_DB: test-misskey
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- 56312:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
submodules: true
|
||||
|
||||
- name: Checkout head
|
||||
uses: actions/checkout@v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: head
|
||||
submodules: true
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6.0.9
|
||||
with:
|
||||
package_json_file: head/package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version-file: head/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
base/pnpm-lock.yaml
|
||||
head/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare report output
|
||||
shell: bash
|
||||
run: mkdir -p "$RUNNER_TEMP/frontend-diagnostics"
|
||||
|
||||
- name: Install dependencies for base
|
||||
working-directory: base
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure base
|
||||
working-directory: base
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build base
|
||||
working-directory: base
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
|
||||
run: pnpm build
|
||||
|
||||
- name: Install dependencies for head
|
||||
working-directory: head
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Configure head
|
||||
working-directory: head
|
||||
run: cp .github/misskey/test.yml .config
|
||||
|
||||
- name: Build head
|
||||
working-directory: head
|
||||
env:
|
||||
FRONTEND_BUNDLE_VISUALIZER: 'true'
|
||||
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
|
||||
FRONTEND_BUNDLE_VISUALIZER_HTML_FILE: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload bundle visualizer
|
||||
id: upload-bundle-visualizer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-bundle-visualizer
|
||||
path: ${{ runner.temp }}/frontend-diagnostics/frontend-bundle-visualizer.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: head/packages-private/diagnostics-frontend
|
||||
run: pnpm exec playwright install --with-deps --no-shell chromium
|
||||
|
||||
- name: Measure frontend browser metrics
|
||||
shell: bash
|
||||
working-directory: head
|
||||
env:
|
||||
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
|
||||
MK_ENABLE_CROSS_ORIGIN_ISOLATION: "true"
|
||||
# 計測ハーネスはhead側のものを両方に使うが、成果物はrunnerのworkspace直下に出す
|
||||
FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR: ${{ github.workspace }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
pnpm --filter diagnostics-frontend run inspect-browser \
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
|
||||
|
||||
- name: Upload browser base heap snapshot
|
||||
id: upload-browser-base-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-base-heap-snapshot
|
||||
path: base-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload browser head heap snapshot
|
||||
id: upload-browser-head-heap-snapshot
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-head-heap-snapshot
|
||||
path: head-heap-snapshot.heapsnapshot
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate browser detailed html
|
||||
shell: bash
|
||||
working-directory: head
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
test -s "$REPORT_DIR/base-browser.json"
|
||||
test -s "$REPORT_DIR/head-browser.json"
|
||||
pnpm --filter diagnostics-frontend run render-browser-html \
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
|
||||
"$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
|
||||
- name: Upload browser detailed html
|
||||
id: upload-browser-detailed-html
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-browser-metrics-detailed-html
|
||||
path: ${{ runner.temp }}/frontend-diagnostics/frontend-browser-detailed-html.html
|
||||
if-no-files-found: error
|
||||
archive: false
|
||||
retention-days: 7
|
||||
|
||||
- name: Generate frontend diagnostics report
|
||||
shell: bash
|
||||
working-directory: head
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FRONTEND_BUNDLE_REPORT_ARTIFACT_URL: ${{ steps.upload-bundle-visualizer.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-base-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
|
||||
FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL: ${{ steps.upload-browser-detailed-html.outputs.artifact-url }}
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
test -s "$REPORT_DIR/base-bundle-stats.json"
|
||||
test -s "$REPORT_DIR/head-bundle-stats.json"
|
||||
test -s "$REPORT_DIR/base-browser.json"
|
||||
test -s "$REPORT_DIR/head-browser.json"
|
||||
pnpm --filter diagnostics-frontend run render-md \
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head" \
|
||||
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json" \
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json" \
|
||||
"$REPORT_DIR/frontend-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"
|
||||
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
|
||||
|
||||
- name: Check frontend diagnostics report
|
||||
shell: bash
|
||||
run: |
|
||||
REPORT_DIR="$RUNNER_TEMP/frontend-diagnostics"
|
||||
test -s "$REPORT_DIR/frontend-diagnostics.md"
|
||||
test -s "$REPORT_DIR/frontend-browser-detailed-html.html"
|
||||
test -s "$REPORT_DIR/pr-number.txt"
|
||||
test -s "$REPORT_DIR/head-sha.txt"
|
||||
cat "$REPORT_DIR/frontend-diagnostics.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload frontend diagnostics report
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-diagnostics
|
||||
path: |
|
||||
${{ runner.temp }}/frontend-diagnostics/base-bundle-stats.json
|
||||
${{ runner.temp }}/frontend-diagnostics/head-bundle-stats.json
|
||||
${{ runner.temp }}/frontend-diagnostics/base-browser.json
|
||||
${{ runner.temp }}/frontend-diagnostics/head-browser.json
|
||||
${{ runner.temp }}/frontend-diagnostics/frontend-diagnostics.md
|
||||
${{ runner.temp }}/frontend-diagnostics/pr-number.txt
|
||||
${{ runner.temp }}/frontend-diagnostics/base-sha.txt
|
||||
${{ runner.temp }}/frontend-diagnostics/head-sha.txt
|
||||
${{ runner.temp }}/frontend-diagnostics/pr-url.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
52
.github/workflows/packages-private.yml
vendored
Normal file
52
.github/workflows/packages-private.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
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
|
||||
- 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
|
||||
@@ -14,6 +14,9 @@
|
||||
- バックエンドで画像処理に用いているライブラリ sharp のシステム要件の変更により、**SSE4.2 命令セットをサポートしていない x86_64 CPU では Misskey が正しく動作しなくなります**。仮想マシンに Misskey をデプロイしている場合や、古いハードウェアをお使いの場合は、アップデート前にお使いの環境をご確認ください。なお、ARM64 など x86_64 ではない環境においてはこの変更による影響はありません。
|
||||
|
||||
### General
|
||||
- Feat: 自分に表示する公開ロールとロールバッジをユーザー側で選べるように
|
||||
- 必ず表示する必要があるロールを作成することもできます(既存のロールについてはすべて表示必須設定となります。必要に応じて設定を変更してください)
|
||||
- 今後新規作成するロールのデフォルト値は表示必須設定なし(ユーザーが表示・非表示を選べる)となります。
|
||||
- Feat: コントロールパネルから二要素認証を解除できるように
|
||||
- Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように
|
||||
(Based on https://github.com/MisskeyIO/misskey/pull/214)
|
||||
@@ -51,12 +54,13 @@
|
||||
- ジョブキュー(エンキュー元のトレースを含む)
|
||||
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
- Enhance: Node.js 22.23.0以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
- Enhance: Docker Image の Node.js を 26.4.0 に、Debian を trixie (v13) に更新
|
||||
- Enhance: URLプレビューの結果を内部でキャッシュするように
|
||||
- Enhance: API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
|
||||
- Enhance: ログ全体の既定levelとlogger domainごとの出力levelを設定できるように
|
||||
- Enhance: バックエンドのログを1行JSON形式で出力できるように
|
||||
- Enhance: OpenTelemetryのTrace Contextを構造化ログへ関連付けられるように
|
||||
- Fix: `/stats` API のレスポンス型が正しくない問題を修正
|
||||
- Fix: ハッシュタグに関連するデータを更新する際のエラーハンドリングを修正
|
||||
- Fix: Sentry 使用環境下にて、Misskey が発行した SQL クエリが span に含まれない問題を修正
|
||||
|
||||
@@ -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:<name>`, 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:<normalized source path>`.
|
||||
2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:<name>`.
|
||||
|
||||
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.
|
||||
@@ -2083,6 +2083,8 @@ _role:
|
||||
isConditionalRole: "これはコンディショナルロールです。"
|
||||
isPublic: "公開ロール"
|
||||
descriptionOfIsPublic: "ユーザーのプロフィールでこのロールが表示されます。"
|
||||
isPublicDisplayRequired: "非表示を許可しない(常に表示)"
|
||||
descriptionOfIsPublicDisplayRequired: "有効にすると、ユーザーはこの公開ロール/ロールバッジを非表示にできません。"
|
||||
options: "オプション"
|
||||
policies: "ポリシー"
|
||||
baseRole: "ベースロール"
|
||||
@@ -3245,6 +3247,13 @@ _gridComponent:
|
||||
patternNotMatch: "この値は{pattern}のパターンに一致しません"
|
||||
notUnique: "この値は一意である必要があります"
|
||||
|
||||
_roleDisplay:
|
||||
title: "ロールの表示/非表示"
|
||||
description: "自分に割り当てられているロールを確認したり、プロフィールやノート上で表示・公開するロールを選択したりできます。"
|
||||
roleExplorableAlert: "このロールは、管理者により、{link}への表示とロールタイムラインの有効化が設定されています。プロフィール上で非表示にすることはできますが、あなたにこのロールが付与されていることが知られる可能性があります。"
|
||||
displayToggle: "ロール/ロールバッジを表示する"
|
||||
alwaysShownByAdmin: "管理者の設定により非表示にすることはできません。"
|
||||
|
||||
_roleSelectDialog:
|
||||
notSelected: "選択されていません"
|
||||
|
||||
|
||||
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"version": "2026.7.0-beta.0",
|
||||
"version": "2026.7.0-beta.1",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -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": {
|
||||
|
||||
32
packages-private/README.md
Normal file
32
packages-private/README.md
Normal file
@@ -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`](./diagnostics-frontend) | フロントエンドをブラウザで起動した際のメトリクスと生成されたchunk情報等をbase/headで比較する |
|
||||
| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する |
|
||||
|
||||
## GitHub Actionsからの呼び出し方
|
||||
|
||||
ワークフロー側にロジックを持たせず、各パッケージのnpm scriptを呼ぶだけにしている。
|
||||
CLIに渡すパスはすべて呼び出し側のカレントディレクトリ基準で解決されるため、
|
||||
ワークフローからは `$GITHUB_WORKSPACE` / `$RUNNER_TEMP` を使った絶対パスを渡すこと。
|
||||
|
||||
```yaml
|
||||
- name: Generate report
|
||||
working-directory: head
|
||||
run: >-
|
||||
pnpm --filter diagnostics-frontend run render-md
|
||||
"$GITHUB_WORKSPACE/base" "$GITHUB_WORKSPACE/head"
|
||||
"$REPORT_DIR/base-bundle-stats.json" "$REPORT_DIR/head-bundle-stats.json"
|
||||
"$REPORT_DIR/base-browser.json" "$REPORT_DIR/head-browser.json"
|
||||
"$REPORT_DIR/report.md"
|
||||
```
|
||||
|
||||
base/head を比較する計測では、**head側のチェックアウトにあるハーネスで両方を計測する**
|
||||
(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。
|
||||
@@ -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,
|
||||
24
packages-private/changelog-checker/package.json
Normal file
24
packages-private/changelog-checker/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
25
packages-private/diagnostics-backend/eslint.config.js
Normal file
25
packages-private/diagnostics-backend/eslint.config.js
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
27
packages-private/diagnostics-backend/package.json
Normal file
27
packages-private/diagnostics-backend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
197
packages-private/diagnostics-backend/src/compare.ts
Normal file
197
packages-private/diagnostics-backend/src/compare.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { copyFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import { readBooleanEnv, 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 {
|
||||
clampHeapSnapshotRounds,
|
||||
selectRepresentativeHeapSnapshotRound,
|
||||
shouldCollectHeapSnapshot,
|
||||
} from './heap-snapshot-sampling';
|
||||
import { measureBackendMemory } from './measure';
|
||||
import { memoryPhases, type MemoryReport } from './types';
|
||||
|
||||
const heapSnapshotLabels = ['base', 'head'] as const;
|
||||
|
||||
type HeapSnapshotLabel = typeof heapSnapshotLabels[number];
|
||||
|
||||
export type CompareOptions = {
|
||||
baseDir: string;
|
||||
headDir: string;
|
||||
baseOutput: string;
|
||||
headOutput: string;
|
||||
};
|
||||
|
||||
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: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshots'),
|
||||
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshots'),
|
||||
};
|
||||
const HEAP_SNAPSHOT_OUTPUT_PATHS = {
|
||||
base: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'base-heap-snapshot.heapsnapshot'),
|
||||
head: join(HEAP_SNAPSHOT_OUTPUT_DIR, 'head-heap-snapshot.heapsnapshot'),
|
||||
};
|
||||
|
||||
export function summarizeSamples(samples: MemoryReport['samples']) {
|
||||
const summary = {} as MemoryReport['summary'];
|
||||
|
||||
for (const phase of memoryPhases) {
|
||||
summary[phase] = {
|
||||
memoryUsage: {},
|
||||
};
|
||||
|
||||
const metricKeys = new Set<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.phases[phase].memoryUsage)) {
|
||||
metricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of metricKeys) {
|
||||
const values = samples.map(sample => sample.phases[phase].memoryUsage[key]);
|
||||
summary[phase].memoryUsage[key] = median(values);
|
||||
}
|
||||
|
||||
const heapSnapshot = summarizeHeapSnapshotDataSamples(
|
||||
samples,
|
||||
sample => sample.phases[phase].heapSnapshot,
|
||||
{ breakdownTopN: HEAP_SNAPSHOT_BREAKDOWN_TOP_N },
|
||||
);
|
||||
if (heapSnapshot != null) summary[phase].heapSnapshot = heapSnapshot;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
type GenSampleOptions = {
|
||||
collectHeapSnapshot?: boolean;
|
||||
heapSnapshotSavePath?: string;
|
||||
};
|
||||
|
||||
async function genSample(label: string, repoDir: string, round: number, options: GenSampleOptions = {}) {
|
||||
process.stderr.write(`[${label}] Resetting database and Redis\n`);
|
||||
await resetState();
|
||||
|
||||
process.stderr.write(`[${label}] Running migrations\n`);
|
||||
// 出力はログとして流しつつ手元にも残す (失敗時にexecaが例外メッセージへ含めてくれる)
|
||||
await execa('pnpm', ['--filter', 'backend', 'migrate'], {
|
||||
cwd: repoDir,
|
||||
stdout: ['pipe', process.stderr],
|
||||
stderr: ['pipe', process.stderr],
|
||||
});
|
||||
|
||||
process.stderr.write(`[${label}] Measuring memory\n`);
|
||||
return await measureBackendMemory(resolve(repoDir, 'packages/backend'), {
|
||||
// warmupラウンド (round <= 0) は捨てるので、重いheap snapshotは取らない
|
||||
...(round <= 0 || options.collectHeapSnapshot === false ? { heapSnapshot: false } : {}),
|
||||
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function heapSnapshotPath(label: HeapSnapshotLabel, round: number) {
|
||||
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
|
||||
}
|
||||
|
||||
async function saveRepresentativeHeapSnapshot(label: HeapSnapshotLabel, samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
|
||||
const round = selectRepresentativeHeapSnapshotRound(
|
||||
samples,
|
||||
summary.afterGc.heapSnapshot?.categories.total,
|
||||
sample => sample.phases.afterGc.heapSnapshot?.categories.total,
|
||||
);
|
||||
if (round == null) return;
|
||||
|
||||
await copyFile(heapSnapshotPath(label, round), HEAP_SNAPSHOT_OUTPUT_PATHS[label]);
|
||||
process.stderr.write(`Selected ${label} heap snapshot round ${round} for artifact\n`);
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 heapSnapshotsEnabled = readBooleanEnv('MK_MEMORY_HEAP_SNAPSHOT', false);
|
||||
const requestedHeapSnapshotRounds = heapSnapshotsEnabled
|
||||
? readIntegerEnv('MK_MEMORY_HEAP_SNAPSHOT_ROUNDS', rounds, 1)
|
||||
: 0;
|
||||
const heapSnapshotRounds = clampHeapSnapshotRounds(rounds, requestedHeapSnapshotRounds);
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await rm(HEAP_SNAPSHOT_WORK_DIRS[label], { recursive: true, force: true });
|
||||
await rm(HEAP_SNAPSHOT_OUTPUT_PATHS[label], { force: true });
|
||||
}
|
||||
|
||||
const reports = {
|
||||
base: {
|
||||
dir: options.baseDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
head: {
|
||||
dir: options.headDir,
|
||||
samples: [] as MemoryReport['samples'],
|
||||
},
|
||||
};
|
||||
|
||||
for (let round = 1; round <= warmupRounds; round++) {
|
||||
process.stderr.write(`Starting warmup round ${round}/${warmupRounds}\n`);
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await genSample(label, reports[label].dir, -round);
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 1; round <= rounds; round++) {
|
||||
const order = round % 2 === 1 ? ['base', 'head'] as const : ['head', 'base'] as const;
|
||||
process.stderr.write(`Starting measurement round ${round}/${rounds}: ${order.join(' -> ')}\n`);
|
||||
|
||||
for (const label of order) {
|
||||
const collectHeapSnapshot = shouldCollectHeapSnapshot(round, rounds, heapSnapshotRounds);
|
||||
const sample = await genSample(label, reports[label].dir, round, {
|
||||
collectHeapSnapshot,
|
||||
...(collectHeapSnapshot ? { heapSnapshotSavePath: heapSnapshotPath(label, round) } : {}),
|
||||
});
|
||||
reports[label].samples.push({
|
||||
...sample,
|
||||
round,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = {
|
||||
base: summarizeSamples(reports.base.samples),
|
||||
head: summarizeSamples(reports.head.samples),
|
||||
};
|
||||
for (const label of heapSnapshotLabels) {
|
||||
await saveRepresentativeHeapSnapshot(label, reports[label].samples, summaries[label]);
|
||||
}
|
||||
|
||||
for (const label of heapSnapshotLabels) {
|
||||
const report: MemoryReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
sampleCount: reports[label].samples.length,
|
||||
aggregation: 'median',
|
||||
comparison: {
|
||||
strategy: 'interleaved-pairs',
|
||||
rounds,
|
||||
warmupRounds,
|
||||
heapSnapshotRounds,
|
||||
startedAt,
|
||||
},
|
||||
summary: summaries[label],
|
||||
samples: reports[label].samples,
|
||||
};
|
||||
|
||||
await writeFile(label === 'base' ? options.baseOutput : options.headOutput, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
35
packages-private/diagnostics-backend/src/db.ts
Normal file
35
packages-private/diagnostics-backend/src/db.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function clampHeapSnapshotRounds(totalRounds: number, requestedRounds: number) {
|
||||
return Math.min(totalRounds, requestedRounds);
|
||||
}
|
||||
|
||||
export function shouldCollectHeapSnapshot(round: number, totalRounds: number, requestedRounds: number) {
|
||||
const snapshotRounds = clampHeapSnapshotRounds(totalRounds, requestedRounds);
|
||||
return round > totalRounds - snapshotRounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。
|
||||
*/
|
||||
export function selectRepresentativeHeapSnapshotRound<T extends { round: number }>(
|
||||
samples: T[],
|
||||
medianTotal: number | null | undefined,
|
||||
getTotal: (sample: T) => number | null | undefined,
|
||||
) {
|
||||
if (medianTotal == null || !Number.isFinite(medianTotal)) return null;
|
||||
|
||||
let selected: { round: number; distance: number } | null = null;
|
||||
for (const sample of samples) {
|
||||
const total = getTotal(sample);
|
||||
if (total == null || !Number.isFinite(total)) continue;
|
||||
|
||||
const distance = Math.abs(total - medianTotal);
|
||||
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.round)) {
|
||||
selected = { round: sample.round, distance };
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.round ?? null;
|
||||
}
|
||||
27
packages-private/diagnostics-backend/src/inspect.ts
Normal file
27
packages-private/diagnostics-backend/src/inspect.ts
Normal file
@@ -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 <baseDir> <headDir> <baseOutput> <headOutput>');
|
||||
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);
|
||||
}
|
||||
29
packages-private/diagnostics-backend/src/measure-once.ts
Normal file
29
packages-private/diagnostics-backend/src/measure-once.ts
Normal file
@@ -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 <backendDir>');
|
||||
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);
|
||||
}
|
||||
129
packages-private/diagnostics-backend/src/measure/index.ts
Normal file
129
packages-private/diagnostics-backend/src/measure/index.ts
Normal file
@@ -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<MemorySample> {
|
||||
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<typeof forkBackendServer>,
|
||||
settings: ReturnType<typeof resolveOptions>,
|
||||
): Promise<HeapSnapshotData | null> {
|
||||
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`);
|
||||
});
|
||||
}
|
||||
}
|
||||
43
packages-private/diagnostics-backend/src/measure/proc.ts
Normal file
43
packages-private/diagnostics-backend/src/measure/proc.ts
Normal file
@@ -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<KS extends readonly string[]>(content: string, keys: KS, path: string): Record<KS[number], number> {
|
||||
const result = {} as Record<KS[number], number>;
|
||||
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);
|
||||
}
|
||||
199
packages-private/diagnostics-backend/src/measure/server.ts
Normal file
199
packages-private/diagnostics-backend/src/measure/server.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<T>(serverProcess: ChildProcess, predicate: (message: unknown) => message is T, description: string, timeout: number) {
|
||||
return new Promise<T>((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<void>(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');
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,10 @@ function getMaxAbsoluteSlopes<T extends Record<string, number>>(readings: { elap
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* メモリ使用量が落ち着くまで繰り返し読み取る。
|
||||
* 起動直後は遅延初期化でじわじわ増え続けるため、直近 `windowSize` 件の傾きが十分小さくなるまで待つ。
|
||||
*/
|
||||
export async function measureMemoryUntilStable<T extends Record<string, number>>(
|
||||
readMemoryUsage: () => Promise<T>,
|
||||
timer: MemoryStabilityTimer = defaultTimer,
|
||||
30
packages-private/diagnostics-backend/src/render-md.ts
Normal file
30
packages-private/diagnostics-backend/src/render-md.ts
Normal file
@@ -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 <baseReport.json> <headReport.json> <output.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);
|
||||
});
|
||||
174
packages-private/diagnostics-backend/src/report/markdown.ts
Normal file
174
packages-private/diagnostics-backend/src/report/markdown.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatKiBAsMb } from 'diagnostics-shared/format';
|
||||
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
|
||||
import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table';
|
||||
import {
|
||||
independentDeltaSummary,
|
||||
isOutsideObservedNoise,
|
||||
type IndependentDeltaSummary,
|
||||
} from 'diagnostics-shared/stats';
|
||||
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];
|
||||
|
||||
const memoryColorThresholdKiB = 100;
|
||||
|
||||
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 summarizeMemoryMetric(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
|
||||
return independentDeltaSummary(
|
||||
base.samples,
|
||||
head.samples,
|
||||
sample => getMemoryValueFromSample(sample, phase, metric),
|
||||
);
|
||||
}
|
||||
|
||||
function getDeltaPercent(summary: IndependentDeltaSummary) {
|
||||
if (summary.baseMedian === 0) return null;
|
||||
return summary.delta * 100 / summary.baseMedian;
|
||||
}
|
||||
|
||||
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) {
|
||||
return renderMetricComparisonTable(
|
||||
base.samples,
|
||||
head.samples,
|
||||
memoryMetrics.map(metric => ({
|
||||
label: `**${formatMemoryMetricName(metric)}**`,
|
||||
getValue: sample => getMemoryValueFromSample(sample, phase, metric),
|
||||
formatValue: formatKiBAsMb,
|
||||
absoluteThreshold: memoryColorThresholdKiB,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function toHeapSnapshotReport(report: MemoryReport): HeapSnapshotReport | null {
|
||||
const summary = report.summary.afterGc.heapSnapshot;
|
||||
if (summary == null) return null;
|
||||
|
||||
return {
|
||||
summary,
|
||||
samples: report.samples.flatMap(sample => {
|
||||
const data = sample.phases.afterGc.heapSnapshot;
|
||||
return data == null ? [] : [{ round: sample.round, data }];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
|
||||
const baseHeapSnapshotReport = toHeapSnapshotReport(base);
|
||||
const headHeapSnapshotReport = toHeapSnapshotReport(head);
|
||||
if (baseHeapSnapshotReport == null || headHeapSnapshotReport == null) return null;
|
||||
|
||||
const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
|
||||
|
||||
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 countNonConvergedMemorySamples(base: MemoryReport, head: MemoryReport) {
|
||||
return [base, head]
|
||||
.flatMap(report => report.samples)
|
||||
.filter(sample => memoryReportPhases.some(phase => !sample.phases[phase.key].memoryStability.converged))
|
||||
.length;
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
lines.push(`_Values are median ± MAD (${base.samples.length} base / ${head.samples.length} head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._`);
|
||||
lines.push('');
|
||||
|
||||
const nonConvergedSamples = countNonConvergedMemorySamples(base, head);
|
||||
if (nonConvergedSamples > 0) {
|
||||
const noun = nonConvergedSamples === 1 ? 'sample' : 'samples';
|
||||
lines.push(`⚠️ **Measurement warning**: ${nonConvergedSamples} memory ${noun} did not converge.`);
|
||||
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 warningSummary = summarizeMemoryMetric(base, head, 'afterGc', warningMetric);
|
||||
const warningDiffPercent = getDeltaPercent(warningSummary);
|
||||
if (
|
||||
warningSummary.delta > 0 &&
|
||||
warningDiffPercent != null &&
|
||||
warningDiffPercent > 5 &&
|
||||
isOutsideObservedNoise(warningSummary)
|
||||
) {
|
||||
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`;
|
||||
}
|
||||
50
packages-private/diagnostics-backend/src/types.ts
Normal file
50
packages-private/diagnostics-backend/src/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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<string, number> | null;
|
||||
};
|
||||
|
||||
/** バックエンドを1回起動して得られる計測結果 */
|
||||
export type MemorySample = {
|
||||
timestamp: string;
|
||||
phases: Record<MemoryPhase, {
|
||||
/** /proc 由来の値はKiB、ランタイム由来の値もKiBに揃えてある */
|
||||
memoryUsage: Record<string, number>;
|
||||
memoryStability: MemoryStability;
|
||||
heapSnapshot: HeapSnapshotData | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** base / head それぞれについて出力されるJSONレポート */
|
||||
export type MemoryReport = {
|
||||
timestamp: string;
|
||||
sampleCount: number;
|
||||
aggregation: string;
|
||||
comparison?: {
|
||||
strategy: string;
|
||||
rounds: number;
|
||||
warmupRounds: number;
|
||||
heapSnapshotRounds?: number;
|
||||
startedAt: string;
|
||||
};
|
||||
summary: Record<MemoryPhase, {
|
||||
memoryUsage: Record<string, number>;
|
||||
heapSnapshot?: HeapSnapshotData;
|
||||
}>;
|
||||
samples: (MemorySample & {
|
||||
round: number;
|
||||
})[];
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
## ⚙️ Backend Diagnostics Report
|
||||
|
||||
### Memory: After GC
|
||||
| Metric | @ Base | @ Head | Δ | MAD |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 1.4 MB |
|
||||
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 1.4 MB |
|
||||
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 1.4 MB |
|
||||
| **External** | 8.2 MB <br> ± 0.1 MB | 8.2 MB <br> ± 0.1 MB | 0 MB<br>0% | 0.1 MB |
|
||||
|
||||
_Values are median ± MAD (3 base / 3 head samples). Delta is Head - Base. Deltas are highlighted when their absolute value reaches the metric threshold and exceeds 3 × MAD._
|
||||
|
||||
### V8 Heap Snapshot Statistics
|
||||
|
||||
| Metric | @ Base | @ Head | Δ | MAD |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 283 KB |
|
||||
| | | | | |
|
||||
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 33 KB |
|
||||
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 31 KB |
|
||||
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 29 KB |
|
||||
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 27 KB |
|
||||
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 25 KB |
|
||||
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 23 KB |
|
||||
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 21 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.
|
||||
|
||||
200
packages-private/diagnostics-backend/test/fixtures/base.json
vendored
Normal file
200
packages-private/diagnostics-backend/test/fixtures/base.json
vendored
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
200
packages-private/diagnostics-backend/test/fixtures/head.json
vendored
Normal file
200
packages-private/diagnostics-backend/test/fixtures/head.json
vendored
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
clampHeapSnapshotRounds,
|
||||
selectRepresentativeHeapSnapshotRound,
|
||||
shouldCollectHeapSnapshot,
|
||||
} from '../src/heap-snapshot-sampling';
|
||||
|
||||
describe('heap snapshot sampling', () => {
|
||||
test('collects only the final requested rounds', () => {
|
||||
const selected = Array.from({ length: 15 }, (_, index) => index + 1)
|
||||
.filter(round => shouldCollectHeapSnapshot(round, 15, 3));
|
||||
expect(selected).toStrictEqual([13, 14, 15]);
|
||||
});
|
||||
|
||||
test('clamps the requested count to the measured round count', () => {
|
||||
expect(clampHeapSnapshotRounds(5, 20)).toBe(5);
|
||||
expect(Array.from({ length: 5 }, (_, index) => index + 1)
|
||||
.filter(round => shouldCollectHeapSnapshot(round, 5, 20)))
|
||||
.toStrictEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('collects no snapshots when the effective count is zero', () => {
|
||||
expect(Array.from({ length: 5 }, (_, index) => index + 1)
|
||||
.filter(round => shouldCollectHeapSnapshot(round, 5, 0)))
|
||||
.toStrictEqual([]);
|
||||
});
|
||||
|
||||
test('selects the snapshot nearest the median and ignores missing rounds', () => {
|
||||
const samples = [
|
||||
{ round: 12, total: null },
|
||||
{ round: 13, total: 100 },
|
||||
{ round: 14, total: 110 },
|
||||
{ round: 15, total: 130 },
|
||||
];
|
||||
expect(selectRepresentativeHeapSnapshotRound(samples, 110, sample => sample.total)).toBe(14);
|
||||
});
|
||||
});
|
||||
136
packages-private/diagnostics-backend/test/render-md.test.ts
Normal file
136
packages-private/diagnostics-backend/test/render-md.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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 { pairedDeltaSummary } from 'diagnostics-shared/stats';
|
||||
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;
|
||||
}
|
||||
|
||||
function replacePssSamples(report: MemoryReport, values: number[]) {
|
||||
const templates = structuredClone(report.samples);
|
||||
report.samples = values.map((Pss, index) => {
|
||||
const sample = structuredClone(templates[index % templates.length]);
|
||||
sample.round = index + 1;
|
||||
sample.phases.afterGc.memoryUsage.Pss = Pss;
|
||||
const privateClean = sample.phases.afterGc.memoryUsage.Private_Clean;
|
||||
sample.phases.afterGc.memoryUsage.Private_Dirty = Pss - privateClean;
|
||||
return sample;
|
||||
});
|
||||
report.sampleCount = report.samples.length;
|
||||
return report;
|
||||
}
|
||||
|
||||
function findMetricRow(markdown: string, metric: string) {
|
||||
const row = markdown.split('\n').find(line => line.startsWith(`| **${metric}**`));
|
||||
if (row === undefined) throw new Error(`expected memory report to contain a ${metric} row`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出力をゴールデンファイルで固定する。
|
||||
* 意図的に変更したときは `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');
|
||||
});
|
||||
|
||||
test('throws when filtering leaves fewer than two heap snapshots per side', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
for (const report of [base, head]) {
|
||||
for (const sample of report.samples.slice(0, -1)) {
|
||||
sample.phases.afterGc.heapSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
})).toThrow('At least two samples per side are required');
|
||||
});
|
||||
|
||||
test('reports the difference of medians and leaves a paired-looking PSS delta uncoloured', async () => {
|
||||
const base = replacePssSamples(await loadFixture('base'), [290_000, 292_900, 295_800, 298_700, 301_600]);
|
||||
const head = replacePssSamples(await loadFixture('head'), [292_900, 296_300, 298_700, 301_600, 290_000]);
|
||||
|
||||
expect(pairedDeltaSummary(
|
||||
base.samples,
|
||||
head.samples,
|
||||
sample => sample.phases.afterGc.memoryUsage.Pss,
|
||||
).median).toBe(2_900);
|
||||
|
||||
const markdown = renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
const row = findMetricRow(markdown, 'PSS');
|
||||
|
||||
expect(row).toContain('295.8 MB <br> ± 2.9 MB');
|
||||
expect(row).toContain('296.3 MB <br> ± 3.4 MB');
|
||||
expect(row).toContain('$\\text{+0.5 MB}$');
|
||||
expect(row).toContain('4.5 MB');
|
||||
expect(row.split('|')).toHaveLength(7);
|
||||
expect(row).not.toMatch(/within noise|increase|decrease|inconclusive/);
|
||||
expect(row).not.toContain('\\color{orange}');
|
||||
expect(findMetricRow(markdown, 'USS')).not.toContain('\\color{orange}');
|
||||
});
|
||||
|
||||
test('keeps the convergence warning without suppressing table colour or the PSS warning', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
base.samples[0].phases.afterGc.memoryStability.converged = false;
|
||||
|
||||
const markdown = renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
const memorySection = markdown.slice(0, markdown.indexOf('### V8 Heap Snapshot Statistics'));
|
||||
|
||||
expect(memorySection).not.toContain('inconclusive');
|
||||
expect(findMetricRow(markdown, 'PSS')).toContain('\\color{orange}');
|
||||
expect(markdown).toContain('⚠️ **Measurement warning**: 1 memory sample did not converge.');
|
||||
expect(markdown).not.toContain('results are marked inconclusive');
|
||||
expect(markdown).toContain('⚠️ **Warning**: Memory usage (PSS)');
|
||||
});
|
||||
|
||||
test('throws for an undersampled memory comparison', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
base.samples = base.samples.slice(0, 1);
|
||||
head.samples = head.samples.slice(0, 1);
|
||||
|
||||
expect(() => renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
})).toThrow('At least two samples per side are required');
|
||||
});
|
||||
|
||||
test('renders an unavailable percentage when the base median is zero', async () => {
|
||||
const base = await loadFixture('base');
|
||||
const head = await loadFixture('head');
|
||||
for (const sample of base.samples) sample.phases.afterGc.memoryUsage.External = 0;
|
||||
|
||||
const markdown = renderMemoryReportMarkdown(base, head, {
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
});
|
||||
|
||||
expect(findMetricRow(markdown, 'External')).toContain('<br>-');
|
||||
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
|
||||
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
|
||||
});
|
||||
77
packages-private/diagnostics-backend/test/server.test.ts
Normal file
77
packages-private/diagnostics-backend/test/server.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
20
packages-private/diagnostics-backend/tsconfig.json
Normal file
20
packages-private/diagnostics-backend/tsconfig.json
Normal file
@@ -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": []
|
||||
}
|
||||
25
packages-private/diagnostics-frontend/eslint.config.js
Normal file
25
packages-private/diagnostics-frontend/eslint.config.js
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
25
packages-private/diagnostics-frontend/package.json
Normal file
25
packages-private/diagnostics-frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "diagnostics-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"inspect-browser": "tsx src/inspect-browser.ts",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"render-browser-html": "tsx src/render-browser-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"
|
||||
}
|
||||
}
|
||||
211
packages-private/diagnostics-frontend/src/browser/controller.ts
Normal file
211
packages-private/diagnostics-frontend/src/browser/controller.ts
Normal file
@@ -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 { enableNetworkTracking, type NetworkTracker } from './network';
|
||||
import type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
|
||||
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<string, number | undefined>,
|
||||
};
|
||||
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<HeadlessChromeController> {
|
||||
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<T>(label: string, options: HeadlessChromeOptions, callback: (browser: HeadlessChromeController) => T | Promise<T>): Promise<T> {
|
||||
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<T>(expression: string, timeoutMs = 30_000): Promise<T> {
|
||||
return await Promise.race([
|
||||
this.page.evaluate(expression),
|
||||
new Promise<never>((_, 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<BrowserMeasurement['performance']> {
|
||||
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<BrowserMeasurement['performance']['webVitals']>(`(() => {
|
||||
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<TabMemory> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
265
packages-private/diagnostics-frontend/src/browser/network.ts
Normal file
265
packages-private/diagnostics-frontend/src/browser/network.ts
Normal file
@@ -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<string, unknown> | undefined) {
|
||||
if (headers == null) return undefined;
|
||||
const normalized = {} as Record<string, string>;
|
||||
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<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* CDPのNetworkドメインを有効化し、リクエスト/WebSocketの生ログを収集し続けるトラッカーを返す。
|
||||
*/
|
||||
export async function enableNetworkTracking(cdp: CDPSession): Promise<NetworkTracker> {
|
||||
const networkRequests: NetworkRequest[] = [];
|
||||
const webSocketConnections: WebSocketConnection[] = [];
|
||||
const requests = new Map<string, NetworkRequest>();
|
||||
const webSockets = new Map<string, WebSocketConnection>();
|
||||
const pendingDetailReads: Promise<void>[] = [];
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}`;
|
||||
255
packages-private/diagnostics-frontend/src/browser/report/html.ts
Normal file
255
packages-private/diagnostics-frontend/src/browser/report/html.ts
Normal file
@@ -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<string, NetworkRequest[]>();
|
||||
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<string, string> | undefined) {
|
||||
if (headers == null || Object.keys(headers).length === 0) return null;
|
||||
return JSON.stringify(headers, null, '\t');
|
||||
}
|
||||
|
||||
function countBy<T extends string>(diffs: RequestDiff[], getKey: (diff: RequestDiff) => T) {
|
||||
const counts = new Map<T, number>();
|
||||
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`
|
||||
<tr>
|
||||
<td>${type}</td>
|
||||
<td class="num">${formatNumber(count)}</td>
|
||||
</tr>`), '');
|
||||
|
||||
return html`
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>${formatNumber(base.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>${formatNumber(head.sampleCount)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">${formatNumber(added)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">${formatNumber(removed)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
${typeCounts.length === 0 ? raw('') : html`
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>${typeRows}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>`}`;
|
||||
}
|
||||
|
||||
function renderDetails(title: string, content: string | null, open = false): Raw {
|
||||
if (content == null || content === '') return raw('');
|
||||
return html`
|
||||
<details${open ? raw(' open') : raw('')}>
|
||||
<summary>${title}</summary>
|
||||
<pre>${content}</pre>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
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`<p class="empty">Request body was present but could not be retrieved from CDP.</p>`
|
||||
: raw('');
|
||||
|
||||
return html`
|
||||
<article class="request ${diff.direction}">
|
||||
<header>
|
||||
<span class="badge">${diff.direction === 'added' ? 'Added in Head' : 'Removed in Head'}</span>
|
||||
<span class="method">${request.method}</span>
|
||||
<span class="type">${request.resourceType}</span>
|
||||
<span class="status">${request.status ?? '-'}</span>
|
||||
</header>
|
||||
<a class="url" href="${request.url}">${request.url}</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>${formatNumber(diff.round)}</dd></div>
|
||||
<div><dt>Base count</dt><dd>${formatNumber(diff.baseCount)}</dd></div>
|
||||
<div><dt>Head count</dt><dd>${formatNumber(diff.headCount)}</dd></div>
|
||||
<div><dt>Encoded</dt><dd>${formatBytes(request.encodedDataLength ?? 0)}</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>${formatBytes(request.decodedBodyLength ?? 0)}</dd></div>
|
||||
<div><dt>MIME</dt><dd>${request.mimeType ?? '-'}</dd></div>
|
||||
<div><dt>Protocol</dt><dd>${request.protocol ?? '-'}</dd></div>
|
||||
<div><dt>Remote</dt><dd>${request.remoteIPAddress == null ? '-' : `${request.remoteIPAddress}:${request.remotePort ?? ''}`}</dd></div>
|
||||
<div><dt>Failed</dt><dd>${request.failed ? (request.errorText ?? 'yes') : 'no'}</dd></div>
|
||||
</dl>
|
||||
${bodyNote}
|
||||
${renderDetails('Request body', requestBody, requestBody != null)}
|
||||
${renderDetails('Request headers', requestHeaders)}
|
||||
${renderDetails('Response headers', responseHeaders)}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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`
|
||||
<section>
|
||||
<h2>Round ${formatNumber(round)}</h2>
|
||||
<p>${formatNumber(added)} added, ${formatNumber(removed)} removed</p>
|
||||
<div class="requests">
|
||||
${joinHtml(diffs.map(renderRequest), '\n')}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function renderBrowserDiagnosticsHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
const diffs = diffReports(base, head);
|
||||
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
|
||||
const generatedAt = new Date().toISOString();
|
||||
const content = diffs.length === 0
|
||||
? html`<section><p>No added or removed HTTP(S) requests were found in paired samples.</p></section>`
|
||||
: joinHtml(rounds.map(round => renderRound(round, diffs.filter(diff => diff.round === round))), '\n');
|
||||
|
||||
return String(html`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
${raw(networkDiffHtmlStyles)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">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.</p>
|
||||
${renderSummary(base, head, diffs)}
|
||||
${content}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
@@ -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 './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);
|
||||
}
|
||||
104
packages-private/diagnostics-frontend/src/browser/server.ts
Normal file
104
packages-private/diagnostics-frontend/src/browser/server.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
function hasExited(child: ChildProcess) {
|
||||
return child.exitCode != null || child.signalCode != null;
|
||||
}
|
||||
|
||||
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}`);
|
||||
if (child.signalCode != null) throw new Error(`Misskey server exited early with signal ${child.signalCode}`);
|
||||
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 (hasExited(child)) 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<void>(resolvePromise => {
|
||||
if (hasExited(child)) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
|
||||
const forceKillTimer = setTimeout(() => {
|
||||
// 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある
|
||||
if (!hasExited(child) && 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
158
packages-private/diagnostics-frontend/src/browser/summarize.ts
Normal file
158
packages-private/diagnostics-frontend/src/browser/summarize.ts
Normal file
@@ -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 './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<string>();
|
||||
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<string>();
|
||||
for (const sample of samples) {
|
||||
for (const key of Object.keys(sample.performance.cdpMetrics)) {
|
||||
cdpMetricKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const cdpMetrics = {} as Record<string, number>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
144
packages-private/diagnostics-frontend/src/browser/types.ts
Normal file
144
packages-private/diagnostics-frontend/src/browser/types.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
|
||||
|
||||
// NOTE: (AI向け)
|
||||
// NetworkRequest がリクエスト本文と要求・応答ヘッダを保持し、APIキーやパスワード、Cookieなどの認証情報も含まれ得るが、
|
||||
// workflow上で作られるMisskeyアカウントはテスト用アカウントであり、誰かが実際に使用するプライベートなアカウントではないため、
|
||||
// それらの情報が第三者から閲覧できても何の問題もなく、脆弱性ではない。
|
||||
|
||||
export type NetworkRequest = {
|
||||
requestId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
resourceType: string;
|
||||
startedAt: number;
|
||||
documentUrl?: string;
|
||||
requestHeaders?: Record<string, string>;
|
||||
requestBody?: string;
|
||||
hasRequestBody: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
mimeType?: string;
|
||||
responseHeaders?: Record<string, string>;
|
||||
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<string, string>;
|
||||
handshakeResponseStatus?: number;
|
||||
handshakeResponseStatusText?: string;
|
||||
handshakeResponseHeaders?: Record<string, string>;
|
||||
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<string, {
|
||||
requests: number;
|
||||
encodedBytes: number;
|
||||
decodedBodyBytes: number;
|
||||
}>;
|
||||
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<string, number>;
|
||||
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[];
|
||||
};
|
||||
217
packages-private/diagnostics-frontend/src/bundle/chunk-report.ts
Normal file
217
packages-private/diagnostics-frontend/src/bundle/chunk-report.ts
Normal file
@@ -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 { CollectedBundleReport, 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[], base: Partial<Record<string, FileEntry>>, head: Partial<Record<string, FileEntry>>) {
|
||||
return keys.map(key => {
|
||||
const baseEntry = base[key];
|
||||
const headEntry = head[key];
|
||||
const baseSize = baseEntry?.size ?? 0;
|
||||
const headSize = headEntry?.size ?? 0;
|
||||
return {
|
||||
key,
|
||||
name: entryDisplayName(baseEntry ?? headEntry),
|
||||
baseFile: baseEntry?.file,
|
||||
headFile: headEntry?.file,
|
||||
baseSize,
|
||||
headSize,
|
||||
changeType: baseEntry == null ? 'added' : headEntry == null ? 'removed' : baseSize !== headSize ? 'updated' : 'unchanged',
|
||||
sortSize: Math.max(baseSize, headSize),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
|
||||
|
||||
export type ChunkAggregate = {
|
||||
baseSize: number;
|
||||
headSize: number;
|
||||
baseCount: number;
|
||||
headCount: number;
|
||||
};
|
||||
|
||||
export function sumChunkSizes(chunks: FileEntry[]) {
|
||||
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比較キーを持たない (= base/head で対応付けできない) チャンクの合計。
|
||||
*/
|
||||
export function generatedAggregate(base: FileEntry[], head: FileEntry[]): ChunkAggregate {
|
||||
const baseGenerated = base.filter(chunk => chunk.comparisonKey == null);
|
||||
const headGenerated = head.filter(chunk => chunk.comparisonKey == null);
|
||||
return {
|
||||
baseSize: sumChunkSizes(baseGenerated),
|
||||
headSize: sumChunkSizes(headGenerated),
|
||||
baseCount: baseGenerated.length,
|
||||
headCount: headGenerated.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasSmallDelta(row: ChunkComparisonRow) {
|
||||
return Math.abs(row.headSize - row.baseSize) <= smallDeltaThreshold;
|
||||
}
|
||||
|
||||
export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
|
||||
return {
|
||||
baseSize: rows.reduce((sum, row) => sum + row.baseSize, 0),
|
||||
headSize: rows.reduce((sum, row) => sum + row.headSize, 0),
|
||||
baseCount: rows.filter(row => row.baseFile != null).length,
|
||||
headCount: rows.filter(row => row.headFile != 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<typeof summarizeChunkChanges>) {
|
||||
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差分の絶対値が大きい順。同着は増加側・元サイズ・名前の順で決定的に並べる。
|
||||
*/
|
||||
export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
|
||||
return Math.abs(b.headSize - b.baseSize) - Math.abs(a.headSize - a.baseSize)
|
||||
|| (b.headSize - b.baseSize) - (a.headSize - a.baseSize)
|
||||
|| b.sortSize - a.sortSize
|
||||
|| a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
export function chunkFileDisplay(row: ChunkComparisonRow) {
|
||||
if (row.baseFile == null) return row.headFile ?? '';
|
||||
if (row.headFile == null || row.baseFile === row.headFile) return row.baseFile;
|
||||
return `${row.baseFile} → ${row.headFile}`;
|
||||
}
|
||||
|
||||
export function chunkMarkdownTable(
|
||||
rows: ChunkComparisonRow[],
|
||||
total?: { baseSize: number; headSize: number },
|
||||
generated?: ChunkAggregate,
|
||||
other?: ChunkAggregate,
|
||||
) {
|
||||
const hasGenerated = generated != null && (generated.baseCount > 0 || generated.headCount > 0);
|
||||
const hasOther = other != null && (other.baseCount > 0 || other.headCount > 0);
|
||||
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
|
||||
|
||||
const lines = [
|
||||
'| Chunk | Base | Head | Δ | Δ (%) |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
if (total != null) {
|
||||
lines.push(`| (total) | ${formatBytes(total.baseSize)} | ${formatBytes(total.headSize)} | ${calcAndFormatDeltaBytes(total.baseSize, total.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.baseSize, total.headSize, 0.1)} |`);
|
||||
lines.push('| | | | | |');
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const chunkFile = chunkFileDisplay(row);
|
||||
if (row.changeType === 'added') {
|
||||
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
|
||||
} else if (row.changeType === 'removed') {
|
||||
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
|
||||
} else {
|
||||
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.baseSize)} | ${formatBytes(row.headSize)} | ${calcAndFormatDeltaBytes(row.baseSize, row.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.baseSize, row.headSize, 0.1)} |`);
|
||||
}
|
||||
}
|
||||
if (hasGenerated) {
|
||||
lines.push(`| (other generated chunks) | ${formatBytes(generated.baseSize)} | ${formatBytes(generated.headSize)} | ${calcAndFormatDeltaBytes(generated.baseSize, generated.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.baseSize, generated.headSize, 0.1)} |`);
|
||||
}
|
||||
if (hasOther) {
|
||||
lines.push(`| (other) | ${formatBytes(other.baseSize)} | ${formatBytes(other.headSize)} | ${calcAndFormatDeltaBytes(other.baseSize, other.headSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.baseSize, other.headSize, 0.1)} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderFrontendChunkReport(base: CollectedBundleReport, head: CollectedBundleReport) {
|
||||
const baseComparable = base.comparableChunks;
|
||||
const headComparable = head.comparableChunks;
|
||||
const allChunkKeys = [...new Set([...Object.keys(baseComparable), ...Object.keys(headComparable)])];
|
||||
const allComparisonRows = getChunkComparisonRows(allChunkKeys, baseComparable, headComparable);
|
||||
|
||||
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
|
||||
const diffSummary = summarizeChunkChanges(changedRows);
|
||||
const diffTotal = {
|
||||
baseSize: sumChunkSizes(base.chunks),
|
||||
headSize: sumChunkSizes(head.chunks),
|
||||
};
|
||||
const diffGenerated = generatedAggregate(base.chunks, head.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 baseStartupFiles = new Set(base.startupFiles);
|
||||
const headStartupFiles = new Set(head.startupFiles);
|
||||
const baseStartupChunks = base.chunks.filter(chunk => baseStartupFiles.has(chunk.file));
|
||||
const headStartupChunks = head.chunks.filter(chunk => headStartupFiles.has(chunk.file));
|
||||
const baseStartupComparable = comparableMap(baseStartupChunks);
|
||||
const headStartupComparable = comparableMap(headStartupChunks);
|
||||
const startupKeys = [...new Set([...Object.keys(baseStartupComparable), ...Object.keys(headStartupComparable)])];
|
||||
const startupComparisonRows = getChunkComparisonRows(startupKeys, baseStartupComparable, headStartupComparable);
|
||||
const startupSummary = summarizeChunkChanges(startupComparisonRows);
|
||||
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
|
||||
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
|
||||
const startupTotal = {
|
||||
baseSize: sumChunkSizes(baseStartupChunks),
|
||||
headSize: sumChunkSizes(headStartupChunks),
|
||||
};
|
||||
const startupGenerated = generatedAggregate(baseStartupChunks, headStartupChunks);
|
||||
|
||||
return [
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Chunk size diff', diffSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(diffRows, diffTotal, diffGenerated, diffOther),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
`<summary>${formatChunkChangeSummary('Startup chunk size', startupSummary)}</summary>`,
|
||||
'',
|
||||
chunkMarkdownTable(startupRows, startupTotal, startupGenerated, startupOther),
|
||||
'',
|
||||
'_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
41
packages-private/diagnostics-frontend/src/bundle/fs-utils.ts
Normal file
41
packages-private/diagnostics-frontend/src/bundle/fs-utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileSize(filePath: string) {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
export async function* traverseDirectory(dir: string): AsyncGenerator<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
180
packages-private/diagnostics-frontend/src/bundle/manifest.ts
Normal file
180
packages-private/diagnostics-frontend/src/bundle/manifest.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
export type BundleManifestChunk = {
|
||||
file: string;
|
||||
src?: string;
|
||||
name?: string;
|
||||
isEntry?: boolean;
|
||||
imports?: string[];
|
||||
};
|
||||
|
||||
export type BundleManifest = Record<string, BundleManifestChunk>;
|
||||
|
||||
/**
|
||||
* 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、
|
||||
* 代表として 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 CollectedBundleReport = {
|
||||
manifest: BundleManifest;
|
||||
chunks: FileEntry[];
|
||||
comparableChunks: Record<string, FileEntry>;
|
||||
chunksByManifestKey: Record<string, FileEntry>;
|
||||
startupFiles: string[];
|
||||
};
|
||||
|
||||
export function findEntryKey(manifest: BundleManifest) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、
|
||||
* これが取れないチャンクは base/head の対応付けができない。
|
||||
*/
|
||||
export function stableChunkKey(chunk: BundleManifestChunk) {
|
||||
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: BundleManifest) {
|
||||
const entryKey = findEntryKey(manifest);
|
||||
const keys = new Set<string>();
|
||||
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 collectBundleReport(repoDir: string): Promise<CollectedBundleReport> {
|
||||
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 BundleManifest;
|
||||
const chunksByFile = new Map<string, FileEntry>();
|
||||
const comparableChunks = new Map<string, FileEntry>();
|
||||
const chunksByManifestKey = new Map<string, FileEntry>();
|
||||
|
||||
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<string>();
|
||||
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],
|
||||
};
|
||||
}
|
||||
204
packages-private/diagnostics-frontend/src/bundle/visualizer.ts
Normal file
204
packages-private/diagnostics-frontend/src/bundle/visualizer.ts
Normal file
@@ -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<string, {
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
nodeMetas?: Record<string, {
|
||||
id: string;
|
||||
isEntry?: boolean;
|
||||
isExternal?: boolean;
|
||||
importedBy?: string[];
|
||||
imported?: { id: string; dynamic?: boolean }[];
|
||||
moduleParts?: Record<string, string>;
|
||||
renderedLength: number;
|
||||
gzipLength: number;
|
||||
brotliLength: number;
|
||||
}>;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, BundleRow>();
|
||||
|
||||
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(base: ReturnType<typeof collectVisualizerReport>, head: ReturnType<typeof collectVisualizerReport>) {
|
||||
const summary = [
|
||||
'bundles',
|
||||
'modules',
|
||||
'entries',
|
||||
//'externals',
|
||||
'staticImports',
|
||||
'dynamicImports',
|
||||
] as const;
|
||||
|
||||
const metrics = [
|
||||
'renderedLength',
|
||||
'gzipLength',
|
||||
'brotliLength',
|
||||
] as const;
|
||||
|
||||
return [
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<tr>',
|
||||
'<th rowspan="2"></th>',
|
||||
'<th rowspan="2">Bundles</th>',
|
||||
'<th rowspan="2">Modules</th>',
|
||||
'<th rowspan="2">Entries</th>',
|
||||
'<th colspan="2">Imports</th>',
|
||||
'<th colspan="3">Size</th>',
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th>Static</th>',
|
||||
'<th>Dynamic</th>',
|
||||
'<th>Rendered</th>',
|
||||
'<th>Gzip</th>',
|
||||
'<th>Brotli</th>',
|
||||
'</tr>',
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
'<tr>',
|
||||
'<th><b>Base</b></th>',
|
||||
...summary.map((key) => `<td>${formatNumber(base.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(base.metrics[key])}</td>`),
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th><b>Head</b></th>',
|
||||
...summary.map((key) => `<td>${formatNumber(head.summary[key])}</td>`),
|
||||
...metrics.map((key) => `<td>${formatBytes(head.metrics[key])}</td>`),
|
||||
'</tr>',
|
||||
'<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>',
|
||||
'<tr>',
|
||||
'<th><b>Δ</b></th>',
|
||||
...summary.map((key) => `<td>${calcAndFormatDeltaNumber(base.summary[key], head.summary[key], 0)}</td>`),
|
||||
...metrics.map((key) => `<td>${calcAndFormatDeltaBytes(base.metrics[key], head.metrics[key], 1000)}</td>`),
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th><b>Δ (%)</b></th>',
|
||||
...summary.map((key) => `<td>${calcAndFormatDeltaPercent(base.summary[key], head.summary[key], 0.1)}</td>`),
|
||||
...metrics.map((key) => `<td>${calcAndFormatDeltaPercent(base.metrics[key], head.metrics[key], 0.1)}</td>`),
|
||||
'</tr>',
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
].join('\n');
|
||||
}
|
||||
128
packages-private/diagnostics-frontend/src/inspect-browser.ts
Normal file
128
packages-private/diagnostics-frontend/src/inspect-browser.ts
Normal file
@@ -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 './browser/scenario';
|
||||
import { startServer, stopServer, waitForServer } from './browser/server';
|
||||
import { selectRepresentativeSample, summarizeSamples } from './browser/summarize';
|
||||
import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/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<BrowserMeasurementSample> {
|
||||
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<typeof startServer> | 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-browser <baseDir> <headDir> <baseOutputJson> <headOutputJson>');
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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 { renderBrowserDiagnosticsHtml } from './browser/report/html';
|
||||
import type { BrowserMetricsReport } from './browser/types';
|
||||
|
||||
async function main() {
|
||||
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
|
||||
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
|
||||
throw new Error('Usage: render-browser-html <baseJson> <headJson> <outHtml>');
|
||||
}
|
||||
|
||||
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), renderBrowserDiagnosticsHtml(base, head));
|
||||
}
|
||||
|
||||
await main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
62
packages-private/diagnostics-frontend/src/render-md.ts
Normal file
62
packages-private/diagnostics-frontend/src/render-md.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 { collectBundleReport } from './bundle/manifest';
|
||||
import { renderFrontendDiagnosticsMarkdown } from './report';
|
||||
import type { BrowserMetricsReport } from './browser/types';
|
||||
import type { VisualizerReport } from './bundle/visualizer';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 7) {
|
||||
throw new Error('Usage: render-md <baseDir> <headDir> <baseBundleStatsJson> <headBundleStatsJson> <baseBrowserJson> <headBrowserJson> <outMd>');
|
||||
}
|
||||
const [
|
||||
baseDirArg,
|
||||
headDirArg,
|
||||
baseBundleStatsFileArg,
|
||||
headBundleStatsFileArg,
|
||||
baseBrowserFileArg,
|
||||
headBrowserFileArg,
|
||||
outputFileArg,
|
||||
] = args as [string, string, string, string, string, string, string];
|
||||
|
||||
const [
|
||||
baseBundle,
|
||||
headBundle,
|
||||
baseBundleStatsJson,
|
||||
headBundleStatsJson,
|
||||
baseBrowserJson,
|
||||
headBrowserJson,
|
||||
] = await Promise.all([
|
||||
collectBundleReport(resolve(baseDirArg)),
|
||||
collectBundleReport(resolve(headDirArg)),
|
||||
readFile(resolve(baseBundleStatsFileArg), 'utf8'),
|
||||
readFile(resolve(headBundleStatsFileArg), 'utf8'),
|
||||
readFile(resolve(baseBrowserFileArg), 'utf8'),
|
||||
readFile(resolve(headBrowserFileArg), 'utf8'),
|
||||
]);
|
||||
|
||||
await writeFile(
|
||||
resolve(outputFileArg),
|
||||
renderFrontendDiagnosticsMarkdown({
|
||||
bundle: {
|
||||
base: baseBundle,
|
||||
head: headBundle,
|
||||
baseStats: JSON.parse(baseBundleStatsJson) as VisualizerReport,
|
||||
headStats: JSON.parse(headBundleStatsJson) as VisualizerReport,
|
||||
visualizerArtifactUrl: readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL'),
|
||||
},
|
||||
browser: {
|
||||
base: JSON.parse(baseBrowserJson) as BrowserMetricsReport,
|
||||
head: JSON.parse(headBrowserJson) as BrowserMetricsReport,
|
||||
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'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
259
packages-private/diagnostics-frontend/src/report.ts
Normal file
259
packages-private/diagnostics-frontend/src/report.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format';
|
||||
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
|
||||
import { renderMetricComparisonTable } from 'diagnostics-shared/metric-table';
|
||||
import { renderFrontendChunkReport } from './bundle/chunk-report';
|
||||
import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './bundle/visualizer';
|
||||
import type { CollectedBundleReport } from './bundle/manifest';
|
||||
import type { BrowserMeasurementSample, BrowserMetricsReport } from './browser/types';
|
||||
|
||||
export type FrontendDiagnosticsMarkdownInput = {
|
||||
bundle: {
|
||||
base: CollectedBundleReport;
|
||||
head: CollectedBundleReport;
|
||||
baseStats: VisualizerReport;
|
||||
headStats: VisualizerReport;
|
||||
/** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */
|
||||
visualizerArtifactUrl: string;
|
||||
};
|
||||
browser: {
|
||||
base: BrowserMetricsReport;
|
||||
head: BrowserMetricsReport;
|
||||
baseHeapSnapshotUrl: string;
|
||||
headHeapSnapshotUrl: string;
|
||||
detailedHtmlUrl?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
|
||||
return resourceTypes.reduce((sum, resourceType) => sum + (sample.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
|
||||
}
|
||||
|
||||
function renderBrowserSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
|
||||
return renderMetricComparisonTable(
|
||||
base.samples,
|
||||
head.samples,
|
||||
[
|
||||
{
|
||||
label: '**Requests**',
|
||||
getValue: sample => sample.network.requestCount,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**Encoded network**',
|
||||
getValue: sample => sample.network.totalEncodedBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Decoded body**',
|
||||
getValue: sample => sample.network.totalDecodedBodyBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Same-origin encoded**',
|
||||
getValue: sample => sample.network.sameOriginEncodedBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Third-party encoded**',
|
||||
getValue: sample => sample.network.thirdPartyEncodedBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Script encoded**',
|
||||
getValue: sample => resourceTypeSampleBytes(sample, ['Script']),
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Stylesheet encoded**',
|
||||
getValue: sample => resourceTypeSampleBytes(sample, ['Stylesheet']),
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Fetch/XHR encoded**',
|
||||
getValue: sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']),
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Image encoded**',
|
||||
getValue: sample => resourceTypeSampleBytes(sample, ['Image']),
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Font encoded**',
|
||||
getValue: sample => resourceTypeSampleBytes(sample, ['Font']),
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**WebSocket connections**',
|
||||
getValue: sample => sample.network.webSocketConnectionCount,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**WebSocket sent**',
|
||||
getValue: sample => sample.network.webSocketSentBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**WebSocket received**',
|
||||
getValue: sample => sample.network.webSocketReceivedBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
{
|
||||
label: '**Page errors**',
|
||||
getValue: sample => sample.diagnostics.pageErrorCount,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**Console log**',
|
||||
getValue: sample => sample.diagnostics.console.log,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**Console warnings**',
|
||||
getValue: sample => sample.diagnostics.console.warning,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**Console errors**',
|
||||
getValue: sample => sample.diagnostics.console.error,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**Console info**',
|
||||
getValue: sample => sample.diagnostics.console.info,
|
||||
formatValue: formatNumber,
|
||||
absoluteThreshold: 1,
|
||||
},
|
||||
{
|
||||
label: '**Page-attributed memory**',
|
||||
getValue: sample => sample.performance.tabMemory.totalBytes,
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: 10_000,
|
||||
},
|
||||
],
|
||||
{ onlySignificantChanges: true },
|
||||
);
|
||||
}
|
||||
|
||||
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 = [
|
||||
'<table>',
|
||||
'<thead>',
|
||||
'<tr>',
|
||||
'<th rowspan="2">Type</th>',
|
||||
'<th colspan="3">Requests</th>',
|
||||
'<th colspan="3">Encoded bytes</th>',
|
||||
'</tr>',
|
||||
'<tr>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'<th>Base</th>',
|
||||
'<th>Head</th>',
|
||||
'<th>Δ</th>',
|
||||
'</tr>',
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
];
|
||||
|
||||
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('<tr>');
|
||||
lines.push(`<td><b>${key}</b></td>`);
|
||||
lines.push(`<td align="right">${formatNumber(baseRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${formatNumber(headRow.requests)}</td>`);
|
||||
lines.push(`<td align="right">${formatColoredDelta(headRow.requests - baseRow.requests, formatNumber)}</td>`);
|
||||
lines.push(`<td align="right">${formatBytes(baseRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${formatBytes(headRow.encodedBytes)}</td>`);
|
||||
lines.push(`<td align="right">${formatColoredDelta(headRow.encodedBytes - baseRow.encodedBytes, formatBytes)}</td>`);
|
||||
lines.push('</tr>');
|
||||
}
|
||||
|
||||
lines.push('</tbody>');
|
||||
lines.push('</table>');
|
||||
|
||||
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 renderFrontendDiagnosticsMarkdown(input: FrontendDiagnosticsMarkdownInput) {
|
||||
const { bundle, browser } = input;
|
||||
const detailedHtmlUrl = browser.detailedHtmlUrl;
|
||||
const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(browser.base), toHeapSnapshotReport(browser.head));
|
||||
const lines = [
|
||||
'## 🖥 Frontend Diagnostics Report',
|
||||
'',
|
||||
renderBrowserSummaryTable(browser.base, browser.head),
|
||||
'',
|
||||
'<i>Only metrics showing significant changes are displayed.</i>',
|
||||
'',
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : `[View details](${detailedHtmlUrl})`,
|
||||
detailedHtmlUrl == null || detailedHtmlUrl === '' ? null : '',
|
||||
'<details>',
|
||||
'<summary>Requests by resource type</summary>',
|
||||
'',
|
||||
renderResourceTypeTable(browser.base, browser.head),
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>V8 heap snapshot statistics</summary>',
|
||||
'',
|
||||
heapSnapshotTable ?? '_No V8 heap snapshot data._',
|
||||
'',
|
||||
//renderHeapSnapshotSankey(toHeapSnapshotReport(browser.head), 'Head'),
|
||||
//'',
|
||||
`Download representative heap snapshot: [base](${browser.baseHeapSnapshotUrl}) / [head](${browser.headHeapSnapshotUrl})`,
|
||||
'</details>',
|
||||
'',
|
||||
'## 📦 Bundle Stats',
|
||||
'',
|
||||
renderFrontendChunkReport(bundle.base, bundle.head),
|
||||
'',
|
||||
renderVisualizerSummaryTable(collectVisualizerReport(bundle.baseStats), collectVisualizerReport(bundle.headStats)),
|
||||
'',
|
||||
`[Open treemap HTML](${bundle.visualizerArtifactUrl})`,
|
||||
'',
|
||||
];
|
||||
|
||||
return lines.filter(line => line != null).join('\n').trimEnd() + '\n';
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
## 🖥 Frontend Diagnostics Report
|
||||
|
||||
| Metric | @ Base | @ Head | Δ | MAD |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| **Encoded network** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+83 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
|
||||
| **Decoded body** | 3.5 MB <br> ± 34 KB | 3.7 MB <br> ± 37 KB | $\color{orange}{\text{+277 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 50 KB |
|
||||
| **Same-origin encoded** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\color{orange}{\text{+82 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 15 KB |
|
||||
| **Script encoded** | 918 KB <br> ± 9 KB | 991 KB <br> ± 9.7 KB | $\color{orange}{\text{+73 KB}}$<br>$\color{orange}{\text{+8\\%}}$ | 13 KB |
|
||||
| **Page-attributed memory** | 92 MB <br> ± 900 KB | 99 MB <br> ± 972 KB | $\color{orange}{\text{+7.3 MB}}$<br>$\color{orange}{\text{+8\\%}}$ | 1.3 MB |
|
||||
|
||||
<i>Only metrics showing significant changes are displayed.</i>
|
||||
|
||||
[View details](https://example.invalid/html)
|
||||
|
||||
<details>
|
||||
<summary>Requests by resource type</summary>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">Type</th>
|
||||
<th colspan="3">Requests</th>
|
||||
<th colspan="3">Encoded bytes</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Base</th>
|
||||
<th>Head</th>
|
||||
<th>Δ</th>
|
||||
<th>Base</th>
|
||||
<th>Head</th>
|
||||
<th>Δ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>Script</b></td>
|
||||
<td align="right">10</td>
|
||||
<td align="right">10</td>
|
||||
<td align="right">0</td>
|
||||
<td align="right">918 KB</td>
|
||||
<td align="right">991 KB</td>
|
||||
<td align="right">$\color{orange}{\text{+73 KB}}$</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Stylesheet</b></td>
|
||||
<td align="right">2</td>
|
||||
<td align="right">2</td>
|
||||
<td align="right">0</td>
|
||||
<td align="right">82 KB</td>
|
||||
<td align="right">88 KB</td>
|
||||
<td align="right">$\color{orange}{\text{+6.5 KB}}$</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Fetch</b></td>
|
||||
<td align="right">6</td>
|
||||
<td align="right">6</td>
|
||||
<td align="right">0</td>
|
||||
<td align="right">41 KB</td>
|
||||
<td align="right">44 KB</td>
|
||||
<td align="right">$\color{orange}{\text{+3.3 KB}}$</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>V8 heap snapshot statistics</summary>
|
||||
|
||||
| Metric | @ Base | @ Head | Δ | MAD |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\text{+8\\%}$ | 15 KB |
|
||||
| | | | | |
|
||||
| $\color{orange}{\rule{8pt}{8pt}}$ **Code** | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 29 KB |
|
||||
| $\color{red}{\rule{8pt}{8pt}}$ **Strings** | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 44 KB |
|
||||
| $\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays** | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 59 KB |
|
||||
| $\color{green}{\rule{8pt}{8pt}}$ **Typed arrays** | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 74 KB |
|
||||
| $\color{yellow}{\rule{8pt}{8pt}}$ **System objects** | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 88 KB |
|
||||
| $\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs** | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 103 KB |
|
||||
| $\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs** | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 118 KB |
|
||||
|
||||
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
|
||||
</details>
|
||||
|
||||
## 📦 Bundle Stats
|
||||
|
||||
<details>
|
||||
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>
|
||||
|
||||
| Chunk | Base | Head | Δ | Δ (%) |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| (total) | 120 KB | 127 KB | $\color{orange}{\text{+6.3 KB}}$ | $\color{orange}{\text{+5.2\\%}}$ |
|
||||
| | | | | |
|
||||
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 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\\%}$ |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Startup chunk size (2 updated, 0 added, 0 removed)</summary>
|
||||
|
||||
| Chunk | Base | Head | Δ | Δ (%) |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| (total) | 114 KB | 120 KB | $\color{orange}{\text{+6 KB}}$ | $\color{orange}{\text{+5.3\\%}}$ |
|
||||
| | | | | |
|
||||
| <details><summary>`vue`</summary> `assets/vue-b2.js` </details> | 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._
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2"></th>
|
||||
<th rowspan="2">Bundles</th>
|
||||
<th rowspan="2">Modules</th>
|
||||
<th rowspan="2">Entries</th>
|
||||
<th colspan="2">Imports</th>
|
||||
<th colspan="3">Size</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Static</th>
|
||||
<th>Dynamic</th>
|
||||
<th>Rendered</th>
|
||||
<th>Gzip</th>
|
||||
<th>Brotli</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th><b>Base</b></th>
|
||||
<td>2</td>
|
||||
<td>6</td>
|
||||
<td>1</td>
|
||||
<td>2</td>
|
||||
<td>3</td>
|
||||
<td>21 KB</td>
|
||||
<td>6.3 KB</td>
|
||||
<td>5.3 KB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><b>Head</b></th>
|
||||
<td>2</td>
|
||||
<td>7</td>
|
||||
<td>1</td>
|
||||
<td>3</td>
|
||||
<td>3</td>
|
||||
<td>31 KB</td>
|
||||
<td>8.4 KB</td>
|
||||
<td>7 KB</td>
|
||||
</tr>
|
||||
<tr><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>
|
||||
<tr>
|
||||
<th><b>Δ</b></th>
|
||||
<td>0</td>
|
||||
<td>$\color{orange}{\text{+1}}$</td>
|
||||
<td>0</td>
|
||||
<td>$\color{orange}{\text{+1}}$</td>
|
||||
<td>0</td>
|
||||
<td>$\color{orange}{\text{+9.8 KB}}$</td>
|
||||
<td>$\color{orange}{\text{+2.1 KB}}$</td>
|
||||
<td>$\color{orange}{\text{+1.8 KB}}$</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><b>Δ (%)</b></th>
|
||||
<td>0%</td>
|
||||
<td>$\color{orange}{\text{+16.7\%}}$</td>
|
||||
<td>0%</td>
|
||||
<td>$\color{orange}{\text{+50\%}}$</td>
|
||||
<td>0%</td>
|
||||
<td>$\color{orange}{\text{+46.7\%}}$</td>
|
||||
<td>$\color{orange}{\text{+33.3\%}}$</td>
|
||||
<td>$\color{orange}{\text{+33.3\%}}$</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
[Open treemap HTML](https://example.invalid/treemap)
|
||||
@@ -0,0 +1,351 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend Browser Network Request Diff</title>
|
||||
<style>
|
||||
: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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frontend Browser Network Request Diff</h1>
|
||||
<p class="meta">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.</p>
|
||||
|
||||
<section class="summary">
|
||||
<div>
|
||||
<span class="label">Base samples</span>
|
||||
<strong>3</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Head samples</span>
|
||||
<strong>3</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Added in Head</span>
|
||||
<strong class="added-text">3</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Removed in Head</span>
|
||||
<strong class="removed-text">0</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Diffs by Resource Type</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Diff requests</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Fetch</td>
|
||||
<td class="num">3</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Round 1</h2>
|
||||
<p>1 added, 0 removed</p>
|
||||
<div class="requests">
|
||||
|
||||
<article class="request added">
|
||||
<header>
|
||||
<span class="badge">Added in Head</span>
|
||||
<span class="method">POST</span>
|
||||
<span class="type">Fetch</span>
|
||||
<span class="status">200</span>
|
||||
</header>
|
||||
<a class="url" href="http://127.0.0.1:61812/vite/new-chunk.js">http://127.0.0.1:61812/vite/new-chunk.js</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>1</dd></div>
|
||||
<div><dt>Base count</dt><dd>0</dd></div>
|
||||
<div><dt>Head count</dt><dd>1</dd></div>
|
||||
<div><dt>Encoded</dt><dd>50 KB</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
|
||||
<div><dt>MIME</dt><dd>text/javascript</dd></div>
|
||||
<div><dt>Protocol</dt><dd>h2</dd></div>
|
||||
<div><dt>Remote</dt><dd>-</dd></div>
|
||||
<div><dt>Failed</dt><dd>no</dd></div>
|
||||
</dl>
|
||||
|
||||
|
||||
<details open>
|
||||
<summary>Request body</summary>
|
||||
<pre>{
|
||||
"i": 1
|
||||
}</pre>
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Response headers</summary>
|
||||
<pre>{
|
||||
"content-type": "text/javascript"
|
||||
}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Round 2</h2>
|
||||
<p>1 added, 0 removed</p>
|
||||
<div class="requests">
|
||||
|
||||
<article class="request added">
|
||||
<header>
|
||||
<span class="badge">Added in Head</span>
|
||||
<span class="method">POST</span>
|
||||
<span class="type">Fetch</span>
|
||||
<span class="status">200</span>
|
||||
</header>
|
||||
<a class="url" href="http://127.0.0.1:61812/vite/new-chunk.js">http://127.0.0.1:61812/vite/new-chunk.js</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>2</dd></div>
|
||||
<div><dt>Base count</dt><dd>0</dd></div>
|
||||
<div><dt>Head count</dt><dd>1</dd></div>
|
||||
<div><dt>Encoded</dt><dd>50 KB</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
|
||||
<div><dt>MIME</dt><dd>text/javascript</dd></div>
|
||||
<div><dt>Protocol</dt><dd>h2</dd></div>
|
||||
<div><dt>Remote</dt><dd>-</dd></div>
|
||||
<div><dt>Failed</dt><dd>no</dd></div>
|
||||
</dl>
|
||||
|
||||
|
||||
<details open>
|
||||
<summary>Request body</summary>
|
||||
<pre>{
|
||||
"i": 2
|
||||
}</pre>
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Response headers</summary>
|
||||
<pre>{
|
||||
"content-type": "text/javascript"
|
||||
}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Round 3</h2>
|
||||
<p>1 added, 0 removed</p>
|
||||
<div class="requests">
|
||||
|
||||
<article class="request added">
|
||||
<header>
|
||||
<span class="badge">Added in Head</span>
|
||||
<span class="method">POST</span>
|
||||
<span class="type">Fetch</span>
|
||||
<span class="status">200</span>
|
||||
</header>
|
||||
<a class="url" href="http://127.0.0.1:61812/vite/new-chunk.js">http://127.0.0.1:61812/vite/new-chunk.js</a>
|
||||
<dl>
|
||||
<div><dt>Round</dt><dd>3</dd></div>
|
||||
<div><dt>Base count</dt><dd>0</dd></div>
|
||||
<div><dt>Head count</dt><dd>1</dd></div>
|
||||
<div><dt>Encoded</dt><dd>50 KB</dd></div>
|
||||
<div><dt>Decoded body</dt><dd>120 KB</dd></div>
|
||||
<div><dt>MIME</dt><dd>text/javascript</dd></div>
|
||||
<div><dt>Protocol</dt><dd>h2</dd></div>
|
||||
<div><dt>Remote</dt><dd>-</dd></div>
|
||||
<div><dt>Failed</dt><dd>no</dd></div>
|
||||
</dl>
|
||||
|
||||
|
||||
<details open>
|
||||
<summary>Request body</summary>
|
||||
<pre>{
|
||||
"i": 3
|
||||
}</pre>
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Response headers</summary>
|
||||
<pre>{
|
||||
"content-type": "text/javascript"
|
||||
}</pre>
|
||||
</details>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 { renderBrowserDiagnosticsHtml } from '../../src/browser/report/html';
|
||||
import type { BrowserMetricsReport } from '../../src/browser/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(() => {
|
||||
// renderBrowserDiagnosticsHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('renders the network request diff html report', async () => {
|
||||
const html = renderBrowserDiagnosticsHtml(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/"><script>alert(1)</script>';
|
||||
|
||||
const html = renderBrowserDiagnosticsHtml(base, head);
|
||||
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { stopServer, waitForServer } from '../../src/browser/server';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
|
||||
return {
|
||||
...actual,
|
||||
spawnSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function signaledChild() {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
signalCode: 'SIGTERM' as NodeJS.Signals,
|
||||
pid: undefined,
|
||||
kill: vi.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('waitForServer fails immediately when the server exited by signal', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ status: 200 });
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(waitForServer('http://127.0.0.1:61812', signaledChild()))
|
||||
.rejects.toThrow('Misskey server exited early with signal SIGTERM');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('stopServer returns immediately when the server already exited by signal', async () => {
|
||||
const child = signaledChild();
|
||||
const stopPromise = stopServer(child);
|
||||
|
||||
expect(child.listenerCount('exit')).toBe(0);
|
||||
await stopPromise;
|
||||
});
|
||||
@@ -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": {}}
|
||||
@@ -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": {}}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { fileExists } from '../../src/bundle/fs-utils';
|
||||
|
||||
function fsError(code: string) {
|
||||
return Object.assign(new Error(`fs error: ${code}`), { code }) as NodeJS.ErrnoException;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('fileExists returns false for ENOENT', async () => {
|
||||
vi.spyOn(fs, 'access').mockRejectedValueOnce(fsError('ENOENT'));
|
||||
|
||||
await expect(fileExists('missing')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('fileExists rethrows errors other than ENOENT', async () => {
|
||||
const error = fsError('EACCES');
|
||||
vi.spyOn(fs, 'access').mockRejectedValueOnce(error);
|
||||
|
||||
await expect(fileExists('restricted')).rejects.toBe(error);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, expect, test } from 'vitest';
|
||||
import { collectBundleReport } from '../../src/bundle/manifest';
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-test-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('fails loudly when the built output is missing', async () => {
|
||||
await expect(collectBundleReport(join(workDir, 'nonexistent'))).rejects.toThrow();
|
||||
});
|
||||
309
packages-private/diagnostics-frontend/test/report.test.ts
Normal file
309
packages-private/diagnostics-frontend/test/report.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* 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 { collectBundleReport } from '../src/bundle/manifest';
|
||||
import { renderFrontendDiagnosticsMarkdown } from '../src/report';
|
||||
import type { BrowserMeasurementSample, BrowserMetricsReport } from '../src/browser/types';
|
||||
import type { VisualizerReport } from '../src/bundle/visualizer';
|
||||
|
||||
const bundleFixturesDir = join(import.meta.dirname, 'bundle/fixtures');
|
||||
const browserFixturesDir = join(import.meta.dirname, 'browser/fixtures');
|
||||
|
||||
/**
|
||||
* ビルド成果物のfixture。
|
||||
*
|
||||
* `collectBundleReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の
|
||||
* 詰め物でよい。ディレクトリ名が `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 = {
|
||||
base: {
|
||||
'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,
|
||||
},
|
||||
head: {
|
||||
// 差が小さすぎる (閾値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<'base' | 'head', Record<string, number>>;
|
||||
|
||||
let repoDirs: { base: string; head: string };
|
||||
let workDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-report-test-'));
|
||||
|
||||
for (const label of ['base', 'head'] 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 = {
|
||||
base: join(workDir, 'base'),
|
||||
head: join(workDir, 'head'),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function loadBundleStats(name: 'base' | 'head') {
|
||||
return JSON.parse(await readFile(join(bundleFixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport;
|
||||
}
|
||||
|
||||
async function loadBrowserReport(name: 'base' | 'head') {
|
||||
return JSON.parse(await readFile(join(browserFixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport;
|
||||
}
|
||||
|
||||
type BrowserReports = {
|
||||
base: BrowserMetricsReport;
|
||||
head: BrowserMetricsReport;
|
||||
};
|
||||
|
||||
async function renderReport(detailedHtmlUrl: string | null, reports?: BrowserReports) {
|
||||
const base = reports?.base ?? await loadBrowserReport('base');
|
||||
const head = reports?.head ?? await loadBrowserReport('head');
|
||||
|
||||
return renderFrontendDiagnosticsMarkdown({
|
||||
bundle: {
|
||||
base: await collectBundleReport(repoDirs.base),
|
||||
head: await collectBundleReport(repoDirs.head),
|
||||
baseStats: await loadBundleStats('base'),
|
||||
headStats: await loadBundleStats('head'),
|
||||
visualizerArtifactUrl: 'https://example.invalid/treemap',
|
||||
},
|
||||
browser: {
|
||||
base,
|
||||
head,
|
||||
baseHeapSnapshotUrl: 'https://example.invalid/base',
|
||||
headHeapSnapshotUrl: 'https://example.invalid/head',
|
||||
detailedHtmlUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function withMetricSamples(
|
||||
report: BrowserMetricsReport,
|
||||
values: number[],
|
||||
setValue: (sample: BrowserMeasurementSample, value: number) => void,
|
||||
): BrowserMetricsReport {
|
||||
const cloned = structuredClone(report);
|
||||
const samples = values.map((value, index) => {
|
||||
const sample = structuredClone(cloned.samples[index % cloned.samples.length]);
|
||||
sample.round = index + 1;
|
||||
setValue(sample, value);
|
||||
return sample;
|
||||
});
|
||||
|
||||
return {
|
||||
...cloned,
|
||||
sampleCount: samples.length,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
function requireMetricRow(markdown: string, label: string) {
|
||||
const row = markdown.split('\n').find(line => line.startsWith(`| **${label}** |`));
|
||||
if (row == null) throw new Error(`Metric row not found: ${label}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出力をゴールデンファイルで固定する。
|
||||
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
|
||||
*/
|
||||
test('renders one frontend diagnostics markdown report from bundle and browser data', async () => {
|
||||
const markdown = await renderReport('https://example.invalid/html');
|
||||
|
||||
await expect(markdown).toMatchFileSnapshot('./__snapshots__/report.md');
|
||||
expect(markdown).toContain('| Metric | @ Base | @ Head | Δ | MAD |');
|
||||
expect(markdown).not.toContain('| Metric | @ Base | @ Head | Δ | MAD | Result |');
|
||||
expect(markdown).toContain('<summary>Requests by resource type</summary>');
|
||||
expect(markdown).toContain('## 📦 Bundle Stats');
|
||||
});
|
||||
|
||||
test('omits the browser details link when no detailed html artifact was uploaded', async () => {
|
||||
const markdown = await renderReport(null);
|
||||
|
||||
expect(markdown).not.toContain('View details');
|
||||
});
|
||||
|
||||
test('renders the difference of independent medians instead of the paired median delta', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[100, 100, 100, 1_000, 1_000],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[0, 0, 200, 200, 200],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
|
||||
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
|
||||
|
||||
expect(row).toContain('100 <br> ± 0');
|
||||
expect(row).toContain('200 <br> ± 0');
|
||||
expect(row).toContain('$\\color{orange}{\\text{+100}}$<br>$\\color{orange}{\\text{+100\\\\%}}$');
|
||||
expect(row).toContain('| 0 |');
|
||||
expect(row.split('|')).toHaveLength(7);
|
||||
expect(row).not.toMatch(/increase|decrease|within noise|inconclusive/);
|
||||
expect(row).not.toContain('\\color{green}');
|
||||
});
|
||||
|
||||
test('hides a threshold-sized change that remains within observed noise', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[100, 110, 120],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[110, 120, 130],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
|
||||
const markdown = await renderReport(null, { base, head });
|
||||
|
||||
expect(markdown).not.toContain('| **Requests** |');
|
||||
});
|
||||
|
||||
test('renders a directional request count delta at the absolute threshold', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[100, 100, 100],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[101, 101, 101],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
|
||||
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
|
||||
|
||||
expect(row).toContain('$\\color{orange}{\\text{+1}}$');
|
||||
});
|
||||
|
||||
test('hides a directional byte change below the existing absolute threshold', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[1_000_000, 1_000_000, 1_000_000],
|
||||
(sample, value) => { sample.network.totalEncodedBytes = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[1_005_000, 1_005_000, 1_005_000],
|
||||
(sample, value) => { sample.network.totalEncodedBytes = value; },
|
||||
);
|
||||
|
||||
const markdown = await renderReport(null, { base, head });
|
||||
|
||||
expect(markdown).not.toContain('| **Encoded network** |');
|
||||
});
|
||||
|
||||
test('renders a directional encoded byte delta at the absolute threshold', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[1_000_000, 1_000_000, 1_000_000],
|
||||
(sample, value) => { sample.network.totalEncodedBytes = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[1_010_000, 1_010_000, 1_010_000],
|
||||
(sample, value) => { sample.network.totalEncodedBytes = value; },
|
||||
);
|
||||
|
||||
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
|
||||
|
||||
expect(row).toContain('$\\color{orange}{\\text{+10 KB}}$');
|
||||
});
|
||||
|
||||
test('colours absolute and relative deltas together when the row is significant', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[100_000_000, 100_000_000, 100_000_000],
|
||||
(sample, value) => { sample.network.totalEncodedBytes = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[100_020_000, 100_020_000, 100_020_000],
|
||||
(sample, value) => { sample.network.totalEncodedBytes = value; },
|
||||
);
|
||||
|
||||
const row = requireMetricRow(await renderReport(null, { base, head }), 'Encoded network');
|
||||
|
||||
expect(row).toContain('100 MB <br> ± 0 B');
|
||||
expect(row).toContain('$\\color{orange}{\\text{+20 KB}}$<br>$\\color{orange}{\\text{+0\\\\%}}$');
|
||||
expect(row).toContain('| 0 B |');
|
||||
expect(row.split('|')).toHaveLength(7);
|
||||
});
|
||||
|
||||
test('renders an unavailable relative delta when the base median is zero', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[0, 0, 0],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[2, 2, 2],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
|
||||
const row = requireMetricRow(await renderReport(null, { base, head }), 'Requests');
|
||||
|
||||
expect(row).toContain('$\\color{orange}{\\text{+2}}$<br>-');
|
||||
expect(row).not.toContain('increase');
|
||||
});
|
||||
|
||||
test('throws for a metric with fewer than two samples on one side', async () => {
|
||||
const base = withMetricSamples(
|
||||
await loadBrowserReport('base'),
|
||||
[100],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
const head = withMetricSamples(
|
||||
await loadBrowserReport('head'),
|
||||
[102, 102],
|
||||
(sample, value) => { sample.network.requestCount = value; },
|
||||
);
|
||||
|
||||
await expect(renderReport(null, { base, head }))
|
||||
.rejects.toThrow('At least two samples per side are required');
|
||||
});
|
||||
20
packages-private/diagnostics-frontend/tsconfig.json
Normal file
20
packages-private/diagnostics-frontend/tsconfig.json
Normal file
@@ -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": []
|
||||
}
|
||||
25
packages-private/diagnostics-shared/eslint.config.js
Normal file
25
packages-private/diagnostics-shared/eslint.config.js
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
24
packages-private/diagnostics-shared/package.json
Normal file
24
packages-private/diagnostics-shared/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "diagnostics-shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./env": "./src/env.ts",
|
||||
"./format": "./src/format.ts",
|
||||
"./html": "./src/html.ts",
|
||||
"./stats": "./src/stats.ts",
|
||||
"./metric-table": "./src/metric-table.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"
|
||||
}
|
||||
}
|
||||
40
packages-private/diagnostics-shared/src/env.ts
Normal file
40
packages-private/diagnostics-shared/src/env.ts
Normal file
@@ -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;
|
||||
}
|
||||
116
packages-private/diagnostics-shared/src/format.ts
Normal file
116
packages-private/diagnostics-shared/src/format.ts
Normal file
@@ -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', '<br>');
|
||||
}
|
||||
|
||||
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('\\%', '\\\\%');
|
||||
}
|
||||
198
packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts
Normal file
198
packages-private/diagnostics-shared/src/heap-snapshot/analyze.ts
Normal file
@@ -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<number, number>();
|
||||
const retainerCounts = new Map<number, number>();
|
||||
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<HeapSnapshotCategory, Record<string, number>>;
|
||||
for (const category of heapSnapshotBreakdownCategories) {
|
||||
breakdowns[category] = {};
|
||||
}
|
||||
|
||||
const { edgeStartIndexes, retainerCounts } = indexEdges(view);
|
||||
const jsArrayElementNodeIndexes = new Set<number>();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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<string, number>, 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<Record<HeapSnapshotCategory, Record<string, number>>>,
|
||||
topN = defaultHeapSnapshotBreakdownTopN,
|
||||
) {
|
||||
const collapsed: NonNullable<HeapSnapshotData['breakdowns']> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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<string, { label: string; color: string; colorHex: string }>;
|
||||
|
||||
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<HeapSnapshotCategory, number>;
|
||||
nodeCounts: Record<HeapSnapshotCategory, number>;
|
||||
/** 内訳が空でないカテゴリだけが入る (`total` は内訳を持たない) */
|
||||
breakdowns?: Partial<Record<HeapSnapshotCategory, Record<string, number>>>;
|
||||
};
|
||||
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
144
packages-private/diagnostics-shared/src/heap-snapshot/render.ts
Normal file
144
packages-private/diagnostics-shared/src/heap-snapshot/render.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatBytes } from '../format';
|
||||
import { renderMetricComparisonTable } from '../metric-table';
|
||||
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 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) {
|
||||
return renderMetricComparisonTable(
|
||||
base.samples,
|
||||
head.samples,
|
||||
heapSnapshotCategories.map(category => ({
|
||||
label: swatch(category),
|
||||
getValue: sample => sample.data.categories[category],
|
||||
formatValue: formatBytes,
|
||||
absoluteThreshold: byteColorThreshold,
|
||||
showMedianMad: category === 'total',
|
||||
showDeltaPercentage: category === 'total',
|
||||
separatorAfter: category === 'total',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
[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 = [
|
||||
`<details><summary>${title} heap snapshot composition</summary>`,
|
||||
'',
|
||||
'```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('```', '', '</details>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -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<Record<HeapSnapshotCategory, number>>): values is Record<HeapSnapshotCategory, number> {
|
||||
return heapSnapshotCategories.every(category => values[category] != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数ラウンド分のheap snapshotを、カテゴリ・内訳ごとの中央値にまとめる。
|
||||
* 全カテゴリ分の値が揃わなければ null を返す。
|
||||
*/
|
||||
export function summarizeHeapSnapshotDataSamples<T>(
|
||||
samples: T[],
|
||||
getData: (sample: T) => HeapSnapshotData | null | undefined,
|
||||
options: { breakdownTopN?: number } = {},
|
||||
) {
|
||||
const data = samples.map(getData);
|
||||
|
||||
const categories: Partial<HeapSnapshotData['categories']> = {};
|
||||
const nodeCounts: Partial<HeapSnapshotData['nodeCounts']> = {};
|
||||
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<HeapSnapshotData['breakdowns']> = {};
|
||||
for (const category of heapSnapshotBreakdownCategories) {
|
||||
const childKeys = new Set(data.flatMap(snapshot => Object.keys(snapshot?.breakdowns?.[category] ?? {})));
|
||||
|
||||
const categoryBreakdown = {} as Record<string, number>;
|
||||
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;
|
||||
}
|
||||
58
packages-private/diagnostics-shared/src/html.ts
Normal file
58
packages-private/diagnostics-shared/src/html.ts
Normal file
@@ -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));
|
||||
}
|
||||
80
packages-private/diagnostics-shared/src/metric-table.ts
Normal file
80
packages-private/diagnostics-shared/src/metric-table.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { formatColoredDelta, formatDeltaPercentInMdTable } from './format';
|
||||
import {
|
||||
independentDeltaSummary,
|
||||
isOutsideObservedNoise,
|
||||
type IndependentDeltaSummary,
|
||||
} from './stats';
|
||||
|
||||
export type MetricComparisonRow<T> = {
|
||||
label: string;
|
||||
getValue: (sample: T) => number;
|
||||
formatValue: (value: number) => string;
|
||||
absoluteThreshold: number;
|
||||
showMedianMad?: boolean;
|
||||
showDeltaPercentage?: boolean;
|
||||
separatorAfter?: boolean;
|
||||
};
|
||||
|
||||
export type MetricComparisonTableOptions = {
|
||||
onlySignificantChanges?: boolean;
|
||||
};
|
||||
|
||||
function isSignificant(summary: IndependentDeltaSummary, absoluteThreshold: number) {
|
||||
return isOutsideObservedNoise(summary) && Math.abs(summary.delta) >= absoluteThreshold;
|
||||
}
|
||||
|
||||
function formatMedian<T>(
|
||||
value: number,
|
||||
spread: number,
|
||||
row: MetricComparisonRow<T>,
|
||||
) {
|
||||
const formatted = row.formatValue(value);
|
||||
if (row.showMedianMad === false) return formatted;
|
||||
return `${formatted} <br> ± ${row.formatValue(spread)}`;
|
||||
}
|
||||
|
||||
function formatDelta<T>(
|
||||
summary: IndependentDeltaSummary,
|
||||
row: MetricComparisonRow<T>,
|
||||
significant: boolean,
|
||||
) {
|
||||
const colorThreshold = significant ? 0 : Number.POSITIVE_INFINITY;
|
||||
const absolute = formatColoredDelta(summary.delta, row.formatValue, colorThreshold);
|
||||
if (row.showDeltaPercentage === false) return absolute;
|
||||
|
||||
const percentage = summary.baseMedian === 0
|
||||
? '-'
|
||||
: formatDeltaPercentInMdTable(summary.delta * 100 / summary.baseMedian, colorThreshold);
|
||||
return `${absolute}<br>${percentage}`;
|
||||
}
|
||||
|
||||
export function renderMetricComparisonTable<T>(
|
||||
baseSamples: T[],
|
||||
headSamples: T[],
|
||||
rows: MetricComparisonRow<T>[],
|
||||
options: MetricComparisonTableOptions = {},
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const summary = independentDeltaSummary(baseSamples, headSamples, row.getValue);
|
||||
const significant = isSignificant(summary, row.absoluteThreshold);
|
||||
if (options.onlySignificantChanges === true && !significant) continue;
|
||||
|
||||
lines.push(`| ${row.label} | ${formatMedian(summary.baseMedian, summary.baseMad, row)} | ${formatMedian(summary.headMedian, summary.headMad, row)} | ${formatDelta(summary, row, significant)} | ${row.formatValue(summary.combinedMad)} |`);
|
||||
if (row.separatorAfter === true) lines.push('| | | | | |');
|
||||
}
|
||||
|
||||
if (lines.length === 0) return '**(No data)**';
|
||||
|
||||
return [
|
||||
'| Metric | @ Base | @ Head | Δ | MAD |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
...lines,
|
||||
].join('\n');
|
||||
}
|
||||
127
packages-private/diagnostics-shared/src/stats.ts
Normal file
127
packages-private/diagnostics-shared/src/stats.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
export type IndependentDeltaSummary = {
|
||||
baseMedian: number;
|
||||
headMedian: number;
|
||||
delta: number;
|
||||
baseMad: number;
|
||||
headMad: number;
|
||||
combinedMad: number;
|
||||
baseSamples: number;
|
||||
headSamples: number;
|
||||
};
|
||||
|
||||
export function independentDeltaSummary<T>(
|
||||
baseSamples: T[],
|
||||
headSamples: T[],
|
||||
getValue: (sample: T) => number,
|
||||
): IndependentDeltaSummary {
|
||||
const baseValues = baseSamples.map(getValue);
|
||||
const headValues = headSamples.map(getValue);
|
||||
if (baseValues.length < 2 || headValues.length < 2) {
|
||||
throw new Error('At least two samples per side are required');
|
||||
}
|
||||
|
||||
const baseMedian = median(baseValues);
|
||||
const headMedian = median(headValues);
|
||||
const baseMad = mad(baseValues);
|
||||
const headMad = mad(headValues);
|
||||
|
||||
return {
|
||||
baseMedian,
|
||||
headMedian,
|
||||
delta: headMedian - baseMedian,
|
||||
baseMad,
|
||||
headMad,
|
||||
combinedMad: Math.hypot(baseMad, headMad),
|
||||
baseSamples: baseValues.length,
|
||||
headSamples: headValues.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOutsideObservedNoise(summary: IndependentDeltaSummary) {
|
||||
return Math.abs(summary.delta) > summary.combinedMad * 3;
|
||||
}
|
||||
|
||||
type RoundedSample = { round: number };
|
||||
|
||||
function indexByRound<T extends RoundedSample>(samples: T[]) {
|
||||
const samplesByRound = new Map<number, T>();
|
||||
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<T extends RoundedSample>(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,
|
||||
};
|
||||
}
|
||||
102
packages-private/diagnostics-shared/test/format.test.ts
Normal file
102
packages-private/diagnostics-shared/test/format.test.ts
Normal file
@@ -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<br>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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
createEmptyHeapSnapshotData,
|
||||
renderHeapSnapshotTable,
|
||||
summarizeHeapSnapshotDataSamples,
|
||||
type HeapSnapshotData,
|
||||
type HeapSnapshotReport,
|
||||
} from '../src/heap-snapshot';
|
||||
|
||||
function snapshot(total: number): HeapSnapshotData {
|
||||
const data = createEmptyHeapSnapshotData();
|
||||
data.categories.total = total;
|
||||
return data;
|
||||
}
|
||||
|
||||
function report(totals: number[]): HeapSnapshotReport {
|
||||
const samples = totals.map((total, index) => ({
|
||||
round: index + 1,
|
||||
data: snapshot(total),
|
||||
}));
|
||||
const summary = summarizeHeapSnapshotDataSamples(samples, sample => sample.data);
|
||||
if (summary == null) throw new Error('expected heap snapshot samples to produce a summary');
|
||||
|
||||
return {
|
||||
summary,
|
||||
samples,
|
||||
};
|
||||
}
|
||||
|
||||
function totalRow(markdown: string) {
|
||||
const row = markdown.split('\n').find(line => line.includes('**Total**'));
|
||||
if (row === undefined) throw new Error('expected heap snapshot table to contain a Total row');
|
||||
return row;
|
||||
}
|
||||
|
||||
describe('renderHeapSnapshotTable', () => {
|
||||
test('leaves a threshold-sized delta uncoloured when it is within observed noise', () => {
|
||||
const row = totalRow(renderHeapSnapshotTable(
|
||||
report([1_000_000, 1_100_000, 1_200_000]),
|
||||
report([1_100_000, 1_200_000, 1_300_000]),
|
||||
));
|
||||
|
||||
expect(row).toContain('$\\text{+100 KB}$');
|
||||
expect(row).not.toContain('within noise');
|
||||
expect(row).not.toContain('\\color{orange}');
|
||||
});
|
||||
|
||||
test('colours both delta lines for a clear increase at or above the absolute threshold', () => {
|
||||
const row = totalRow(renderHeapSnapshotTable(
|
||||
report([1_000_000, 1_000_000, 1_000_000]),
|
||||
report([1_200_000, 1_200_000, 1_200_000]),
|
||||
));
|
||||
|
||||
expect(row).toContain('$\\color{orange}{\\text{+200 KB}}$');
|
||||
expect(row).toContain('$\\color{orange}{\\text{+20\\\\%}}$');
|
||||
expect(row).not.toContain('increase');
|
||||
});
|
||||
|
||||
test('leaves both delta lines uncoloured below the absolute threshold', () => {
|
||||
const row = totalRow(renderHeapSnapshotTable(
|
||||
report([1_000_000, 1_000_000, 1_000_000]),
|
||||
report([1_050_000, 1_050_000, 1_050_000]),
|
||||
));
|
||||
|
||||
expect(row).toContain('$\\text{+50 KB}$<br>$\\text{+5\\\\%}$');
|
||||
expect(row.split('|')[4]).not.toContain('\\color{');
|
||||
});
|
||||
|
||||
test('throws when only one snapshot per side reaches the renderer', () => {
|
||||
expect(() => renderHeapSnapshotTable(
|
||||
report([1_000_000]),
|
||||
report([1_200_000]),
|
||||
)).toThrow('At least two samples per side are required');
|
||||
});
|
||||
|
||||
test('uses two-line formatting only for Total and puts a five-column separator after it', () => {
|
||||
const table = renderHeapSnapshotTable(
|
||||
report([1_000_000, 1_000_000, 1_000_000]),
|
||||
report([1_200_000, 1_200_000, 1_200_000]),
|
||||
);
|
||||
const lines = table.split('\n');
|
||||
const totalIndex = lines.findIndex(line => line.includes('**Total**'));
|
||||
const categoryRow = lines.find(line => line.includes('**Code**'));
|
||||
|
||||
expect(lines.slice(0, 2)).toStrictEqual([
|
||||
'| Metric | @ Base | @ Head | Δ | MAD |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
]);
|
||||
expect(table).not.toContain('Result');
|
||||
expect(totalIndex).toBeGreaterThan(1);
|
||||
expect(lines[totalIndex].match(/<br>/g)).toHaveLength(3);
|
||||
expect(lines[totalIndex + 1]).toBe('| | | | | |');
|
||||
expect(categoryRow).toBeDefined();
|
||||
expect(categoryRow).not.toContain('<br>');
|
||||
expect(categoryRow).not.toContain('<details>');
|
||||
expect(categoryRow).not.toContain('→');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user