Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-bundle-size.test.mjs
T

562 lines
19 KiB
JavaScript

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