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
+21
View File
@@ -1,22 +1,43 @@
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "slices-must-use-public-api",
severity: "error",
from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
},
to: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)",
pathNot: "^src/$1/$2/",
},
},
{
name: "shared-must-not-depend-on-layers",
severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" },
},
{
name: "entities-must-not-depend-on-higher-layers",
severity: "error",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" },
},
{
name: "features-must-not-depend-on-higher-layers",
severity: "error",
from: { path: "^src/features/" },
to: { path: "^src/(pages|app)/" },
},
{
name: "pages-must-not-depend-on-app",
severity: "error",
from: { path: "^src/pages/" },
to: { path: "^src/app/" },
},
+4 -2
View File
@@ -19,8 +19,10 @@
"openapi:check": "node scripts/openapi-check.mjs",
"hygiene:check": "node scripts/repository-hygiene-check.mjs",
"knip": "knip",
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build",
"deps": "node --test scripts/dependency-cruiser-config.test.mjs && depcruise src --config dependency-cruiser.cjs --output-type err-long",
"bundle:check": "node --test scripts/bundle-budget.test.mjs && node scripts/bundle-budget.mjs",
"bundle:baseline": "npm run build && node scripts/bundle-budget.mjs baseline",
"verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check",
"prepare": "husky",
"preview": "vite preview"
},
+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
}
+26
View File
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -10,6 +11,7 @@ import {
AccountsPageError,
AccountsPageLoading,
} from "@/pages/accounts";
import { server } from "@/test/server";
import { theme } from "../theme";
import { AppRoutes } from "./AppRoutes";
@@ -89,6 +91,30 @@ describe("AppRoutes", () => {
);
});
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" },
}),
),
);
renderRoutes("/accounts");
await waitFor(() =>
expect(
screen.getByRole("heading", {
level: 2,
name: "Нет инвестиционных счетов",
}),
).toBeInTheDocument(),
);
expect(
screen.getByRole("navigation", { name: "WealthLedger" }),
).toBeInTheDocument();
});
it.each([
["loading", AccountsPageLoading, "status"],
["error", AccountsPageError, "alert"],
+94 -19
View File
@@ -1,15 +1,48 @@
import { Loader } from "@mantine/core";
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import { AccountPage } from "@/pages/account";
import { AccountEventsPage } from "@/pages/account-events";
import { AccountOperationsPage } from "@/pages/account-operations";
import { AccountsPage } from "@/pages/accounts";
import { LoginPage } from "@/pages/login";
import { NotFoundPage } from "@/pages/not-found";
import { RegisterPage } from "@/pages/register";
import { SettingsPage } from "@/pages/settings";
import { AppShell } from "@/shared/ui";
import { AppBreadcrumbs } from "../ui/AppBreadcrumbs";
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
});
const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage };
});
const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage };
});
const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage };
});
const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login");
return { default: LoginPage };
});
const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage };
});
const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage };
});
const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("./ProtectedLayout");
return { default: ProtectedLayout };
});
function LazyPage({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<Loader />}>{children}</Suspense>;
}
function Protected({ children }: { children: React.ReactNode }) {
const { user, ready } = useAuth();
@@ -40,7 +73,9 @@ export function AppRoutes() {
path="/login"
element={
<Public>
<LoginPage />
<LazyPage>
<LoginPage />
</LazyPage>
</Public>
}
/>
@@ -48,30 +83,70 @@ export function AppRoutes() {
path="/register"
element={
<Public>
<RegisterPage />
<LazyPage>
<RegisterPage />
</LazyPage>
</Public>
}
/>
<Route
element={
<Protected>
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
<LazyPage>
<ProtectedLayout />
</LazyPage>
</Protected>
}
>
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/accounts/:accountId" element={<AccountPage />} />
<Route
path="/accounts"
element={
<LazyPage>
<AccountsPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId"
element={
<LazyPage>
<AccountPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
element={
<LazyPage>
<AccountEventsPage />
</LazyPage>
}
/>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
element={
<LazyPage>
<AccountOperationsPage />
</LazyPage>
}
/>
<Route
path="/settings"
element={
<LazyPage>
<SettingsPage />
</LazyPage>
}
/>
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
<Route
path="*"
element={
<LazyPage>
<NotFoundPage />
</LazyPage>
}
/>
</Routes>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { AppBreadcrumbs } from "@/app/ui";
import { AppShell } from "@/shared/ui";
export function ProtectedLayout() {
return <AppShell contentAboveOutlet={<AppBreadcrumbs />} />;
}
+1
View File
@@ -1 +1,2 @@
export { AppRoutes } from "./AppRoutes";
export { RouteErrorBoundary } from "./RouteErrorBoundary";
+1 -2
View File
@@ -1,8 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router";
import { AppRoutes } from "@/app/routes";
import { RouteErrorBoundary } from "@/app/routes/RouteErrorBoundary";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { theme } from "@/app/theme";
import { AuthProvider } from "@/features/auth";
+1
View File
@@ -0,0 +1 @@
export { AppBreadcrumbs } from "./AppBreadcrumbs";
+1 -1
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { z } from "zod";
import { validationMessage } from "../../../shared/i18n";
import { validationMessage } from "@/shared/i18n";
function isEmailCharacterInvalid(character: string): boolean {
return (
+13
View File
@@ -0,0 +1,13 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { ru } from "./locales/ru";
void i18n.use(initReactI18next).init({
resources: { ru },
lng: "ru",
fallbackLng: "ru",
interpolation: { escapeValue: false },
});
export { i18n };
-13
View File
@@ -1,14 +1 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { ru } from "./locales/ru";
void i18n.use(initReactI18next).init({
resources: { ru },
lng: "ru",
fallbackLng: "ru",
interpolation: { escapeValue: false },
});
export { validationMessage } from "./zod";
export { i18n };
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ParseKeys } from "i18next";
import { i18n } from ".";
import { i18n } from "./i18n";
export function validationMessage(
key: ParseKeys,
+1
View File
@@ -7,4 +7,5 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
build: { manifest: "bundle-manifest.json" },
});
+15 -4
View File
@@ -1,14 +1,25 @@
import { fileURLToPath, URL } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
test: {
environment: "jsdom",
exclude: [...configDefaults.exclude, "scripts/**/*.test.mjs"],
setupFiles: "./src/test/setup.ts",
coverage: { reporter: ["text", "html"] },
coverage: {
all: true,
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/test/**", "src/**/*.test.{ts,tsx}"],
reporter: ["text", "html"],
thresholds: {
statements: 82,
branches: 75,
functions: 75,
lines: 83,
},
},
},
});
import { fileURLToPath, URL } from "node:url";