chore: simplify frontend verification

This commit is contained in:
2026-08-26 23:39:40 +03:00
parent 3d9b6194c6
commit f09baa68fc
10 changed files with 10 additions and 196 deletions
-1
View File
@@ -1,3 +1,2 @@
npx lint-staged npx lint-staged
npm run openapi:check
npm run deps npm run deps
+1 -1
View File
@@ -22,7 +22,7 @@ npm ci
npm run verify npm run verify
``` ```
Gate non-mutating и проверяет OpenAPI snapshot, strict TypeScript, Biome, risk-based tests/coverage ratchet, Knip, dependency boundaries, build и bundle budget. `npm run openapi:sync` — отдельная записывающая команда для атомарного обновления snapshot/types; обе стороны API contract сверяются отдельной backend командой `npm run test:openapi-contract`. Gate non-mutating и проверяет strict TypeScript, Biome, risk-based tests/coverage ratchet, Knip, dependency boundaries и build. Если backend API contract меняется, committed frontend snapshot `src/shared/api/spec/openapi.json` обновляется явным копированием backend OpenAPI document, затем `npm run openapi:generate` регенерирует committed types; обе стороны API contract сверяются отдельной backend командой `npm run test:openapi-contract`.
Актуальные scripts проверять в `package.json`/`npm run`. Актуальные scripts проверять в `package.json`/`npm run`.
## Enforceable boundaries ## Enforceable boundaries
+5 -6
View File
@@ -22,12 +22,11 @@ npm run dev
npm run verify npm run verify
``` ```
Команда сначала отклоняет runtime вне Node 22, затем проверяет committed Команда сначала отклоняет runtime вне Node 22, затем проверяет repository
OpenAPI snapshot, repository hygiene, strict TypeScript, Biome, один hygiene, strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip,
coverage-backed запуск Vitest, Knip, dependency rules, production build и dependency rules и production build. Если backend-контракт изменился,
bundle budget. Для обновления OpenAPI artifacts применяется только обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI
`npm run openapi:sync`; обычный `openapi:check` обращается лишь к committed document и затем выполните `npm run openapi:generate`.
snapshot.
## Документация ## Документация
+2 -2
View File
@@ -1,8 +1,8 @@
# API Layer # API Layer
OpenAPI backend является источником истины. После изменения backend-контракта запускается `npm run openapi:sync`; она получает опубликованный документ, валидирует генерацией и только затем атомарно обновляет `src/shared/api/spec/openapi.json` и `src/shared/api/generated/schema.d.ts`. Оба файла committed; generated файл не редактируется вручную. OpenAPI backend является источником истины. После изменения backend-контракта committed snapshot `src/shared/api/spec/openapi.json` обновляют простым копированием опубликованного документа backend, а затем регенерируют `src/shared/api/generated/schema.d.ts` командой `npm run openapi:generate`. Оба файла committed; generated файл не редактируется вручную.
`npm run openapi:generate` и `npm run openapi:check` используют snapshot offline. `verify` никогда не требует backend; OpenAPI endpoint разрешён только явной sync-команде. `npm run openapi:generate` использует committed snapshot offline. `verify` никогда не требует backend и не пытается автоматически синхронизировать OpenAPI artifacts.
Все HTTP-вызовы используют `apiClient` и `apiQueryClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. Query options берутся из `apiQueryClient`, а сохранённые cache keys и policies задаются вызывающим slice. Все HTTP-вызовы используют `apiClient` и `apiQueryClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. Query options берутся из `apiQueryClient`, а сохранённые cache keys и policies задаются вызывающим slice.
+1 -4
View File
@@ -15,12 +15,9 @@
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:sync": "node scripts/openapi-sync.mjs",
"openapi:check": "node scripts/openapi-check.mjs",
"knip": "knip", "knip": "knip",
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long", "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
"bundle:check": "node scripts/bundle-budget.mjs", "verify": "npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build",
"verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check",
"prepare": "husky", "prepare": "husky",
"preview": "vite preview" "preview": "vite preview"
}, },
-100
View File
@@ -1,100 +0,0 @@
import { statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
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;
}
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;
}
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);
}
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`,
);
}
}
const distributionDirectory = resolve("dist");
const manifestPath = resolve(distributionDirectory, "bundle-manifest.json");
async function loadJson(path) {
return JSON.parse(await readFile(path, "utf8"));
}
function getAssetSize(file) {
return statSync(resolve(distributionDirectory, file)).size;
}
async function run() {
const manifest = await loadJson(manifestPath);
const initialBytes = measureAssets(
getInitialAssetFiles(manifest),
getAssetSize,
);
assertBundleBudget(initialBytes);
console.log(`Initial JavaScript payload: ${initialBytes} bytes`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
await run();
}
export {
assertBundleBudget,
getInitialAssetFiles,
INITIAL_BUNDLE_BUDGET_BYTES,
measureAssets,
};
-36
View File
@@ -1,36 +0,0 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const snapshot = "src/shared/api/spec/openapi.json";
const generated = "src/shared/api/generated/schema.d.ts";
const directory = await mkdtemp(join(tmpdir(), "wealth-ledger-openapi-"));
const expected = join(directory, "schema.d.ts");
try {
await new Promise((resolve, reject) => {
const child = spawn(
"npx",
["--no-install", "openapi-typescript", snapshot, "-o", expected],
{
stdio: "inherit",
},
);
child.on("error", reject);
child.on("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`openapi-typescript exited with ${code}`)),
);
});
if (
(await readFile(expected, "utf8")) !== (await readFile(generated, "utf8"))
) {
throw new Error(
"Generated OpenAPI types drift from src/shared/api/spec/openapi.json. Run npm run openapi:generate.",
);
}
} finally {
await rm(directory, { recursive: true, force: true });
}
-44
View File
@@ -1,44 +0,0 @@
import { spawn } from "node:child_process";
import { mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const baseUrl = process.env.VITE_API_BASE_URL ?? "http://localhost:3000";
const snapshot = "src/shared/api/spec/openapi.json";
const generated = "src/shared/api/generated/schema.d.ts";
const directory = await mkdtemp(join(tmpdir(), "wealth-ledger-openapi-"));
const nextSnapshot = join(directory, "openapi.json");
const nextGenerated = join(directory, "schema.d.ts");
try {
const response = await fetch(`${baseUrl}/api/docs-json`, {
headers: { Accept: "application/json" },
});
if (!response.ok)
throw new Error(
`OpenAPI sync failed: ${response.status} ${response.statusText}`,
);
const document = await response.json();
if (!document || typeof document !== "object")
throw new Error("OpenAPI sync failed: endpoint did not return an object");
await writeFile(nextSnapshot, `${JSON.stringify(document, null, 2)}\n`);
await new Promise((resolve, reject) => {
const child = spawn(
"npx",
["--no-install", "openapi-typescript", nextSnapshot, "-o", nextGenerated],
{
stdio: "inherit",
},
);
child.on("error", reject);
child.on("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`openapi-typescript exited with ${code}`)),
);
});
await rename(nextSnapshot, snapshot);
await rename(nextGenerated, generated);
} finally {
await rm(directory, { recursive: true, force: true });
}
-1
View File
@@ -7,5 +7,4 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })], plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
build: { manifest: "bundle-manifest.json" },
}); });
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineConfig({
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
test: { test: {
environment: "jsdom", environment: "jsdom",
exclude: [...configDefaults.exclude, "scripts/**/*.test.mjs"], exclude: configDefaults.exclude,
setupFiles: "./src/test/setup.ts", setupFiles: "./src/test/setup.ts",
coverage: { coverage: {
all: true, all: true,