1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-21 19:24:43 +02:00

Compare commits

...

63 Commits

Author SHA1 Message Date
syuilo
b0d1510abc Merge branch 'develop' into feature/role-hidden-setting 2026-07-21 22:44:17 +09:00
syuilo
98ae62c432 enhance(gh): 複数サンプルの値の表示を改善するなど (#17759)
* docs: design backend diagnostics memory noise handling

* chore(gh): tweak workflow

* docs: clarify shared heap snapshot reporting

* docs: plan backend diagnostics memory noise handling

* chore: ignore local worktrees

* feat(diagnostics): add independent delta summary

* feat(diagnostics): make heap snapshot deltas noise-aware

* docs: correct heap snapshot threshold plan

* fix(diagnostics): suppress backend memory noise

* ci: refine backend diagnostics sampling

* test(diagnostics): address final review

* wip

* Update heap-snapshot-sampling.ts

* wip

* wip

* docs: design frontend metric noise reporting

* docs: plan frontend metric noise reporting

* fix(diagnostics): suppress frontend metric noise

* docs: remove temporary frontend metric plans

* test(diagnostics): cover frontend metric thresholds

* docs: design shared diagnostics metric table

* docs: plan shared diagnostics metric table

* refactor(diagnostics): simplify independent delta statistics

* feat(diagnostics): add shared metric table renderer

* refactor(diagnostics): use shared heap snapshot table

* refactor(diagnostics): use shared backend metric table

* refactor(diagnostics): use shared frontend metric table

* docs: remove temporary metric table plans

* tweak

* docs: design metric table no-data output

* fix(diagnostics): show marker for empty metric tables

* chore: remove temporary diagnostics design
2026-07-21 22:43:22 +09:00
Soli
41e730a763 fix(backend): 本番ビルドで @opentelemetry/instrumentation が解決できない問題を修正 (#17756)
Signed-off-by: Soli0222 <github@str08.net>
2026-07-21 18:48:58 +09:00
syuilo
41b11902a8 Merge branch 'develop' into feature/role-hidden-setting 2026-07-21 11:27:23 +09:00
syuilo
b51d5ba0a0 frontend bundle diagnostics と frontend browser diagnostics を統合 (#17757)
* refactor(gh): unify frontend diagnostics

* refactor(gh): unify frontend diagnostics markdown rendering

* docs(gh): restore frontend diagnostics comments

* wip

* fix

* refactor: 呼称をbase/headに統一

* tweak

* Update types.ts

* fix
2026-07-21 11:26:53 +09:00
syuilo
159b1a4484 Update .coderabbit.yaml 2026-07-21 10:30:46 +09:00
syuilo
f138a48419 Update .coderabbit.yaml 2026-07-21 10:09:21 +09:00
syuilo
1dcd6c287c Update .coderabbit.yaml 2026-07-21 10:06:26 +09:00
syuilo
ccd04fbcd9 Update .coderabbit.yaml 2026-07-21 10:03:23 +09:00
syuilo
b2d07ca6e3 Update .coderabbit.yaml 2026-07-21 10:01:21 +09:00
かっこかり
ab369784fb refactor(gh): CI用スクリプトをpackageとして整理 (#17727)
* refactor(gh): CI用スクリプトをpackageとして整理

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* remove old scripts

* migrate

* refactor 1

* refactor 2

* fix comment

* fix

* fix

* fix

* fix

* remove vite-node from changelog-checker

* fix lint

* fix

* refactor

* update deps

* fix

* spec: rename packages
2026-07-20 20:09:22 +09:00
かっこかり
7157f37011 Update CHANGELOG.md [ci skip] 2026-07-20 19:38:47 +09:00
おさむのひと
bbd57c751c enhance: OTel設定時、JSONログにtrace_id, span_id, trace_flagがつくように (#17748)
* enhance: OTel設定時、JSONログにtrace_id, span_id, trace_flagがつくように

* fix
2026-07-20 19:00:54 +09:00
github-actions[bot]
36c442a7b4 Bump version to 2026.7.0-beta.1 2026-07-20 09:50:46 +00:00
syuilo
1cdfdfac31 Merge branch 'develop' into feature/role-hidden-setting 2026-07-17 22:09:16 +09:00
kakkokari-gtyih
981d327bf3 fix 2026-07-17 10:14:21 +09:00
kakkokari-gtyih
41cd2eff0c fix: tweak settings 2026-07-17 10:11:52 +09:00
kakkokari-gtyih
1b38954fcd Merge remote-tracking branch 'msky/develop' into feature/role-hidden-setting 2026-07-17 10:03:09 +09:00
syuilo
bd6123ed5c Merge branch 'develop' into feature/role-hidden-setting 2026-07-16 17:40:01 +09:00
syuilo
3923e39810 Merge branch 'develop' into feature/role-hidden-setting 2026-07-16 12:20:46 +09:00
syuilo
1c61d7da51 Merge branch 'develop' into feature/role-hidden-setting 2026-07-15 22:35:03 +09:00
syuilo
a5b858b45b Update CHANGELOG.md 2026-07-15 18:19:46 +09:00
かっこかり
1cdd177478 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 17:07:18 +09:00
syuilo
0623cabc81 Merge branch 'develop' into feature/role-hidden-setting 2026-07-14 14:03:56 +09:00
kakkokari-gtyih
7fbb4828ce fix 2026-07-08 20:14:38 +09:00
syuilo
a4d2273682 Merge branch 'develop' into feature/role-hidden-setting 2026-07-05 14:13:02 +09:00
かっこかり
25090b6bc0 Update CHANGELOG.md 2026-07-05 13:40:58 +09:00
kakkokari-gtyih
4d987e8843 fix test 2026-07-04 15:48:11 +09:00
kakkokari-gtyih
87b88d7347 add comment 2026-07-04 15:29:32 +09:00
kakkokari-gtyih
eaac254498 fix 2026-07-04 15:26:06 +09:00
kakkokari-gtyih
3df1f0e780 ✌️ 2026-07-04 15:21:05 +09:00
kakkokari-gtyih
23ca8b2286 fix 2026-07-04 10:13:11 +09:00
kakkokari-gtyih
f95f5691d3 Merge remote-tracking branch 'msky/develop' into feature/role-hidden-setting 2026-07-04 10:11:34 +09:00
kakkokari-gtyih
eeae7a3c16 fix 2026-07-04 10:09:04 +09:00
kakkokari-gtyih
398f9a6b9b fix 2026-07-03 17:48:10 +09:00
kakkokari-gtyih
28044973e7 fix 2026-07-03 17:23:49 +09:00
kakkokari-gtyih
25aae46c59 fix test 2026-07-03 17:19:29 +09:00
kakkokari-gtyih
9cc4c7d46b fix 2026-07-03 17:11:24 +09:00
kakkokari-gtyih
dfc313cadb refactor 2026-07-03 17:08:06 +09:00
kakkokari-gtyih
17fa6cd13a fix 2026-07-03 16:32:37 +09:00
kakkokari-gtyih
91db5ad709 fix 2026-07-03 16:21:46 +09:00
kakkokari-gtyih
8f35f576e0 🎨 2026-07-03 16:20:30 +09:00
kakkokari-gtyih
fcd0f0c8d2 update migration 2026-07-03 15:18:35 +09:00
kakkokari-gtyih
da35321f28 Merge branch 'develop' into feature/role-hidden-setting 2026-07-03 15:16:41 +09:00
kakkokari-gtyih
640ddb2a58 Update Changelog 2026-07-03 10:12:25 +09:00
kakkokari-gtyih
ab7e9770b1 update migration 2026-07-03 10:08:44 +09:00
kakkokari-gtyih
6f1c91d9c7 Merge branch 'develop' into feature/role-hidden-setting 2026-07-03 10:07:14 +09:00
mattyatea
510952f861 fix: move changelog entries to latest release 2026-06-29 23:23:03 +09:00
mattyatea
e90f664922 fix: address PR CI failures 2026-06-29 23:14:45 +09:00
mattyatea
4cca0c220e chore: remove local agent harness files 2026-06-29 22:58:03 +09:00
mattyatea
be9762c169 fix: address role display review issues 2026-06-29 22:47:26 +09:00
mattyatea
c1bd11eada Merge remote-tracking branch 'origin/develop' into feature/role-hidden-setting 2026-06-29 22:26:20 +09:00
mattyatea
2debf09bf2 Tighten user entity test types 2026-06-29 22:23:26 +09:00
mattyatea
a4a3606435 fixup! 4263f62e7b
Created by IntelliJ Git plugin for drop selected changes operation
2026-05-23 21:01:38 +09:00
mattyatea
9f3d80a97a Merge remote-tracking branch 'origin/develop' into feature/role-hidden-setting
# Conflicts:
#	CHANGELOG.md
2026-05-23 20:49:25 +09:00
mattyatea
e57e88ebc6 feat(frontend): ロール設定ページを追加 2026-05-23 20:24:40 +09:00
mattyatea
d490936bf6 fix(frontend): 非公開ロールの常時表示設定を隠す 2026-05-23 20:24:40 +09:00
mattyatea
63a8520191 fix(backend): 非表示ロールのバッジを返さないように 2026-05-23 20:24:40 +09:00
mattyatea
f88e647a83 docs: ロール表示設定のCHANGELOGを更新 2026-05-23 20:24:39 +09:00
mattyatea
4263f62e7b test: ロール表示設定のテストを追加 2026-05-23 20:24:39 +09:00
mattyatea
d0b8940723 feat(frontend): ロール表示設定を追加 2026-05-23 20:24:39 +09:00
mattyatea
f8e28df711 feat(backend): 公開ロールの非表示設定に対応 2026-05-23 20:24:39 +09:00
mattyatea
19605600d3 feat(backend): ロールの常時表示設定を追加 2026-05-23 20:24:39 +09:00
155 changed files with 10370 additions and 7410 deletions

View File

@@ -15,3 +15,14 @@ reviews:
- "dependabot[bot]"
- "renovate[bot]"
- "github-actions[bot]"
path_instructions:
- path: "**/*"
instructions: |
【レビュー対象外】
- フォーマット違反、型エラー、SPDXヘッダーの記載漏れ等、静的解析で検出可能な問題については、別WorkflowCI/CDでチェックされるため、レビュー対象外として無視してください。
【注力してほしい観点】
- ビジネスロジックの不備、セキュリティ、パフォーマンス、設計パターンの適切性などに焦点を当ててレビューしてください。
path_filters:
- "!CHANGELOG.md"
- "!**/__snapshots__/**"

View File

@@ -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);
});

View File

@@ -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`);

View File

@@ -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,
})),
};
}

View File

@@ -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();

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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();
}

View File

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

View File

@@ -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);

View File

@@ -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');
}

View File

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

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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
View 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

View File

@@ -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 に含まれない問題を修正

View File

@@ -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.

View File

@@ -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: "選択されていません"

View File

@@ -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": {

View 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側のチェックアウトにあるハーネスで両方を計測する**
(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。

View File

@@ -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,

View 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"
}
}

View File

@@ -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}`);
}
}

View File

@@ -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;
}

View File

@@ -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));

View File

@@ -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);
});
});

View File

@@ -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/**/*"
]
}

View 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,
},
},
},
];

View 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"
}
}

View 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`);
}
}

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

View File

@@ -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;
}

View 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);
}

View 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);
}

View 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`);
});
}
}

View 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);
}

View 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');
});
}

View File

@@ -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,

View 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);
});

View 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`;
}

View 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;
})[];
};

View File

@@ -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.

View 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": {}
}
}
}
}
]
}

View 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": {}
}
}
}
}
]
}

View File

@@ -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);
});
});

View 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 |');
});

View 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);
}
});
});

View File

@@ -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,
});

View 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": []
}

View 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,
},
},
},
];

View 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"
}
}

View 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);
}
}

View File

@@ -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),
},
};
}

View 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,
})),
};
}

View File

@@ -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);
}`;

View 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>
`);
}

View File

@@ -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);
}

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

View 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,
};
}

View 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[];
};

View 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');
}

View 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;
}
}
}

View 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],
};
}

View 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');
}

View 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);
});

View File

@@ -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);
});

View 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'),
},
}),
);

View 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';
}

View File

@@ -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)

View File

@@ -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>{
&quot;i&quot;: 1
}</pre>
</details>
<details>
<summary>Response headers</summary>
<pre>{
&quot;content-type&quot;: &quot;text/javascript&quot;
}</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>{
&quot;i&quot;: 2
}</pre>
</details>
<details>
<summary>Response headers</summary>
<pre>{
&quot;content-type&quot;: &quot;text/javascript&quot;
}</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>{
&quot;i&quot;: 3
}</pre>
</details>
<details>
<summary>Response headers</summary>
<pre>{
&quot;content-type&quot;: &quot;text/javascript&quot;
}</pre>
</details>
</article>
</div>
</section>
</main>
</body>
</html>

View File

@@ -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
}
}
}
}
]
}

View File

@@ -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
}
}
}
}
]
}

View File

@@ -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('&lt;script&gt;alert(1)&lt;/script&gt;');
});

View File

@@ -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;
});

View File

@@ -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": {}}

View File

@@ -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": {}}

View File

@@ -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);
});

View File

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

View 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');
});

View 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": []
}

View 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,
},
},
},
];

View 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"
}
}

View 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;
}

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll('\'', '&#39;');
}
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('\\%', '\\\\%');
}

View 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),
};
}

View File

@@ -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;
}

View File

@@ -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: {},
};
}

View File

@@ -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';

View 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');
}

View File

@@ -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;
}

View 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));
}

View 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');
}

View 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,
};
}

View 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('&amp;&lt;&gt;&quot;&#39;');
});
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');
});
});

View File

@@ -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