Files
anonymous_github/test/error-codes.test.js
T

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);
});
});