chore: simplify frontend verification
This commit is contained in:
@@ -22,7 +22,7 @@ npm ci
|
|||||||
npm run verify
|
npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, strict TypeScript, Biome, risk-based tests/coverage ratchet, Knip, dependency boundaries, build и bundle budget. `npm run openapi:sync` — отдельная записывающая команда для атомарного обновления snapshot/types; runtime contract обеих сторон сверяется из workspace через `bash specs/scripts/verify-runtime-openapi.sh`.
|
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`.
|
||||||
Актуальные scripts проверять в `package.json`/`npm run`.
|
Актуальные scripts проверять в `package.json`/`npm run`.
|
||||||
|
|
||||||
## Enforceable boundaries
|
## Enforceable boundaries
|
||||||
|
|||||||
@@ -42,6 +42,6 @@ snapshot.
|
|||||||
- [Remediation registry](../docs/development/audit-remediation.md)
|
- [Remediation registry](../docs/development/audit-remediation.md)
|
||||||
|
|
||||||
Runtime OpenAPI backend сверяется с frontend snapshot отдельной read-only
|
Runtime OpenAPI backend сверяется с frontend snapshot отдельной read-only
|
||||||
командой из workspace: `bash specs/scripts/verify-runtime-openapi.sh`.
|
backend командой `npm run test:openapi-contract`.
|
||||||
Полный multi-repository change проверяется из workspace командой
|
Полный multi-repository change проверяется из workspace командой
|
||||||
`bash specs/scripts/verify-workspace-change.sh <change>`.
|
native OpenSpec validation and the canonical gates of each affected repository.
|
||||||
|
|||||||
+3
-6
@@ -7,7 +7,6 @@
|
|||||||
"node": "22.x"
|
"node": "22.x"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"runtime:check": "node scripts/verify-node-version.mjs",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
@@ -18,12 +17,10 @@
|
|||||||
"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:sync": "node scripts/openapi-sync.mjs",
|
||||||
"openapi:check": "node scripts/openapi-check.mjs",
|
"openapi:check": "node scripts/openapi-check.mjs",
|
||||||
"hygiene:check": "node scripts/repository-hygiene-check.mjs",
|
|
||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"deps": "node --test scripts/dependency-cruiser-config.test.mjs && depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "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:check": "node scripts/bundle-budget.mjs",
|
||||||
"bundle:baseline": "npm run build && node scripts/bundle-budget.mjs baseline",
|
"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",
|
||||||
"verify": "npm run runtime:check && npm run openapi:check && npm run hygiene: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,5 +1,5 @@
|
|||||||
import { statSync } from "node:fs";
|
import { statSync } from "node:fs";
|
||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
@@ -64,41 +64,8 @@ function assertBundleBudget(initialBytes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 distributionDirectory = resolve("dist");
|
||||||
const manifestPath = resolve(distributionDirectory, "bundle-manifest.json");
|
const manifestPath = resolve(distributionDirectory, "bundle-manifest.json");
|
||||||
const baselinePath = resolve("scripts/lazy-route-bundle-baseline.json");
|
|
||||||
|
|
||||||
async function loadJson(path) {
|
async function loadJson(path) {
|
||||||
return JSON.parse(await readFile(path, "utf8"));
|
return JSON.parse(await readFile(path, "utf8"));
|
||||||
@@ -108,27 +75,13 @@ function getAssetSize(file) {
|
|||||||
return statSync(resolve(distributionDirectory, file)).size;
|
return statSync(resolve(distributionDirectory, file)).size;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run(mode) {
|
async function run() {
|
||||||
const manifest = await loadJson(manifestPath);
|
const manifest = await loadJson(manifestPath);
|
||||||
const initialBytes = measureAssets(
|
const initialBytes = measureAssets(
|
||||||
getInitialAssetFiles(manifest),
|
getInitialAssetFiles(manifest),
|
||||||
getAssetSize,
|
getAssetSize,
|
||||||
);
|
);
|
||||||
assertBundleBudget(initialBytes);
|
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`);
|
console.log(`Initial JavaScript payload: ${initialBytes} bytes`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,17 +89,12 @@ if (
|
|||||||
process.argv[1] &&
|
process.argv[1] &&
|
||||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
) {
|
) {
|
||||||
const mode = process.argv[2] ?? "check";
|
await run();
|
||||||
if (mode !== "check" && mode !== "baseline") {
|
|
||||||
throw new Error("Usage: node scripts/bundle-budget.mjs [check|baseline]");
|
|
||||||
}
|
|
||||||
await run(mode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
assertBundleBudget,
|
assertBundleBudget,
|
||||||
getInitialAssetFiles,
|
getInitialAssetFiles,
|
||||||
getLazyRouteAssetSizes,
|
|
||||||
INITIAL_BUNDLE_BUDGET_BYTES,
|
INITIAL_BUNDLE_BUDGET_BYTES,
|
||||||
measureAssets,
|
measureAssets,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"src/app/routes/ProtectedLayout.tsx": 3057,
|
|
||||||
"src/pages/account-events/index.ts": 4331,
|
|
||||||
"src/pages/account-operations/index.ts": 13473,
|
|
||||||
"src/pages/account/index.ts": 31277,
|
|
||||||
"src/pages/accounts/index.ts": 6205,
|
|
||||||
"src/pages/login/index.ts": 662,
|
|
||||||
"src/pages/not-found/index.ts": 877,
|
|
||||||
"src/pages/register/index.ts": 668,
|
|
||||||
"src/pages/settings/index.ts": 38980
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
|
|
||||||
function git(args) {
|
|
||||||
return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIgnored(file) {
|
|
||||||
try {
|
|
||||||
execFileSync("git", ["check-ignore", "--no-index", "-q", file], {
|
|
||||||
cwd: root,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 1) return false;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(condition, message) {
|
|
||||||
if (!condition) throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackedEnvironmentFiles = git(["ls-files", "--", ".env", ".env.*"])
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
assert(
|
|
||||||
JSON.stringify(trackedEnvironmentFiles) === JSON.stringify([".env.example"]),
|
|
||||||
`Only .env.example may be tracked; found: ${trackedEnvironmentFiles.join(", ") || "none"}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const file of [".env", ".env.local", ".env.development", ".env.test"]) {
|
|
||||||
assert(isIgnored(file), `Local environment file must be ignored: ${file}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(!isIgnored(".env.example"), ".env.example must remain versionable");
|
|
||||||
|
|
||||||
console.log("Frontend repository hygiene verified");
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const packageJson = JSON.parse(
|
|
||||||
readFileSync(resolve(root, "package.json"), "utf8"),
|
|
||||||
);
|
|
||||||
const nvmVersion = readFileSync(resolve(root, ".nvmrc"), "utf8").trim();
|
|
||||||
const expectedMajor = nvmVersion.replace(/^v/, "").split(".")[0];
|
|
||||||
const expectedEngine = `${expectedMajor}.x`;
|
|
||||||
|
|
||||||
if (packageJson.engines?.node !== expectedEngine) {
|
|
||||||
throw new Error(
|
|
||||||
`Node runtime contracts disagree: .nvmrc=${nvmVersion}, package.json engines.node=${packageJson.engines?.node ?? "missing"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actualVersion = process.versions.node;
|
|
||||||
const actualMajor = actualVersion.split(".")[0];
|
|
||||||
if (actualMajor !== expectedMajor) {
|
|
||||||
console.error(
|
|
||||||
`Unsupported Node.js runtime: expected ${expectedEngine}, actual ${actualVersion}. Activate .nvmrc before running repository checks.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Node.js runtime verified: ${actualVersion} (${expectedEngine})`);
|
|
||||||
Reference in New Issue
Block a user