1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 17:55:06 +02:00
This commit is contained in:
syuilo
2026-06-22 09:37:21 +09:00
parent ab00860066
commit 7f0568d5d1
3 changed files with 224 additions and 85 deletions

View File

@@ -1,14 +1,14 @@
import { readFile, writeFile } from 'node:fs/promises';
const inputFile = process.argv[2];
const outputFile = process.argv[3];
const [beforeFile, afterFile, outputFile] = process.argv.slice(2);
if (inputFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <stats.json> <report.md>');
if (beforeFile == null || afterFile == null || outputFile == null) {
console.error('Usage: node .github/scripts/frontend-bundle-visualizer-report.mjs <before-stats.json> <after-stats.json> <report.md>');
process.exit(1);
}
const byteFormatter = new Intl.NumberFormat('en-US');
const numberFormatter = new Intl.NumberFormat('en-US');
function formatBytes(value) {
if (!Number.isFinite(value) || value <= 0) return '0 B';
@@ -25,6 +25,10 @@ function formatBytes(value) {
return `${byteFormatter.format(Number(size.toFixed(maximumFractionDigits)))} ${units[unitIndex]}`;
}
function formatNumber(value) {
return numberFormatter.format(value);
}
function formatPercent(value) {
return `${value.toFixed(1)}%`;
}
@@ -34,6 +38,38 @@ function sharePercent(value, total) {
return formatPercent((value / total) * 100);
}
function formatMathText(text) {
return text
.replaceAll('\\', '\\\\')
.replaceAll('{', '\\{')
.replaceAll('}', '\\}')
.replaceAll('%', '\\\\%');
}
function formatColoredDiff(text, diff) {
const color = diff > 0 ? 'orange' : 'green';
return `$\\color{${color}}{\\text{${formatMathText(text)}}}$`;
}
function formatDiff(before, after, formatter) {
const diff = after - before;
if (diff === 0) return formatter(0);
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatter(Math.abs(diff))}`, diff);
}
function formatDiffPercent(before, after) {
if (before === 0 && after === 0) return '0%';
if (before === 0) return '-';
const diff = after - before;
if (diff === 0) return '0%';
const sign = diff > 0 ? '+' : '-';
return formatColoredDiff(`${sign}${formatPercent(Math.abs(diff / before) * 100)}`, diff);
}
function tableCell(value) {
return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' ');
}
@@ -52,96 +88,149 @@ function tableCode(value) {
return tableCell(code(value));
}
const data = JSON.parse(await readFile(inputFile, 'utf8'));
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
function collectReport(data) {
const nodeParts = data.nodeParts ?? {};
const nodeMetas = Object.values(data.nodeMetas ?? {});
const moduleRows = [];
const bundleMap = new Map();
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
parts: [],
};
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
row.parts.push({ bundleId, ...part });
const bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
for (const meta of nodeMetas) {
const row = {
id: meta.id,
bundles: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
importedByCount: meta.importedBy?.length ?? 0,
importedCount: meta.imported?.length ?? 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
for (const [bundleId, partUid] of Object.entries(meta.moduleParts ?? {})) {
const part = nodeParts[partUid];
if (part == null) continue;
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
row.bundles += 1;
row.renderedLength += part.renderedLength;
row.gzipLength += part.gzipLength;
row.brotliLength += part.brotliLength;
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 bundle = bundleMap.get(bundleId) ?? {
id: bundleId,
modules: 0,
renderedLength: 0,
gzipLength: 0,
brotliLength: 0,
};
bundle.modules += 1;
bundle.renderedLength += part.renderedLength;
bundle.gzipLength += part.gzipLength;
bundle.brotliLength += part.brotliLength;
bundleMap.set(bundleId, bundle);
}
if (row.bundles > 0) {
moduleRows.push(row);
}
}
let staticImports = 0;
let dynamicImports = 0;
for (const meta of nodeMetas) {
for (const imported of meta.imported ?? []) {
if (imported.dynamic) {
dynamicImports += 1;
} else {
staticImports += 1;
}
}
}
const bundleRows = [...bundleMap.values()].sort((a, b) => b.renderedLength - a.renderedLength);
const hotModules = [...moduleRows].sort((a, b) => b.renderedLength - a.renderedLength);
const totalRendered = moduleRows.reduce((sum, row) => sum + row.renderedLength, 0);
const totalGzip = moduleRows.reduce((sum, row) => sum + row.gzipLength, 0);
const totalBrotli = moduleRows.reduce((sum, row) => sum + row.brotliLength, 0);
return {
options: data.options ?? {},
summary: {
bundles: bundleRows.length,
modules: moduleRows.length,
entries: nodeMetas.filter((meta) => meta.isEntry).length,
externals: nodeMetas.filter((meta) => meta.isExternal).length,
staticImports,
dynamicImports,
},
metrics: {
renderedLength: totalRendered,
gzipLength: totalGzip,
brotliLength: totalBrotli,
},
hotModules,
};
}
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);
const entries = nodeMetas.filter((meta) => meta.isEntry).length;
const externals = nodeMetas.filter((meta) => meta.isExternal).length;
function renderSummaryTable(before, after) {
const metrics = [
{ key: 'bundles', label: 'Bundles' },
{ key: 'modules', label: 'Modules' },
{ key: 'entries', label: 'Entries' },
{ key: 'externals', label: 'Externals' },
{ key: 'staticImports', label: 'Static Imports' },
{ key: 'dynamicImports', label: 'Dynamic Imports' },
];
return [
`| | ${metrics.map((metric) => metric.label).join(' | ')} |`,
`|---|${metrics.map(() => '---:').join('|')}|`,
`| Before | ${metrics.map((metric) => formatNumber(before.summary[metric.key])).join(' | ')} |`,
`| After | ${metrics.map((metric) => formatNumber(after.summary[metric.key])).join(' | ')} |`,
`| Diff | ${metrics.map((metric) => formatDiff(before.summary[metric.key], after.summary[metric.key], formatNumber)).join(' | ')} |`,
`| Diff (%) | ${metrics.map((metric) => formatDiffPercent(before.summary[metric.key], after.summary[metric.key])).join(' | ')} |`,
];
}
function renderMetricTable(before, after) {
const metrics = [
{ key: 'renderedLength', label: 'Rendered size' },
];
if (before.options.gzip || after.options.gzip) {
metrics.push({ key: 'gzipLength', label: 'Gzip size' });
}
if (before.options.brotli || after.options.brotli) {
metrics.push({ key: 'brotliLength', label: 'Brotli size' });
}
const lines = [
'| Metric | Before | After | Diff | Diff (%) |',
'|---|---:|---:|---:|---:|',
];
for (const metric of metrics) {
const beforeValue = before.metrics[metric.key];
const afterValue = after.metrics[metric.key];
lines.push(`| ${metric.label} | ${formatBytes(beforeValue)} | ${formatBytes(afterValue)} | ${formatDiff(beforeValue, afterValue, formatBytes)} | ${formatDiffPercent(beforeValue, afterValue)} |`);
}
return lines;
}
const beforeData = JSON.parse(await readFile(beforeFile, 'utf8'));
const afterData = JSON.parse(await readFile(afterFile, 'utf8'));
const before = collectReport(beforeData);
const after = collectReport(afterData);
const lines = [
'# Bundle Report',
'',
'| Bundles | Modules | Entries | Externals | Static Imports | Dynamic Imports |',
'|---:|---:|---:|---:|---:|---:|',
`| ${bundleRows.length} | ${moduleRows.length} | ${entries} | ${externals} | ${staticImports} | ${dynamicImports} |`,
...renderSummaryTable(before, after),
'',
...renderMetricTable(before, after),
'',
'## Top 10',
'',
'| Metric | Total |',
'|---|---:|',
`| Rendered size | ${formatBytes(totalRendered)} |`,
];
if (data.options?.gzip) {
lines.push(`| Gzip size | ${formatBytes(totalGzip)} |`);
}
if (data.options?.brotli) {
lines.push(`| Brotli size | ${formatBytes(totalBrotli)} |`);
}
lines.push('', '## Top 10', '');
for (const row of hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, totalRendered)} (${formatBytes(row.renderedLength)})`);
for (const row of after.hotModules.slice(0, 10)) {
lines.push(`- ${code(row.id)}: ${sharePercent(row.renderedLength, after.metrics.renderedLength)} (${formatBytes(row.renderedLength)})`);
}
lines.push(
@@ -152,8 +241,8 @@ lines.push(
'|---|---:|---:|---:|---:|---:|---:|---:|',
);
for (const row of hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, totalRendered)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
for (const row of after.hotModules.slice(0, 15)) {
lines.push(`| ${tableCode(row.id)} | ${row.bundles} | ${formatBytes(row.renderedLength)} | ${sharePercent(row.renderedLength, after.metrics.renderedLength)} | ${formatBytes(row.gzipLength)} | ${formatBytes(row.brotliLength)} | ${row.importedCount} | ${row.importedByCount} |`);
}
await writeFile(outputFile, `${lines.join('\n')}\n`);

View File

@@ -38,7 +38,7 @@ jobs:
run-id: ${{ github.event.workflow_run.id }}
- name: Generate visualizer report
run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md"
run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/before-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/after-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md"
- name: Comment on pull request
uses: actions/github-script@v9
@@ -52,6 +52,7 @@ jobs:
const reportDir = path.join(process.env.RUNNER_TEMP, 'frontend-bundle-visualizer');
const reportPath = path.join(reportDir, 'report.md');
const prNumberPath = path.join(reportDir, 'pr-number.txt');
const baseShaPath = path.join(reportDir, 'base-sha.txt');
const headShaPath = path.join(reportDir, 'head-sha.txt');
const workflowRun = context.payload.workflow_run;
const headSha = workflowRun.head_sha;
@@ -104,11 +105,16 @@ jobs:
}
const report = fs.readFileSync(reportPath, 'utf8').trim();
const baseSha = fs.existsSync(baseShaPath)
? fs.readFileSync(baseShaPath, 'utf8').trim()
: null;
const shortSha = headSha.slice(0, 7);
const shortBaseSha = baseSha != null ? baseSha.slice(0, 7) : null;
let body = [
marker,
'## Frontend bundle visualizer',
'',
...(shortBaseSha != null ? [`Base: \`${shortBaseSha}\``] : []),
`Head: \`${shortSha}\``,
'',
report,

View File

@@ -37,50 +37,94 @@ jobs:
name: Build frontend bundle visualizer
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Backport visualizer tooling to base if needed
shell: bash
run: |
if ! grep -q 'FRONTEND_BUNDLE_VISUALIZER' before/packages/frontend/vite.config.ts; then
cp after/packages/frontend/package.json before/packages/frontend/package.json
cp after/packages/frontend/vite.config.ts before/packages/frontend/vite.config.ts
cp after/pnpm-lock.yaml before/pnpm-lock.yaml
fi
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: .node-version
node-version-file: after/.node-version
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
after/pnpm-lock.yaml
- name: Install dependencies
- name: Install dependencies for base
working-directory: before
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies
- name: Build frontend dependencies for base
working-directory: before
run: pnpm --filter "frontend^..." run build
- name: Prepare visualizer output
run: mkdir -p "$RUNNER_TEMP/frontend-bundle-visualizer"
- name: Build frontend visualizer
- name: Build frontend visualizer for base
working-directory: before
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/stats.json
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/before-stats.json
run: pnpm --filter frontend run build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Build frontend dependencies for pull request
working-directory: after
run: pnpm --filter "frontend^..." run build
- name: Build frontend visualizer for pull request
working-directory: after
env:
FRONTEND_BUNDLE_VISUALIZER: 'true'
FRONTEND_BUNDLE_VISUALIZER_TEMPLATE: raw-data
FRONTEND_BUNDLE_VISUALIZER_FILE: ${{ runner.temp }}/frontend-bundle-visualizer/after-stats.json
run: pnpm --filter frontend run build
- name: Generate visualizer report
run: node .github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md"
run: node after/.github/scripts/frontend-bundle-visualizer-report.mjs "$RUNNER_TEMP/frontend-bundle-visualizer/before-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/after-stats.json" "$RUNNER_TEMP/frontend-bundle-visualizer/report.md"
- name: Check visualizer report
run: |
test -s "$RUNNER_TEMP/frontend-bundle-visualizer/stats.json"
test -s "$RUNNER_TEMP/frontend-bundle-visualizer/before-stats.json"
test -s "$RUNNER_TEMP/frontend-bundle-visualizer/after-stats.json"
test -s "$RUNNER_TEMP/frontend-bundle-visualizer/report.md"
cat "$RUNNER_TEMP/frontend-bundle-visualizer/report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Write report metadata
run: |
printf '%s\n' "${{ github.event.pull_request.number }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/pr-number.txt"
printf '%s\n' "${{ github.event.pull_request.base.sha }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/base-sha.txt"
printf '%s\n' "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$RUNNER_TEMP/frontend-bundle-visualizer/pr-url.txt"