diff --git a/packages/bundlesize/README.md b/packages/bundlesize/README.md index 6267a182..d928a6fd 100644 --- a/packages/bundlesize/README.md +++ b/packages/bundlesize/README.md @@ -413,6 +413,37 @@ Notes: } ``` +### Compare sizes measured by another tool against a baseline + +`--type compare` needs no configuration file. It reads a sizes file in the shape `size-limit --json` prints, an array of `{ name, size, sizeLimit?, passed? }` with sizes in bytes, and renders a markdown table with a "vs main" column showing each change in kB and percent: + +```sh +> size-limit --json > tmp/sizes.json || true +> bundlesize --type compare --current tmp/sizes.json --baseline tmp/baseline/sizes.json --base-sha "$BASE_SHA" --kilo 1000 --silent -o tmp/pr-stats.md +``` + +| Status | Section | Size (gzip) | vs main | Limit (gzip) | +| ------ | ----------- | ----------- | ------------------- | ------------ | +| ✅ | first-paint | 150.43 kB | ▼ −717 B (−0.47%) | 153 kB | + +`size-limit` exits 1 when a limit is exceeded, which is exactly when the report matters, hence the `|| true`: let `compare` own the exit code. A missing `--baseline` file is not an error: the table shows `n/a` and a note says no baseline was found. An unreadable or malformed one is treated the same way, with a warning, so a broken artifact on main cannot fail every pull request. Entries in the baseline that are gone from this run are listed as removed. Entry names must be unique, so give each size-limit check its own `name`. Other flags: + +- `--header`: markdown heading above the table (default `## Bundle Size`). +- `--footer`: markdown appended after the table. Pass it as `--footer="..."`: a value starting with `-`, such as a list item, is otherwise read as a flag. +- `--base-ref`: branch the baseline was measured on (default `main`). +- `--base-sha`: commit the baseline was measured on, shown in the note. Pass the commit of the run that produced the baseline file, which is not always the pull request's base commit. +- `--kilo` (required): bytes per kB, `1000` for size-limit's units or `1024` for rsbuild's. +- `--threshold`: changes under this many bytes are shown as no change. +- `--silent`: exit 0 even when an entry is over its limit. + +The same renderer is exported for scripts that measure sizes themselves: + +```js +import { compareSizes } from "@node-cli/bundlesize"; + +const { data, passed } = compareSizes({ current, baseline, baseSha }); +``` + ## Get help ```sh diff --git a/packages/bundlesize/bundlesize.config.js b/packages/bundlesize/bundlesize.config.js index 28d5cc55..3be0ddd7 100644 --- a/packages/bundlesize/bundlesize.config.js +++ b/packages/bundlesize/bundlesize.config.js @@ -14,5 +14,13 @@ export default { path: "dist/parse.js", limit: "1.5 kB", }, + { + path: "dist/compare.js", + limit: "2 kB", + }, + { + path: "dist/compareStats.js", + limit: "2 kB", + }, ], }; diff --git a/packages/bundlesize/src/__tests__/cli.test.ts b/packages/bundlesize/src/__tests__/cli.test.ts new file mode 100644 index 00000000..7fb3f60e --- /dev/null +++ b/packages/bundlesize/src/__tests__/cli.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { compareStats } from "../compareStats.js"; +import { getOutputFile } from "../utilities.js"; + +const originalArgv = process.argv; + +afterEach(() => { + process.argv = originalArgv; + vi.resetModules(); +}); + +/** + * Parse argv with the real CLI flag definitions, so a renamed flag in parse.ts + * breaks this test instead of silently breaking the documented CLI. + */ +const parseFlags = async (argv: string[]) => { + process.argv = ["node", "bundlesize", ...argv]; + vi.resetModules(); + const { config } = await import("../parse.js"); + return config.flags; +}; + +describe("compare CLI flags", () => { + it("maps the documented kebab-case flags into a working compare run", async () => { + const dir = mkdtempSync(join(tmpdir(), "bundlesize-cli-")); + try { + const current = join(dir, "current.json"); + const baseline = join(dir, "baseline.json"); + writeFileSync(current, JSON.stringify([{ name: "a", size: 2000 }])); + writeFileSync(baseline, JSON.stringify([{ name: "a", size: 1000 }])); + const flags = await parseFlags([ + "--type", + "compare", + "--current", + current, + "--baseline", + baseline, + "--base-ref", + "trunk", + "--base-sha", + "abc1234", + "--kilo", + "1000", + "--threshold", + "5", + "--footer=- see docs", + "-o", + join(dir, "out.md"), + ]); + expect(flags.type).toBe("compare"); + const result = compareStats({ flags }); + expect(result.exitCode).toBe(0); + expect(result.outputFile).toBe(join(dir, "out.md")); + expect(result.data).toContain("| vs trunk |"); + expect(result.data).toContain("| ▲ +1 kB (+100.00%) |"); + expect(result.data).toContain("Compared with `trunk` @ `abc1234`."); + expect(result.data).toContain("- see docs"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("getOutputFile", () => { + it("keeps an absolute output path as is", () => { + expect(getOutputFile("/tmp/report.md")).toBe("/tmp/report.md"); + }); +}); diff --git a/packages/bundlesize/src/__tests__/compare.test.ts b/packages/bundlesize/src/__tests__/compare.test.ts new file mode 100644 index 00000000..2090257d --- /dev/null +++ b/packages/bundlesize/src/__tests__/compare.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { compareSizes, formatDelta, formatSize } from "../compare.js"; + +describe("formatSize", () => { + it("uses the kilo base it is given", () => { + expect(formatSize(153000, 1000)).toBe("153 kB"); + expect(formatSize(268288, 1024)).toBe("262 kB"); + expect(formatSize(266076, 1024)).toBe("259.84 kB"); + }); + + it("never prints an undefined unit or a whole next unit", () => { + expect(formatSize(0.5, 1024)).toBe("0.5 B"); + expect(formatSize(1048575, 1024)).toBe("1 MB"); + expect(formatSize(999999, 1000)).toBe("1 MB"); + expect(formatSize(1023.999, 1024)).toBe("1 kB"); + }); + + it("handles zero, bytes and megabytes", () => { + expect(formatSize(0, 1024)).toBe("0 B"); + expect(formatSize(512, 1024)).toBe("512 B"); + expect(formatSize(1402880, 1024)).toBe("1.34 MB"); + }); +}); + +describe("formatDelta", () => { + const base = { kilo: 1024, threshold: 0 }; + + it("shows growth with an up arrow, kB and percent", () => { + expect(formatDelta({ ...base, size: 266076, previous: 264806 })).toBe( + "▲ +1.24 kB (+0.48%)", + ); + }); + + it("shows shrinkage with a down arrow and minus signs", () => { + expect(formatDelta({ ...base, size: 49152, previous: 50176 })).toBe( + "▼ −1 kB (−2.04%)", + ); + }); + + it("shows no change for equal sizes", () => { + expect(formatDelta({ ...base, size: 1000, previous: 1000 })).toBe( + "= no change", + ); + }); + + it("treats changes under the threshold as no change", () => { + expect( + formatDelta({ kilo: 1024, threshold: 15, size: 1014, previous: 1000 }), + ).toBe("= no change"); + expect( + formatDelta({ kilo: 1024, threshold: 15, size: 1015, previous: 1000 }), + ).toBe("▲ +15 B (+1.50%)"); + }); + + it("labels an entry missing from the baseline as new", () => { + expect(formatDelta({ ...base, size: 1000, previous: undefined })).toBe( + "new", + ); + }); + + it("omits the percentage when the baseline was empty", () => { + expect(formatDelta({ ...base, size: 2048, previous: 0 })).toBe("▲ +2 kB"); + }); + + it("never points the arrow against the direction of change", () => { + const threshold = 15; + for (const diff of [-1000, -16, -15, -14, -1, 0, 1, 14, 15, 16, 1000]) { + const delta = formatDelta({ + kilo: 1000, + threshold, + size: 5000 + diff, + previous: 5000, + }); + if (Math.abs(diff) < threshold) { + expect(delta).toBe("= no change"); + } else if (diff > 0) { + expect(delta.startsWith("▲ +")).toBe(true); + } else { + expect(delta.startsWith("▼ −")).toBe(true); + } + } + }); +}); + +describe("compareSizes", () => { + const current = [ + { name: "first-paint", size: 150425, sizeLimit: 153000, passed: true }, + ]; + + it("renders the table with a vs main column and the baseline commit", () => { + const { data, passed } = compareSizes({ + current, + baseline: [{ name: "first-paint", size: 151142 }], + baseSha: "a41c9e2d0b5f", + kilo: 1000, + }); + expect(passed).toBe(true); + expect(data).toBe( + [ + "## Bundle Size", + "", + "| Status | Section | Size (gzip) | vs main | Limit (gzip) |", + "| --- | --- | --- | --- | --- |", + "| ✅ | first-paint | 150.43 kB | ▼ −717 B (−0.47%) | 153 kB |", + "", + "Compared with `main` @ `a41c9e2`.", + "", + ].join("\n"), + ); + }); + + it("says so when there is no baseline", () => { + const { data } = compareSizes({ current, baseSha: "a41c9e2" }); + expect(data).toContain("| ✅ | first-paint | 146.9 kB | n/a | 149.41 kB |"); + expect(data).toContain( + "⚠️ No baseline found for `main` @ `a41c9e2`, so no change is shown.", + ); + }); + + it("omits the commit when none is given", () => { + const { data } = compareSizes({ current, baseline: [] }); + expect(data).toContain("Compared with `main`."); + expect(data).toContain("| new |"); + }); + + it("uses the custom header, base ref and footer", () => { + const { data } = compareSizes({ + current, + baseline: current, + header: "### First-paint bundle", + baseRef: "develop", + footer: "Budget lives in .size-limit.json.", + }); + expect(data.startsWith("### First-paint bundle\n")).toBe(true); + expect(data).toContain("| vs develop |"); + expect(data).toContain("Compared with `develop`."); + expect( + data.endsWith("Budget lives in .size-limit.json.\n"), + ).toBe(true); + }); + + it("fails an entry over its limit even when it says it passed", () => { + const result = compareSizes({ + current: [{ name: "a", size: 11, sizeLimit: 10, passed: true }], + }); + expect(result.passed).toBe(false); + expect(result.data).toContain("| ❌ | a |"); + }); + + it("lists baseline entries that are gone from this run", () => { + const { data } = compareSizes({ + current: [{ name: "a", size: 1 }], + baseline: [ + { name: "a", size: 1 }, + { name: "gone", size: 12288 }, + ], + }); + expect(data).toContain("| ➖ | gone | — | removed (−12 kB) | — |"); + }); + + it("says why the baseline is missing when a reason is given", () => { + const { data } = compareSizes({ + current: [{ name: "a", size: 1 }], + baseSha: "abc1234", + missingReason: "the baseline file could not be read", + }); + expect(data).toContain( + "⚠️ No baseline found for `main` @ `abc1234` (the baseline file could not be read), so no change is shown.", + ); + }); + + it("fails when an entry is over its limit, with or without passed", () => { + expect( + compareSizes({ current: [{ name: "a", size: 11, sizeLimit: 10 }] }) + .passed, + ).toBe(false); + expect( + compareSizes({ current: [{ name: "a", size: 1, passed: false }] }).passed, + ).toBe(false); + expect(compareSizes({ current: [{ name: "a", size: 1 }] }).passed).toBe( + true, + ); + }); + + it("marks failing rows and shows a dash for a missing limit", () => { + const { data } = compareSizes({ + current: [ + { name: "over", size: 11, sizeLimit: 10 }, + { name: "free", size: 5 }, + ], + }); + expect(data).toContain("| ❌ | over | 11 B | n/a | 10 B |"); + expect(data).toContain("| ✅ | free | 5 B | n/a | — |"); + }); + + it("escapes the base ref in the header and keeps the note on one line", () => { + const { data } = compareSizes({ + current: [{ name: "x>y", size: 1 }], + baseline: [], + baseRef: "a|b\n\n## Forged", + }); + expect(data).toContain("| vs a\\|b ## Forged | Limit (gzip) |"); + expect(data).toContain("Compared with `a|b ## Forged`."); + expect(data).toContain("| x>y |"); + expect(data.split("\n").some((line) => line.startsWith("## Forged"))).toBe( + false, + ); + }); + + it("keeps backticks in the base ref from breaking the code span", () => { + const { data } = compareSizes({ + current: [{ name: "a", size: 1 }], + baseline: [], + baseRef: "ma`in", + baseSha: "ab`c1234", + }); + expect(data).toContain("Compared with `main` @ `abc123`."); + }); + + it("escapes pipes in names so the table keeps its columns", () => { + const { data } = compareSizes({ current: [{ name: "a|b", size: 1 }] }); + expect(data).toContain("| a\\|b |"); + }); + + it("keeps every name inside its own table row", () => { + const { data } = compareSizes({ + current: [ + { name: "a\n\n## Injected\n