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
@@ -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,
},
},
},
];
@@ -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"
}
}
@@ -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');
}
@@ -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;
}
}
}
@@ -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],
};
}
@@ -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);
});
@@ -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');
}
@@ -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');
}
@@ -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)
@@ -0,0 +1 @@
{"nodeParts": {"p0": {"renderedLength": 1100.0, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2200.0, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3300.0000000000005, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4400.0, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5500.0, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6600.000000000001, "gzipLength": 1800, "brotliLength": 1500}, "p6": {"renderedLength": 7700.000000000001, "gzipLength": 2100, "brotliLength": 1750}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [{"id": "m6", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m6": {"id": "/src/mod6.ts", "isEntry": false, "importedBy": ["m5"], "imported": [], "moduleParts": {"assets/boot-a1.js": "p6"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}}
@@ -0,0 +1 @@
{"nodeParts": {"p0": {"renderedLength": 1000, "gzipLength": 300, "brotliLength": 250}, "p1": {"renderedLength": 2000, "gzipLength": 600, "brotliLength": 500}, "p2": {"renderedLength": 3000, "gzipLength": 900, "brotliLength": 750}, "p3": {"renderedLength": 4000, "gzipLength": 1200, "brotliLength": 1000}, "p4": {"renderedLength": 5000, "gzipLength": 1500, "brotliLength": 1250}, "p5": {"renderedLength": 6000, "gzipLength": 1800, "brotliLength": 1500}}, "nodeMetas": {"m0": {"id": "/src/mod0.ts", "isEntry": true, "importedBy": [], "imported": [{"id": "m1", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p0"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m1": {"id": "/src/mod1.ts", "isEntry": false, "importedBy": ["m0"], "imported": [{"id": "m2", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p1"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m2": {"id": "/src/mod2.ts", "isEntry": false, "importedBy": ["m1"], "imported": [{"id": "m3", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p2"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m3": {"id": "/src/mod3.ts", "isEntry": false, "importedBy": ["m2"], "imported": [{"id": "m4", "dynamic": false}], "moduleParts": {"assets/vue-b2.js": "p3"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m4": {"id": "/src/mod4.ts", "isEntry": false, "importedBy": ["m3"], "imported": [{"id": "m5", "dynamic": true}], "moduleParts": {"assets/boot-a1.js": "p4"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}, "m5": {"id": "/src/mod5.ts", "isEntry": false, "importedBy": ["m4"], "imported": [], "moduleParts": {"assets/vue-b2.js": "p5"}, "renderedLength": 0, "gzipLength": 0, "brotliLength": 0}}, "options": {}}
@@ -0,0 +1,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();
});
@@ -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": []
}