mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-08 18:02:52 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
@@ -1,30 +1,3 @@
|
||||
// Mocking the obscenity library to control its behavior in tests.
|
||||
vi.mock("obscenity", () => {
|
||||
return {
|
||||
RegExpMatcher: class {
|
||||
private dummy: string[] = ["foo", "bar", "leet", "code"];
|
||||
constructor(_opts: any) {}
|
||||
hasMatch(input: string): boolean {
|
||||
const lower = input.toLowerCase();
|
||||
const decoded = lower
|
||||
.replace(/4/g, "a")
|
||||
.replace(/3/g, "e")
|
||||
.replace(/1/g, "i")
|
||||
.replace(/0/g, "o")
|
||||
.replace(/5/g, "s")
|
||||
.replace(/7/g, "t");
|
||||
return this.dummy.some((token) => decoded.includes(token));
|
||||
}
|
||||
},
|
||||
collapseDuplicatesTransformer: () => ({}),
|
||||
englishRecommendedTransformers: {},
|
||||
englishDataset: { build: () => ({}) },
|
||||
resolveConfusablesTransformer: () => ({}),
|
||||
resolveLeetSpeakTransformer: () => ({}),
|
||||
skipNonAlphabeticTransformer: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mocks the output of translation functions to return predictable values.
|
||||
vi.mock("../src/client/Utils", () => ({
|
||||
translateText: (key: string, vars?: any) =>
|
||||
@@ -32,53 +5,11 @@ vi.mock("../src/client/Utils", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
fixProfaneUsername,
|
||||
isProfaneUsername,
|
||||
MAX_USERNAME_LENGTH,
|
||||
validateUsername,
|
||||
} from "../src/core/validations/username";
|
||||
|
||||
describe("username.ts functions", () => {
|
||||
const shadowNames = [
|
||||
"NicePeopleOnly",
|
||||
"BeKindPlz",
|
||||
"LearningManners",
|
||||
"StayClassy",
|
||||
"BeNicer",
|
||||
"NeedHugs",
|
||||
"MakeFriends",
|
||||
];
|
||||
|
||||
describe("isProfaneUsername & fixProfaneUsername with leet decoding (mocked)", () => {
|
||||
test.each([
|
||||
{ username: "l33t", profane: true }, // decodes to "leet"
|
||||
{ username: "L33T", profane: true },
|
||||
{ username: "l33tc0de", profane: true }, // decodes to "leetcode", contains "leet" and "code"
|
||||
{ username: "L33TC0DE", profane: true },
|
||||
{ username: "foo123", profane: true }, // contains "foo"
|
||||
{ username: "b4r", profane: true }, // decodes to "bar"
|
||||
{ username: "safeName", profane: false },
|
||||
{ username: "s4f3", profane: false }, // decodes to "safe" but "safe" not in dummy list
|
||||
])('isProfaneUsername("%s") → %s', ({ username, profane }) => {
|
||||
expect(isProfaneUsername(username)).toBe(profane);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ username: "safeName" },
|
||||
{ username: "l33t" },
|
||||
{ username: "b4rUser" },
|
||||
])('fixProfaneUsername("%s") behavior', ({ username }) => {
|
||||
const profane = isProfaneUsername(username);
|
||||
const fixed = fixProfaneUsername(username);
|
||||
if (!profane) {
|
||||
expect(fixed).toBe(username);
|
||||
} else {
|
||||
// When profane: result should be one of shadowNames
|
||||
expect(shadowNames).toContain(fixed);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUsername", () => {
|
||||
test("rejects non-string", () => {
|
||||
// @ts-expect-error: Testing non-string input to validateUsername on purpose
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
localAttackHeld: false,
|
||||
overlappingRailroads: [],
|
||||
},
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
describe("Lang Metadata Check", () => {
|
||||
const langDir = path.join(__dirname, "../resources/lang");
|
||||
const flagDir = path.join(__dirname, "../resources/flags");
|
||||
const metadataFile = path.join(langDir, "metadata.json");
|
||||
|
||||
test("metadata languages point to existing lang json and flag files", () => {
|
||||
if (!fs.existsSync(metadataFile)) {
|
||||
console.log(
|
||||
"No resources/lang/metadata.json file found. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataFile, "utf-8"));
|
||||
if (!Array.isArray(metadata) || metadata.length === 0) {
|
||||
console.log(
|
||||
"No language entries found in metadata.json. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const entry of metadata) {
|
||||
const code = entry?.code;
|
||||
const svg = entry?.svg;
|
||||
if (typeof code !== "string" || code.length === 0) {
|
||||
errors.push(
|
||||
`metadata entry missing valid code: ${JSON.stringify(entry)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof svg !== "string" || svg.length === 0) {
|
||||
errors.push(
|
||||
`[${code}]: metadata svg is missing or not a non-empty string`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const langFilePath = path.join(langDir, `${code}.json`);
|
||||
if (!fs.existsSync(langFilePath)) {
|
||||
errors.push(`[${code}]: lang json file does not exist: ${code}.json`);
|
||||
}
|
||||
|
||||
const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`;
|
||||
const flagPath = path.join(flagDir, svgFile);
|
||||
if (!fs.existsSync(flagPath)) {
|
||||
errors.push(`[${code}]: SVG file does not exist: ${svgFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"Metadata lang or SVG file check failed:\n" + errors.join("\n"),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
createMatcher,
|
||||
PrivilegeCheckerImpl,
|
||||
shadowNames,
|
||||
} from "../src/server/Privilege";
|
||||
|
||||
const bannedWords = [
|
||||
"hitler",
|
||||
"adolf",
|
||||
"nazi",
|
||||
"jew",
|
||||
"auschwitz",
|
||||
"whitepower",
|
||||
"heil",
|
||||
"chair", // Test word to verify custom banned words work
|
||||
];
|
||||
|
||||
const matcher = createMatcher(bannedWords);
|
||||
|
||||
// Create a minimal PrivilegeCheckerImpl for testing censorUsername
|
||||
const mockCosmetics = { patterns: {}, colorPalettes: {} };
|
||||
const mockDecoder = () => new Uint8Array();
|
||||
const checker = new PrivilegeCheckerImpl(
|
||||
mockCosmetics,
|
||||
mockDecoder,
|
||||
bannedWords,
|
||||
);
|
||||
const emptyChecker = new PrivilegeCheckerImpl(mockCosmetics, mockDecoder, []);
|
||||
|
||||
describe("UsernameCensor", () => {
|
||||
describe("isProfane (via matcher.hasMatch)", () => {
|
||||
test("detects exact banned words", () => {
|
||||
expect(matcher.hasMatch("hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("nazi")).toBe(true);
|
||||
expect(matcher.hasMatch("auschwitz")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects custom banned words like 'chair'", () => {
|
||||
expect(matcher.hasMatch("chair")).toBe(true);
|
||||
expect(matcher.hasMatch("Chair")).toBe(true);
|
||||
expect(matcher.hasMatch("CHAIR")).toBe(true);
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words case-insensitively", () => {
|
||||
expect(matcher.hasMatch("Hitler")).toBe(true);
|
||||
expect(matcher.hasMatch("NAZI")).toBe(true);
|
||||
expect(matcher.hasMatch("Adolf")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with leet speak", () => {
|
||||
expect(matcher.hasMatch("h1tl3r")).toBe(true);
|
||||
expect(matcher.hasMatch("4d0lf")).toBe(true);
|
||||
expect(matcher.hasMatch("n4z1")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with duplicated characters", () => {
|
||||
expect(matcher.hasMatch("hiiitler")).toBe(true);
|
||||
expect(matcher.hasMatch("naazzii")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with accented characters", () => {
|
||||
expect(matcher.hasMatch("Adölf")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words as substrings", () => {
|
||||
expect(matcher.hasMatch("xhitlerx")).toBe(true);
|
||||
expect(matcher.hasMatch("IloveNazi")).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clean usernames", () => {
|
||||
expect(matcher.hasMatch("CoolPlayer")).toBe(false);
|
||||
expect(matcher.hasMatch("GameMaster")).toBe(false);
|
||||
expect(matcher.hasMatch("xXx_Sniper_xXx")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("censorUsername", () => {
|
||||
test("returns clean usernames unchanged", () => {
|
||||
expect(checker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
|
||||
expect(checker.censorUsername("GameMaster")).toBe("GameMaster");
|
||||
});
|
||||
|
||||
test("replaces profane usernames with a shadow name", () => {
|
||||
const result = checker.censorUsername("hitler");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("replaces leet speak profane usernames with a shadow name", () => {
|
||||
const result = checker.censorUsername("h1tl3r");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("preserves clean clan tag when username is profane", () => {
|
||||
const result = checker.censorUsername("[COOL]hitler");
|
||||
expect(result).toMatch(/^\[COOL\] /);
|
||||
const nameAfterTag = result.replace("[COOL] ", "");
|
||||
expect(shadowNames).toContain(nameAfterTag);
|
||||
});
|
||||
|
||||
test("removes profane clan tag but keeps clean username", () => {
|
||||
expect(checker.censorUsername("[NAZI]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with leet speak profanity", () => {
|
||||
expect(checker.censorUsername("[N4Z1]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag with uppercased banned word", () => {
|
||||
expect(checker.censorUsername("[ADOLF]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes clan tag containing banned word substring", () => {
|
||||
expect(checker.censorUsername("[JEWS]CoolPlayer")).toBe("CoolPlayer");
|
||||
});
|
||||
|
||||
test("removes profane clan tag and censors profane username", () => {
|
||||
const result = checker.censorUsername("[NAZI]hitler");
|
||||
// No clan tag prefix, just a shadow name
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("removes leet speak profane clan tag and censors leet speak username", () => {
|
||||
const result = checker.censorUsername("[N4Z1]h1tl3r");
|
||||
// No clan tag prefix, just a shadow name
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
|
||||
test("returns deterministic shadow name for same input", () => {
|
||||
const a = checker.censorUsername("hitler");
|
||||
const b = checker.censorUsername("hitler");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("handles username with no clan tag", () => {
|
||||
expect(checker.censorUsername("NormalPlayer")).toBe("NormalPlayer");
|
||||
});
|
||||
|
||||
test("empty banned words list still catches englishDataset profanity", () => {
|
||||
// The emptyChecker still uses englishDataset, so common profanity is caught
|
||||
expect(emptyChecker.censorUsername("CoolPlayer")).toBe("CoolPlayer");
|
||||
// Verify a known english profanity gets censored even without custom banned words
|
||||
const result = emptyChecker.censorUsername("fuck");
|
||||
expect(shadowNames).toContain(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,623 @@
|
||||
import fs from "fs";
|
||||
import IntlMessageFormat from "intl-messageformat";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
|
||||
const PROJECT_ROOT = path.join(__dirname, "..");
|
||||
const LANGUAGE_DIR = path.join(PROJECT_ROOT, "resources", "lang");
|
||||
const FLAG_DIR = path.join(PROJECT_ROOT, "resources", "flags");
|
||||
const METADATA_FILE = path.join(LANGUAGE_DIR, "metadata.json");
|
||||
|
||||
/**
|
||||
* Regex patterns for keys that are intentionally generated dynamically.
|
||||
* This keeps dynamic handling explicit and reviewable.
|
||||
*/
|
||||
const DYNAMIC_KEY_PATTERNS: RegExp[] = [
|
||||
/^difficulty\.[^.]+$/,
|
||||
/^map\.[^.]+$/,
|
||||
/^map_categories\.[^.]+$/,
|
||||
/^chat\.[^.]+\.[^.]+$/,
|
||||
/^player_stats_table\.unit\.[^.]+$/,
|
||||
/^host_modal\.teams_.+$/,
|
||||
/^public_lobby\.teams_.+$/,
|
||||
/^team_colors\.[^.]+$/,
|
||||
/^territory_patterns\.pattern\.[^.]+$/,
|
||||
/^territory_patterns\.pattern_owned\.[^.]+$/,
|
||||
/^territory_patterns\.color_palette\.[^.]+$/,
|
||||
/^build_menu\.desc\.[^.]+$/,
|
||||
/^unit_type\.[^.]+$/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that are intentionally not expected to be used via translateText.
|
||||
*/
|
||||
const IGNORED_UNUSED_KEY_PATTERNS: RegExp[] = [
|
||||
/^lang\./, // language metadata, not a UI translation key
|
||||
];
|
||||
|
||||
type NestedTranslations = Record<string, unknown>;
|
||||
|
||||
type ParsedTranslationFile = {
|
||||
file: string;
|
||||
flatMessages: Record<string, string>;
|
||||
};
|
||||
|
||||
type ScanResult = {
|
||||
usedKeys: Set<string>;
|
||||
referencedStaticKeys: Set<string>;
|
||||
dynamicPrefixes: Set<string>;
|
||||
};
|
||||
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
keys.push(...flattenKeys(value as Record<string, unknown>, fullKey));
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function flattenTranslations(
|
||||
obj: NestedTranslations,
|
||||
file: string,
|
||||
parentKey = "",
|
||||
result: Record<string, string> = {},
|
||||
errors: string[] = [],
|
||||
): Record<string, string> {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = parentKey ? `${parentKey}.${key}` : key;
|
||||
if (typeof value === "string") {
|
||||
result[fullKey] = value;
|
||||
continue;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
flattenTranslations(
|
||||
value as NestedTranslations,
|
||||
file,
|
||||
fullKey,
|
||||
result,
|
||||
errors,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
errors.push(
|
||||
`${file}:${fullKey} has invalid type ${Array.isArray(value) ? "array" : typeof value}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function listLanguageJsonFiles(): string[] {
|
||||
return fs
|
||||
.readdirSync(LANGUAGE_DIR)
|
||||
.filter((file) => file.endsWith(".json") && file !== "metadata.json")
|
||||
.sort();
|
||||
}
|
||||
|
||||
function loadTranslationFiles(): {
|
||||
files: ParsedTranslationFile[];
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
const files: ParsedTranslationFile[] = [];
|
||||
|
||||
for (const file of listLanguageJsonFiles()) {
|
||||
const fullPath = path.join(LANGUAGE_DIR, file);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(fullPath, "utf-8");
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file}: failed to read file (${details})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file}: invalid JSON (${details})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
errors.push(`${file}: root must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push({
|
||||
file,
|
||||
flatMessages: flattenTranslations(
|
||||
parsed as NestedTranslations,
|
||||
file,
|
||||
"",
|
||||
{},
|
||||
errors,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { files, errors };
|
||||
}
|
||||
|
||||
function getAllFiles(
|
||||
dir: string,
|
||||
extensions: string[],
|
||||
/** Tracks visited real paths to guard against symlink cycles. */
|
||||
seen: Set<string> = new Set(),
|
||||
): string[] {
|
||||
const realDir = fs.realpathSync(dir);
|
||||
if (seen.has(realDir)) return []; // cycle via directory symlink
|
||||
seen.add(realDir);
|
||||
|
||||
const results: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...getAllFiles(fullPath, extensions, seen));
|
||||
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function prefixToRegex(prefix: string): RegExp {
|
||||
return new RegExp(`^${escapeRegex(prefix)}.+$`);
|
||||
}
|
||||
|
||||
function isTranslateTextCall(node: ts.CallExpression): boolean {
|
||||
if (ts.isIdentifier(node.expression)) {
|
||||
return node.expression.text === "translateText";
|
||||
}
|
||||
if (ts.isPropertyAccessExpression(node.expression)) {
|
||||
return node.expression.name.text === "translateText";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStringLiteralLike(
|
||||
node: ts.Node,
|
||||
): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral {
|
||||
return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
|
||||
}
|
||||
|
||||
function getPlusOperands(expr: ts.Expression): ts.Expression[] {
|
||||
if (
|
||||
ts.isBinaryExpression(expr) &&
|
||||
expr.operatorToken.kind === ts.SyntaxKind.PlusToken
|
||||
) {
|
||||
return [...getPlusOperands(expr.left), ...getPlusOperands(expr.right)];
|
||||
}
|
||||
return [expr];
|
||||
}
|
||||
|
||||
function getStaticStringFromPlus(expr: ts.Expression): string | null {
|
||||
const parts = getPlusOperands(expr);
|
||||
let value = "";
|
||||
for (const part of parts) {
|
||||
if (!isStringLiteralLike(part)) return null;
|
||||
value += part.text;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getDynamicPrefixFromPlus(expr: ts.Expression): string | null {
|
||||
const parts = getPlusOperands(expr);
|
||||
if (parts.length === 0) return null;
|
||||
const first = parts[0];
|
||||
if (!isStringLiteralLike(first)) return null;
|
||||
if (parts.every((part) => isStringLiteralLike(part))) return null;
|
||||
return first.text || null;
|
||||
}
|
||||
|
||||
function extractDataI18nKeys(content: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
const attrRegex =
|
||||
/data-i18n(?:-title|-alt|-aria-label|-placeholder)?\s*=\s*["']([^"']+)["']/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = attrRegex.exec(content)) !== null) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function extractTranslationKeyLikeAttrs(content: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
const keyLikeAttrRegex =
|
||||
/\b(?:translationKey|labelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = keyLikeAttrRegex.exec(content)) !== null) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function isPotentialTranslationKey(
|
||||
key: string,
|
||||
rootKeys: Set<string>,
|
||||
enKeySet: Set<string>,
|
||||
allowBareRoot = false,
|
||||
): boolean {
|
||||
if (enKeySet.has(key)) return true;
|
||||
if (allowBareRoot && rootKeys.has(key)) return true;
|
||||
if (!key.includes(".")) return false;
|
||||
const root = key.split(".")[0];
|
||||
return rootKeys.has(root);
|
||||
}
|
||||
|
||||
function isKeyNamedProperty(name: ts.PropertyName): boolean {
|
||||
if (ts.isIdentifier(name)) return /key$/i.test(name.text);
|
||||
if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
|
||||
return /key$/i.test(name.text);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectFromExpression(
|
||||
expression: ts.Expression,
|
||||
result: ScanResult,
|
||||
rootKeys: Set<string>,
|
||||
enKeySet: Set<string>,
|
||||
allowBareRoot = false,
|
||||
): void {
|
||||
if (isStringLiteralLike(expression)) {
|
||||
if (
|
||||
isPotentialTranslationKey(
|
||||
expression.text,
|
||||
rootKeys,
|
||||
enKeySet,
|
||||
allowBareRoot,
|
||||
)
|
||||
) {
|
||||
result.referencedStaticKeys.add(expression.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isTemplateExpression(expression)) {
|
||||
const prefix = expression.head.text;
|
||||
if (
|
||||
prefix.length > 0 &&
|
||||
/[._]$/.test(prefix) &&
|
||||
isPotentialTranslationKey(prefix, rootKeys, enKeySet)
|
||||
) {
|
||||
result.dynamicPrefixes.add(prefix);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isBinaryExpression(expression) &&
|
||||
expression.operatorToken.kind === ts.SyntaxKind.PlusToken
|
||||
) {
|
||||
const staticValue = getStaticStringFromPlus(expression);
|
||||
if (staticValue !== null) {
|
||||
if (isPotentialTranslationKey(staticValue, rootKeys, enKeySet)) {
|
||||
result.referencedStaticKeys.add(staticValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = getDynamicPrefixFromPlus(expression);
|
||||
if (
|
||||
prefix !== null &&
|
||||
/[._]$/.test(prefix) &&
|
||||
isPotentialTranslationKey(prefix, rootKeys, enKeySet)
|
||||
) {
|
||||
result.dynamicPrefixes.add(prefix);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isParenthesizedExpression(expression)) {
|
||||
collectFromExpression(
|
||||
expression.expression,
|
||||
result,
|
||||
rootKeys,
|
||||
enKeySet,
|
||||
allowBareRoot,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ts.isConditionalExpression(expression)) {
|
||||
collectFromExpression(
|
||||
expression.whenTrue,
|
||||
result,
|
||||
rootKeys,
|
||||
enKeySet,
|
||||
allowBareRoot,
|
||||
);
|
||||
collectFromExpression(
|
||||
expression.whenFalse,
|
||||
result,
|
||||
rootKeys,
|
||||
enKeySet,
|
||||
allowBareRoot,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function scanTsFile(
|
||||
filePath: string,
|
||||
rootKeys: Set<string>,
|
||||
enKeySet: Set<string>,
|
||||
): ScanResult {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
content,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
const result: ScanResult = {
|
||||
usedKeys: new Set<string>(),
|
||||
referencedStaticKeys: new Set<string>(),
|
||||
dynamicPrefixes: new Set<string>(),
|
||||
};
|
||||
|
||||
const dataI18nKeys = extractDataI18nKeys(content);
|
||||
for (const key of dataI18nKeys) {
|
||||
result.referencedStaticKeys.add(key);
|
||||
}
|
||||
const keyLikeAttrKeys = extractTranslationKeyLikeAttrs(content);
|
||||
for (const key of keyLikeAttrKeys) {
|
||||
result.referencedStaticKeys.add(key);
|
||||
}
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
// Broad match: any string literal in any .ts/.tsx file that exactly
|
||||
// matches an en.json key is counted as "used". This is intentionally
|
||||
// permissive to avoid false-positive "unused" reports, but it means a
|
||||
// key appearing in an unrelated context (e.g. a log message or object
|
||||
// key that happens to share the same name) will mask a genuinely
|
||||
// unused translation key.
|
||||
if (isStringLiteralLike(node) && enKeySet.has(node.text)) {
|
||||
result.usedKeys.add(node.text);
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(node) && isTranslateTextCall(node)) {
|
||||
const firstArg = node.arguments[0];
|
||||
if (firstArg !== undefined) {
|
||||
collectFromExpression(firstArg, result, rootKeys, enKeySet, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isPropertyAssignment(node) &&
|
||||
isKeyNamedProperty(node.name) &&
|
||||
ts.isExpression(node.initializer)
|
||||
) {
|
||||
collectFromExpression(node.initializer, result, rootKeys, enKeySet);
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
/key$/i.test(node.name.text) &&
|
||||
node.initializer !== undefined
|
||||
) {
|
||||
collectFromExpression(node.initializer, result, rootKeys, enKeySet);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
|
||||
for (const key of dataI18nKeys) {
|
||||
if (enKeySet.has(key)) {
|
||||
result.usedKeys.add(key);
|
||||
}
|
||||
}
|
||||
for (const key of keyLikeAttrKeys) {
|
||||
if (enKeySet.has(key)) {
|
||||
result.usedKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("Translation System", () => {
|
||||
test("metadata languages point to existing lang json and flag files", () => {
|
||||
if (!fs.existsSync(METADATA_FILE)) {
|
||||
console.log(
|
||||
"No resources/lang/metadata.json file found. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fs.readFileSync(METADATA_FILE, "utf-8"));
|
||||
if (!Array.isArray(metadata) || metadata.length === 0) {
|
||||
console.log(
|
||||
"No language entries found in metadata.json. Skipping check.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const knownLanguageFiles = new Set(listLanguageJsonFiles());
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const entry of metadata) {
|
||||
const code = entry?.code;
|
||||
const svg = entry?.svg;
|
||||
|
||||
if (typeof code !== "string" || code.length === 0) {
|
||||
errors.push(
|
||||
`metadata entry missing valid code: ${JSON.stringify(entry)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof svg !== "string" || svg.length === 0) {
|
||||
errors.push(
|
||||
`[${code}]: metadata svg is missing or not a non-empty string`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!knownLanguageFiles.has(`${code}.json`)) {
|
||||
errors.push(`[${code}]: lang json file does not exist: ${code}.json`);
|
||||
}
|
||||
|
||||
const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`;
|
||||
const flagPath = path.join(FLAG_DIR, svgFile);
|
||||
if (!fs.existsSync(flagPath)) {
|
||||
errors.push(`[${code}]: SVG file does not exist: ${svgFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(
|
||||
"Metadata lang or SVG file check failed:\n" + errors.join("\n"),
|
||||
);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("all translation strings are valid ICU messages", () => {
|
||||
const { files, errors } = loadTranslationFiles();
|
||||
|
||||
for (const { file, flatMessages } of files) {
|
||||
for (const [key, message] of Object.entries(flatMessages)) {
|
||||
try {
|
||||
new IntlMessageFormat(message, "en");
|
||||
} catch (error) {
|
||||
const details =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
errors.push(`${file}:${key} has invalid ICU syntax (${details})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("ICU translation validation failed:\n" + errors.join("\n"));
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("en.json keys stay in sync with source usage", () => {
|
||||
const enJsonPath = path.join(LANGUAGE_DIR, "en.json");
|
||||
const enJson = JSON.parse(fs.readFileSync(enJsonPath, "utf-8"));
|
||||
const allKeys = flattenKeys(enJson);
|
||||
const enKeySet = new Set(allKeys);
|
||||
const rootKeys = new Set(Object.keys(enJson as Record<string, unknown>));
|
||||
|
||||
const srcDir = path.join(PROJECT_ROOT, "src");
|
||||
const sourceFiles = getAllFiles(srcDir, [".ts", ".tsx", ".js", ".jsx"]);
|
||||
|
||||
const usedKeys = new Set<string>();
|
||||
const referencedStaticKeys = new Set<string>();
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const scan = scanTsFile(file, rootKeys, enKeySet);
|
||||
for (const key of scan.usedKeys) usedKeys.add(key);
|
||||
for (const key of scan.referencedStaticKeys) {
|
||||
referencedStaticKeys.add(key);
|
||||
}
|
||||
for (const prefix of scan.dynamicPrefixes) dynamicPrefixes.add(prefix);
|
||||
}
|
||||
|
||||
const indexHtmlPath = path.join(PROJECT_ROOT, "index.html");
|
||||
if (fs.existsSync(indexHtmlPath)) {
|
||||
const htmlContent = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
const htmlDataI18nKeys = extractDataI18nKeys(htmlContent);
|
||||
for (const key of htmlDataI18nKeys) {
|
||||
referencedStaticKeys.add(key);
|
||||
if (enKeySet.has(key)) {
|
||||
usedKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const derivedDynamicPatterns = Array.from(dynamicPrefixes)
|
||||
.sort()
|
||||
.map((prefix) => prefixToRegex(prefix));
|
||||
const dynamicKeyPatterns = [
|
||||
...DYNAMIC_KEY_PATTERNS,
|
||||
...derivedDynamicPatterns,
|
||||
];
|
||||
|
||||
const unusedKeys: string[] = [];
|
||||
const dynamicKeys: string[] = [];
|
||||
const missingKeys: string[] = [];
|
||||
|
||||
// NOTE: The isPotentialTranslationKey check below intentionally skips any
|
||||
// referenced key whose root namespace (the part before the first ".") is
|
||||
// not already present in en.json's rootKeys. This means keys under entirely
|
||||
// new namespaces (e.g. "brand_new_namespace.some_key") will NOT be reported
|
||||
// as missing. This trade-off was chosen to reduce false-positive noise from
|
||||
// string literals that look like translation keys but aren't (config keys,
|
||||
// CSS classes, etc.). It is a known limitation: if a real translation key
|
||||
// is added under a brand-new namespace and no en.json entry exists yet,
|
||||
// this test will not catch it.
|
||||
for (const key of Array.from(referencedStaticKeys).sort()) {
|
||||
if (enKeySet.has(key)) continue;
|
||||
if (!isPotentialTranslationKey(key, rootKeys, enKeySet)) continue;
|
||||
missingKeys.push(key);
|
||||
}
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (usedKeys.has(key)) continue;
|
||||
if (IGNORED_UNUSED_KEY_PATTERNS.some((pattern) => pattern.test(key))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDynamic = dynamicKeyPatterns.some((pattern) => pattern.test(key));
|
||||
if (isDynamic) {
|
||||
dynamicKeys.push(key);
|
||||
} else {
|
||||
unusedKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const hasFailing = missingKeys.length > 0 || unusedKeys.length > 0;
|
||||
if (hasFailing) {
|
||||
if (derivedDynamicPatterns.length > 0) {
|
||||
console.log(
|
||||
`\nDerived dynamic patterns (${derivedDynamicPatterns.length}):\n` +
|
||||
derivedDynamicPatterns.map((p) => ` ${p.source}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (dynamicKeys.length > 0) {
|
||||
console.log(
|
||||
`\nDynamically referenced keys (${dynamicKeys.length}) - verify manually:\n` +
|
||||
dynamicKeys.map((k) => ` ${k}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (missingKeys.length > 0) {
|
||||
console.error(
|
||||
`\nMissing translation keys in en.json (${missingKeys.length}):\n` +
|
||||
missingKeys.map((k) => ` ${k}`).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
if (unusedKeys.length > 0) {
|
||||
console.error(
|
||||
`\nUnused translation keys (${unusedKeys.length}):\n` +
|
||||
unusedKeys.map((k) => ` ${k}`).join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missingKeys).toEqual([]);
|
||||
expect(unusedKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,8 @@ describe("RadialMenuElements", () => {
|
||||
id: () => 1,
|
||||
isAlliedWith: vi.fn(() => false),
|
||||
isPlayer: vi.fn(() => true),
|
||||
isTraitor: vi.fn(() => false),
|
||||
isDisconnected: vi.fn(() => false),
|
||||
} as unknown as PlayerView;
|
||||
|
||||
mockGame = {
|
||||
@@ -339,6 +341,8 @@ describe("RadialMenuElements", () => {
|
||||
id: () => 2,
|
||||
isAlliedWith: vi.fn(() => true),
|
||||
isPlayer: vi.fn(() => true),
|
||||
isTraitor: vi.fn(() => false),
|
||||
isDisconnected: vi.fn(() => false),
|
||||
} as unknown as PlayerView;
|
||||
mockParams.selected = allyPlayer;
|
||||
mockGame.owner = vi.fn(() => allyPlayer);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GameUpdateType } from "src/core/game/GameUpdates";
|
||||
import { vi, type Mocked } from "vitest";
|
||||
import { TrainExecution } from "../../../src/core/execution/TrainExecution";
|
||||
import { Game, Player, Unit, UnitType } from "../../../src/core/game/Game";
|
||||
@@ -112,7 +113,7 @@ describe("TrainStation", () => {
|
||||
|
||||
expect(game.addUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isActive: false,
|
||||
type: GameUpdateType.RailroadDestructionEvent,
|
||||
}),
|
||||
);
|
||||
expect(stationA.getRailroads().size).toBe(0);
|
||||
|
||||
@@ -19,13 +19,18 @@ import {
|
||||
} from "../src/client/graphics/layers/RadialMenuElements";
|
||||
|
||||
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
|
||||
const makePlayer = (id: string) =>
|
||||
const makePlayer = (
|
||||
id: string,
|
||||
opts?: { isTraitor?: boolean; isDisconnected?: boolean },
|
||||
) =>
|
||||
({
|
||||
id: () => id,
|
||||
isAlliedWith: (other: any) =>
|
||||
other && typeof other.id === "function" && other.id() !== id
|
||||
? true
|
||||
: true,
|
||||
isTraitor: () => opts?.isTraitor ?? false,
|
||||
isDisconnected: () => opts?.isDisconnected ?? false,
|
||||
}) as unknown as import("../src/core/game/GameView").PlayerView;
|
||||
|
||||
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
@@ -82,7 +87,26 @@ describe("RadialMenuElements ally break", () => {
|
||||
const ally = findAllyBreak(items)!;
|
||||
expect(ally).toBeTruthy();
|
||||
expect(ally.name).toBe("break");
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
expect(typeof ally.color).toBe("function");
|
||||
expect(ally.color(params)).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("shows break option with orange color when allied to traitor", () => {
|
||||
const params = makeParams({
|
||||
selected: makePlayer("p2", { isTraitor: true }),
|
||||
});
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
expect(ally.color(params)).toBe(COLORS.breakAllyNoDebuff);
|
||||
});
|
||||
|
||||
test("shows boat button instead of break when allied to disconnected player", () => {
|
||||
const params = makeParams({
|
||||
selected: makePlayer("p2", { isDisconnected: true }),
|
||||
});
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
expect(findAllyBreak(items)).toBeUndefined();
|
||||
expect(items.find((i) => i.id === "boat")).toBeDefined();
|
||||
});
|
||||
|
||||
test("break action calls handleBreakAlliance and closes menu", () => {
|
||||
|
||||
Reference in New Issue
Block a user