From 3a296551f6016e3eab304e84530eb030dc132953 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 16 Oct 2026 16:57:08 +0200 Subject: [PATCH] fix(perf): enforce route budgets and ratchet coverage thresholds Allow mode: enforce in the report config, fail the command on over-budget and unavailable routes, and make the markdown wording follow the configured mode. Recalibrate the budgets against the webpack build (baseline x1.15, rounded up to the next 10 KiB) now that the old Turbopack numbers no longer apply, so the Docker image build gate blocks genuine bundle regressions instead of only warning. Raise the Vitest coverage thresholds to the measured floor (37.17/30.1/31.79/38.17 -> 36/29/30/37). --- docs/performance-budgets.md | 24 +++++++------ scripts/performance-budgets.json | 26 +++++++-------- scripts/performance-report.mjs | 23 ++++++++++--- scripts/performance-report.test.mjs | 52 ++++++++++++++++++++++++++++- vitest.config.ts | 8 ++--- 5 files changed, 100 insertions(+), 33 deletions(-) diff --git a/docs/performance-budgets.md b/docs/performance-budgets.md index 48852e80..228d61c4 100644 --- a/docs/performance-budgets.md +++ b/docs/performance-budgets.md @@ -1,4 +1,4 @@ -# Informational route JavaScript budgets +# Route JavaScript budgets (enforced) Run after the existing production build; no second build or server is needed: @@ -28,20 +28,22 @@ The definition follows the data exposed by the installed Next 16.3.8 build and t ## Initial limits -The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred. +The limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are size guardrails, not validated speed targets. -The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack. +Baselines were last recalibrated against a local webpack production build (Next 16.4.1, `next build --webpack`) and cross-checked against the report the Docker image build emits: `/me` 621460/195411, `/news` 616712/193199, `/events` 617121/193450, `/search` 618536/194216, `/admin/catalog` 1049094/330369, `/admin/studio/furni` 1397566/447765 (raw/gzip). The earlier Turbopack numbers (previous revision) were **not** comparable and were dropped. + +The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade). | Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit | | --- | ---: | ---: | ---: | ---: | -| `/me` | 767156 | 238571 | 890880 | 276480 | -| `/news` | 765367 | 237599 | 880640 | 276480 | -| `/events` | 765851 | 237964 | 890880 | 276480 | -| `/search` | 765851 | 237964 | 890880 | 276480 | -| `/admin/catalog` | 1654898 | 492619 | 1904640 | 573440 | -| `/admin/studio/furni` | 1241312 | 391541 | 1433600 | 450560 | +| `/me` | 621460 | 195411 | 716800 | 225280 | +| `/news` | 616712 | 193199 | 716800 | 225280 | +| `/events` | 617121 | 193450 | 716800 | 225280 | +| `/search` | 618536 | 194216 | 716800 | 225280 | +| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 | +| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 | -Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` remains `mode: informational`. Exceeding a limit produces `over-budget` and a warning, with exit code 0. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**, also exit code 0. Malformed budget configuration or an unwritable output directory fails the command. This keeps initial CI reporting non-blocking while preventing invalid configuration from quietly disabling limits. +Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state. -Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`. +Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`. diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json index b1acf0a9..96e95ebe 100644 --- a/scripts/performance-budgets.json +++ b/scripts/performance-budgets.json @@ -1,36 +1,36 @@ { "schemaVersion": 1, - "mode": "informational", + "mode": "enforce", "routes": [ { "route": "/me", - "maxInitialRawBytes": 890880, - "maxInitialGzipBytes": 276480 + "maxInitialRawBytes": 716800, + "maxInitialGzipBytes": 225280 }, { "route": "/news", - "maxInitialRawBytes": 880640, - "maxInitialGzipBytes": 276480 + "maxInitialRawBytes": 716800, + "maxInitialGzipBytes": 225280 }, { "route": "/events", - "maxInitialRawBytes": 890880, - "maxInitialGzipBytes": 276480 + "maxInitialRawBytes": 716800, + "maxInitialGzipBytes": 225280 }, { "route": "/search", - "maxInitialRawBytes": 890880, - "maxInitialGzipBytes": 276480 + "maxInitialRawBytes": 716800, + "maxInitialGzipBytes": 225280 }, { "route": "/admin/catalog", - "maxInitialRawBytes": 1904640, - "maxInitialGzipBytes": 573440 + "maxInitialRawBytes": 1208320, + "maxInitialGzipBytes": 389120 }, { "route": "/admin/studio/furni", - "maxInitialRawBytes": 1433600, - "maxInitialGzipBytes": 450560 + "maxInitialRawBytes": 1607680, + "maxInitialGzipBytes": 522240 } ] } diff --git a/scripts/performance-report.mjs b/scripts/performance-report.mjs index 234cfbed..1403085f 100644 --- a/scripts/performance-report.mjs +++ b/scripts/performance-report.mjs @@ -7,12 +7,12 @@ const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"]; export function validateBudgets(config) { if ( config?.schemaVersion !== 1 || - config.mode !== "informational" || + (config.mode !== "informational" && config.mode !== "enforce") || !Array.isArray(config.routes) || config.routes.length === 0 ) throw new Error( - "Budget config requires schemaVersion 1, mode informational and nonempty routes.", + "Budget config requires schemaVersion 1, mode informational or enforce, and nonempty routes.", ); if ( Object.keys(config).some( @@ -288,7 +288,7 @@ export function collectReport(nextDir, config, metadata = {}) { }); return { schemaVersion: 1, - mode: "informational", + mode: config.mode, generatedAt: new Date().toISOString(), buildId, commitSha: metadata.commitSha ?? null, @@ -308,6 +308,10 @@ export function collectReport(nextDir, config, metadata = {}) { } export function markdownReport(report) { + const modeLine = + report.mode === "enforce" + ? `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: enforce; an over-budget or unavailable route fails the command and the image build.` + : `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`; const lines = [ "# Route JavaScript size report", "", @@ -315,7 +319,7 @@ export function markdownReport(report) { "", report.gzip, "", - `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`, + modeLine, "", "| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |", "| --- | --- | ---: | ---: | ---: | ---: | ---: |", @@ -370,6 +374,17 @@ export function main(argv = process.argv.slice(2)) { console.warn( `Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`, ); + if (report.mode === "enforce") { + const failed = report.routes.filter((row) => row.status !== "measured"); + if (failed.length > 0) { + console.error( + `Performance budgets enforced: ${failed + .map((row) => `${row.route} (${row.status})`) + .join(", ")}`, + ); + process.exitCode = 1; + } + } return report; } if ( diff --git a/scripts/performance-report.test.mjs b/scripts/performance-report.test.mjs index 92f8adc0..a385ea3f 100644 --- a/scripts/performance-report.test.mjs +++ b/scripts/performance-report.test.mjs @@ -373,7 +373,10 @@ describe("route JS measurement", () => { expect(() => validateBudgets({ ...config, routes: [budget, budget] }), ).toThrow(); - expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow(); + expect(validateBudgets({ ...config, mode: "enforce" }).mode).toBe( + "enforce", + ); + expect(() => validateBudgets({ ...config, mode: "strict" })).toThrow(); expect(() => validateBudgets({ ...config, typo: true })).toThrow(); }); it("emits warnings and artifacts with exit zero when a build is unavailable", () => { @@ -423,4 +426,51 @@ describe("route JS measurement", () => { expect(markdown).toContain("not measured browser traffic"); expect(markdown).toContain("Raw bytes"); }); + describe("enforce mode", () => { + const run = (dir, budgets) => { + const write = (relative, value) => { + const file = path.join(dir, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); + }; + write("budgets.json", budgets); + return spawnSync( + process.execPath, + [ + "scripts/performance-report.mjs", + "--next-dir", + dir, + "--config", + path.join(dir, "budgets.json"), + "--output-dir", + path.join(dir, "reports"), + ], + { encoding: "utf8" }, + ); + }; + it("passes when every route is measured within budget", () => { + const enforce = { ...config, mode: "enforce" }; + const runResult = run(fixture().dir, enforce); + expect(runResult.status).toBe(0); + expect(runResult.stdout).toContain("Mode: enforce"); + expect(runResult.stderr).not.toContain("Performance budgets enforced"); + }); + it("fails when a route is over budget", () => { + const enforce = { + schemaVersion: 1, + mode: "enforce", + routes: [{ ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 }], + }; + const runResult = run(fixture().dir, enforce); + expect(runResult.status).toBe(1); + expect(runResult.stderr).toContain("Performance budgets enforced"); + expect(runResult.stderr).toContain("/news (over-budget)"); + }); + it("fails when a route cannot be measured, so broken manifests do not pass quietly", () => { + const enforce = { ...config, mode: "enforce" }; + const runResult = run(path.join(fixture().dir, "missing"), enforce); + expect(runResult.status).toBe(1); + expect(runResult.stderr).toContain("/news (unavailable)"); + }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d69edfb2..729fce42 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -38,10 +38,10 @@ export default defineConfig({ provider: "v8", reporter: ["text", "lcov", "html"], thresholds: { - statements: 12, - branches: 9, - functions: 10, - lines: 13, + statements: 36, + branches: 29, + functions: 30, + lines: 37, }, include: ["src/**/*.{ts,tsx}"], exclude: [