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

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
This commit is contained in:
かっこかり
2026-07-20 20:09:22 +09:00
committed by GitHub
parent 7157f37011
commit ab369784fb
101 changed files with 8111 additions and 6707 deletions

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-browser`](./diagnostics-frontend-browser) | ヘッドレスChromeでフロントエンドを操作し、メモリ・ネットワーク等の指標をbase/headで比較する |
| [`diagnostics-frontend-bundle`](./diagnostics-frontend-bundle) | フロントエンドのビルド成果物のchunkサイズをbase/headで比較する |
| [`changelog-checker`](./changelog-checker) | `CHANGELOG.md` の追記内容を検証する |
## GitHub Actionsからの呼び出し方
ワークフロー側にロジックを持たせず、各パッケージのnpm scriptを呼ぶだけにしている。
CLIに渡すパスはすべて呼び出し側のカレントディレクトリ基準で解決されるため、
ワークフローからは `$GITHUB_WORKSPACE` / `$RUNNER_TEMP` を使った絶対パスを渡すこと。
```yaml
- name: Generate report
working-directory: after
run: >-
pnpm --filter diagnostics-frontend-bundle run render-md
"$GITHUB_WORKSPACE/before" "$GITHUB_WORKSPACE/after"
"$REPORT_DIR/before-stats.json" "$REPORT_DIR/after-stats.json"
"$REPORT_DIR/report.md"
```
base/head を比較する計測では、**head側のチェックアウトにあるハーネスで両方を計測する**
(計測コードの差分ではなくビルド成果物の差分だけが結果に出るようにするため)。

View File

@@ -0,0 +1,23 @@
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',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];

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

@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Release } from './parser.js';
export class Result {
public readonly success: boolean;
public readonly message?: string;
private constructor(success: boolean, message?: string) {
this.success = success;
this.message = message;
}
static ofSuccess(): Result {
return new Result(true);
}
static ofFailed(message?: string): Result {
return new Result(false, message);
}
}
/**
* develop -> masterまたはrelease -> masterを想定したパターン。
* base側の先頭とhead側で追加された分のリリースより1つ前のバージョンが等価であるかチェックする。
*/
export function checkNewRelease(base: Release[], head: Release[]): Result {
const releaseCountDiff = head.length - base.length;
if (releaseCountDiff <= 0) {
return Result.ofFailed('Invalid release count.');
}
// 追加分を除いた残り (= 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側の最新リリース配下に書き加えられているかをチェックする。
*/
export function checkNewTopic(base: Release[], head: Release[]): Result {
if (head.length !== base.length) {
return Result.ofFailed('Invalid release count.');
}
const headLatest = head[0];
for (let relIdx = 0; relIdx < base.length; relIdx++) {
const baseItem = base[relIdx];
const headItem = head[relIdx];
if (baseItem.releaseName !== headItem.releaseName) {
// リリースの順番が変わってると成立しないのでエラーにする
return Result.ofFailed(`Release is different. base:${baseItem.releaseName}, head:${headItem.releaseName}`);
}
if (baseItem.categories.length !== headItem.categories.length) {
// カテゴリごと書き加えられたパターン
if (headLatest.releaseName !== headItem.releaseName) {
// 最新リリース以外に追記されていた場合
return Result.ofFailed(`There is an error in the update history. expected additions:${headLatest.releaseName}, actual additions:${headItem.releaseName}`);
}
} else {
// カテゴリ数の変動はないのでリスト項目の数をチェック
for (let catIdx = 0; catIdx < baseItem.categories.length; catIdx++) {
const baseCategory = baseItem.categories[catIdx];
const headCategory = headItem.categories[catIdx];
if (baseCategory.categoryName !== headCategory.categoryName) {
// カテゴリの順番が変わっていると成立しないのでエラーにする
return Result.ofFailed(`Category is different. base:${baseCategory.categoryName}, head:${headCategory.categoryName}`);
}
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}`);
}
}
}
}
}
return Result.ofSuccess();
}

View File

@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as process from 'process';
import * as fs from 'fs';
import { parseChangeLog } from './parser.js';
import { checkNewRelease, checkNewTopic } from './checker.js';
function abort(message?: string) {
if (message) {
console.error(message);
}
process.exit(1);
}
function main() {
if (!fs.existsSync('./CHANGELOG-base.md') || !fs.existsSync('./CHANGELOG-head.md')) {
abort('CHANGELOG-base.md or CHANGELOG-head.md is missing.');
return;
}
const base = parseChangeLog('./CHANGELOG-base.md');
const head = parseChangeLog('./CHANGELOG-head.md');
const result = (base.length < head.length)
? checkNewRelease(base, head)
: checkNewTopic(base, head);
if (!result.success) {
abort(result.message);
return;
}
}
main();

View File

@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'node:fs';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import { Heading, List, Node } from 'mdast';
import { toString } from 'mdast-util-to-string';
export class Release {
public readonly releaseName: string;
public readonly categories: ReleaseCategory[];
constructor(releaseName: string, categories: ReleaseCategory[] = []) {
this.releaseName = releaseName;
this.categories = [...categories];
}
}
export class ReleaseCategory {
public readonly categoryName: string;
public readonly items: string[];
constructor(categoryName: string, items: string[] = []) {
this.categoryName = categoryName;
this.items = [...items];
}
}
function isHeading(node: Node): node is Heading {
return node.type === 'heading';
}
function isList(node: Node): node is List {
return node.type === 'list';
}
export function parseChangeLog(path: string): Release[] {
const input = fs.readFileSync(path, { encoding: 'utf8' });
const processor = unified().use(remarkParse);
const releases: Release[] = [];
const root = processor.parse(input);
let release: Release | null = null;
let category: ReleaseCategory | null = null;
for (const it of root.children) {
if (isHeading(it) && it.depth === 2) {
// リリース
release = new Release(toString(it));
releases.push(release);
// 直前のリリースのカテゴリを引き継ぐと、カテゴリ見出しの無いリスト項目が
// 前のリリースに混入するのでリセットする
category = null;
} else if (isHeading(it) && it.depth === 3 && release) {
// リリース配下のカテゴリ
category = new ReleaseCategory(toString(it));
release.categories.push(category);
} else if (isList(it) && category) {
for (const listItem of it.children) {
// カテゴリ配下のリスト項目
category.items.push(toString(listItem));
}
}
}
return releases;
}

View File

@@ -0,0 +1,505 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expect, describe, test } from 'vitest';
import { Release, ReleaseCategory } from '../src/parser.js';
import { checkNewRelease, checkNewTopic } from '../src/checker.js';
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 result = checkNewRelease(base, head);
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 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 result = checkNewRelease(base, head);
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 result = checkNewRelease(base, head);
console.log(result.message);
expect(result.success).toBe(false);
});
// 先頭だけ比較していると、より古いリリースの書き換えを見逃す
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', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
];
const result = checkNewTopic(base, head);
expect(result.success).toBe(true);
});
test('最新バージョンにカテゴリを追加したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
];
const result = checkNewTopic(base, head);
expect(result.success).toBe(true);
});
test('最新バージョンからカテゴリを削除したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat3',
'feat4',
]),
]),
];
const result = checkNewTopic(base, head);
expect(result.success).toBe(true);
});
test('最新バージョンに追記したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
'feat3',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
];
const result = checkNewTopic(base, head);
expect(result.success).toBe(true);
});
test('最新バージョンから削除したときはエラーにならない', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
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',
]),
]),
];
const result = checkNewTopic(base, head);
expect(result.success).toBe(true);
});
test('古いバージョンにカテゴリを追加したときはエラーになる', () => {
const base = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
new ReleaseCategory('Client', [
'feat1',
'feat2',
]),
]),
];
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',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
]),
];
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',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
'feat3',
]),
]),
];
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',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
];
const head = [
new Release('2024.12.1', [
new ReleaseCategory('Server', [
'feat1',
'feat2',
]),
]),
new Release('2024.12.0', [
new ReleaseCategory('Server', [
'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', '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

@@ -0,0 +1,30 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"lib": [
"esnext"
]
},
"include": [
"src/**/*",
"test/**/*"
],
"exclude": [
"node_modules",
]
}

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 { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { median } from 'diagnostics-shared/stats';
import { summarizeHeapSnapshotDataSamples, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
import { resetState } from './db';
import { measureBackendMemory } from './measure';
import { memoryPhases, type MemoryReport } from './types';
const 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;
}
async function genSample(label: string, repoDir: string, round: number, options: { heapSnapshotSavePath?: string } = {}) {
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 ? { heapSnapshot: false } : {}),
heapSnapshotSavePath: options.heapSnapshotSavePath ?? null,
});
}
function heapSnapshotPath(label: HeapSnapshotLabel, round: number) {
return join(HEAP_SNAPSHOT_WORK_DIRS[label], `round-${round}.heapsnapshot`);
}
/**
* 中央値に最も近いラウンドを代表として選ぶ。外れ値のスナップショットを成果物にしないため。
*/
function selectRepresentativeHeapSnapshotRound(samples: MemoryReport['samples'], summary: MemoryReport['summary']) {
const medianTotal = summary.afterGc.heapSnapshot?.categories.total;
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: HeapSnapshotLabel, 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 });
}
/**
* base / head を交互に計測してJSONレポートを書き出す。
* 交互にするのは、実行順やマシンの状態による偏りを両者に均等に載せるため。
*/
export async function compareBackendMemory(options: CompareOptions) {
const rounds = readIntegerEnv('MK_MEMORY_COMPARE_ROUNDS', 5, 1);
const warmupRounds = readIntegerEnv('MK_MEMORY_COMPARE_WARMUP_ROUNDS', 1, 0);
const startedAt = new Date().toISOString();
for (const label of heapSnapshotLabels) {
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 sample = await genSample(label, reports[label].dir, round, { 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,
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,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

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { setTimeout } from 'node:timers/promises';
type MemoryStabilityTimer = {
now: () => number;
wait: (durationMs: number) => Promise<void>;
};
const intervalMs = 2000;
const maxWaitMs = 10000;
const windowSize = 3;
const slopeThresholdKiBPerSecond = 256;
const stabilityMetrics = ['Pss', 'Private_Dirty'] as const;
const defaultTimer: MemoryStabilityTimer = {
now: () => performance.now(),
wait: durationMs => setTimeout(durationMs),
};
function getMaxAbsoluteSlopes<T extends Record<string, number>>(readings: { elapsedMs: number; memoryUsage: T }[]) {
const result = {} as Record<typeof stabilityMetrics[number], number>;
for (const metric of stabilityMetrics) {
let maxAbsoluteSlope = 0;
for (let i = 1; i < readings.length; i++) {
const previous = readings[i - 1];
const current = readings[i];
const durationSeconds = (current.elapsedMs - previous.elapsedMs) / 1000;
maxAbsoluteSlope = Math.max(maxAbsoluteSlope, Math.abs(current.memoryUsage[metric] - previous.memoryUsage[metric]) / durationSeconds);
}
result[metric] = maxAbsoluteSlope;
}
return result;
}
/**
* メモリ使用量が落ち着くまで繰り返し読み取る。
* 起動直後は遅延初期化でじわじわ増え続けるため、直近 `windowSize` 件の傾きが十分小さくなるまで待つ。
*/
export async function measureMemoryUntilStable<T extends Record<string, number>>(
readMemoryUsage: () => Promise<T>,
timer: MemoryStabilityTimer = defaultTimer,
) {
const startedAt = timer.now();
const readings: { elapsedMs: number; memoryUsage: T }[] = [];
let maxAbsoluteSlopesKiBPerSecond: Record<typeof stabilityMetrics[number], number> | null = null;
while (true) {
const memoryUsage = await readMemoryUsage();
const elapsedMs = timer.now() - startedAt;
readings.push({ elapsedMs, memoryUsage });
let converged = false;
if (readings.length >= windowSize) {
const latestSlopes = getMaxAbsoluteSlopes(readings.slice(-windowSize));
maxAbsoluteSlopesKiBPerSecond = latestSlopes;
converged = stabilityMetrics.every(metric => latestSlopes[metric] <= slopeThresholdKiBPerSecond);
}
if (converged || elapsedMs >= maxWaitMs) {
return {
memoryUsage,
stability: {
converged,
readingCount: readings.length,
elapsedMs,
maxAbsoluteSlopesKiBPerSecond,
},
};
}
await timer.wait(Math.min(intervalMs, maxWaitMs - elapsedMs));
}
}

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,182 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatColoredDelta, formatDeltaPercentInMdTable, formatKiBAsMb } from 'diagnostics-shared/format';
import { median, pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats';
import { renderHeapSnapshotTable } from 'diagnostics-shared/heap-snapshot';
import type { MemoryPhase, MemoryReport } from '../types';
export type RenderMemoryReportOptions = {
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
};
const memoryReportPhases = [
{
key: 'afterGc',
title: 'After GC',
},
] as const satisfies readonly { key: MemoryPhase; title: string }[];
const memoryMetrics = [
'HeapUsed',
'Pss',
'USS',
'External',
] as const;
type MemoryMetric = typeof memoryMetrics[number];
function formatMemoryMetricName(metric: MemoryMetric) {
return metric === 'Pss' ? 'PSS' : metric;
}
function getMemoryValueFromSample(sample: MemoryReport['samples'][number], phase: MemoryPhase, metric: MemoryMetric) {
const memoryUsage = sample.phases[phase].memoryUsage;
// USSは直接取れないのでPrivateの合算で近似する
if (metric !== 'USS') return memoryUsage[metric];
return memoryUsage.Private_Clean + memoryUsage.Private_Dirty;
}
function getSampleValues(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return report.samples.map(sample => getMemoryValueFromSample(sample, phase, metric));
}
function getMemoryValue(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
if (metric !== 'USS') return report.summary[phase].memoryUsage[metric];
return median(getSampleValues(report, phase, metric));
}
function getSampleSpread(report: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
return sampleSpread(getSampleValues(report, phase, metric));
}
function renderMainTableForPhase(base: MemoryReport, head: MemoryReport, phase: MemoryPhase) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
function formatDeltaMemory(deltaKiB: number) {
return formatColoredDelta(deltaKiB, v => formatKiBAsMb(v), 100); // 0.1 MB threshold
}
for (const metric of memoryMetrics) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
const summary = pairedDeltaSummary(base.samples, head.samples, (sample) => getMemoryValueFromSample(sample, phase, metric));
const percent = summary.median * 100 / baseValue;
const deltaMedian = `${formatDeltaMemory(summary.median)}<br>${formatDeltaPercentInMdTable(percent, 0.1)}`;
lines.push(`| **${formatMemoryMetricName(metric)}** | ${formatKiBAsMb(baseValue)} <br> ± ${formatKiBAsMb(baseSpread)} | ${formatKiBAsMb(headValue)} <br> ± ${formatKiBAsMb(headSpread)} | ${deltaMedian} | ${formatKiBAsMb(summary.mad)} | ${formatDeltaMemory(summary.min)} | ${formatDeltaMemory(summary.max)} |`);
}
return lines.join('\n');
}
function renderHeapSnapshotSection(base: MemoryReport, head: MemoryReport) {
const baseHeapSnapshotReport = {
summary: base.summary.afterGc.heapSnapshot!,
samples: base.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const headHeapSnapshotReport = {
summary: head.summary.afterGc.heapSnapshot!,
samples: head.samples.map(sample => ({
round: sample.round,
data: sample.phases.afterGc.heapSnapshot!,
})),
};
const table = renderHeapSnapshotTable(baseHeapSnapshotReport, headHeapSnapshotReport);
if (table == null) return null;
const lines = [
'### V8 Heap Snapshot Statistics',
'',
table,
'',
];
// Sankeyはイズが多く読み取りづらかったため現在は無効。復活させる余地を残して残置する
for (const graph of [
//renderHeapSnapshotSankey(baseHeapSnapshotReport, 'Base'),
//renderHeapSnapshotSankey(headHeapSnapshotReport, 'Head'),
] as (string | null)[]) {
if (graph == null) continue;
lines.push(graph);
lines.push('');
}
return lines.join('\n');
}
function getDiffPercent(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
return ((headValue - baseValue) * 100) / baseValue;
}
/**
* 増加分がサンプルのばらつきを明確に超えているかを見る。
* 測定イズで警告が出続けるのを避けるため、合成ばらつきの3倍を閾値にする。
*/
function isBeyondSampleNoise(base: MemoryReport, head: MemoryReport, phase: MemoryPhase, metric: MemoryMetric) {
const baseValue = getMemoryValue(base, phase, metric);
const headValue = getMemoryValue(head, phase, metric);
const delta = headValue - baseValue;
if (delta <= 0) return false;
const baseSpread = getSampleSpread(base, phase, metric);
const headSpread = getSampleSpread(head, phase, metric);
if (baseSpread == null || headSpread == null) return true;
const combinedSpread = Math.hypot(baseSpread, headSpread);
return delta > combinedSpread * 3;
}
export function renderMemoryReportMarkdown(base: MemoryReport, head: MemoryReport, options: RenderMemoryReportOptions) {
const lines = [
'## ⚙️ Backend Diagnostics Report',
'',
];
//const summary = measurementSummary(base, head);
//if (summary != null) {
// lines.push(summary);
// lines.push('');
//}
for (const phase of memoryReportPhases) {
lines.push(`### Memory: ${phase.title}`);
lines.push(renderMainTableForPhase(base, head, phase.key));
lines.push('');
}
const heapSnapshotSection = renderHeapSnapshotSection(base, head);
if (heapSnapshotSection != null) {
lines.push(heapSnapshotSection);
lines.push('');
}
lines.push(`Download representative heap snapshot: [base](${options.baseHeapSnapshotUrl}) / [head](${options.headHeapSnapshotUrl})`);
lines.push('');
const warningMetric = 'Pss';
const warningDiffPercent = getDiffPercent(base, head, 'afterGc', warningMetric);
if (warningDiffPercent > 5 && isBeyondSampleNoise(base, head, 'afterGc', warningMetric)) {
lines.push(`⚠️ **Warning**: Memory usage (${formatMemoryMetricName(warningMetric)}) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.`);
lines.push('');
}
return `${lines.join('\n')}\n`;
}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
/** 計測フェーズ。将来的に増やせるようリスト化してある */
export const memoryPhases = ['afterGc'] as const;
export type MemoryPhase = typeof memoryPhases[number];
export type MemoryStability = {
converged: boolean;
readingCount: number;
elapsedMs: number;
maxAbsoluteSlopesKiBPerSecond: Record<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;
startedAt: string;
};
summary: Record<MemoryPhase, {
memoryUsage: Record<string, number>;
heapSnapshot?: HeapSnapshotData;
}>;
samples: (MemorySample & {
round: number;
})[];
};

View File

@@ -0,0 +1,29 @@
## ⚙️ Backend Diagnostics Report
### Memory: After GC
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **HeapUsed** | 152 MB <br> ± 1 MB | 168 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+10.5\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ |
| **PSS** | 202 MB <br> ± 1 MB | 218 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ |
| **USS** | 184 MB <br> ± 1 MB | 200 MB <br> ± 1 MB | $\color{orange}{\text{+16 MB}}$<br>$\color{orange}{\text{+8.7\\%}}$ | 0 MB | $\color{orange}{\text{+16 MB}}$ | $\color{orange}{\text{+16 MB}}$ |
| **External** | 8.2 MB <br> ± 0.1 MB | 8.2 MB <br> ± 0.1 MB | 0 MB<br>0% | 0 MB | 0 MB | 0 MB |
### V8 Heap Snapshot Statistics
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 40 MB <br> ± 200 KB | 44 MB <br> ± 200 KB | $\color{orange}{\text{+3.2 MB}}$<br>$\color{orange}{\text{+7.9\\%}}$ | 0 B | $\color{orange}{\text{+3.2 MB}}$ | $\color{orange}{\text{+3.2 MB}}$ |
| | | | | | | |
| <details><summary>$\color{orange}{\rule{8pt}{8pt}}$ **Code**</summary>11.8% → 11.8%</details> | 4.7 MB | 5.1 MB | $\color{orange}{\text{+376 KB}}$ | 0 B | $\color{orange}{\text{+376 KB}}$ | $\color{orange}{\text{+376 KB}}$ |
| <details><summary>$\color{red}{\rule{8pt}{8pt}}$ **Strings**</summary>11% → 11%</details> | 4.4 MB | 4.8 MB | $\color{orange}{\text{+352 KB}}$ | 0 B | $\color{orange}{\text{+352 KB}}$ | $\color{orange}{\text{+352 KB}}$ |
| <details><summary>$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**</summary>10.3% → 10.3%</details> | 4.1 MB | 4.5 MB | $\color{orange}{\text{+328 KB}}$ | 0 B | $\color{orange}{\text{+328 KB}}$ | $\color{orange}{\text{+328 KB}}$ |
| <details><summary>$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**</summary>9.5% → 9.5%</details> | 3.8 MB | 4.1 MB | $\color{orange}{\text{+304 KB}}$ | 0 B | $\color{orange}{\text{+304 KB}}$ | $\color{orange}{\text{+304 KB}}$ |
| <details><summary>$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**</summary>8.8% → 8.8%</details> | 3.5 MB | 3.8 MB | $\color{orange}{\text{+280 KB}}$ | 0 B | $\color{orange}{\text{+280 KB}}$ | $\color{orange}{\text{+280 KB}}$ |
| <details><summary>$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**</summary>8% → 8%</details> | 3.2 MB | 3.5 MB | $\color{orange}{\text{+256 KB}}$ | 0 B | $\color{orange}{\text{+256 KB}}$ | $\color{orange}{\text{+256 KB}}$ |
| <details><summary>$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**</summary>7.3% → 7.3%</details> | 2.9 MB | 3.2 MB | $\color{orange}{\text{+232 KB}}$ | 0 B | $\color{orange}{\text{+232 KB}}$ | $\color{orange}{\text{+232 KB}}$ |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
⚠️ **Warning**: Memory usage (PSS) has increased by more than 5% and exceeds the observed sample noise. Please verify this is not an unintended change.

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,29 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { expect, test } from 'vitest';
import { renderMemoryReportMarkdown } from '../src/report/markdown';
import type { MemoryReport } from '../src/types';
const fixturesDir = join(import.meta.dirname, 'fixtures');
async function loadFixture(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as MemoryReport;
}
/**
* 出力をゴールデンファイルで固定する。
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
*/
test('renders the backend memory report', async () => {
const markdown = renderMemoryReportMarkdown(await loadFixture('base'), await loadFixture('head'), {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
});
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
});

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

@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { expect, test } from 'vitest';
import { measureMemoryUntilStable } from '../src/measure/stability';
function createTimer() {
let elapsedMs = 0;
return {
now: () => elapsedMs,
wait: async (durationMs: number) => {
elapsedMs += durationMs;
},
};
}
async function measure(readings: Record<string, number>[]) {
let readCount = 0;
const result = await measureMemoryUntilStable(
async () => readings[readCount++],
createTimer(),
);
return { result, readCount };
}
test('adopts the latest reading once Pss and Private_Dirty slopes converge', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500, HeapUsed: 300 },
{ Pss: 1100, Private_Dirty: 520, HeapUsed: 310 },
{ Pss: 1200, Private_Dirty: 540, HeapUsed: 320 },
];
const { result, readCount } = await measure(readings);
expect(readCount).toBe(3);
expect(result.memoryUsage).toStrictEqual(readings[2]);
expect(result.stability).toStrictEqual({
converged: true,
readingCount: 3,
elapsedMs: 4000,
maxAbsoluteSlopesKiBPerSecond: {
Pss: 50,
Private_Dirty: 10,
},
});
});
test('uses only the latest readings when determining convergence', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 2000, Private_Dirty: 1000 },
{ Pss: 3000, Private_Dirty: 1500 },
{ Pss: 3040, Private_Dirty: 1520 },
{ Pss: 3080, Private_Dirty: 1540 },
];
const { result, readCount } = await measure(readings);
expect(readCount).toBe(5);
expect(result.stability.converged).toBe(true);
expect(result.stability.maxAbsoluteSlopesKiBPerSecond).toStrictEqual({
Pss: 20,
Private_Dirty: 10,
});
});
test('bounds the wait and reports the latest slopes when memory does not converge', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 520 },
{ Pss: 2200, Private_Dirty: 540 },
{ Pss: 2800, Private_Dirty: 560 },
{ Pss: 3400, Private_Dirty: 580 },
{ Pss: 4000, Private_Dirty: 600 },
];
const { result, readCount } = await measure(readings);
expect(readCount).toBe(6);
expect(result.memoryUsage).toStrictEqual(readings[5]);
expect(result.stability).toStrictEqual({
converged: false,
readingCount: 6,
elapsedMs: 10000,
maxAbsoluteSlopesKiBPerSecond: {
Pss: 300,
Private_Dirty: 10,
},
});
});
test('does not treat opposing adjacent slopes as convergence', async () => {
const readings = [
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 500 },
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 500 },
{ Pss: 1000, Private_Dirty: 500 },
{ Pss: 1600, Private_Dirty: 500 },
];
const { result, readCount } = await measure(readings);
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-browser",
"private": true,
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"inspect": "tsx src/inspect.ts",
"lint": "pnpm typecheck && pnpm eslint",
"render-html": "tsx src/render-html.ts",
"render-md": "tsx src/render-md.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"diagnostics-shared": "workspace:*"
},
"devDependencies": {
"@types/node": "26.1.1",
"frontend": "workspace:*",
"playwright": "1.61.1",
"tsx": "4.23.1",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}

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 type { Browser, BrowserContext, CDPSession, Page } from 'playwright';
import { enableNetworkTracking, type NetworkTracker } from './network';
import type { BrowserDiagnostics, BrowserMeasurement, NetworkRequest, TabMemory, WebSocketConnection } from '../types';
export type HeadlessChromeOptions = {
scenarioTimeoutMs: number;
baseUrl: string;
};
export class HeadlessChromeController {
private readonly diagnostics = {
pageErrorCount: 0,
console: {} as Record<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,128 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { readIntegerEnv, readOptionalEnv } from 'diagnostics-shared/env';
import { analyzeHeapSnapshot, defaultHeapSnapshotBreakdownTopN } from 'diagnostics-shared/heap-snapshot';
import { HeadlessChromeController } from './browser/controller';
import { summarizeNetwork } from './browser/network';
import { prepareInstance, runSignupAndPostScenario, scenarioDescription } from './scenario';
import { startServer, stopServer, waitForServer } from './server';
import { selectRepresentativeSample, summarizeSamples } from './summarize';
import type { BrowserMeasurementSample, BrowserMetricsReport } from './types';
type Label = 'base' | 'head';
const labels = ['base', 'head'] as const satisfies readonly Label[];
const baseUrl = process.env.FRONTEND_BROWSER_METRICS_URL ?? 'http://127.0.0.1:61812';
const sampleCount = readIntegerEnv('FRONTEND_BROWSER_METRICS_SAMPLE_COUNT', 5, 1);
const heapSnapshotBreakdownTopN = readIntegerEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_BREAKDOWN_TOP_N', defaultHeapSnapshotBreakdownTopN, 1);
// 成果物 (artifact) としてアップロードされるファイルの出力先。CIではworkspace直下を指す
const heapSnapshotOutputDir = resolve(readOptionalEnv('FRONTEND_BROWSER_HEAP_SNAPSHOT_OUTPUT_DIR') ?? process.cwd());
const heapSnapshotWorkDirs = {
base: join(heapSnapshotOutputDir, 'frontend-browser-base-heap-snapshots'),
head: join(heapSnapshotOutputDir, 'frontend-browser-head-heap-snapshots'),
} as const;
const heapSnapshotOutputPaths = {
base: join(heapSnapshotOutputDir, 'base-heap-snapshot.heapsnapshot'),
head: join(heapSnapshotOutputDir, 'head-heap-snapshot.heapsnapshot'),
} as const;
function heapSnapshotPath(label: Label, round: number) {
return join(heapSnapshotWorkDirs[label], `round-${round}.heapsnapshot`);
}
/**
* ブラウザを毎ラウンド立ち上げ直して1サンプル分を計測する。
* ブラウザを使い回すとキャッシュや前ラウンドのGC残渣が乗るため、必ず作り直す。
*/
async function measureSample(label: Label, round: number, heapSnapshotSavePath: string): Promise<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 <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 { renderHtml } from './report/html';
import type { BrowserMetricsReport } from './types';
async function main() {
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
throw new Error('Usage: render-html <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), renderHtml(base, head));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { readOptionalEnv, readRequiredEnv } from 'diagnostics-shared/env';
import { renderMarkdown } from './report/markdown';
import type { BrowserMetricsReport } from './types';
async function main() {
const [baseFileArg, headFileArg, outputFileArg] = process.argv.slice(2);
if (baseFileArg == null || headFileArg == null || outputFileArg == null) {
throw new Error('Usage: render-md <baseJson> <headJson> <outMd>');
}
const base = JSON.parse(await readFile(resolve(baseFileArg), 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(resolve(headFileArg), 'utf8')) as BrowserMetricsReport;
await writeFile(resolve(outputFileArg), renderMarkdown(base, head, {
baseHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_BASE_HEAP_SNAPSHOT_ARTIFACT_URL'),
headHeapSnapshotUrl: readRequiredEnv('FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL'),
detailedHtmlUrl: readOptionalEnv('FRONTEND_BROWSER_DETAILED_HTML_ARTIFACT_URL'),
}));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});

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 renderHtml(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const diffs = diffReports(base, head);
const rounds = [...new Set(diffs.map(diff => diff.round))].toSorted((a, b) => a - b);
const generatedAt = new Date().toISOString();
const content = diffs.length === 0
? html`<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,189 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { formatBytes, formatColoredDelta, formatNumber } from 'diagnostics-shared/format';
import { pairedDeltaSummary, sampleSpread } from 'diagnostics-shared/stats';
import { renderHeapSnapshotTable, type HeapSnapshotReport } from 'diagnostics-shared/heap-snapshot';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport } from '../types';
export type RenderMarkdownOptions = {
baseHeapSnapshotUrl: string;
headHeapSnapshotUrl: string;
detailedHtmlUrl?: string | null;
};
function renderMetricRow(
label: string,
base: BrowserMetricsReport,
head: BrowserMetricsReport,
getSummaryValue: (summary: BrowserMeasurement) => number,
getSampleValue: (sample: BrowserMeasurementSample) => number,
formatter: (value: number) => string,
significantThreshold = 0,
skipIfNotSignificant = true,
) {
const baseValue = getSummaryValue(base.summary);
const headValue = getSummaryValue(head.summary);
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
const summary = pairedDeltaSummary(base.samples, head.samples, sample => getSampleValue(sample));
// 有意な閾値に満たない場合はそもそもrowとして出力しない
if (skipIfNotSignificant && (Math.abs(summary.median) < significantThreshold)) return null;
const deltaMedian = formatColoredDelta(summary.median, formatter, significantThreshold);
return `| **${label}** | ${formatter(baseValue)} | ${formatter(headValue)} | ${deltaMedian} | ${formatter(summary.mad)} | ${formatColoredDelta(summary.min, formatter, significantThreshold)} | ${formatColoredDelta(summary.max, formatter, significantThreshold)} |`;
}
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
}
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
return resourceTypeBytes(sample, resourceTypes);
}
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport, all = false) {
//function getMetric(report: BrowserMeasurement, key: string) {
// return report.performance.cdpMetrics[key];
//}
const rows = [
//renderMetricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
renderMetricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, formatNumber, 1, !all),
//renderMetricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, formatNumber),
renderMetricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, formatBytes, 10000, !all),
renderMetricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, formatBytes, 10000, !all),
renderMetricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, formatBytes, 10000, !all),
renderMetricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, formatBytes, 10000, !all),
renderMetricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), formatBytes, 10000, !all),
renderMetricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), formatBytes, 10000, !all),
renderMetricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), formatBytes, 10000, !all),
renderMetricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), formatBytes, 10000, !all),
renderMetricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), formatBytes, 10000, !all),
//renderMetricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs),
//renderMetricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs),
//renderMetricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, formatNumber),
//renderMetricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs),
//renderMetricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs),
//renderMetricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), formatBytes),
//renderMetricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), formatBytes),
//renderMetricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, formatBytes, 10000),
//renderMetricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, formatNumber),
//renderMetricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), formatNumber),
//renderMetricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), formatNumber),
//renderMetricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), formatNumber),
//renderMetricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), formatNumber),
//renderMetricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs),
//renderMetricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs),
renderMetricRow('WebSocket connections', base, head, summary => summary.network.webSocketConnectionCount, sample => sample.network.webSocketConnectionCount, formatNumber, 1, !all),
renderMetricRow('WebSocket sent', base, head, summary => summary.network.webSocketSentBytes, sample => sample.network.webSocketSentBytes, formatBytes, 10000, !all),
renderMetricRow('WebSocket received', base, head, summary => summary.network.webSocketReceivedBytes, sample => sample.network.webSocketReceivedBytes, formatBytes, 10000, !all),
renderMetricRow('Page errors', base, head, summary => summary.diagnostics.pageErrorCount, sample => sample.diagnostics.pageErrorCount, formatNumber, 1, !all),
renderMetricRow('Console log', base, head, summary => summary.diagnostics.console.log, sample => sample.diagnostics.console.log, formatNumber, 1, !all),
renderMetricRow('Console warnings', base, head, summary => summary.diagnostics.console.warning, sample => sample.diagnostics.console.warning, formatNumber, 1, !all),
renderMetricRow('Console errors', base, head, summary => summary.diagnostics.console.error, sample => sample.diagnostics.console.error, formatNumber, 1, !all),
renderMetricRow('Console info', base, head, summary => summary.diagnostics.console.info, sample => sample.diagnostics.console.info, formatNumber, 1, !all),
renderMetricRow('Page-attributed memory', base, head, summary => summary.performance.tabMemory.totalBytes, sample => sample.performance.tabMemory.totalBytes, formatBytes, 10000, !all),
].filter(row => row != null);
return [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
...rows,
].join('\n');
}
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
const keys = [...new Set([
...preferredOrder,
...Object.keys(base.summary.network.byResourceType),
...Object.keys(head.summary.network.byResourceType),
])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null);
const lines = [
'<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 renderMarkdown(base: BrowserMetricsReport, head: BrowserMetricsReport, options: RenderMarkdownOptions) {
const detailedHtmlUrl = options.detailedHtmlUrl;
const heapSnapshotTable = renderHeapSnapshotTable(toHeapSnapshotReport(base), toHeapSnapshotReport(head));
const lines = [
'## 🖥 Frontend Browser Diagnostics Report',
'',
renderSummaryTable(base, head),
'',
'<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._',
'',
//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';
}

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 './browser/controller';
export const scenarioDescription = 'fresh browser signup, first timeline note, after the note becomes visible';
/**
* 各ラウンドを同じ初期状態から始めるため、DBを消して管理者だけ作り直す。
*/
export async function prepareInstance(baseUrl: string) {
await resetState(baseUrl);
await registerUser(baseUrl, 'admin', 'admin1234', true);
}
export async function runSignupAndPostScenario(chrome: HeadlessChromeController, baseUrl: string) {
const page = chrome.page;
const noteText = `Frontend browser metrics ${Date.now()}`;
await visitHome(page, baseUrl);
await signupThroughUi(page, { username: 'alice', password: 'password' });
await closeUserSetupDialog(page);
await postNote(page, noteText, 10_000);
// 投稿直後の非同期処理が落ち着いてから計測したいので少し待つ
await sleep(1000);
}

View File

@@ -0,0 +1,99 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
export function sleep(ms: number) {
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
}
function commandName(command: string) {
if (process.platform !== 'win32') return command;
if (command === 'pnpm') return 'pnpm.cmd';
return command;
}
/**
* 計測対象のリポジトリで Misskey テストサーバーを起動する。
* POSIXでは detached にして、後で子孫プロセスごとまとめて落とせるようにする。
*/
export function startServer(label: string, repoDir: string) {
process.stderr.write(`[${label}] Starting Misskey test server\n`);
const child = spawn(commandName('pnpm'), ['start:test'], {
cwd: repoDir,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
});
child.stdout.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
child.stderr.on('data', data => process.stderr.write(`[server:${label}] ${data}`));
return child;
}
const serverStartupTimeoutMs = 120_000;
export async function waitForServer(baseUrl: string, child: ChildProcess) {
const startedAt = Date.now();
while (Date.now() - startedAt < serverStartupTimeoutMs) {
if (child.exitCode != null) throw new Error(`Misskey server exited early with code ${child.exitCode}`);
try {
// 応答が返らないままだとfetchが待ち続け、外側の120秒の上限を超えてしまう
const remainingMs = serverStartupTimeoutMs - (Date.now() - startedAt);
const response = await fetch(`${baseUrl}/`, {
redirect: 'manual',
signal: AbortSignal.timeout(remainingMs),
});
if (response.status < 500) return;
} catch {
// 中断・接続拒否いずれもまだ起動中とみなしてリトライする
}
await sleep(1_000);
}
throw new Error(`Timed out waiting for ${baseUrl}`);
}
export async function stopServer(child: ChildProcess) {
if (child.exitCode != null) return;
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else if (child.pid != null) {
try {
// プロセスグループごと落とさないと pnpm 配下の node が残る
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
}
}
await new Promise<void>(resolvePromise => {
if (child.exitCode != null) {
resolvePromise();
return;
}
const forceKillTimer = setTimeout(() => {
// 猶予の間に終了していれば、PIDが再利用されて無関係のプロセスを撃つ恐れがある
if (child.exitCode == null && child.pid != null) {
try {
if (process.platform === 'win32') {
spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else {
process.kill(-child.pid, 'SIGKILL');
}
} catch {
child.kill('SIGKILL');
}
}
resolvePromise();
}, 10_000);
forceKillTimer.unref();
child.once('exit', () => {
clearTimeout(forceKillTimer);
resolvePromise();
});
});
}

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 './browser/diagnostics';
import type { BrowserMeasurement, BrowserMeasurementSample, BrowserMetricsReport, NetworkSummary } from './types';
export type SummarizeOptions = {
url: string;
heapSnapshotBreakdownTopN: number;
};
/**
* 中央値に最も近いサンプルを1つ選ぶ。
* largestRequests のように中央値を取れない項目は、代表サンプルの値をそのまま採用する。
*/
export function selectRepresentativeSample(samples: BrowserMeasurementSample[], getValue: (sample: BrowserMeasurementSample) => number) {
const medianValue = finiteMedian(samples.map(getValue), 0);
let selected: { sample: BrowserMeasurementSample; distance: number } | null = null;
for (const sample of samples) {
const value = getValue(sample);
if (!Number.isFinite(value)) continue;
const distance = Math.abs(value - medianValue);
if (selected == null || distance < selected.distance || (distance === selected.distance && sample.round < selected.sample.round)) {
selected = {
sample,
distance,
};
}
}
return selected?.sample ?? samples[0];
}
function summarizeResourceType(samples: BrowserMeasurementSample[], resourceType: string) {
return {
requests: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.requests), 0),
encodedBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.encodedBytes), 0),
decodedBodyBytes: finiteMedian(samples.map(sample => sample.network.byResourceType[resourceType]?.decodedBodyBytes), 0),
};
}
export function summarizeNetworkSamples(samples: BrowserMeasurementSample[]): NetworkSummary {
const resourceTypes = new Set<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,139 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { HeapSnapshotData } from 'diagnostics-shared/heap-snapshot';
export type NetworkRequest = {
requestId: string;
url: string;
method: string;
resourceType: string;
startedAt: number;
documentUrl?: string;
requestHeaders?: Record<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,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,83 @@
## 🖥 Frontend Browser Diagnostics Report
| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Encoded network** | 1 MB | 1.1 MB | $\color{orange}{\text{+83 KB}}$ | 816 B | $\color{orange}{\text{+82 KB}}$ | $\color{orange}{\text{+84 KB}}$ |
| **Decoded body** | 3.5 MB | 3.7 MB | $\color{orange}{\text{+277 KB}}$ | 2.7 KB | $\color{orange}{\text{+274 KB}}$ | $\color{orange}{\text{+279 KB}}$ |
| **Same-origin encoded** | 1 MB | 1.1 MB | $\color{orange}{\text{+82 KB}}$ | 800 B | $\color{orange}{\text{+81 KB}}$ | $\color{orange}{\text{+82 KB}}$ |
| **Script encoded** | 918 KB | 991 KB | $\color{orange}{\text{+73 KB}}$ | 720 B | $\color{orange}{\text{+73 KB}}$ | $\color{orange}{\text{+74 KB}}$ |
| **Page-attributed memory** | 92 MB | 99 MB | $\color{orange}{\text{+7.3 MB}}$ | 72 KB | $\color{orange}{\text{+7.3 MB}}$ | $\color{orange}{\text{+7.4 MB}}$ |
<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 | Δ median | Δ MAD | Δ min | Δ max |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| $\color{gray}{\rule{8pt}{8pt}}$ **Total** | 1 MB <br> ± 10 KB | 1.1 MB <br> ± 11 KB | $\text{+82 KB}$<br>$\color{orange}{\text{+8\\%}}$ | 800 B | $\text{+81 KB}$ | $\text{+82 KB}$ |
| | | | | | | |
| <details><summary>$\color{orange}{\rule{8pt}{8pt}}$ **Code**</summary>200% → 200%</details> | 2 MB | 2.2 MB | $\color{orange}{\text{+163 KB}}$ | 1.6 KB | $\color{orange}{\text{+162 KB}}$ | $\color{orange}{\text{+165 KB}}$ |
| <details><summary>$\color{red}{\rule{8pt}{8pt}}$ **Strings**</summary>300% → 300%</details> | 3.1 MB | 3.3 MB | $\color{orange}{\text{+245 KB}}$ | 2.4 KB | $\color{orange}{\text{+242 KB}}$ | $\color{orange}{\text{+247 KB}}$ |
| <details><summary>$\color{cyan}{\rule{8pt}{8pt}}$ **JS arrays**</summary>400% → 400%</details> | 4.1 MB | 4.4 MB | $\color{orange}{\text{+326 KB}}$ | 3.2 KB | $\color{orange}{\text{+323 KB}}$ | $\color{orange}{\text{+330 KB}}$ |
| <details><summary>$\color{green}{\rule{8pt}{8pt}}$ **Typed arrays**</summary>500% → 500%</details> | 5.1 MB | 5.5 MB | $\color{orange}{\text{+408 KB}}$ | 4 KB | $\color{orange}{\text{+404 KB}}$ | $\color{orange}{\text{+412 KB}}$ |
| <details><summary>$\color{yellow}{\rule{8pt}{8pt}}$ **System objects**</summary>600% → 600%</details> | 6.1 MB | 6.6 MB | $\color{orange}{\text{+490 KB}}$ | 4.8 KB | $\color{orange}{\text{+485 KB}}$ | $\color{orange}{\text{+494 KB}}$ |
| <details><summary>$\color{violet}{\rule{8pt}{8pt}}$ **Other JS objs**</summary>700% → 700%</details> | 7.1 MB | 7.7 MB | $\color{orange}{\text{+571 KB}}$ | 5.6 KB | $\color{orange}{\text{+566 KB}}$ | $\color{orange}{\text{+577 KB}}$ |
| <details><summary>$\color{pink}{\rule{8pt}{8pt}}$ **Other non-JS objs**</summary>800% → 800%</details> | 8.2 MB | 8.8 MB | $\color{orange}{\text{+653 KB}}$ | 6.4 KB | $\color{orange}{\text{+646 KB}}$ | $\color{orange}{\text{+659 KB}}$ |
Download representative heap snapshot: [base](https://example.invalid/base) / [head](https://example.invalid/head)
</details>

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,69 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { renderHtml } from '../src/report/html';
import { renderMarkdown } from '../src/report/markdown';
import type { BrowserMetricsReport } from '../src/types';
const fixturesDir = join(import.meta.dirname, 'fixtures');
async function loadFixture(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}.json`), 'utf8')) as BrowserMetricsReport;
}
beforeEach(() => {
// renderHtml() が生成時刻を埋め込むので、スナップショットが揺れないよう時刻を固定する
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-18T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
/**
* 出力をゴールデンファイルで固定する。
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
*/
test('renders the browser diagnostics markdown report', async () => {
const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
detailedHtmlUrl: 'https://example.invalid/html',
});
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
});
test('omits the details link when no detailed html artifact was uploaded', async () => {
const markdown = renderMarkdown(await loadFixture('base'), await loadFixture('head'), {
baseHeapSnapshotUrl: 'https://example.invalid/base',
headHeapSnapshotUrl: 'https://example.invalid/head',
detailedHtmlUrl: null,
});
expect(markdown).not.toContain('View details');
});
test('renders the network request diff html report', async () => {
const html = renderHtml(await loadFixture('base'), await loadFixture('head'));
await expect(html).toMatchFileSnapshot('./__snapshots__/render-html.html');
});
test('escapes html metacharacters coming from the browser session', async () => {
const base = await loadFixture('base');
const head = await loadFixture('head');
// CDP由来の値は原理的には任意の文字列になりうるので、生HTMLとして出ないことを確かめる
head.samples[0].networkRequests[0].url = 'http://127.0.0.1:61812/"><script>alert(1)</script>';
const html = renderHtml(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,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,22 @@
{
"name": "diagnostics-frontend-bundle",
"private": true,
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"lint": "pnpm typecheck && pnpm eslint",
"render-md": "tsx src/render-md.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"diagnostics-shared": "workspace:*"
},
"devDependencies": {
"@types/node": "26.1.1",
"tsx": "4.23.1",
"typescript": "5.9.3",
"vite": "8.1.4",
"vitest": "4.1.10"
}
}

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 { CollectedReport, FileEntry } from './manifest';
/**
* この差分以下のチャンクは個別に出さず `(other)` にまとめる。
* ハッシュ文字列の揺れ等でサイズが数バイト動くだけの行がノイズになるため。
*/
const smallDeltaThreshold = 5;
/** diff表に個別行として出す上限。これを超えた分は `(other)` に集約する */
const diffRowLimit = 30;
function entryDisplayName(entry: FileEntry | undefined) {
if (entry == null) return '';
return entry.displayName || entry.file;
}
export function getChunkComparisonRows(keys: string[], before: Partial<Record<string, FileEntry>>, after: Partial<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),
};
});
}
export type ChunkComparisonRow = ReturnType<typeof getChunkComparisonRows>[number];
export type ChunkAggregate = {
beforeSize: number;
afterSize: number;
beforeCount: number;
afterCount: number;
};
export function sumChunkSizes(chunks: FileEntry[]) {
return chunks.reduce((sum, chunk) => sum + chunk.size, 0);
}
/**
* 比較キーを持たない (= before/after で対応付けできない) チャンクの合計。
*/
export function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate {
const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null);
const afterGenerated = after.filter(chunk => chunk.comparisonKey == null);
return {
beforeSize: sumChunkSizes(beforeGenerated),
afterSize: sumChunkSizes(afterGenerated),
beforeCount: beforeGenerated.length,
afterCount: afterGenerated.length,
};
}
export function hasSmallDelta(row: ChunkComparisonRow) {
return Math.abs(row.afterSize - row.beforeSize) <= smallDeltaThreshold;
}
export function comparisonRowsAggregate(rows: ChunkComparisonRow[]): ChunkAggregate {
return {
beforeSize: rows.reduce((sum, row) => sum + row.beforeSize, 0),
afterSize: rows.reduce((sum, row) => sum + row.afterSize, 0),
beforeCount: rows.filter(row => row.beforeFile != null).length,
afterCount: rows.filter(row => row.afterFile != null).length,
};
}
export function comparableMap(chunks: FileEntry[]) {
const entries: [string, FileEntry][] = [];
for (const chunk of chunks) {
if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]);
}
return Object.fromEntries(entries);
}
export function summarizeChunkChanges(rows: ChunkComparisonRow[]) {
return {
updated: rows.filter((row) => row.changeType === 'updated').length,
added: rows.filter((row) => row.changeType === 'added').length,
removed: rows.filter((row) => row.changeType === 'removed').length,
};
}
export function formatChunkChangeSummary(label: string, summary: ReturnType<typeof summarizeChunkChanges>) {
return `${label} (${summary.updated} updated, ${summary.added} added, ${summary.removed} removed)`;
}
/**
* 差分の絶対値が大きい順。同着は増加側・元サイズ・名前の順で決定的に並べる。
*/
export function compareChunkComparisonRows(a: ChunkComparisonRow, b: ChunkComparisonRow) {
return Math.abs(b.afterSize - b.beforeSize) - Math.abs(a.afterSize - a.beforeSize)
|| (b.afterSize - b.beforeSize) - (a.afterSize - a.beforeSize)
|| b.sortSize - a.sortSize
|| a.name.localeCompare(b.name);
}
export function chunkFileDisplay(row: ChunkComparisonRow) {
if (row.beforeFile == null) return row.afterFile ?? '';
if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile;
return `${row.beforeFile}${row.afterFile}`;
}
export function chunkMarkdownTable(
rows: ChunkComparisonRow[],
total?: { beforeSize: number; afterSize: number },
generated?: ChunkAggregate,
other?: ChunkAggregate,
) {
const hasGenerated = generated != null && (generated.beforeCount > 0 || generated.afterCount > 0);
const hasOther = other != null && (other.beforeCount > 0 || other.afterCount > 0);
if (rows.length === 0 && total == null && !hasGenerated && !hasOther) return '_No data_';
const lines = [
'| Chunk | Before | After | Δ | Δ (%) |',
'| --- | ---: | ---: | ---: | ---: |',
];
if (total != null) {
lines.push(`| (total) | ${formatBytes(total.beforeSize)} | ${formatBytes(total.afterSize)} | ${calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(total.beforeSize, total.afterSize, 0.1)} |`);
lines.push('| | | | | |');
}
for (const row of rows) {
const chunkFile = chunkFileDisplay(row);
if (row.changeType === 'added') {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`);
} else if (row.changeType === 'removed') {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`);
} else {
lines.push(`| <details><summary>\`${escapeMdTableCell(row.name)}\`</summary> \`${escapeMdTableCell(chunkFile)}\` </details> | ${formatBytes(row.beforeSize)} | ${formatBytes(row.afterSize)} | ${calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(row.beforeSize, row.afterSize, 0.1)} |`);
}
}
if (hasGenerated) {
lines.push(`| (other generated chunks) | ${formatBytes(generated.beforeSize)} | ${formatBytes(generated.afterSize)} | ${calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(generated.beforeSize, generated.afterSize, 0.1)} |`);
}
if (hasOther) {
lines.push(`| (other) | ${formatBytes(other.beforeSize)} | ${formatBytes(other.afterSize)} | ${calcAndFormatDeltaBytes(other.beforeSize, other.afterSize, 1000)} | ${calcAndFormatDeltaPercentInMdTable(other.beforeSize, other.afterSize, 0.1)} |`);
}
return lines.join('\n');
}
export function renderFrontendChunkReport(before: CollectedReport, after: CollectedReport) {
const beforeComparable = before.comparableChunks;
const afterComparable = after.comparableChunks;
const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])];
const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable);
const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged');
const diffSummary = summarizeChunkChanges(changedRows);
const diffTotal = {
beforeSize: sumChunkSizes(before.chunks),
afterSize: sumChunkSizes(after.chunks),
};
const diffGenerated = generatedAggregate(before.chunks, after.chunks);
const largeDeltaRows = changedRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const diffRows = largeDeltaRows.slice(0, diffRowLimit);
// 表示上限で切り捨てた行も `(other)` に含める。落とすと合計が実際の変化量と合わなくなる
const diffOther = comparisonRowsAggregate([
...changedRows.filter(hasSmallDelta),
...largeDeltaRows.slice(diffRowLimit),
]);
const beforeStartupFiles = new Set(before.startupFiles);
const afterStartupFiles = new Set(after.startupFiles);
const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file));
const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file));
const beforeStartupComparable = comparableMap(beforeStartupChunks);
const afterStartupComparable = comparableMap(afterStartupChunks);
const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])];
const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable);
const startupSummary = summarizeChunkChanges(startupComparisonRows);
const startupOther = comparisonRowsAggregate(startupComparisonRows.filter(hasSmallDelta));
const startupRows = startupComparisonRows.filter(row => !hasSmallDelta(row)).sort(compareChunkComparisonRows);
const startupTotal = {
beforeSize: sumChunkSizes(beforeStartupChunks),
afterSize: sumChunkSizes(afterStartupChunks),
};
const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks);
return [
'<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,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
/**
* Windows の `\` 区切りを `/` に揃える。manifest 側のキーが常に `/` 区切りのため、
* 実ファイルパスと突き合わせる前に正規化する必要がある。
*/
export function normalizePath(filePath: string) {
return filePath.split(path.sep).join('/');
}
export async function fileExists(filePath: string) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function fileSize(filePath: string) {
const stat = await fs.stat(filePath);
return stat.size;
}
export async function* traverseDirectory(dir: string): AsyncGenerator<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,171 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileExists, fileSize, normalizePath, traverseDirectory } from './fs-utils';
import type { Manifest, ManifestChunk } from 'vite';
/**
* 比較対象とするロケール。ロケール別チャンクは全ロケール分だと数が多すぎるため、
* 代表として ja-JP のみを見る。
*/
const locale = 'ja-JP';
/**
* `src` を持たないチャンクのうち、名前がビルド間で安定していて比較可能なもの。
*/
const stableNamedChunks = new Set(['vue', 'i18n']);
export type FileEntry = {
comparisonKey: string | null;
displayName: string;
file: string;
manifestKeys: string[];
size: number;
};
export type CollectedReport = {
manifest: Manifest;
chunks: FileEntry[];
comparableChunks: Record<string, FileEntry>;
chunksByManifestKey: Record<string, FileEntry>;
startupFiles: string[];
};
export function findEntryKey(manifest: Manifest) {
const entries = Object.entries(manifest);
return entries.find(([key, chunk]) => key === 'src/_boot_.ts' || chunk.src === 'src/_boot_.ts')?.[0]
?? entries.find(([, chunk]) => chunk.name === 'entry' && chunk.isEntry)?.[0]
?? entries.find(([, chunk]) => chunk.isEntry)?.[0]
?? null;
}
/**
* ビルド間で安定するチャンク識別子。出力ファイル名はハッシュ付きで毎回変わるため、
* これが取れないチャンクは before/after の対応付けができない。
*/
export function stableChunkKey(chunk: ManifestChunk) {
if (chunk.src != null) return `src:${normalizePath(chunk.src)}`;
if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`;
return null;
}
/**
* 起動時に必ず読み込まれるチャンク (entry とその静的 import) の manifest キーを集める。
*/
export function collectStartupManifestKeys(manifest: Manifest) {
const entryKey = findEntryKey(manifest);
const keys = new Set<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 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 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,32 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { readRequiredEnv } from 'diagnostics-shared/env';
import { collectReport } from './manifest';
import { renderBundleReportMarkdown } from './report';
import type { VisualizerReport } from './visualizer';
async function main() {
const [beforeDir, afterDir, beforeStatsFile, afterStatsFile, outFile] = process.argv.slice(2).map(arg => path.resolve(arg));
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (outFile == null) throw new Error('Usage: render-md <beforeDir> <afterDir> <beforeStatsJson> <afterStatsJson> <outMd>');
// 未設定のまま `undefined` という文字列をコメントに埋め込まないよう、ここで落とす
const visualizerArtifactUrl = readRequiredEnv('FRONTEND_BUNDLE_REPORT_ARTIFACT_URL');
const before = await collectReport(beforeDir);
const after = await collectReport(afterDir);
const beforeStats = JSON.parse(await fs.readFile(beforeStatsFile, 'utf8')) as VisualizerReport;
const afterStats = JSON.parse(await fs.readFile(afterStatsFile, 'utf8')) as VisualizerReport;
await fs.writeFile(outFile, renderBundleReportMarkdown(before, after, beforeStats, afterStats, { visualizerArtifactUrl }));
}
await main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { renderFrontendChunkReport } from './chunk-report';
import { collectVisualizerReport, renderVisualizerSummaryTable, type VisualizerReport } from './visualizer';
import type { CollectedReport } from './manifest';
export type RenderBundleReportOptions = {
/** rollup-plugin-visualizer が出力したtreemap HTMLのartifact URL */
visualizerArtifactUrl: string;
};
export function renderBundleReportMarkdown(
before: CollectedReport,
after: CollectedReport,
beforeStats: VisualizerReport,
afterStats: VisualizerReport,
options: RenderBundleReportOptions,
) {
return [
'## 📦 Frontend Bundle Report',
'',
renderFrontendChunkReport(before, after),
'',
'## Bundle Stats',
'',
renderVisualizerSummaryTable(collectVisualizerReport(beforeStats), collectVisualizerReport(afterStats)),
'',
`[Open treemap HTML](${options.visualizerArtifactUrl})`,
].join('\n');
}

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(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>${formatNumber(before.summary[key])}</td>`),
...metrics.map((key) => `<td>${formatBytes(before.metrics[key])}</td>`),
'</tr>',
'<tr>',
'<th><b>After</b></th>',
...summary.map((key) => `<td>${formatNumber(after.summary[key])}</td>`),
...metrics.map((key) => `<td>${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>${calcAndFormatDeltaNumber(before.summary[key], after.summary[key], 0)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaBytes(before.metrics[key], after.metrics[key], 1000)}</td>`),
'</tr>',
'<tr>',
'<th><b>Δ (%)</b></th>',
...summary.map((key) => `<td>${calcAndFormatDeltaPercent(before.summary[key], after.summary[key], 0.1)}</td>`),
...metrics.map((key) => `<td>${calcAndFormatDeltaPercent(before.metrics[key], after.metrics[key], 0.1)}</td>`),
'</tr>',
'</tbody>',
'</table>',
].join('\n');
}

View File

@@ -0,0 +1,100 @@
## 📦 Frontend Bundle Report
<details>
<summary>Chunk size diff (2 updated, 0 added, 0 removed)</summary>
| Chunk | Before | After | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (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 | Before | After | Δ | Δ (%) |
| --- | ---: | ---: | ---: | ---: |
| (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>
## Bundle Stats
<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>
<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>After</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 @@
{"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 @@
{"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,105 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterAll, beforeAll, expect, test } from 'vitest';
import { collectReport } from '../src/manifest';
import { renderBundleReportMarkdown } from '../src/report';
import type { VisualizerReport } from '../src/visualizer';
const fixturesDir = join(import.meta.dirname, 'fixtures');
/**
* ビルド成果物のfixture。
*
* `collectReport` はファイルの中身を見ずサイズしか使わないので、実体は指定バイト数の
* 詰め物でよい。ディレクトリ名が `built` になるためリポジトリにはコミットできず
* (ルートの .gitignore がビルド成果物として除外する)、テスト実行時に組み立てている。
*/
const manifest = {
'src/_boot_.ts': { file: 'assets/boot-a1.js', src: 'src/_boot_.ts', name: 'boot', isEntry: true, imports: ['_vue.js', '_i18n.js'] },
'_vue.js': { file: 'assets/vue-b2.js', name: 'vue' },
// `scripts/` 配下はロケール別に出力されるので ja-JP/ に解決される
'_i18n.js': { file: 'scripts/i18n-c3.js', name: 'i18n' },
'src/pages/foo.vue': { file: 'assets/foo-d4.js', src: 'src/pages/foo.vue', name: 'foo' },
// .js 以外はチャンクとして数えない
'src/pages/style.css': { file: 'assets/style-e5.css', src: 'src/pages/style.css' },
};
const fileSizes = {
before: {
'assets/boot-a1.js': 20_000,
'assets/vue-b2.js': 90_000,
'assets/foo-d4.js': 5_000,
'assets/style-e5.css': 100,
'ja-JP/i18n-c3.js': 4_000,
'ja-JP/orphan.js': 1_200,
},
after: {
// 差が小さすぎる (閾値5バイト以下) ので「(other)」に集約される
'assets/boot-a1.js': 20_003,
// 明確に増えるので diff表に行として出る
'assets/vue-b2.js': 96_000,
'assets/foo-d4.js': 5_000,
'assets/style-e5.css': 100,
'ja-JP/i18n-c3.js': 4_000,
// manifestに載らない出力なので「(other generated chunks)」に集約される
'ja-JP/orphan.js': 1_500,
},
} as const satisfies Record<'before' | 'after', Record<string, number>>;
let repoDirs: { before: string; after: string };
let workDir: string;
beforeAll(async () => {
workDir = await mkdtemp(join(tmpdir(), 'diagnostics-frontend-bundle-'));
for (const label of ['before', 'after'] as const) {
const outDir = join(workDir, label, 'built/_frontend_vite_');
await mkdir(outDir, { recursive: true });
await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest));
for (const [file, size] of Object.entries(fileSizes[label])) {
const path = join(outDir, file);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, 'x'.repeat(size));
}
}
repoDirs = {
before: join(workDir, 'before'),
after: join(workDir, 'after'),
};
});
afterAll(async () => {
await rm(workDir, { recursive: true, force: true });
});
async function loadStats(name: string) {
return JSON.parse(await readFile(join(fixturesDir, `${name}-stats.json`), 'utf8')) as VisualizerReport;
}
/**
* 出力をゴールデンファイルで固定する。
* 意図的に変更したときは `vitest -u` で更新し、__snapshots__ の差分もレビューすること。
*/
test('renders the frontend bundle report', async () => {
const markdown = renderBundleReportMarkdown(
await collectReport(repoDirs.before),
await collectReport(repoDirs.after),
await loadStats('before'),
await loadStats('after'),
{ visualizerArtifactUrl: 'https://example.invalid/treemap' },
);
await expect(markdown).toMatchFileSnapshot('./__snapshots__/render-md.md');
});
test('fails loudly when the built output is missing', async () => {
await expect(collectReport(join(workDir, 'nonexistent'))).rejects.toThrow();
});

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,23 @@
{
"name": "diagnostics-shared",
"private": true,
"type": "module",
"exports": {
"./env": "./src/env.ts",
"./format": "./src/format.ts",
"./html": "./src/html.ts",
"./stats": "./src/stats.ts",
"./heap-snapshot": "./src/heap-snapshot/index.ts"
},
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"lint": "pnpm typecheck && pnpm eslint",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "26.1.1",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}

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,177 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
formatBytes,
formatDeltaBytes,
formatDeltaPercentInMdTable,
formatPercent,
} from '../format';
import { mad, pairedDeltaSummary } from '../stats';
import {
heapSnapshotCategories,
heapSnapshotCategory,
type HeapSnapshotCategory,
type HeapSnapshotReport,
} from './categories';
/** これ未満のバイト差分には色を付けない (0.1 MB) */
const byteColorThreshold = 100_000;
function categoryValue(report: HeapSnapshotReport, category: HeapSnapshotCategory) {
return report.summary.categories[category];
}
function categorySampleSpread(report: HeapSnapshotReport, category: HeapSnapshotCategory) {
const values = report.samples
.map(sample => sample.data.categories[category])
.filter(value => Number.isFinite(value));
if (values.length < 2) throw new Error(`Not enough samples for category ${category}`);
return mad(values);
}
function swatch(category: HeapSnapshotCategory) {
return `$\\color{${heapSnapshotCategory[category].color}}{\\rule{8pt}{8pt}}$ **${heapSnapshotCategory[category].label}**`;
}
/**
* base / head のheap snapshotをカテゴリ別に比較するMarkdownテーブルを描画する。
*/
export function renderHeapSnapshotTable(base: HeapSnapshotReport, head: HeapSnapshotReport) {
const lines = [
'| Metric | Base | Head | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
const baseTotal = categoryValue(base, 'total');
const headTotal = categoryValue(head, 'total');
for (const category of heapSnapshotCategories) {
const baseValue = categoryValue(base, category);
const headValue = categoryValue(head, category);
const summary = pairedDeltaSummary(base.samples, head.samples, sample => sample.data.categories[category]);
const deltaColumns = `${formatBytes(summary.mad)} | ${formatDeltaBytes(summary.min, byteColorThreshold)} | ${formatDeltaBytes(summary.max, byteColorThreshold)}`;
if (category === 'total') {
// Totalだけはばらつきと変化率も併記する
const percent = summary.median * 100 / baseValue;
const deltaMedian = `${formatDeltaBytes(summary.median, byteColorThreshold)}<br>${formatDeltaPercentInMdTable(percent, 0.1)}`;
const baseText = `${formatBytes(baseValue)} <br> ± ${formatBytes(categorySampleSpread(base, category))}`;
const headText = `${formatBytes(headValue)} <br> ± ${formatBytes(categorySampleSpread(head, category))}`;
lines.push(`| ${swatch(category)} | ${baseText} | ${headText} | ${deltaMedian} | ${deltaColumns} |`);
lines.push('| | | | | | | |');
} else {
// 各カテゴリはTotalに占める割合の推移をdetailsで見せる
const basePercent = formatPercent((baseValue * 100) / baseTotal);
const headPercent = formatPercent((headValue * 100) / headTotal);
const metricText = `<details><summary>${swatch(category)}</summary>${basePercent}${headPercent}</details>`;
lines.push(`| ${metricText} | ${formatBytes(baseValue)} | ${formatBytes(headValue)} | ${formatDeltaBytes(summary.median, byteColorThreshold)} | ${deltaColumns} |`);
}
}
if (lines.length === 2) return null;
return lines.join('\n');
}
const sankeyChildMinRatio = 0.3;
const sankeyParentMinPercent = 10;
function escapeCsvValue(value: string) {
return `"${String(value).replaceAll('"', '""')}"`;
}
function formatSankeyPercentValue(value: number) {
const rounded = Math.round(value * 100) / 100;
if (rounded === 0 && value > 0) return '0.01';
if (Number.isInteger(rounded)) return String(rounded);
return rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
}
/**
* heap snapshotの構成比をmermaidのsankey図として描画する。
* 全体に占める割合が小さいカテゴリ・内訳は `Other` にまとめる。
*/
export function renderHeapSnapshotSankey(report: HeapSnapshotReport, title: string) {
const total = categoryValue(report, 'total');
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (total == null || total <= 0) return null;
const categories = heapSnapshotCategories
.filter(category => category !== 'total')
.map(category => {
const value = categoryValue(report, category);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (value == null || value <= 0) return null;
const breakdownEntries = Object.entries(report.summary.breakdowns?.[category] ?? {})
.filter(([, childValue]) => Number.isFinite(childValue) && childValue > 0)
.toSorted((a, b) => b[1] - a[1]);
const breakdownTotal = breakdownEntries.reduce((sum, [, childValue]) => sum + childValue, 0);
const percent = (value * 100) / total;
const childEntries: [string, number][] = [];
let otherPercent = 0;
if (breakdownTotal > 0 && percent > sankeyParentMinPercent) {
for (const [childName, childValue] of breakdownEntries) {
const childRatio = childValue / breakdownTotal;
if (childRatio >= sankeyChildMinRatio) {
childEntries.push([childName.replace(/^[^:]+:\s*/, ''), percent * childRatio]);
} else {
otherPercent += percent * childRatio;
}
}
if (childEntries.length > 0 && otherPercent > 0) {
childEntries.push(['Other', otherPercent]);
}
}
return { category, percent, childEntries };
})
.filter(value => value != null);
if (categories.length === 0) return null;
const nodeColors: Record<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,84 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function median(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
const center = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[center];
return Math.round((sorted[center - 1] + sorted[center]) / 2);
}
export function mad(values: number[]) {
if (values.length < 2) throw new Error('Not enough samples to calculate MAD');
const center = median(values);
return median(values.map(value => Math.abs(value - center)));
}
/**
* 有限値のみを対象に中央値を求める。有限値が1つも無い場合は `defaultValue` を返す。
*/
export function finiteMedian(values: (number | null | undefined)[]): number | null;
export function finiteMedian(values: (number | null | undefined)[], defaultValue: number): number;
export function finiteMedian(values: (number | null | undefined)[], defaultValue: number | null = null) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return defaultValue;
return median(finiteValues);
}
/**
* サンプルのばらつき (MAD) を求める。サンプルが2つ未満で求められない場合は null を返す。
*/
export function sampleSpread(values: (number | null | undefined)[]) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length < 2) return null;
return mad(finiteValues);
}
type RoundedSample = { round: number };
function indexByRound<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,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import { html, joinHtml, raw, Raw } from '../src/html';
describe('html', () => {
test('escapes interpolated values by default', () => {
const value = '<script>alert(1)</script>';
expect(String(html`<p>${value}</p>`)).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
});
test('escapes values used in attribute position', () => {
const url = 'x"><script>alert(1)</script>';
expect(String(html`<a href="${url}"></a>`)).toBe('<a href="x&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"></a>');
});
test('leaves the static parts of the template untouched', () => {
expect(String(html`<p class="a">x</p>`)).toBe('<p class="a">x</p>');
});
test('renders nullish values as an empty string', () => {
expect(String(html`<p>${null}${undefined}</p>`)).toBe('<p></p>');
});
test('does not double-escape nested fragments', () => {
const inner = html`<b>${'a&b'}</b>`;
expect(String(html`<p>${inner}</p>`)).toBe('<p><b>a&amp;b</b></p>');
});
// 配列をそのまま文字列化するとカンマ区切りで潰れる。実際に過去これで表が壊れた
test('joins arrays without separators instead of stringifying them', () => {
const items = [html`<li>1</li>`, html`<li>2</li>`];
expect(String(html`<ul>${items}</ul>`)).toBe('<ul><li>1</li><li>2</li></ul>');
});
test('escapes array elements that are not fragments', () => {
expect(String(html`<p>${['<a>', '<b>']}</p>`)).toBe('<p>&lt;a&gt;&lt;b&gt;</p>');
});
});
describe('raw', () => {
test('embeds trusted markup without escaping', () => {
expect(String(html`<style>${raw('a > b { color: red }')}</style>`)).toBe('<style>a > b { color: red }</style>');
});
test('produces a Raw fragment', () => {
expect(raw('x')).toBeInstanceOf(Raw);
expect(html`x`).toBeInstanceOf(Raw);
});
});
describe('joinHtml', () => {
test('joins fragments with the given separator', () => {
expect(String(joinHtml([html`<li>1</li>`, html`<li>2</li>`], '\n'))).toBe('<li>1</li>\n<li>2</li>');
});
test('returns an empty fragment for an empty list', () => {
expect(String(joinHtml([], '\n'))).toBe('');
});
});

View File

@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, test } from 'vitest';
import { finiteMedian, mad, median, pairedDeltaSummary, sampleSpread } from '../src/stats';
describe('median', () => {
test('takes the middle value of an odd-length sample', () => {
expect(median([3, 1, 2])).toBe(2);
});
// 偶数長では平均を整数に丸める。KiB単位の整数値を扱う前提のため
test('rounds the average of an even-length sample', () => {
expect(median([1, 2])).toBe(2);
expect(median([1, 4])).toBe(3);
});
});
describe('mad', () => {
test('measures the median absolute deviation', () => {
expect(mad([1, 1, 1])).toBe(0);
expect(mad([1, 2, 3])).toBe(1);
});
test('refuses to compute from a single sample', () => {
expect(() => mad([1])).toThrow();
});
});
describe('finiteMedian', () => {
test('ignores non-finite entries', () => {
expect(finiteMedian([1, null, undefined, Number.NaN, 3])).toBe(2);
});
test('returns null by default when nothing is finite', () => {
expect(finiteMedian([null, undefined])).toBeNull();
});
test('returns the supplied default when nothing is finite', () => {
expect(finiteMedian([null, undefined], 0)).toBe(0);
});
});
describe('sampleSpread', () => {
test('needs at least two finite samples', () => {
expect(sampleSpread([1])).toBeNull();
expect(sampleSpread([1, null])).toBeNull();
expect(sampleSpread([1, 3])).toBe(1);
});
});
describe('pairedDeltaSummary', () => {
const base = [
{ round: 1, value: 100 },
{ round: 2, value: 200 },
{ round: 3, value: 300 },
];
test('compares base and head within the same round', () => {
const head = [
{ round: 1, value: 110 },
{ round: 2, value: 230 },
{ round: 3, value: 320 },
];
expect(pairedDeltaSummary(base, head, sample => sample.value)).toStrictEqual({
median: 20,
mad: 10,
min: 10,
max: 30,
samples: 3,
});
});
test('drops rounds that only one side has', () => {
const head = [
{ round: 1, value: 110 },
{ round: 2, value: 230 },
{ round: 9, value: 999 },
];
expect(pairedDeltaSummary(base, head, sample => sample.value).samples).toBe(2);
});
// 1サンプルでも中央値・最小・最大は定まる (偏差は常に0)
test('summarizes a single paired round without treating MAD as an error', () => {
expect(pairedDeltaSummary([base[0]], [{ round: 1, value: 130 }], sample => sample.value)).toStrictEqual({
median: 30,
mad: 0,
min: 30,
max: 30,
samples: 1,
});
});
test('fails loudly when no round is shared', () => {
expect(() => pairedDeltaSummary(base, [{ round: 9, value: 1 }], sample => sample.value)).toThrow(/no rounds in common/);
});
// 負のroundはwarmupを表すので集計に混ぜない
test('ignores warmup rounds', () => {
const warmupBase = [{ round: -1, value: 0 }, ...base];
const warmupHead = [{ round: -1, value: 9999 }, { round: 1, value: 110 }, { round: 2, value: 230 }, { round: 3, value: 320 }];
expect(pairedDeltaSummary(warmupBase, warmupHead, sample => sample.value).samples).toBe(3);
});
});

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