chore: simplify frontend verification
This commit is contained in:
@@ -1,3 +1,2 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
npm run openapi:check
|
|
||||||
npm run deps
|
npm run deps
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user