diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af490ee..d1dad2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,54 @@ jobs: path: dist/sql.html retention-days: 14 + # Bundle-size report (#275): measure the self-contained artifact on every PR and + # upload it as an artifact — raw/gzip/Brotli, per-module + per-package attribution, + # and deltas vs. the PR base when a base report can be produced. Reporting only: + # it builds the same bytes as the release, never a budget that fails the build. + size: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + # Full history so the PR base commit is present for the base report below. + fetch-depth: 0 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + - run: npm ci --no-audit --no-fund + # Base report: check the PR base commit out into a worktree and run *its* + # size-report. Fully tolerant — the base predates this tooling on the first + # PR, so a failure here just means "no deltas", never a failed job. + - name: Base report (PR only, best-effort) + if: github.event_name == 'pull_request' + continue-on-error: true + run: | + set +e + base='${{ github.event.pull_request.base.sha }}' + git worktree add /tmp/base-tree "$base" || exit 0 + ( cd /tmp/base-tree \ + && npm ci --no-audit --no-fund \ + && npm run size-report -- --out /tmp/base-report ) || exit 0 + cp /tmp/base-report/bundle-size-report.json base-report.json 2>/dev/null || true + - name: Size report + run: | + set -euo pipefail + if [ -f base-report.json ]; then + npm run size-report -- --base base-report.json + else + echo "No base report available — emitting current report without deltas." + npm run size-report + fi + - uses: actions/upload-artifact@v7 + with: + name: bundle-size-report + path: | + bundle-report/bundle-size-report.json + bundle-report/bundle-size-report.md + bundle-report/esbuild-meta.json + retention-days: 14 + # Release-bundle smoke test: assemble the curl|sh artifact, extract it, and boot # the zero-dep Python runner exactly as an end user would — proving the bundle # layout, the SPA-path discovery, and config.json generation all work before a diff --git a/.gitignore b/.gitignore index c2d8c4e..2f626c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Build output /dist/ +# Bundle-size report output (npm run size-report) — a CI artifact, not committed +/bundle-report/ + # Test / coverage /coverage/ /test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4501c15..cc153a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Bundle-size report on every PR** (#275). `npm run size-report` builds the + production artifact once (through the same `buildArtifact` the release uses, so + the measured bytes are byte-for-byte the shipped `dist/sql.html`) and emits a + machine-readable `bundle-size-report.json`, a human-readable + `bundle-size-report.md`, and the raw `esbuild-meta.json`. It records raw/gzip/ + Brotli sizes for the artifact, JS bundle, and minified CSS; attributes JS output + bytes to input modules and npm packages (hand-written `src/**`, generated + `src/generated/**`, and each external package reported separately); lists the + top-30 modules and entry-point/chunk totals; and, given a base report + (`--base`), appends absolute + percentage deltas. A new CI `size` job produces + the report on every PR — with best-effort deltas vs. the PR base — and uploads + it as an artifact. Reporting only: no bundle-size budget fails the build yet + (thresholds are a follow-up once baseline variance is known). Pure attribution/ + formatting lives in `build/size-report-lib.mjs`, unit-tested in + `tests/unit/size-report.test.js`. - **Helm chart.** A chart at `helm/altinity-sql-browser/` deploys the nginx image to Kubernetes (Deployment + ClusterIP Service + config ConfigMap + optional Ingress/HPA), non-root, config via `.Values.config` → `/sql/config.json`, CSP diff --git a/build/build.mjs b/build/build.mjs index 971ef08..f02a519 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -9,6 +9,7 @@ import { build, transform } from 'esbuild'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; @@ -41,8 +42,13 @@ async function buildStamp() { return commit ? `v${version} (${commit})` : `v${version}`; } -async function main() { - const result = await build({ +// The esbuild options for the single production entry point. Shared verbatim by +// the release build (`main`) and the bundle-size report (build/size-report.mjs) +// so the report measures the artifact users actually receive. `metafile` is the +// only knob: the report needs esbuild's input→output byte attribution, and it is +// pure metadata that never changes the emitted bytes. +export function esbuildOptions({ metafile = false } = {}) { + return { entryPoints: [resolve(root, 'src/main.ts')], bundle: true, format: 'iife', @@ -50,7 +56,18 @@ async function main() { minify: true, write: false, legalComments: 'none', - }); + metafile, + }; +} + +// Produce the exact bytes that ship in dist/sql.html without writing anything, so +// callers (the release build, the size report) share one source of truth. Returns +// the assembled `html` plus its three inlined parts — `script` (JS bundle, stamp +// substituted), `styles` (minified CSS), `thirdParty` (the notices comment) — and +// the esbuild `metafile` when requested (undefined otherwise). Keeping this the +// single builder is what guarantees the report and the release stay byte-identical. +export async function buildArtifact({ metafile = false } = {}) { + const result = await build(esbuildOptions({ metafile })); // Replace the `__ASB_BUILD__` placeholder (a string literal in src/main.ts) // with the build stamp before the bundle is inlined — same token-replace seam // as the styles/script splices below. replaceAll is robust to either quote @@ -74,12 +91,25 @@ async function main() { .replace('/*__STYLES__*/', () => styles) .replace('/*__SCRIPT__*/', () => script); + return { html, script, styles, thirdParty, metafile: result.metafile }; +} + +async function main() { + const { html } = await buildArtifact(); await mkdir(resolve(root, 'dist'), { recursive: true }); await writeFile(resolve(root, 'dist/sql.html'), html); console.log('built dist/sql.html (' + html.length + ' bytes)'); } -main().catch((e) => { - console.error(e); - process.exit(1); -}); +// Only run the release build when invoked as a script, not when imported for its +// exports (build/size-report.mjs, tests). Compare *realpaths*: Node sets +// import.meta.url to the symlink-resolved location, so a plain resolve() of +// argv[1] (which doesn't follow symlinks) would miscompare when the checkout sits +// under a symlinked path — silently skipping the release build. realpathSync on +// argv[1] closes that gap. +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((e) => { + console.error(e); + process.exit(1); + }); +} diff --git a/build/size-report-lib.mjs b/build/size-report-lib.mjs new file mode 100644 index 0000000..82c9182 --- /dev/null +++ b/build/size-report-lib.mjs @@ -0,0 +1,249 @@ +// Pure attribution + formatting for the bundle-size report (issue #275). +// +// No I/O, no compression, no esbuild — the runner (build/size-report.mjs) does the +// build, measures raw/gzip/Brotli on real buffers, and feeds the numbers plus the +// esbuild metafile in here. Everything below is a pure function of its arguments so +// it is unit-tested directly (tests/unit/size-report.test.js), the same way the +// schema-build tooling is tested even though build/ sits outside the src/ coverage +// gate. + +export const REPORT_SCHEMA_VERSION = 1; + +// A byte figure at three compression levels. Compression is NOT additive across +// modules — gzip/Brotli of the whole is smaller than the sum of the parts — so +// only whole artifacts (html/js/css) ever carry gzip/brotli; per-module figures +// are raw contributed output bytes only. +function triple(raw, gzip, brotli) { + return { raw, gzip, brotli }; +} + +// esbuild metafile input keys are repo-relative POSIX paths ('src/main.ts', +// 'node_modules/chart.js/dist/chart.js'), occasionally with a leading './'. +export function normalizeInputPath(p) { + return p.replace(/^\.\//, ''); +} + +// Attribute one input file to an ownership bucket per the issue's rules: +// src/generated/** -> generated project code +// src/** (everything else) -> hand-written project source +// node_modules//** -> external, grouped under +// node_modules/@scope// -> external, grouped under @scope/ +// Nested deps (a/node_modules/b) attribute to the *leaf* package (b), which is the +// copy whose bytes actually shipped. Anything else lands in 'other' so it is +// surfaced explicitly rather than hidden. +export function classifyInput(rawPath) { + const path = normalizeInputPath(rawPath); + const nm = path.lastIndexOf('node_modules/'); + if (nm !== -1) { + const rest = path.slice(nm + 'node_modules/'.length); + const parts = rest.split('/'); + const pkg = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]; + return { owner: 'external', group: pkg, pkg }; + } + if (path.startsWith('src/generated/')) return { owner: 'generated', group: 'src/generated' }; + if (path.startsWith('src/')) return { owner: 'project', group: 'src' }; + return { owner: 'other', group: 'other' }; +} + +// Flatten one esbuild output's `inputs` map into a sorted module list, each tagged +// with its contributed output bytes and ownership. Sorted by bytes desc, then path +// asc, so output is deterministic regardless of metafile key order. +export function attributeModules(output) { + const modules = Object.entries(output.inputs || {}).map(([path, info]) => ({ + path: normalizeInputPath(path), + bytes: info.bytesInOutput || 0, + ...classifyInput(path), + })); + modules.sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path)); + return modules; +} + +// Roll a module list up into ownership totals and a per-external-package breakdown. +export function summarize(modules) { + const totalBytes = modules.reduce((n, m) => n + m.bytes, 0); + const byOwner = { project: 0, generated: 0, external: 0, other: 0 }; + const pkgBytes = new Map(); + for (const m of modules) { + byOwner[m.owner] += m.bytes; + if (m.owner === 'external') pkgBytes.set(m.group, (pkgBytes.get(m.group) || 0) + m.bytes); + } + const pct = (n) => (totalBytes ? (n / totalBytes) * 100 : 0); + const ownership = {}; + for (const [owner, bytes] of Object.entries(byOwner)) ownership[owner] = { bytes, pct: pct(bytes) }; + const packages = [...pkgBytes.entries()] + .map(([name, bytes]) => ({ name, bytes, pct: pct(bytes) })) + .sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name)); + return { totalBytes, ownership, packages }; +} + +// Top N modules by contributed output bytes, each with its percentage of the total. +export function topModules(modules, totalBytes, n = 30) { + const pct = (b) => (totalBytes ? (b / totalBytes) * 100 : 0); + return modules.slice(0, n).map((m) => ({ + path: m.path, bytes: m.bytes, pct: pct(m.bytes), owner: m.owner, group: m.group, + })); +} + +// Entry-point / output-chunk totals. The single-file build has exactly one chunk, +// but the report models a list so it stays meaningful if hosted builds ever add +// code splitting or route-level lazy loading (per the issue). +export function entryChunks(metafile) { + return Object.entries(metafile.outputs || {}).map(([file, out]) => ({ + file, entryPoint: out.entryPoint || null, bytes: out.bytes || 0, + })).sort((a, b) => b.bytes - a.bytes || a.file.localeCompare(b.file)); +} + +// Assemble the full machine-readable report from measured sizes + the metafile. +// `sizes` carries the three whole-artifact measurements; `outputKey` names the JS +// output chunk inside the metafile whose inputs we attribute. +export function buildReport({ sizes, metafile, outputKey }) { + const output = metafile.outputs[outputKey]; + const modules = attributeModules(output); + const { totalBytes, ownership, packages } = summarize(modules); + return { + schemaVersion: REPORT_SCHEMA_VERSION, + artifact: triple(sizes.artifact.raw, sizes.artifact.gzip, sizes.artifact.brotli), + js: triple(sizes.js.raw, sizes.js.gzip, sizes.js.brotli), + css: triple(sizes.css.raw, sizes.css.gzip, sizes.css.brotli), + totalOutputBytes: totalBytes, + entryPoints: entryChunks(metafile), + ownership, + packages, + topModules: topModules(modules, totalBytes, 30), + notes: [ + 'Percentages are of raw contributed output bytes (metafile bytesInOutput); ' + + 'gzip/Brotli are measured per whole artifact only — compression is not additive across modules.', + ], + }; +} + +// A single scalar delta: absolute change and percentage-of-base. base===0 yields a +// null pct (no meaningful ratio) rather than Infinity. +export function computeDelta(current, base) { + const abs = current - base; + return { current, base, abs, pct: base ? (abs / base) * 100 : null }; +} + +// Diff a current report against a base report. Covers the three whole-artifact +// sizes (raw/gzip/Brotli), ownership buckets, and per-package bytes — including +// packages that appeared or disappeared between the two builds. +export function diffReports(current, base) { + const sizeDelta = (key) => ({ + raw: computeDelta(current[key].raw, base[key].raw), + gzip: computeDelta(current[key].gzip, base[key].gzip), + brotli: computeDelta(current[key].brotli, base[key].brotli), + }); + const ownership = {}; + for (const owner of Object.keys(current.ownership)) { + ownership[owner] = computeDelta(current.ownership[owner].bytes, base.ownership?.[owner]?.bytes || 0); + } + const baseP = new Map((base.packages || []).map((p) => [p.name, p.bytes])); + const curP = new Map((current.packages || []).map((p) => [p.name, p.bytes])); + const names = [...new Set([...baseP.keys(), ...curP.keys()])].sort(); + const packages = names.map((name) => ({ + name, ...computeDelta(curP.get(name) || 0, baseP.get(name) || 0), + })).filter((p) => p.abs !== 0).sort((a, b) => Math.abs(b.abs) - Math.abs(a.abs) || a.name.localeCompare(b.name)); + return { + artifact: sizeDelta('artifact'), + js: sizeDelta('js'), + css: sizeDelta('css'), + totalOutputBytes: computeDelta(current.totalOutputBytes, base.totalOutputBytes || 0), + ownership, + packages, + }; +} + +// Human-readable byte size: B up to 1 KiB, then KiB/MiB with one decimal (base 2, +// matching how developers reason about bundle sizes). +export function formatBytes(n) { + const sign = n < 0 ? '-' : ''; + const abs = Math.abs(n); + if (abs < 1024) return `${sign}${abs} B`; + if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KiB`; + return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MiB`; +} + +// A signed size with its percentage, for delta cells: '+1.2 KiB (+3.4%)'. +function formatDelta(d) { + const s = d.abs >= 0 ? '+' : ''; + const pct = d.pct === null ? 'new' : `${d.abs >= 0 ? '+' : ''}${d.pct.toFixed(1)}%`; + if (d.abs === 0) return '—'; + return `${s}${formatBytes(d.abs)} (${pct})`; +} + +function sizeRow(label, t, delta) { + const cells = [label, formatBytes(t.raw), formatBytes(t.gzip), formatBytes(t.brotli)]; + if (delta) cells.push(formatDelta(delta.gzip)); + return `| ${cells.join(' | ')} |`; +} + +// Render the Markdown report. `deltas` (from diffReports) is optional; when present +// a delta column (keyed on gzip transfer size) is added to the size table and a +// per-package change table is appended. +export function renderMarkdown(report, deltas) { + const L = []; + L.push('# Bundle size report'); + L.push(''); + L.push(deltas + ? 'Sizes for the self-contained `dist/sql.html` and its inlined parts, with deltas vs. the PR base.' + : 'Sizes for the self-contained `dist/sql.html` and its inlined parts.'); + L.push(''); + const head = ['Artifact', 'Raw', 'gzip', 'Brotli']; + if (deltas) head.push('Δ gzip vs base'); + L.push(`| ${head.join(' | ')} |`); + L.push(`|${head.map(() => '---').join('|')}|`); + L.push(sizeRow('`dist/sql.html`', report.artifact, deltas?.artifact)); + L.push(sizeRow('JS bundle', report.js, deltas?.js)); + L.push(sizeRow('CSS (minified)', report.css, deltas?.css)); + L.push(''); + + L.push('## Ownership (raw contributed output bytes)'); + L.push(''); + const oHead = ['Owner', 'Bytes', '% of JS output']; + if (deltas) oHead.push('Δ vs base'); + L.push(`| ${oHead.join(' | ')} |`); + L.push(`|${oHead.map(() => '---').join('|')}|`); + for (const [owner, v] of Object.entries(report.ownership)) { + if (v.bytes === 0 && owner === 'other') continue; + const cells = [owner, formatBytes(v.bytes), `${v.pct.toFixed(1)}%`]; + if (deltas) cells.push(formatDelta(deltas.ownership[owner])); + L.push(`| ${cells.join(' | ')} |`); + } + L.push(''); + + L.push('## External packages'); + L.push(''); + L.push('| Package | Bytes | % of JS output |'); + L.push('|---|---|---|'); + for (const p of report.packages) L.push(`| \`${p.name}\` | ${formatBytes(p.bytes)} | ${p.pct.toFixed(1)}% |`); + L.push(''); + + L.push('## Entry points / chunks'); + L.push(''); + L.push('| Output | Entry point | Bytes |'); + L.push('|---|---|---|'); + for (const c of report.entryPoints) L.push(`| \`${c.file}\` | ${c.entryPoint ? `\`${c.entryPoint}\`` : '—'} | ${formatBytes(c.bytes)} |`); + L.push(''); + + L.push('## Top 30 modules by contributed output bytes'); + L.push(''); + L.push('| Module | Owner | Bytes | % |'); + L.push('|---|---|---|---|'); + for (const m of report.topModules) L.push(`| \`${m.path}\` | ${m.owner} | ${formatBytes(m.bytes)} | ${m.pct.toFixed(1)}% |`); + L.push(''); + + if (deltas && deltas.packages.length) { + L.push('## Package changes vs base'); + L.push(''); + L.push('| Package | Base | Current | Δ |'); + L.push('|---|---|---|---|'); + for (const p of deltas.packages) { + L.push(`| \`${p.name}\` | ${formatBytes(p.base)} | ${formatBytes(p.current)} | ${formatDelta(p)} |`); + } + L.push(''); + } + + for (const note of report.notes) L.push(`> ${note}`); + L.push(''); + return L.join('\n'); +} diff --git a/build/size-report.mjs b/build/size-report.mjs new file mode 100644 index 0000000..b013e1b --- /dev/null +++ b/build/size-report.mjs @@ -0,0 +1,97 @@ +// Bundle-size report runner (issue #275). +// +// Builds the production artifact once (via build/build.mjs's shared buildArtifact, +// so the report measures the exact bytes users receive), measures raw/gzip/Brotli +// with deterministic compression, attributes the JS output to input modules and npm +// packages via the esbuild metafile, and emits a machine-readable JSON report, a +// human-readable Markdown report, and the raw esbuild metafile. Given a base report +// (`--base`), it appends absolute + percentage deltas. +// +// Usage: node build/size-report.mjs [--out ] [--base ] +// --out output directory (default: bundle-report/) +// --base a prior bundle-size-report.json to diff against (deltas when present) +// +// Reporting only — it never alters production loading semantics. metafile:true is +// pure metadata; the emitted bytes are identical to `npm run build`. + +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { gzipSync, brotliCompressSync, constants } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { buildArtifact } from './build.mjs'; +import { buildReport, diffReports, renderMarkdown, formatBytes } from './size-report-lib.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); + +// Deterministic compression settings so the same bytes always report the same +// sizes across machines and CI runs: gzip at max level, Brotli at max quality. +function sizes(text) { + const buf = Buffer.from(text, 'utf8'); + return { + raw: buf.length, + gzip: gzipSync(buf, { level: 9 }).length, + brotli: brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }).length, + }; +} + +function parseArgs(argv) { + const args = { out: 'bundle-report', base: null }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--out') args.out = argv[++i]; + else if (argv[i] === '--base') args.base = argv[++i]; + } + return args; +} + +async function readJson(path) { + try { + return JSON.parse(await readFile(path, 'utf8')); + } catch { + return null; + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const outDir = resolve(root, args.out); + + const { html, script, styles, metafile } = await buildArtifact({ metafile: true }); + + // The JS output chunk is the one esbuild marks with an entryPoint; fall back to + // the sole output if none is marked (keeps working under future config changes). + const outputEntry = Object.entries(metafile.outputs).find(([, o]) => o.entryPoint) + || Object.entries(metafile.outputs)[0]; + const outputKey = outputEntry[0]; + + const report = buildReport({ + metafile, + outputKey, + sizes: { artifact: sizes(html), js: sizes(script), css: sizes(styles) }, + }); + + const base = args.base ? await readJson(resolve(process.cwd(), args.base)) : null; + const deltas = base ? diffReports(report, base) : null; + + await mkdir(outDir, { recursive: true }); + // Also (re)write the real artifact so `dist/sql.html` exists and its on-disk size + // matches the report even when the report job runs without a separate build step. + await mkdir(resolve(root, 'dist'), { recursive: true }); + await writeFile(resolve(root, 'dist/sql.html'), html); + await writeFile(resolve(outDir, 'esbuild-meta.json'), JSON.stringify(metafile, null, 2)); + await writeFile(resolve(outDir, 'bundle-size-report.json'), JSON.stringify(report, null, 2)); + await writeFile(resolve(outDir, 'bundle-size-report.md'), renderMarkdown(report, deltas)); + + const a = report.artifact; + console.log(`bundle-size report -> ${args.out}/`); + console.log(` dist/sql.html: ${formatBytes(a.raw)} raw, ${formatBytes(a.gzip)} gzip, ${formatBytes(a.brotli)} brotli`); + console.log(` JS: ${formatBytes(report.js.raw)} raw / ${formatBytes(report.js.gzip)} gzip` + + ` CSS: ${formatBytes(report.css.raw)} raw / ${formatBytes(report.css.gzip)} gzip`); + if (deltas) console.log(` Δ artifact gzip vs base: ${formatBytes(deltas.artifact.gzip.abs)}`); + else console.log(' (no base report — deltas omitted)'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/package.json b/package.json index 0041ade..a289ad3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "prebuild": "npm run check:schemas", "build": "node build/build.mjs", + "size-report": "node build/size-report.mjs", "pretest": "npm run check:schemas && npm run check:types", "test": "TZ=America/New_York vitest run --coverage --config tests/vitest.config.ts", "test:watch": "TZ=America/New_York vitest --config tests/vitest.config.ts", diff --git a/tests/unit/size-report.test.js b/tests/unit/size-report.test.js new file mode 100644 index 0000000..d9259b4 --- /dev/null +++ b/tests/unit/size-report.test.js @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { + REPORT_SCHEMA_VERSION, + normalizeInputPath, + classifyInput, + attributeModules, + summarize, + topModules, + entryChunks, + buildReport, + computeDelta, + diffReports, + formatBytes, + renderMarkdown, +} from '../../build/size-report-lib.mjs'; + +// A minimal esbuild-metafile shape covering every ownership bucket the report +// distinguishes: hand-written src, generated src, a plain external package, and a +// scoped external package. +const OUTPUT = { + bytes: 300, + entryPoint: 'src/main.ts', + inputs: { + 'src/main.ts': { bytesInOutput: 100 }, + 'src/generated/json-schema-validators.js': { bytesInOutput: 60 }, + 'node_modules/chart.js/dist/chart.js': { bytesInOutput: 80 }, + 'node_modules/@codemirror/view/dist/index.js': { bytesInOutput: 40 }, + './node_modules/chart.js/dist/helpers.js': { bytesInOutput: 20 }, + }, +}; +const METAFILE = { outputs: { 'main.js': OUTPUT } }; +const SIZES = { + artifact: { raw: 1000, gzip: 400, brotli: 350 }, + js: { raw: 900, gzip: 360, brotli: 300 }, + css: { raw: 100, gzip: 40, brotli: 35 }, +}; + +describe('classifyInput', () => { + it('normalizes a leading ./', () => { + expect(normalizeInputPath('./src/main.ts')).toBe('src/main.ts'); + expect(normalizeInputPath('src/main.ts')).toBe('src/main.ts'); + }); + + it('separates generated from hand-written project source', () => { + expect(classifyInput('src/main.ts')).toEqual({ owner: 'project', group: 'src' }); + expect(classifyInput('src/generated/json-schemas.js')) + .toEqual({ owner: 'generated', group: 'src/generated' }); + }); + + it('groups a plain external package under its name', () => { + expect(classifyInput('node_modules/chart.js/dist/chart.js')) + .toEqual({ owner: 'external', group: 'chart.js', pkg: 'chart.js' }); + }); + + it('groups a scoped external package under @scope/name', () => { + expect(classifyInput('node_modules/@codemirror/view/dist/index.js')) + .toEqual({ owner: 'external', group: '@codemirror/view', pkg: '@codemirror/view' }); + }); + + it('attributes a nested dependency to its leaf package', () => { + expect(classifyInput('node_modules/a/node_modules/@scope/b/index.js')) + .toEqual({ owner: 'external', group: '@scope/b', pkg: '@scope/b' }); + }); + + it('falls back to other for an unrecognized path', () => { + expect(classifyInput('build/template.html')).toEqual({ owner: 'other', group: 'other' }); + }); +}); + +describe('attributeModules', () => { + it('flattens, tags, and sorts modules by bytes desc then path asc', () => { + const mods = attributeModules(OUTPUT); + expect(mods.map((m) => m.bytes)).toEqual([100, 80, 60, 40, 20]); + expect(mods[0]).toEqual({ path: 'src/main.ts', bytes: 100, owner: 'project', group: 'src' }); + // The './'-prefixed input is normalized in the reported path. + expect(mods.find((m) => m.bytes === 20).path).toBe('node_modules/chart.js/dist/helpers.js'); + }); + + it('tolerates an output with no inputs', () => { + expect(attributeModules({ bytes: 0 })).toEqual([]); + }); +}); + +describe('summarize', () => { + it('rolls up ownership totals and per-package external bytes', () => { + const { totalBytes, ownership, packages } = summarize(attributeModules(OUTPUT)); + expect(totalBytes).toBe(300); + expect(ownership.project.bytes).toBe(100); + expect(ownership.generated.bytes).toBe(60); + expect(ownership.external.bytes).toBe(140); + expect(ownership.project.pct).toBeCloseTo(33.33, 1); + // chart.js aggregates its two files (80 + 20); sorted desc. + expect(packages).toEqual([ + { name: 'chart.js', bytes: 100, pct: 100 / 300 * 100 }, + { name: '@codemirror/view', bytes: 40, pct: 40 / 300 * 100 }, + ]); + }); + + it('reports zero percentages rather than dividing by zero', () => { + const { totalBytes, ownership } = summarize([]); + expect(totalBytes).toBe(0); + expect(ownership.project.pct).toBe(0); + }); +}); + +describe('topModules', () => { + it('truncates to N and attaches percentages', () => { + const mods = attributeModules(OUTPUT); + const top = topModules(mods, 300, 2); + expect(top).toHaveLength(2); + expect(top[0]).toEqual({ + path: 'src/main.ts', bytes: 100, pct: 100 / 300 * 100, owner: 'project', group: 'src', + }); + }); + + it('defaults to the top 30', () => { + const many = Array.from({ length: 40 }, (_, i) => ({ + path: `src/m${i}.ts`, bytes: 40 - i, owner: 'project', group: 'src', + })); + expect(topModules(many, 1000)).toHaveLength(30); + }); +}); + +describe('entryChunks', () => { + it('lists each output with its entry point and bytes, sorted by size', () => { + const chunks = entryChunks({ + outputs: { + 'a.js': { bytes: 10, entryPoint: 'src/a.ts' }, + 'b.js': { bytes: 20 }, + }, + }); + expect(chunks).toEqual([ + { file: 'b.js', entryPoint: null, bytes: 20 }, + { file: 'a.js', entryPoint: 'src/a.ts', bytes: 10 }, + ]); + }); + + it('tolerates a metafile with no outputs', () => { + expect(entryChunks({})).toEqual([]); + }); +}); + +describe('buildReport', () => { + it('assembles a complete, versioned report', () => { + const r = buildReport({ sizes: SIZES, metafile: METAFILE, outputKey: 'main.js' }); + expect(r.schemaVersion).toBe(REPORT_SCHEMA_VERSION); + expect(r.artifact).toEqual(SIZES.artifact); + expect(r.js).toEqual(SIZES.js); + expect(r.css).toEqual(SIZES.css); + expect(r.totalOutputBytes).toBe(300); + expect(r.entryPoints).toEqual([{ file: 'main.js', entryPoint: 'src/main.ts', bytes: 300 }]); + expect(r.ownership.external.bytes).toBe(140); + expect(r.packages[0].name).toBe('chart.js'); + expect(r.topModules).toHaveLength(5); + expect(r.notes[0]).toMatch(/not additive/); + }); +}); + +describe('computeDelta', () => { + it('computes absolute change and percentage of base', () => { + expect(computeDelta(120, 100)).toEqual({ current: 120, base: 100, abs: 20, pct: 20 }); + expect(computeDelta(80, 100)).toEqual({ current: 80, base: 100, abs: -20, pct: -20 }); + }); + + it('returns a null percentage when the base is zero', () => { + expect(computeDelta(50, 0)).toEqual({ current: 50, base: 0, abs: 50, pct: null }); + }); +}); + +describe('diffReports', () => { + const current = buildReport({ sizes: SIZES, metafile: METAFILE, outputKey: 'main.js' }); + + it('diffs sizes, ownership, and per-package bytes including added/removed packages', () => { + const base = JSON.parse(JSON.stringify(current)); + base.artifact.gzip = 380; + base.packages = [ + { name: 'chart.js', bytes: 90, pct: 30 }, + { name: 'gone-pkg', bytes: 15, pct: 5 }, + ]; + const d = diffReports(current, base); + expect(d.artifact.gzip).toEqual({ current: 400, base: 380, abs: 20, pct: 20 / 380 * 100 }); + // chart.js grew 90->100; @codemirror/view is new (0 base); gone-pkg removed (0 current). + const byName = Object.fromEntries(d.packages.map((p) => [p.name, p.abs])); + expect(byName['chart.js']).toBe(10); + expect(byName['@codemirror/view']).toBe(40); + expect(byName['gone-pkg']).toBe(-15); + // Unchanged packages are dropped from the delta list. + expect(d.packages.every((p) => p.abs !== 0)).toBe(true); + }); + + it('treats a missing base ownership bucket as zero', () => { + const base = JSON.parse(JSON.stringify(current)); + delete base.ownership.generated; + const d = diffReports(current, base); + expect(d.ownership.generated).toEqual({ current: 60, base: 0, abs: 60, pct: null }); + }); +}); + +describe('formatBytes', () => { + it('renders B / KiB / MiB with a sign', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2.0 KiB'); + expect(formatBytes(1024 * 1024 * 3)).toBe('3.0 MiB'); + expect(formatBytes(-2048)).toBe('-2.0 KiB'); + }); +}); + +describe('renderMarkdown', () => { + const report = buildReport({ sizes: SIZES, metafile: METAFILE, outputKey: 'main.js' }); + + it('renders every section without a delta column when no base is given', () => { + const md = renderMarkdown(report); + expect(md).toContain('# Bundle size report'); + expect(md).toContain('| `dist/sql.html` |'); + expect(md).toContain('## Ownership'); + expect(md).toContain('## External packages'); + expect(md).toContain('| `chart.js` |'); + expect(md).toContain('## Entry points / chunks'); + expect(md).toContain('## Top 30 modules by contributed output bytes'); + expect(md).not.toContain('Δ gzip vs base'); + expect(md).not.toContain('## Package changes vs base'); + expect(md).toMatch(/not additive/); + }); + + it('adds delta columns and a package-changes table when a base is given', () => { + const base = JSON.parse(JSON.stringify(report)); + base.artifact.gzip = 380; + base.packages = [{ name: 'chart.js', bytes: 90, pct: 30 }]; + const md = renderMarkdown(report, diffReports(report, base)); + expect(md).toContain('Δ gzip vs base'); + expect(md).toContain('deltas vs. the PR base'); + expect(md).toContain('## Package changes vs base'); + // A grown package shows a signed delta with percentage. + expect(md).toMatch(/\+\d/); + }); + + it('omits the package-changes table when nothing changed', () => { + const md = renderMarkdown(report, diffReports(report, report)); + expect(md).toContain('Δ gzip vs base'); + expect(md).not.toContain('## Package changes vs base'); + // A zero delta renders as an em dash. + expect(md).toContain('| — |'); + }); + + it('hides an all-zero other bucket but keeps real ownership rows', () => { + const md = renderMarkdown(report); + expect(md).toContain('| project |'); + expect(md).not.toMatch(/\| other \|/); + }); +});