mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-04-21 21:06:01 +02:00
162 lines
5.2 KiB
JavaScript
162 lines
5.2 KiB
JavaScript
const { expect } = require("chai");
|
|
const { readFileSync, readdirSync, statSync } = require("fs");
|
|
const { join } = require("path");
|
|
|
|
const LOCALE_PATH = join(__dirname, "..", "public", "i18n", "locale-en.json");
|
|
const SRC_DIR = join(__dirname, "..", "src");
|
|
|
|
/**
|
|
* Collect all .ts files under a directory recursively.
|
|
*/
|
|
function collectTsFiles(dir) {
|
|
const files = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) {
|
|
files.push(...collectTsFiles(full));
|
|
} else if (full.endsWith(".ts")) {
|
|
files.push(full);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Extract error codes from backend source files.
|
|
*
|
|
* Matches patterns such as:
|
|
* 1. new AnonymousError("error_code", ...) -- thrown errors
|
|
* 2. res.status(NNN).json({ error: "code" }) -- direct responses
|
|
*
|
|
* Only string literals that look like error codes (contain at least one
|
|
* underscore, indicating a snake_case identifier) are extracted.
|
|
*/
|
|
function extractBackendErrorCodes(files) {
|
|
const codes = new Map(); // code -> [{ file, line }]
|
|
|
|
const patterns = [
|
|
// new AnonymousError("code") -- including across ternary expressions
|
|
// e.g. new AnonymousError("repo_not_found",
|
|
// new AnonymousError(condition ? msg : "fallback_code",
|
|
/AnonymousError\([^)]*["']([a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+)["']/,
|
|
// { error: "code" } -- direct JSON responses
|
|
/\{\s*error:\s*["']([a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+)["']/,
|
|
];
|
|
|
|
for (const file of files) {
|
|
const content = readFileSync(file, "utf-8");
|
|
const lines = content.split("\n");
|
|
const relPath = file.replace(join(__dirname, "..") + "/", "");
|
|
|
|
// Per-line matching for simple cases
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (/^\s*(import |\/\/|\/\*|\* )/.test(line)) continue;
|
|
|
|
for (const pattern of patterns) {
|
|
const match = line.match(pattern);
|
|
if (match) {
|
|
const code = match[1];
|
|
if (!codes.has(code)) {
|
|
codes.set(code, []);
|
|
}
|
|
codes.get(code).push({ file: relPath, line: i + 1 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multi-line matching for AnonymousError calls that span lines
|
|
// (e.g. ternary expressions where the string is on the next line)
|
|
const multiLinePattern =
|
|
/AnonymousError\([\s\S]*?["']([a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+)["']/g;
|
|
let m;
|
|
while ((m = multiLinePattern.exec(content)) !== null) {
|
|
const code = m[1];
|
|
const lineNum =
|
|
content.substring(0, m.index + m[0].length).split("\n").length;
|
|
if (!codes.has(code)) {
|
|
codes.set(code, []);
|
|
}
|
|
// Avoid duplicate entries from the per-line pass
|
|
const existing = codes.get(code);
|
|
if (!existing.some((e) => e.file === relPath && e.line === lineNum)) {
|
|
existing.push({ file: relPath, line: lineNum });
|
|
}
|
|
}
|
|
}
|
|
|
|
return codes;
|
|
}
|
|
|
|
describe("Error code coverage", function () {
|
|
let localeErrors;
|
|
let backendCodes;
|
|
|
|
before(function () {
|
|
const locale = JSON.parse(readFileSync(LOCALE_PATH, "utf-8"));
|
|
localeErrors = locale.ERRORS || {};
|
|
backendCodes = extractBackendErrorCodes(collectTsFiles(SRC_DIR));
|
|
});
|
|
|
|
it("locale file is valid JSON with an ERRORS object", function () {
|
|
expect(localeErrors).to.be.an("object").that.is.not.empty;
|
|
});
|
|
|
|
it("every backend error code has a frontend translation", function () {
|
|
const missing = [];
|
|
for (const [code, locations] of backendCodes) {
|
|
if (!localeErrors[code]) {
|
|
const where = locations
|
|
.map((l) => ` ${l.file}:${l.line}`)
|
|
.join("\n");
|
|
missing.push(` "${code}" used in:\n${where}`);
|
|
}
|
|
}
|
|
expect(missing, `Missing translations:\n${missing.join("\n")}`).to.have
|
|
.length(0);
|
|
});
|
|
|
|
it("every frontend translation corresponds to a backend error code", function () {
|
|
const unused = [];
|
|
for (const code of Object.keys(localeErrors)) {
|
|
if (!backendCodes.has(code)) {
|
|
unused.push(` "${code}"`);
|
|
}
|
|
}
|
|
// This is a warning, not a hard failure -- some codes may only be used
|
|
// on the frontend itself (e.g. "unreachable", "request_error").
|
|
// We report them so developers can clean up stale entries.
|
|
if (unused.length > 0) {
|
|
console.log(
|
|
` ⚠ ${unused.length} locale key(s) not found in backend source (frontend-only or stale):\n${unused.join("\n")}`
|
|
);
|
|
}
|
|
});
|
|
|
|
it("locale error messages are non-empty strings", function () {
|
|
const empty = [];
|
|
for (const [code, message] of Object.entries(localeErrors)) {
|
|
if (typeof message !== "string" || message.trim() === "") {
|
|
empty.push(code);
|
|
}
|
|
}
|
|
expect(empty, `Empty/invalid messages for: ${empty.join(", ")}`).to.have
|
|
.length(0);
|
|
});
|
|
|
|
it("backend error codes use consistent snake_case format", function () {
|
|
const invalid = [];
|
|
// Allow snake_case with optional camelCase segments (e.g. pullRequestId_not_specified)
|
|
const validPattern = /^[a-zA-Z][a-zA-Z0-9]*(_[a-zA-Z0-9]+)*$/;
|
|
for (const code of backendCodes.keys()) {
|
|
if (!validPattern.test(code)) {
|
|
invalid.push(code);
|
|
}
|
|
}
|
|
expect(
|
|
invalid,
|
|
`Invalid error code format: ${invalid.join(", ")}`
|
|
).to.have.length(0);
|
|
});
|
|
});
|