From bceaf8ca6426003379f8067f28f0bc2d165072ca Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:09:06 +0200 Subject: [PATCH] ci: omit unchanged bundle size scenarios --- benchmarks/bundle-size/package.json | 11 +- scripts/benchmarks/bundle-size/pr-report.mjs | 64 ++++++--- .../benchmarks/bundle-size/pr-report.test.mjs | 131 ++++++++++++++++++ 3 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 scripts/benchmarks/bundle-size/pr-report.test.mjs diff --git a/benchmarks/bundle-size/package.json b/benchmarks/bundle-size/package.json index 8cd8f73f41..65d8d7483b 100644 --- a/benchmarks/bundle-size/package.json +++ b/benchmarks/bundle-size/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "build": "node ../../scripts/benchmarks/bundle-size/measure.mjs --skip-package-builds" + "build": "node ../../scripts/benchmarks/bundle-size/measure.mjs --skip-package-builds", + "test:unit": "node --test ../../scripts/benchmarks/bundle-size/pr-report.test.mjs" }, "nx": { "targets": { @@ -23,6 +24,14 @@ "target": "build" } ] + }, + "test:unit": { + "inputs": [ + "default", + "^production", + "{workspaceRoot}/scripts/benchmarks/bundle-size/pr-report.mjs", + "{workspaceRoot}/scripts/benchmarks/bundle-size/pr-report.test.mjs" + ] } } }, diff --git a/scripts/benchmarks/bundle-size/pr-report.mjs b/scripts/benchmarks/bundle-size/pr-report.mjs index 5118d877ec..8c58563230 100644 --- a/scripts/benchmarks/bundle-size/pr-report.mjs +++ b/scripts/benchmarks/bundle-size/pr-report.mjs @@ -268,10 +268,16 @@ async function main() { ? resolveBaselineFromCurrentJson(baselineCurrent) : resolveBaselineFromHistory(historyEntries, args.baseSha) + const metrics = current.metrics || [] const rows = [] - for (const metric of current.metrics || []) { + for (const metric of metrics) { const baselineValue = baseline.benchesByName.get(metric.id) + + if (Number.isFinite(baselineValue) && metric.gzipBytes === baselineValue) { + continue + } + const historySeries = (seriesByScenario.get(metric.id) || []).slice( // Reserve one slot for the current metric so the sparkline stays at trendPoints. -args.trendPoints + 1, @@ -290,6 +296,7 @@ async function main() { raw: metric.rawBytes, brotli: metric.brotliBytes, initial: metric.initialGzipBytes, + hasBaseline: Number.isFinite(baselineValue), deltaCell: formatDelta(metric.gzipBytes, baselineValue), trendCell: sparkline(historySeries.slice(-args.trendPoints)), }) @@ -299,30 +306,45 @@ async function main() { lines.push(args.marker) lines.push('## Bundle Size Benchmarks') lines.push('') - lines.push(`- Commit: \`${formatShortSha(current.sha)}\``) - lines.push( - `- Measured at: \`${current.measuredAt || current.generatedAt || 'unknown'}\``, - ) - lines.push(`- Baseline source: \`${baseline.source}\``) - if (args.dashboardUrl) { - lines.push(`- Dashboard: [bundle-size history](${args.dashboardUrl})`) - } - lines.push('') - lines.push( - '| Scenario | Current (gzip) | Delta vs baseline | Initial gzip | Raw | Brotli | Trend |', - ) - lines.push('| --- | ---: | ---: | ---: | ---: | ---: | --- |') - for (const row of rows) { + if (metrics.length > 0 && rows.length === 0) { lines.push( - `| \`${row.id}\` | ${formatBytes(row.current)} | ${row.deltaCell} | ${formatBytes(row.initial)} | ${formatBytes(row.raw)} | ${formatBytes(row.brotli)} | ${row.trendCell} |`, + 'This pull request does not affect bundle size in any measured scenario.', ) - } + } else if (metrics.length === 0) { + lines.push('No bundle-size scenarios were measured.') + } else { + lines.push(`- Commit: \`${formatShortSha(current.sha)}\``) + lines.push( + `- Measured at: \`${current.measuredAt || current.generatedAt || 'unknown'}\``, + ) + lines.push(`- Baseline source: \`${baseline.source}\``) + if (args.dashboardUrl) { + lines.push(`- Dashboard: [bundle-size history](${args.dashboardUrl})`) + } + lines.push('') + lines.push( + rows.some((row) => !row.hasBaseline) + ? 'The following scenarios have bundle-size changes or lack baseline data for comparison:' + : 'The following scenarios have bundle-size changes compared with the baseline:', + ) + lines.push('') + lines.push( + '| Scenario | Current (gzip) | Delta vs baseline | Initial gzip | Raw | Brotli | Trend |', + ) + lines.push('| --- | ---: | ---: | ---: | ---: | ---: | --- |') - lines.push('') - lines.push( - '_Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better._', - ) + for (const row of rows) { + lines.push( + `| \`${row.id}\` | ${formatBytes(row.current)} | ${row.deltaCell} | ${formatBytes(row.initial)} | ${formatBytes(row.raw)} | ${formatBytes(row.brotli)} | ${row.trendCell} |`, + ) + } + + lines.push('') + lines.push( + '_Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better._', + ) + } const markdown = lines.join('\n') + '\n' await fsp.mkdir(path.dirname(outputPath), { recursive: true }) diff --git a/scripts/benchmarks/bundle-size/pr-report.test.mjs b/scripts/benchmarks/bundle-size/pr-report.test.mjs new file mode 100644 index 0000000000..f70081452d --- /dev/null +++ b/scripts/benchmarks/bundle-size/pr-report.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import test from 'node:test' + +const execFileAsync = promisify(execFile) +const reportScript = new URL('./pr-report.mjs', import.meta.url) + +function metric(id, gzipBytes) { + return { + id, + gzipBytes, + rawBytes: gzipBytes * 3, + brotliBytes: gzipBytes - 100, + initialGzipBytes: gzipBytes - 10, + } +} + +async function generateReport({ current, baseline, history, baseSha }) { + const fixtureDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'bundle-size-pr-report-'), + ) + + try { + const currentPath = path.join(fixtureDir, 'current.json') + const outputPath = path.join(fixtureDir, 'report.md') + const args = [ + reportScript.pathname, + '--current', + currentPath, + '--output', + outputPath, + ] + + await fs.writeFile(currentPath, JSON.stringify(current)) + + if (baseline) { + const baselinePath = path.join(fixtureDir, 'baseline.json') + await fs.writeFile(baselinePath, JSON.stringify(baseline)) + args.push('--baseline', baselinePath) + } + + if (history) { + const historyPath = path.join(fixtureDir, 'history.json') + await fs.writeFile(historyPath, JSON.stringify(history)) + args.push('--history', historyPath) + } + + if (baseSha) { + args.push('--base-sha', baseSha) + } + + await execFileAsync(process.execPath, args) + return await fs.readFile(outputPath, 'utf8') + } finally { + await fs.rm(fixtureDir, { recursive: true, force: true }) + } +} + +function currentJson(metrics) { + return { + benchmarkName: 'Bundle Size (gzip)', + measuredAt: '2026-07-19T09:45:56.787Z', + sha: '126aa13f296b1234', + metrics, + } +} + +test('renders a concise message when no measured scenario changed', async () => { + const current = currentJson([metric('react-router.minimal', 1_000)]) + const report = await generateReport({ current, baseline: current }) + + assert.equal( + report, + '\n## Bundle Size Benchmarks\n\nThis pull request does not affect bundle size in any measured scenario.\n', + ) +}) + +test('renders only scenarios that changed against the historical baseline', async () => { + const current = currentJson([ + metric('react-router.minimal', 1_000), + metric('react-router.full', 1_200), + ]) + const history = { + entries: { + [current.benchmarkName]: [ + { + commit: { id: 'baseline-sha' }, + benches: [ + { name: 'react-router.minimal', value: 1_000 }, + { name: 'react-router.full', value: 1_100 }, + ], + }, + ], + }, + } + const report = await generateReport({ + current, + history, + baseSha: 'baseline-sha', + }) + + assert.match( + report, + /The following scenarios have bundle-size changes compared with the baseline:/, + ) + assert.doesNotMatch(report, /`react-router\.minimal`/) + assert.match( + report, + /\| `react-router\.full` \| 1\.17 KiB \| \+100 B \(\+9\.09%\) \|/, + ) +}) + +test('keeps scenarios that do not have baseline data visible', async () => { + const current = currentJson([ + metric('react-router.minimal', 1_000), + metric('new-scenario', 900), + ]) + const baseline = currentJson([metric('react-router.minimal', 1_000)]) + const report = await generateReport({ current, baseline }) + + assert.match( + report, + /The following scenarios have bundle-size changes or lack baseline data for comparison:/, + ) + assert.doesNotMatch(report, /`react-router\.minimal`/) + assert.match(report, /\| `new-scenario` \| 900 B \| n\/a \|/) +})