feat: enforce frontend quality guardrails

This commit is contained in:
2026-08-24 13:07:14 +03:00
parent 826936c6a4
commit 1fc269adec
18 changed files with 542 additions and 42 deletions
+144
View File
@@ -0,0 +1,144 @@
import { statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
export const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024;
function manifestEntries(manifest) {
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
throw new Error("Vite manifest must be an object");
}
return Object.entries(manifest);
}
function assetFile(entry, entryName) {
if (!entry || typeof entry.file !== "string" || !entry.file.endsWith(".js")) {
throw new Error(
`Manifest entry '${entryName}' must reference a JavaScript file`,
);
}
return entry.file;
}
export function getInitialAssetFiles(manifest) {
const entries = manifestEntries(manifest);
const entryCandidates = entries.filter(
([, entry]) => entry?.isEntry === true,
);
if (entryCandidates.length !== 1) {
throw new Error("Vite manifest must contain exactly one JavaScript entry");
}
const files = [];
const visitedEntries = new Set();
const visit = (entryName) => {
if (visitedEntries.has(entryName)) return;
visitedEntries.add(entryName);
const entry = manifest[entryName];
if (!entry)
throw new Error(`Vite manifest is missing eager import '${entryName}'`);
files.push(assetFile(entry, entryName));
for (const importedEntry of entry.imports ?? []) visit(importedEntry);
};
visit(entryCandidates[0][0]);
return files;
}
export function measureAssets(files, getSize) {
return files.reduce((total, file) => {
const size = getSize(file);
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error(`Cannot measure bundle asset '${file}'`);
}
return total + size;
}, 0);
}
export function assertBundleBudget(initialBytes) {
if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) {
throw new Error(
`Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`,
);
}
}
export function getLazyRouteAssetSizes(manifest, getSize, baseline) {
const routeEntries = manifestEntries(manifest).filter(
([, entry]) =>
entry?.isDynamicEntry === true && typeof entry.src === "string",
);
if (routeEntries.length === 0) {
throw new Error("Vite manifest must contain lazy route chunks");
}
const routeSizes = Object.fromEntries(
routeEntries
.map(([entryName, entry]) => [
entry.src,
getSize(assetFile(entry, entryName)),
])
.sort(([left], [right]) => left.localeCompare(right)),
);
if (!baseline) return routeSizes;
for (const [route, size] of Object.entries(routeSizes)) {
if (!Number.isSafeInteger(baseline[route])) {
throw new Error(`Missing baseline for lazy route chunk '${route}'`);
}
if (size > baseline[route]) {
throw new Error(
`Lazy route chunk '${route}' is ${size} bytes; baseline is ${baseline[route]} bytes`,
);
}
}
return routeSizes;
}
const distributionDirectory = resolve("dist");
const manifestPath = resolve(distributionDirectory, "bundle-manifest.json");
const baselinePath = resolve("scripts/lazy-route-bundle-baseline.json");
async function loadJson(path) {
return JSON.parse(await readFile(path, "utf8"));
}
function getAssetSize(file) {
return statSync(resolve(distributionDirectory, file)).size;
}
async function run(mode) {
const manifest = await loadJson(manifestPath);
const initialBytes = measureAssets(
getInitialAssetFiles(manifest),
getAssetSize,
);
assertBundleBudget(initialBytes);
const baseline =
mode === "baseline" ? undefined : await loadJson(baselinePath);
const lazyRouteSizes = getLazyRouteAssetSizes(
manifest,
getAssetSize,
baseline,
);
if (mode === "baseline") {
await writeFile(
baselinePath,
`${JSON.stringify(lazyRouteSizes, null, "\t")}\n`,
);
}
console.log(`Initial JavaScript payload: ${initialBytes} bytes`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
const mode = process.argv[2] ?? "check";
if (mode !== "check" && mode !== "baseline") {
throw new Error("Usage: node scripts/bundle-budget.mjs [check|baseline]");
}
await run(mode);
}
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assertBundleBudget,
getInitialAssetFiles,
getLazyRouteAssetSizes,
INITIAL_BUNDLE_BUDGET_BYTES,
measureAssets,
} from "./bundle-budget.mjs";
const manifest = {
"index.html": {
file: "assets/entry.js",
isEntry: true,
imports: ["_shared"],
dynamicImports: ["src/pages/account/ui/AccountPage.tsx"],
},
_shared: { file: "assets/shared.js", imports: ["_runtime"] },
_runtime: { file: "assets/runtime.js" },
"src/pages/account/ui/AccountPage.tsx": {
file: "assets/account.js",
isDynamicEntry: true,
src: "src/pages/account/ui/AccountPage.tsx",
},
"src/app/routes/ProtectedLayout.tsx": {
file: "assets/protected-layout.js",
isDynamicEntry: true,
src: "src/app/routes/ProtectedLayout.tsx",
},
};
const sizes = new Map([
["assets/entry.js", 200 * 1024],
["assets/shared.js", 175 * 1024],
["assets/runtime.js", 100 * 1024],
["assets/account.js", 250 * 1024],
["assets/protected-layout.js", 50 * 1024],
]);
test("measures only the eager import graph of the entry bundle", () => {
const assets = getInitialAssetFiles(manifest);
assert.deepEqual(assets, [
"assets/entry.js",
"assets/shared.js",
"assets/runtime.js",
]);
assert.equal(
measureAssets(assets, (asset) => sizes.get(asset)),
475 * 1024,
);
});
test("rejects an initial bundle above the configured budget", () => {
assert.throws(
() => assertBundleBudget(INITIAL_BUNDLE_BUDGET_BYTES + 1),
/JavaScript payload/,
);
});
test("rejects malformed manifests and lazy-route size regressions", () => {
assert.throws(() => getInitialAssetFiles({}), /entry/);
assert.deepEqual(
getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset)),
{
"src/app/routes/ProtectedLayout.tsx": 50 * 1024,
"src/pages/account/ui/AccountPage.tsx": 250 * 1024,
},
);
assert.throws(
() =>
getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), {
"src/app/routes/ProtectedLayout.tsx": 50 * 1024,
"src/pages/account/ui/AccountPage.tsx": 249 * 1024,
}),
/route chunk/,
);
assert.throws(
() =>
getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), {
"src/pages/account/ui/AccountPage.tsx": 250 * 1024,
}),
/Missing baseline/,
);
});
+117
View File
@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
const projectDirectory = dirname(scriptsDirectory);
const dependencyCruiser = join(
projectDirectory,
"node_modules/dependency-cruiser/bin/dependency-cruise.mjs",
);
const dependencyConfig = join(projectDirectory, "dependency-cruiser.cjs");
async function withFixture(files, run) {
const fixtureDirectory = await mkdtemp(join(tmpdir(), "wealth-ledger-deps-"));
try {
await Promise.all(
Object.entries(files).map(async ([relativePath, contents]) => {
const filePath = join(fixtureDirectory, relativePath);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, contents);
}),
);
return await run(fixtureDirectory);
} finally {
await rm(fixtureDirectory, { recursive: true, force: true });
}
}
function cruise(fixtureDirectory) {
return spawnSync(
process.execPath,
[
dependencyCruiser,
"src",
"--config",
dependencyConfig,
"--output-type",
"err-long",
],
{ cwd: fixtureDirectory, encoding: "utf8" },
);
}
function output(result) {
return `${result.stdout}${result.stderr}`;
}
test("rejects a source import cycle", async () => {
await withFixture(
{
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
"src/shared/i18n/index.ts": 'export { value } from "./zod";\n',
"src/shared/i18n/zod.ts": 'export { value } from ".";\n',
},
(fixtureDirectory) => {
const result = cruise(fixtureDirectory);
assert.notEqual(result.status, 0, output(result));
assert.match(output(result), /no-circular/);
},
);
});
test("rejects a deep import into another slice", async () => {
await withFixture(
{
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
"src/features/auth/ui/Form.ts": 'import "../../../shared/i18n/zod";\n',
"src/shared/i18n/index.ts": "export {};\n",
"src/shared/i18n/zod.ts": "export {};\n",
},
(fixtureDirectory) => {
const result = cruise(fixtureDirectory);
assert.notEqual(result.status, 0, output(result));
assert.match(output(result), /slices-must-use-public-api/);
},
);
});
test("rejects an index.tsx import from another slice", async () => {
await withFixture(
{
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
"src/features/auth/ui/Form.ts": 'import "../../../shared/i18n";\n',
"src/shared/i18n/index.tsx": "export {};\n",
},
(fixtureDirectory) => {
const result = cruise(fixtureDirectory);
assert.notEqual(result.status, 0, output(result));
assert.match(output(result), /slices-must-use-public-api/);
},
);
});
test("allows imports within a slice and through another slice public API", async () => {
await withFixture(
{
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
"src/features/auth/model/local.ts": "export const local = true;\n",
"src/features/auth/ui/Form.ts": [
'import { local } from "../model/local";\n',
'import { value } from "../../../shared/i18n";\n',
"void local;\n",
"void value;\n",
].join(""),
"src/shared/i18n/index.ts": 'export { value } from "./zod";\n',
"src/shared/i18n/zod.ts": "export const value = true;\n",
},
(fixtureDirectory) => {
const result = cruise(fixtureDirectory);
assert.equal(result.status, 0, output(result));
},
);
});
+11
View File
@@ -0,0 +1,11 @@
{
"src/app/routes/ProtectedLayout.tsx": 3137,
"src/pages/account-events/index.ts": 4468,
"src/pages/account-operations/index.ts": 13861,
"src/pages/account/index.ts": 17018,
"src/pages/accounts/index.ts": 6174,
"src/pages/login/index.ts": 669,
"src/pages/not-found/index.ts": 850,
"src/pages/register/index.ts": 675,
"src/pages/settings/index.ts": 38876
}