562 lines
19 KiB
JavaScript
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);
|
|
});
|
|
});
|