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"); }); });