import { afterEach, describe, expect, it, vi } from "vitest"; import { BUDGETS, baselineAssetKey, classifyBudgetStatus, classifyChunk, countRouteFiles, evaluateBudgets, findUnexpectedEmptyJsChunks, formatDriftPercent, getLargestJsChunks, isRouteChunkName, isVendorChunkName, loadBudgetsConfig, lookupBudget, normalizeAssetBaselineName, readAssetFiles, runBundleSizeGate, runCli, summarizePerAssetEntries, toPerRouteJsonReport, } from "./verify-bundle-size.mjs"; vi.mock("node:fs/promises", () => { const state = { readFile: vi.fn(), readdir: vi.fn(), stat: vi.fn(), }; return { __state: state, default: state, readFile: (...args) => state.readFile(...args), readdir: (...args) => state.readdir(...args), stat: (...args) => state.stat(...args), }; }); const fsPromisesModule = await import("node:fs/promises"); const fsState = fsPromisesModule.__state; function makeDirent(name) { return { name, isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false, }; } function stubAssets(files) { fsState.readFile.mockReset(); fsState.readdir.mockReset(); fsState.stat.mockReset(); fsState.readdir.mockResolvedValue(files.map((file) => makeDirent(file.name))); fsState.readFile.mockImplementation(async (full) => { const base = String(full).split("/").pop(); const match = files.find((file) => file.name === base); if (!match) { throw Object.assign(new Error(`readFile: ${full} not stubbed`), { code: "ENOENT" }); } return Buffer.alloc(match.contentSize ?? Math.max(1, match.size), "a"); }); fsState.stat.mockImplementation(async (full) => { const base = String(full).split("/").pop(); const match = files.find((file) => file.name === base); if (!match) { throw Object.assign(new Error(`stat: ${full} not stubbed`), { code: "ENOENT" }); } return { size: match.size }; }); } function captureLogger() { const log = []; const error = []; return { log: (msg) => log.push(msg), error: (msg) => error.push(msg), logs: log, errors: error, }; } afterEach(() => { fsState.readFile.mockReset(); fsState.readdir.mockReset(); fsState.stat.mockReset(); }); describe("classifyChunk", () => { it("classifies root, vendor, route, other and css", () => { expect(classifyChunk("entry.client-AAAA.js")).toBe("root"); expect(classifyChunk("root-BBBB.js")).toBe("root"); expect(classifyChunk("manifest-9cc2.js")).toBe("root"); expect(classifyChunk("chunk-ZZZZ-1234.js")).toBe("vendor"); expect(classifyChunk("vendor-editor-XXXX.js")).toBe("vendor"); expect(classifyChunk("(_lang)-AAAA.js")).toBe("route"); expect(classifyChunk("events._id-AAAA.js")).toBe("route"); expect(classifyChunk("dashboard-AAAAAA.js")).toBe("route"); expect(classifyChunk("AppLayout-AAAA.js")).toBe("other"); expect(classifyChunk("AppLayout-AAAA.css")).toBe("css"); expect(classifyChunk("README.txt")).toBeNull(); }); }); describe("evaluateBudgets", () => { it("returns PASS results when every group stays under budget", () => { const files = [ { name: "entry.client-AAAA.js", size: 100 }, { name: "root-BBBB.js", size: 200 }, { name: "manifest-CCCC.js", size: 300 }, { name: "chunk-DDDD.js", size: 400 }, { name: "vendor-editor-EEEE.js", size: 500 }, { name: "(_lang)-FFFF.js", size: 700 }, { name: "AppLayout-GGGG.js", size: 1_000 }, { name: "AppLayout-HHHH.css", size: 250 }, ]; const results = evaluateBudgets(files, BUDGETS); expect(results.every((r) => r.pass)).toBe(true); const totals = Object.fromEntries(results.map((r) => [r.group, r.total])); expect(totals.root).toBe(600); expect(totals.initial_payload_js).toBe(600); expect(totals.manifest_js).toBe(300); expect(totals.vendor).toBe(900); expect(totals.route).toBe(700); expect(totals.other).toBe(1_000); expect(totals.total_js).toBe(3_200); expect(totals.css).toBe(250); }); it("flags FAIL on route group when over budget", () => { const tightBudgets = { ...BUDGETS, route: 500 }; const files = [ { name: "(_lang)-AAAA.js", size: 400 }, { name: "events._id-BBBB.js", size: 200 }, // total route = 600 > 500 { name: "entry.client-CCCC.js", size: 100 }, ]; const results = evaluateBudgets(files, tightBudgets); const route = results.find((r) => r.group === "route"); expect(route.pass).toBe(false); expect(route.total).toBe(600); expect(route.budget).toBe(500); // Other groups still pass at their normal budgets. expect(results.find((r) => r.group === "root").pass).toBe(true); expect(results.find((r) => r.group === "vendor").pass).toBe(true); }); it("accumulates total_js across all four JS groups", () => { const files = [ { name: "entry.client-A.js", size: 11 }, { name: "root-A.js", size: 13 }, { name: "manifest-A.js", size: 17 }, { name: "chunk-A.js", size: 19 }, { name: "vendor-A.js", size: 23 }, { name: "(_lang)-A.js", size: 29 }, { name: "foo._id-A.js", size: 31 }, { name: "Component-A.js", size: 37 }, { name: "Component-A.css", size: 999 }, // CSS must not bleed into total_js ]; const results = evaluateBudgets(files, BUDGETS); const totalJs = results.find((r) => r.group === "total_js").total; expect(totalJs).toBe(11 + 13 + 17 + 19 + 23 + 29 + 31 + 37); expect(results.find((r) => r.group === "css").total).toBe(999); }); it("keeps initial payload aggregate limited to root-classified boot assets", () => { const results = evaluateBudgets( [ { name: "entry.client-A.js", size: 11 }, { name: "root-A.js", size: 13 }, { name: "manifest-A.js", size: 17 }, { name: "vendor-react-A.js", size: 19 }, { name: "(_lang)-A.js", size: 23 }, { name: "Component-A.js", size: 29 }, ], BUDGETS, ); expect(results.find((r) => r.group === "initial_payload_js")).toMatchObject({ total: 11 + 13 + 17, count: 3, budget: BUDGETS.initial_payload_js, pass: true, }); }); it("flags FAIL on initial payload when root-classified boot assets exceed budget", () => { const results = evaluateBudgets([{ name: "entry.client-A.js", size: 601 }], { ...BUDGETS, root: 1_000, initial_payload_js: 600, total_js: 1_000, }); expect(results.find((r) => r.group === "initial_payload_js")).toMatchObject({ total: 601, count: 1, budget: 600, pass: false, }); expect(results.find((r) => r.group === "root").pass).toBe(true); }); it("adds a dedicated manifest JS budget signal", () => { const results = evaluateBudgets([{ name: "manifest-CCCC.js", size: 601 }], { ...BUDGETS, manifest_js: 600 }); const manifest = results.find((r) => r.group === "manifest_js"); expect(manifest).toMatchObject({ total: 601, count: 1, budget: 600, pass: false }); }); }); describe("formatDriftPercent", () => { it("renders signed percentage drift relative to budget", () => { expect(formatDriftPercent(120, 100)).toBe("+20.0%"); expect(formatDriftPercent(80, 100)).toBe("-20.0%"); expect(formatDriftPercent(100, 100)).toBe("+0.0%"); expect(formatDriftPercent(100, 0)).toBe("n/a"); }); }); describe("build artifact helpers", () => { it("normalizes hashed asset names to stable baseline names", () => { expect(normalizeAssetBaselineName("readyz-l0sNRNKZ.js")).toBe("readyz"); expect(normalizeAssetBaselineName("entry.client-CLC9Ig9o.js")).toBe("entry.client"); expect(normalizeAssetBaselineName("root-CClBj9mx.css")).toBe("root"); expect(normalizeAssetBaselineName("workflows._id.run.stream-B4wANh-F.js")).toBe("workflows._id.run.stream"); expect(normalizeAssetBaselineName("forgot-password-BlT4YmRV.js")).toBe("forgot-password"); }); it("flags unexpected empty JS chunks while accepting known route artifacts", () => { const unexpected = findUnexpectedEmptyJsChunks([ { name: "readyz-l0sNRNKZ.js", size: 0 }, { name: "new-empty-route-ABC123.js", size: 0 }, { name: "empty.css", size: 0 }, { name: "normal.js", size: 12 }, ]); expect(unexpected).toEqual([{ name: "new-empty-route-ABC123.js", size: 0, baselineName: "new-empty-route" }]); }); it("returns largest non-empty JS chunks in deterministic order", () => { expect( getLargestJsChunks( [ { name: "small.js", size: 10 }, { name: "large-b.js", size: 200 }, { name: "large-a.js", size: 200 }, { name: "empty.js", size: 0 }, { name: "style.css", size: 300 }, ], 2, ), ).toEqual([ { name: "large-a.js", size: 200 }, { name: "large-b.js", size: 200 }, ]); }); it("counts route files without test or server route helpers", () => { expect(countRouteFiles("apps/web/app/routes")).toBeGreaterThan(0); }); }); describe("readAssetFiles", () => { it("returns null when the assets directory does not exist", async () => { fsState.readdir.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); const result = await readAssetFiles("/nope/does-not-exist"); expect(result).toBeNull(); }); }); describe("runBundleSizeGate", () => { it("SKIPs gracefully when no build artifacts are present", async () => { fsState.readdir.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); const logger = captureLogger(); const outcome = await runBundleSizeGate({ assetsDir: "/nope", logger }); expect(outcome.skipped).toBe(true); expect(outcome.results).toEqual([]); expect(logger.logs.some((line) => line.startsWith("SKIP "))).toBe(true); expect(logger.errors).toEqual([]); }); it("reports PASS lines and no failures when files are under budget", async () => { stubAssets([ { name: "entry.client-A.js", size: 100 }, { name: "chunk-A.js", size: 100 }, { name: "(_lang)-A.js", size: 100 }, { name: "AppLayout-A.js", size: 100 }, { name: "AppLayout-A.css", size: 50 }, ]); const logger = captureLogger(); const outcome = await runBundleSizeGate({ assetsDir: "/tmp/assets", logger }); expect(outcome.skipped).toBe(false); expect(outcome.failures).toHaveLength(0); expect(outcome.unexpectedEmptyJsChunks).toEqual([]); expect(outcome.largestJsChunks.map((chunk) => chunk.name)).toContain("entry.client-A.js"); const passLines = logger.logs.filter((line) => line.startsWith("PASS ")); expect(passLines.length).toBeGreaterThanOrEqual(7); expect(logger.logs).toContain("Largest JS chunks:"); expect(logger.errors).toEqual([]); }); it("emits FAIL output when a group exceeds its budget", async () => { stubAssets([{ name: "entry.client-A.js", size: 10_000_000 }]); const tightBudgets = { ...BUDGETS, root: 100, total_js: 100 }; const logger = captureLogger(); const outcome = await runBundleSizeGate({ assetsDir: "/tmp/assets", budgets: tightBudgets, logger, }); expect(outcome.skipped).toBe(false); expect(outcome.failures.length).toBeGreaterThanOrEqual(1); expect(logger.errors.some((line) => line.startsWith("FAIL group=root"))).toBe(true); }); it("fails when a new zero-byte JS chunk is not allowlisted", async () => { stubAssets([{ name: "new-empty-route-ABC123.js", size: 0 }]); const logger = captureLogger(); const outcome = await runBundleSizeGate({ assetsDir: "/tmp/assets", logger }); expect(outcome.failures.some((failure) => failure.group === "empty_js_chunks")).toBe(true); expect(outcome.unexpectedEmptyJsChunks).toEqual([ { name: "new-empty-route-ABC123.js", size: 0, baselineName: "new-empty-route" }, ]); expect(logger.errors).toContain("FAIL empty_js_chunks unexpected=1"); }); }); describe("per-route budget config", () => { it("loads bundle-budgets.json with required sections", () => { const cfg = loadBudgetsConfig(); expect(cfg.default.limitKb).toBeGreaterThan(0); expect(cfg.default.warningKb).toBeGreaterThan(0); expect(Object.keys(cfg.routes).length).toBeGreaterThanOrEqual(12); expect(Object.keys(cfg.vendorChunks).length).toBeGreaterThanOrEqual(3); }); it("pins emitted workflow and AI chunk names to explicit budgets", () => { const cfg = loadBudgetsConfig(); for (const key of [ "workflow-engine", "workflows", "workflows._id", "workflows._id.run.api", "workflows._id.run.manual", "workflows._id.run.stream", "workflows._id.run.stream._executionId", "chatbot", "chatbot-widget", "chatbot-interactive-widget", "ai-usage", "pricing.ai-overage", ]) { expect(lookupBudget(key, cfg)).toMatchObject({ kind: "route", key, budget: expect.objectContaining({ limitKb: expect.any(Number), warningKb: expect.any(Number) }), }); } }); }); describe("baselineAssetKey", () => { it("strips rollup content hashes and the .js extension", () => { expect(baselineAssetKey("vendor-react-DXdLCaXs.js")).toBe("vendor-react"); expect(baselineAssetKey("auth.magic-link-BjyKP6Px.js")).toBe("auth.magic-link"); expect(baselineAssetKey("(_lang)-2VbFPUi7.js")).toBe("(_lang)"); }); }); describe("isVendorChunkName / isRouteChunkName", () => { it("classifies vendor chunks via the vendor/chunk prefix", () => { expect(isVendorChunkName("vendor-react-AAAA.js")).toBe(true); expect(isVendorChunkName("chunk-AAAA.js")).toBe(true); expect(isVendorChunkName("AppLayout-AAAA.js")).toBe(false); }); it("excludes root/entry/manifest/vendor from route classification", () => { expect(isRouteChunkName("entry.client-AAAA.js")).toBe(false); expect(isRouteChunkName("root-AAAA.js")).toBe(false); expect(isRouteChunkName("manifest-AAAA.js")).toBe(false); expect(isRouteChunkName("vendor-react-AAAA.js")).toBe(false); }); it("includes route group + dynamic segment chunks", () => { expect(isRouteChunkName("(_lang)-AAAA.js")).toBe(true); expect(isRouteChunkName("events._id-AAAA.js")).toBe(true); }); }); describe("lookupBudget", () => { const config = { default: { limitKb: 200, warningKb: 180 }, routes: { "auth/magic-link": { limitKb: 80, warningKb: 75 }, "kb/$slug": { limitKb: 150, warningKb: 135 }, "(_lang)": { limitKb: 180, warningKb: 160 }, }, vendorChunks: { "vendor-react": { limitKb: 150, warningKb: 135 }, "vendor-editor": { limitKb: 200, warningKb: 180 }, "vendor-editor-tiptap": { limitKb: 120, warningKb: 110 }, "vendor-editor-prosemirror": { limitKb: 150, warningKb: 135 }, "vendor-misc": { limitKb: 300, warningKb: 280 }, }, }; it("matches vendor-chunk baseline keys to vendorChunks", () => { const res = lookupBudget("vendor-react", config); expect(res).toEqual({ kind: "vendor", key: "vendor-react", budget: config.vendorChunks["vendor-react"] }); }); it("matches the longest configured vendor chunk key before falling back to a shorter prefix", () => { expect(lookupBudget("vendor-editor-tiptap", config)).toEqual({ kind: "vendor", key: "vendor-editor-tiptap", budget: config.vendorChunks["vendor-editor-tiptap"], }); expect(lookupBudget("vendor-editor-prosemirror", config)).toEqual({ kind: "vendor", key: "vendor-editor-prosemirror", budget: config.vendorChunks["vendor-editor-prosemirror"], }); }); it("treats unknown vendor- chunks as vendor with no budget (default fallback)", () => { const res = lookupBudget("vendor-unknown", config); expect(res.kind).toBe("vendor"); expect(res.budget).toBeNull(); }); it("matches dot-form route ids by converting to slash form", () => { const res = lookupBudget("auth.magic-link", config); expect(res?.budget).toEqual(config.routes["auth/magic-link"]); }); it("matches route group keys verbatim", () => { const res = lookupBudget("(_lang)", config); expect(res?.budget).toEqual(config.routes["(_lang)"]); }); it("returns null budget when route is not configured", () => { const res = lookupBudget("some.unknown.route", config); expect(res?.budget).toBeNull(); }); }); describe("classifyBudgetStatus", () => { const budget = { limitKb: 100, warningKb: 90 }; it("ok when gzip <= warningKb", () => { expect(classifyBudgetStatus(50 * 1024, budget)).toBe("ok"); expect(classifyBudgetStatus(90 * 1024, budget)).toBe("ok"); }); it("warning when warningKb < gzip <= limitKb", () => { expect(classifyBudgetStatus(95 * 1024, budget)).toBe("warning"); // Boundary: gzip == limit is still warning (not exceeds, since exceeds is strict >). expect(classifyBudgetStatus(100 * 1024, budget)).toBe("warning"); }); it("exceeds when gzip > limitKb", () => { expect(classifyBudgetStatus(150 * 1024, budget)).toBe("exceeds"); }); it("reports unbudgeted when no budget is supplied", () => { expect(classifyBudgetStatus(50_000, null)).toBe("unbudgeted"); }); }); describe("summarizePerAssetEntries", () => { it("counts each status bucket", () => { const entries = [ { status: "ok" }, { status: "ok" }, { status: "warning" }, { status: "exceeds" }, { status: "unbudgeted" }, ]; expect(summarizePerAssetEntries(entries)).toEqual({ ok: 2, warning: 1, exceeds: 1, unbudgeted: 1 }); }); }); describe("JSON report mode", () => { it("serializes the per-route report shape consumed by PR comments", () => { expect( toPerRouteJsonReport({ skipped: false, totals: { ok: 1, warning: 0, exceeds: 0, unbudgeted: 0 }, entries: [ { file: "auth.login-AAAA.js", key: "auth/login", kind: "route", rawSize: 1234, gzipSize: 456, status: "ok", hasExplicitBudget: true, budget: { limitKb: 100, warningKb: 90 }, }, ], }), ).toEqual({ skipped: false, totals: { ok: 1, warning: 0, exceeds: 0, unbudgeted: 0 }, entries: [ { file: "auth.login-AAAA.js", key: "auth/login", kind: "route", rawSize: 1234, gzipSize: 456, status: "ok", hasExplicitBudget: true, limitKb: 100, warningKb: 90, }, ], }); }); it("keeps --json output parseable and non-blocking even when hard group budgets would fail", async () => { stubAssets([{ name: "entry.client-AAAA.js", size: 10_000_000, contentSize: 10_000_000 }]); const logger = captureLogger(); let output = ""; const exitCode = await runCli({ args: ["--json"], logger, stdout: { write: (chunk) => { output += chunk; } }, }); expect(exitCode).toBe(0); expect(() => JSON.parse(output)).not.toThrow(); expect(JSON.parse(output)).toMatchObject({ skipped: false, totals: { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 }, entries: [], }); expect(logger.logs).toEqual([]); expect(logger.errors).toEqual([]); }); it("reuses the hard-budget asset snapshot for the default per-route report", async () => { stubAssets([ { name: "entry.client-AAAA.js", size: 100 }, { name: "vendor-react-BBBB.js", size: 100 }, ]); const logger = captureLogger(); const exitCode = await runCli({ args: [], logger, stdout: { write: () => {} } }); expect(exitCode).toBe(0); expect(fsState.readdir).toHaveBeenCalledTimes(1); expect(fsState.stat).toHaveBeenCalledTimes(2); expect(logger.logs.some((line) => line.startsWith("Per-route summary:"))).toBe(true); }); });