From cc714b427ae45166f2230946823276df5b4ee0d2 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 08:41:31 +0200 Subject: [PATCH] ci: report per-route JavaScript budgets from Docker build --- .dockerignore | 4 + .gitea/workflows/ci.yaml | 9 + .gitignore | 2 + Dockerfile | 4 +- docs/performance-budgets.md | 39 ++++ scripts/ci-deploy.sh | 16 ++ scripts/performance-budgets.json | 36 +++ scripts/performance-report.mjs | 338 ++++++++++++++++++++++++++++ scripts/performance-report.test.mjs | 297 ++++++++++++++++++++++++ 9 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 docs/performance-budgets.md create mode 100644 scripts/performance-budgets.json create mode 100644 scripts/performance-report.mjs create mode 100644 scripts/performance-report.test.mjs diff --git a/.dockerignore b/.dockerignore index 27835586..73032f50 100644 --- a/.dockerignore +++ b/.dockerignore @@ -40,3 +40,7 @@ db_backup_*.sql .docker-install .docker-install.tmp.* .env.install.* +build-reports +test-results +playwright-report +blob-report diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8a449b17..ce0cda90 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -80,6 +80,15 @@ jobs: DEPLOY_BRANCH: ${{ gitea.ref_name }} run: bash scripts/ci-deploy.sh + - name: Upload JavaScript size report + if: always() + uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2 + with: + name: javascript-size-report + path: build-reports/ + if-no-files-found: warn + retention-days: 14 + # Publish only after checks and the production deployment have succeeded. # Serial execution also avoids two builds competing on the self-hosted runner. publish-container: diff --git a/.gitignore b/.gitignore index 6e7497fc..dbbb0699 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ blob-report/ .docker-install .docker-install.tmp.* .env.install.* +build-reports/ +!/docs/performance-budgets.md diff --git a/Dockerfile b/Dockerfile index 06abe42c..8ac61a66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,8 @@ RUN --mount=type=cache,target=/app/.next/cache \ DATABASE_URL="mysql://build:build@127.0.0.1:9/build" \ HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \ AUTH_SECRET="build-fixture-not-for-runtime-use-000000000000" \ - pnpm run build + pnpm run build \ + && PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports FROM node:26.8.2-alpine AS runner ARG NEXT_DEPLOYMENT_ID="unknown" @@ -54,6 +55,7 @@ RUN apk add --no-cache tini curl \ COPY --from=builder --chown=nextjs:nextjs /app/public ./public COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nextjs /app/build-reports ./build-reports COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migrations COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs USER nextjs diff --git a/docs/performance-budgets.md b/docs/performance-budgets.md new file mode 100644 index 00000000..25c1c32f --- /dev/null +++ b/docs/performance-budgets.md @@ -0,0 +1,39 @@ +# Informational route JavaScript budgets + +Run after the existing production build; no second build or server is needed: + +```sh +node scripts/performance-report.mjs --next-dir .next --config scripts/performance-budgets.json --output-dir build-reports +``` + +The command writes `report.json` and `report.md` and prints the Markdown report. An optional `PERFORMANCE_COMMIT_SHA` environment variable records the commit declared by the build caller; the script does not infer that an existing build matches the current checkout. JSON also records `BUILD_ID`, Node/zlib versions, manifest provenance, exact file paths, sizes and source entries. + +## What is measured + +For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent. + +The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests. + +- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope. +- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes. +- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total. +- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded. +- This report makes no claims about execution cost, LCP, hydration time or real-user performance. + +## 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: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred. + +| 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 | + +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. + +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`. + diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index 2a5862a8..2a16b5d0 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -99,6 +99,22 @@ echo "Building $image" DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \ --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . check_current +# Read reports from the already-built image; do not start an extra application. +report_container="" +if report_container="$(docker create --entrypoint /bin/true "$image")"; then + mkdir -p build-reports + if docker cp "$report_container:/app/build-reports/." build-reports; then + cat build-reports/report.md + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + cat build-reports/report.md >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "Warning: build performance report unavailable" >&2 + fi + docker rm "$report_container" >/dev/null +else + echo "Warning: could not extract build performance report" >&2 +fi pnpm db:migrate check_current diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json new file mode 100644 index 00000000..b1acf0a9 --- /dev/null +++ b/scripts/performance-budgets.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "mode": "informational", + "routes": [ + { + "route": "/me", + "maxInitialRawBytes": 890880, + "maxInitialGzipBytes": 276480 + }, + { + "route": "/news", + "maxInitialRawBytes": 880640, + "maxInitialGzipBytes": 276480 + }, + { + "route": "/events", + "maxInitialRawBytes": 890880, + "maxInitialGzipBytes": 276480 + }, + { + "route": "/search", + "maxInitialRawBytes": 890880, + "maxInitialGzipBytes": 276480 + }, + { + "route": "/admin/catalog", + "maxInitialRawBytes": 1904640, + "maxInitialGzipBytes": 573440 + }, + { + "route": "/admin/studio/furni", + "maxInitialRawBytes": 1433600, + "maxInitialGzipBytes": 450560 + } + ] +} diff --git a/scripts/performance-report.mjs b/scripts/performance-report.mjs new file mode 100644 index 00000000..7803fb76 --- /dev/null +++ b/scripts/performance-report.mjs @@ -0,0 +1,338 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"]; +export function validateBudgets(config) { + if ( + config?.schemaVersion !== 1 || + config.mode !== "informational" || + !Array.isArray(config.routes) || + config.routes.length === 0 + ) + throw new Error( + "Budget config requires schemaVersion 1, mode informational and nonempty routes.", + ); + if ( + Object.keys(config).some( + (key) => !["schemaVersion", "mode", "routes"].includes(key), + ) + ) + throw new Error("Unknown budget config field."); + const seen = new Set(); + for (const entry of config.routes) { + if ( + !entry || + typeof entry.route !== "string" || + !/^\/(?:[^?#\s]*)$/.test(entry.route) || + seen.has(entry.route) || + Object.keys(entry).some((key) => !LIMIT_KEYS.includes(key)) + ) + throw new Error("Invalid or duplicate budget route."); + seen.add(entry.route); + for (const key of LIMIT_KEYS.slice(1)) + if ( + entry[key] !== null && + (!Number.isSafeInteger(entry[key]) || entry[key] <= 0) + ) + throw new Error( + `Invalid ${key} for ${entry.route}: use positive integer bytes or null.`, + ); + } + return config; +} + +/** Parse Next's generated JSON assignment, never execute the manifest as JavaScript. */ +export function parseClientManifest(source, appPath) { + const assignment = + /globalThis\.__RSC_MANIFEST\[("(?:[^"\\]|\\.)*")\]\s*=\s*/g; + const match = assignment.exec(source); + if (!match || JSON.parse(match[1]) !== appPath) + throw new Error("Unsupported client-reference manifest assignment."); + const value = source + .slice(match.index + match[0].length) + .trim() + .replace(/;$/, ""); + return JSON.parse(value); +} + +export function normalizeAsset(value) { + if (typeof value !== "string") + throw new Error("Non-string JavaScript asset."); + const clean = value.split(/[?#]/, 1)[0].replace(/^\/_next\//, ""); + if ( + !clean.startsWith("static/") || + clean.includes("\\") || + clean + .split("/") + .some((part) => part === ".." || part === "." || part === "") || + !clean.endsWith(".js") + ) + throw new Error(`Unsupported JavaScript asset: ${value}`); + return clean; +} + +function filesFrom(values) { + if (!Array.isArray(values)) + throw new Error("Unsupported JavaScript chunk list."); + return values.map(normalizeAsset); +} + +export function measureRoute({ + budget, + appPath, + buildManifest, + clientManifest, + readAsset, +}) { + const entries = clientManifest?.entryJSFiles; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) + throw new Error( + "entryJSFiles is unavailable; this manifest layout is not supported.", + ); + const sourceEntries = Object.keys(entries); + if ( + !sourceEntries.some((key) => + key.replaceAll("\\", "/").endsWith(`/app${appPath}`), + ) + ) + throw new Error("Route page entry is absent from entryJSFiles."); + const bootstrap = filesFrom( + buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles, + ); + if (bootstrap.length === 0) + throw new Error("Initial bootstrap chunks are unavailable."); + const origins = new Map(); + const add = (file, origin) => { + const sources = origins.get(file) ?? new Set(); + sources.add(origin); + origins.set(file, sources); + }; + for (const file of bootstrap) add(file, "bootstrap"); + for (const [entry, values] of Object.entries(entries)) + for (const file of filesFrom(values)) add(file, entry); + const size = (file, sources) => { + const bytes = readAsset(file); + return { + path: file, + rawBytes: bytes.length, + gzipBytes: gzipSync(bytes, { level: 9 }).length, + sources: [...sources].sort(), + }; + }; + const chunks = [...origins] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([file, sources]) => size(file, sources)); + const polyfills = [...new Set(filesFrom(buildManifest.polyfillFiles ?? []))] + .filter((file) => !origins.has(file) && !file.endsWith(".module.js")) + .sort() + .map((file) => size(file, ["legacy-nomodule-polyfill"])); + const rawBytes = chunks.reduce((total, file) => total + file.rawBytes, 0); + const gzipBytes = chunks.reduce((total, file) => total + file.gzipBytes, 0); + const exceeded = []; + if ( + budget.maxInitialRawBytes !== null && + rawBytes > budget.maxInitialRawBytes + ) + exceeded.push("raw"); + if ( + budget.maxInitialGzipBytes !== null && + gzipBytes > budget.maxInitialGzipBytes + ) + exceeded.push("gzip"); + return { + route: budget.route, + appPath, + status: exceeded.length ? "over-budget" : "measured", + initial: { chunkCount: chunks.length, rawBytes, gzipBytes, chunks }, + legacyPolyfills: { + rawBytes: polyfills.reduce((n, f) => n + f.rawBytes, 0), + gzipBytes: polyfills.reduce((n, f) => n + f.gzipBytes, 0), + chunks: polyfills, + }, + budget: { + maxInitialRawBytes: budget.maxInitialRawBytes, + maxInitialGzipBytes: budget.maxInitialGzipBytes, + }, + exceeded, + }; +} + +function jsonFile(file) { + return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, "")); +} +function within(root, relative) { + const resolved = path.resolve(root, relative); + if (!resolved.startsWith(path.resolve(root) + path.sep)) + throw new Error("Manifest path escapes the build directory."); + return resolved; +} +export function collectReport(nextDir, config, metadata = {}) { + validateBudgets(config); + let routeMap, appMap, manifestError; + let buildId = null; + try { + buildId = readFileSync(path.join(nextDir, "BUILD_ID"), "utf8").trim(); + if (!buildId) + throw new Error("A completed production BUILD_ID is required."); + routeMap = jsonFile(path.join(nextDir, "app-path-routes-manifest.json")); + appMap = jsonFile(path.join(nextDir, "server/app-paths-manifest.json")); + } catch (error) { + manifestError = error.message; + } + const routes = config.routes.map((budget) => { + try { + if (manifestError) throw new Error(manifestError); + const matches = Object.entries(routeMap) + .filter(([, route]) => route === budget.route) + .map(([appPath]) => appPath); + if (matches.length !== 1) + throw new Error( + `Expected one build entry for ${budget.route}, found ${matches.length}.`, + ); + const appPath = matches[0], + serverEntry = appMap[appPath]; + if (typeof serverEntry !== "string" || !serverEntry.endsWith("/page.js")) + throw new Error("Unsupported server app entry."); + const clientFile = within( + path.join(nextDir, "server"), + serverEntry.replace(/\.js$/, "_client-reference-manifest.js"), + ); + const clientManifest = parseClientManifest( + readFileSync(clientFile, "utf8"), + appPath, + ); + let buildManifest, + buildManifestSource = `server/${serverEntry.replace(/\.js$/, "/build-manifest.json")}`; + try { + buildManifest = jsonFile(within(nextDir, buildManifestSource)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + buildManifestSource = "build-manifest.json"; + buildManifest = jsonFile(path.join(nextDir, buildManifestSource)); + } + return { + ...measureRoute({ + budget, + appPath, + buildManifest, + clientManifest, + readAsset: (file) => readFileSync(within(nextDir, file)), + }), + manifests: { + client: path.relative(nextDir, clientFile).split(path.sep).join("/"), + build: buildManifestSource, + }, + }; + } catch (error) { + return { + route: budget.route, + status: "unavailable", + reason: error.message, + initial: null, + budget: { + maxInitialRawBytes: budget.maxInitialRawBytes, + maxInitialGzipBytes: budget.maxInitialGzipBytes, + }, + }; + } + }); + return { + schemaVersion: 1, + mode: "informational", + generatedAt: new Date().toISOString(), + buildId, + commitSha: metadata.commitSha ?? null, + commitProvenance: + "Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.", + nodeVersion: process.version, + zlibVersion: process.versions.zlib, + manifestFormat: "Next App Router client-reference entryJSFiles", + definition: + "Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.", + gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.", + excluded: + "CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.", + routes, + }; +} + +export function markdownReport(report) { + const lines = [ + "# Route JavaScript size report", + "", + report.definition, + "", + report.gzip, + "", + `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`, + "", + "| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |", + "| --- | --- | ---: | ---: | ---: | ---: | ---: |", + ]; + for (const row of report.routes) + lines.push( + `| ${row.route} | ${row.status} | ${row.initial?.chunkCount ?? "—"} | ${row.initial?.rawBytes ?? "—"} | ${row.initial?.gzipBytes ?? "—"} | ${row.budget.maxInitialRawBytes ?? "observe"} | ${row.budget.maxInitialGzipBytes ?? "observe"} |`, + ); + lines.push("", report.excluded, ""); + for (const row of report.routes) + if (row.reason) + lines.push( + `- ${row.route}: unavailable — ${row.reason.replaceAll("\n", " ")}`, + ); + lines.push( + "", + "Exact file lists, provenance and legacy polyfill sizes are in report.json.", + "", + ); + return lines.join("\n"); +} + +export function main(argv = process.argv.slice(2)) { + const options = { + "--next-dir": ".next", + "--config": "scripts/performance-budgets.json", + "--output-dir": ".next/performance-report", + }; + for (let i = 0; i < argv.length; i += 2) { + if (!Object.hasOwn(options, argv[i]) || !argv[i + 1]) + throw new Error( + "Usage: node scripts/performance-report.mjs [--next-dir DIR] [--config FILE] [--output-dir DIR]", + ); + options[argv[i]] = argv[i + 1]; + } + const config = validateBudgets(jsonFile(options["--config"])); + const report = collectReport(options["--next-dir"], config, { + commitSha: process.env.PERFORMANCE_COMMIT_SHA || null, + }); + mkdirSync(options["--output-dir"], { recursive: true }); + writeFileSync( + path.join(options["--output-dir"], "report.json"), + `${JSON.stringify(report, null, 2)}\n`, + ); + writeFileSync( + path.join(options["--output-dir"], "report.md"), + markdownReport(report), + ); + console.log(markdownReport(report)); + for (const row of report.routes) + if (row.status !== "measured") + console.warn( + `Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`, + ); + return report; +} +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + main(); + } catch (error) { + console.error( + `Performance report configuration/output error: ${error.message}`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/performance-report.test.mjs b/scripts/performance-report.test.mjs new file mode 100644 index 00000000..cceae38b --- /dev/null +++ b/scripts/performance-report.test.mjs @@ -0,0 +1,297 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; +import { afterEach, describe, expect, it } from "vitest"; +import { + collectReport, + markdownReport, + measureRoute, + normalizeAsset, + parseClientManifest, + validateBudgets, +} from "./performance-report.mjs"; + +const budget = { + route: "/news", + maxInitialRawBytes: 1000, + maxInitialGzipBytes: 1000, +}; +const config = { schemaVersion: 1, mode: "informational", routes: [budget] }; +const appPath = "/(site)/news/page"; +const buildManifest = { + rootMainFiles: ["static/chunks/runtime.js", "static/chunks/shared.js"], + polyfillFiles: ["static/chunks/polyfill.js"], +}; +const clientManifest = { + entryJSFiles: { + "[project]/src/app/layout": ["static/chunks/shared.js"], + "[project]/src/app/error": ["static/chunks/boundary.js"], + "[project]/src/app/(site)/news/page": [ + "/_next/static/chunks/shared.js?dpl=old", + "static/chunks/page.js", + ], + }, + clientModules: { dynamic: { chunks: ["static/chunks/dynamic-only.js"] } }, +}; +const files = { + "static/chunks/runtime.js": Buffer.from("const runtime = true;"), + "static/chunks/shared.js": Buffer.from("shared".repeat(50)), + "static/chunks/boundary.js": Buffer.from("boundary"), + "static/chunks/page.js": Buffer.from("page"), + "static/chunks/polyfill.js": Buffer.from("legacy"), +}; +const folders = []; +afterEach(() => { + for (const folder of folders.splice(0)) + rmSync(folder, { recursive: true, force: true }); +}); +function fixture() { + const dir = mkdtempSync(path.join(tmpdir(), "route-js-report-")); + folders.push(dir); + const write = (relative, value) => { + const file = path.join(dir, relative); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync( + file, + typeof value === "string" || Buffer.isBuffer(value) + ? value + : JSON.stringify(value), + ); + }; + write("BUILD_ID", "fixture-build"); + write("app-path-routes-manifest.json", { [appPath]: "/news" }); + write("server/app-paths-manifest.json", { + [appPath]: "app/(site)/news/page.js", + }); + write( + "server/app/(site)/news/page_client-reference-manifest.js", + `globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {};\nglobalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = ${JSON.stringify(clientManifest)};`, + ); + write("server/app/(site)/news/page/build-manifest.json", buildManifest); + for (const [file, bytes] of Object.entries(files)) write(file, bytes); + return { dir, write }; +} + +describe("route JS measurement", () => { + it("requires a completed production build instead of measuring development output", () => { + const { dir } = fixture(); + rmSync(path.join(dir, "BUILD_ID")); + expect(collectReport(dir, config).routes[0]).toMatchObject({ + status: "unavailable", + initial: null, + }); + }); + it("does not guess between multiple app paths for one route", () => { + const { dir, write } = fixture(); + write("app-path-routes-manifest.json", { + [appPath]: "/news", + "/(other)/news/page": "/news", + }); + expect(collectReport(dir, config).routes[0]).toMatchObject({ + status: "unavailable", + initial: null, + }); + }); + + it("deduplicates normalized shared chunks within each route and sums per-file gzip", () => { + const row = measureRoute({ + budget, + appPath, + buildManifest, + clientManifest, + readAsset: (file) => files[file], + }); + const initial = Object.entries(files).filter( + ([file]) => !file.includes("polyfill"), + ); + expect(row.initial.chunkCount).toBe(4); + expect(row.initial.rawBytes).toBe( + initial.reduce((n, [, bytes]) => n + bytes.length, 0), + ); + expect(row.initial.gzipBytes).toBe( + initial.reduce( + (n, [, bytes]) => n + gzipSync(bytes, { level: 9 }).length, + 0, + ), + ); + expect( + row.initial.chunks.find((f) => f.path.endsWith("shared.js")).sources, + ).toHaveLength(3); + expect(row.initial.chunks.some((f) => f.path.includes("dynamic"))).toBe( + false, + ); + expect(row.legacyPolyfills.rawBytes).toBe(6); + }); + it("uses route-specific bootstrap instead of unrelated global bootstrap", () => { + const row = measureRoute({ + budget, + appPath, + buildManifest: { + ...buildManifest, + rootMainFiles: ["static/chunks/not-used.js"], + rootMainFilesTree: { [appPath]: ["static/chunks/runtime.js"] }, + }, + clientManifest, + readAsset: (file) => files[file], + }); + expect(row.initial.chunkCount).toBe(4); + }); + it("reports exceeded budgets without throwing", () => { + expect( + measureRoute({ + budget: { ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 }, + appPath, + buildManifest, + clientManifest, + readAsset: (file) => files[file], + }), + ).toMatchObject({ status: "over-budget", exceeded: ["raw", "gzip"] }); + }); + it("parses generated data without executing trailing JavaScript", () => { + expect( + parseClientManifest( + `globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {"entryJSFiles":{}};`, + appPath, + ), + ).toEqual({ entryJSFiles: {} }); + expect(() => + parseClientManifest( + `globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {}; globalThis.executed = true;`, + appPath, + ), + ).toThrow(); + }); + it.each([ + "../secrets.js", + "static/../secrets.js", + "https://cdn.test/a.js", + "static\\secret.js", + "static/chunks/a.css", + ])("rejects unsupported asset paths %s", (file) => { + expect(() => normalizeAsset(file)).toThrow(); + }); + it("reports exact emitted bytes with build provenance", () => { + const { dir } = fixture(); + expect(collectReport(dir, config, { commitSha: "abc1234" })).toMatchObject({ + buildId: "fixture-build", + commitSha: "abc1234", + routes: [{ status: "measured", initial: { chunkCount: 4 } }], + }); + }); + it("keeps unavailable manifests separate from a zero metric", () => { + const { dir } = fixture(); + rmSync( + path.join( + dir, + "server/app/(site)/news/page_client-reference-manifest.js", + ), + ); + const row = collectReport(dir, config).routes[0]; + expect(row.status).toBe("unavailable"); + expect(row.initial).toBeNull(); + }); + it("does not publish partial totals when one referenced asset is missing", () => { + const { dir } = fixture(); + rmSync(path.join(dir, "static/chunks/page.js")); + expect(collectReport(dir, config).routes[0]).toMatchObject({ + status: "unavailable", + initial: null, + }); + }); + it("falls back to the root build manifest only when the route manifest is absent", () => { + const { dir, write } = fixture(); + write("build-manifest.json", buildManifest); + rmSync(path.join(dir, "server/app/(site)/news/page/build-manifest.json")); + expect(collectReport(dir, config).routes[0].manifests.build).toBe( + "build-manifest.json", + ); + }); + it("marks unsupported entryJSFiles layouts unavailable", () => { + const { dir, write } = fixture(); + write( + "server/app/(site)/news/page_client-reference-manifest.js", + `globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {};`, + ); + expect(collectReport(dir, config).routes[0].status).toBe("unavailable"); + }); + it("does not deduplicate shared files across independent cold route totals", () => { + const row = measureRoute({ + budget, + appPath, + buildManifest, + clientManifest, + readAsset: (file) => files[file], + }); + const another = measureRoute({ + budget: { ...budget, route: "/other" }, + appPath, + buildManifest, + clientManifest, + readAsset: (file) => files[file], + }); + expect(another.initial.rawBytes).toBe(row.initial.rawBytes); + }); + it("rejects invalid threshold schemas instead of silently dropping limits", () => { + for (const limit of [-1, 0, 1.5, "100", undefined]) + expect(() => + validateBudgets({ + ...config, + routes: [{ ...budget, maxInitialRawBytes: limit }], + }), + ).toThrow(); + expect(() => + validateBudgets({ ...config, routes: [budget, budget] }), + ).toThrow(); + expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow(); + expect(() => validateBudgets({ ...config, typo: true })).toThrow(); + }); + it("emits warnings and artifacts with exit zero when a build is unavailable", () => { + const { dir, write } = fixture(); + write("budgets.json", config); + const run = spawnSync( + process.execPath, + [ + "scripts/performance-report.mjs", + "--next-dir", + path.join(dir, "missing"), + "--config", + path.join(dir, "budgets.json"), + "--output-dir", + path.join(dir, "reports"), + ], + { encoding: "utf8" }, + ); + expect(run.status).toBe(0); + expect(run.stderr).toContain("Performance warning:"); + expect(run.stdout).toContain("unavailable"); + }); + it("exits nonzero for invalid configuration", () => { + const { dir, write } = fixture(); + write("budgets.json", { + ...config, + routes: [{ ...budget, maxInitialRawBytes: -1 }], + }); + const run = spawnSync( + process.execPath, + [ + "scripts/performance-report.mjs", + "--next-dir", + dir, + "--config", + path.join(dir, "budgets.json"), + "--output-dir", + path.join(dir, "reports"), + ], + { encoding: "utf8" }, + ); + expect(run.status).toBe(1); + }); + it("labels markdown with the envelope definition, never as loading time", () => { + const { dir } = fixture(); + const markdown = markdownReport(collectReport(dir, config)); + expect(markdown).toContain("not measured browser traffic"); + expect(markdown).toContain("Raw bytes"); + }); +});