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: [