298 lines
9.2 KiB
JavaScript
298 lines
9.2 KiB
JavaScript
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");
|
|
});
|
|
});
|