Remove 7 now-stale IDENTICAL_VALUE_ALLOWLIST entries (audit.title, auth.roleOperationsManager, auth.roleRentalEmployee, demo.scenarios.startScenario, demo.scenarios.roles.operations_manager/ rental_employee, navigation.items.audit) now that they are genuinely translated -- their old comments describing them as "deliberately untranslated" were no longer true. Add two new checks: one closing the embedded-English/Dutch-substring blind spot the whole-string identity test structurally cannot catch (a mid-sentence phrase surviving inside otherwise-translated prose), one asserting no locale file contains "MobilityOps" or the word "PoC".
324 lines
14 KiB
TypeScript
324 lines
14 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// Pure Node-context checks (no browser needed): every locale must define exactly the
|
|
// same set of translation keys. A missing key would otherwise silently fall back to
|
|
// showing the raw key string in production -- this test makes that impossible to ship.
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
|
|
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
|
|
|
|
function collectKeyPaths(value: unknown, prefix = ""): string[] {
|
|
if (value === null || typeof value !== "object") {
|
|
return [prefix];
|
|
}
|
|
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
|
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
|
|
);
|
|
}
|
|
|
|
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
|
|
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
|
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
}
|
|
|
|
const namespaces = fs
|
|
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => f.replace(/\.json$/, ""));
|
|
|
|
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
|
|
expect(namespaces.length).toBeGreaterThan(0);
|
|
|
|
for (const namespace of namespaces) {
|
|
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
|
|
|
|
for (const language of LANGUAGES) {
|
|
if (language === "nl-BE") continue;
|
|
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
|
|
const missing = referenceKeys.filter((k) => !keys.includes(k));
|
|
const extra = keys.filter((k) => !referenceKeys.includes(k));
|
|
|
|
expect(
|
|
missing,
|
|
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
|
|
).toEqual([]);
|
|
expect(
|
|
extra,
|
|
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
|
|
).toEqual([]);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("no locale file contains an empty string value", () => {
|
|
for (const language of LANGUAGES) {
|
|
for (const namespace of namespaces) {
|
|
const data = loadNamespace(language, namespace);
|
|
const keys = collectKeyPaths(data);
|
|
for (const keyPath of keys) {
|
|
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
|
|
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
|
return undefined;
|
|
}, data);
|
|
if (typeof value === "string") {
|
|
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value ---
|
|
// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1).
|
|
// A regression here means someone re-introduced a per-locale brand key/value instead of
|
|
// interpolating {{productName}} from the shared constant.
|
|
|
|
test("no locale file defines an 'appName' key or the literal brand string", () => {
|
|
for (const language of LANGUAGES) {
|
|
for (const namespace of namespaces) {
|
|
const data = loadNamespace(language, namespace);
|
|
const raw = JSON.stringify(data);
|
|
expect(
|
|
raw.includes("Fleet Ops"),
|
|
`${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` +
|
|
`use {{productName}} interpolation instead so the brand can never drift per locale`,
|
|
).toBe(false);
|
|
const keys = collectKeyPaths(data);
|
|
expect(
|
|
keys.some((k) => k === "appName" || k.endsWith(".appName")),
|
|
`${language}/${namespace}.json defines an "appName" key -- the brand name must come ` +
|
|
`from the PRODUCT_NAME constant, never a translatable key`,
|
|
).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
|
|
for (const language of LANGUAGES) {
|
|
for (const namespace of namespaces) {
|
|
const raw = JSON.stringify(loadNamespace(language, namespace));
|
|
expect(
|
|
raw.includes("MobilityOps"),
|
|
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
|
|
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
|
|
).toBe(false);
|
|
expect(
|
|
/\bPoC\b/.test(raw),
|
|
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
|
|
`in user-facing copy`,
|
|
).toBe(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
|
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
|
// the literal English string under the right key and still pass). For every "real prose"
|
|
// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from
|
|
// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and
|
|
// "Dutch text copy-pasted into French" in one pass.
|
|
|
|
// Exact (namespace, key-path) pairs that are legitimately identical across two or more
|
|
// locales: real proper nouns/brand names, deliberately-untranslated role titles, and
|
|
// genuine cross-language cognates (identical spelling in Dutch/French/English). This is
|
|
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
|
// hide an unrelated real mistranslation under the same key in a different namespace.
|
|
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
|
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
|
"common.language.nl-BE", // language-picker options show each language's own endonym
|
|
"common.language.fr-BE",
|
|
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
|
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
|
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
|
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
|
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
|
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
|
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
|
"fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French
|
|
"integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French
|
|
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
|
"knowledge.retrievalFlow.question",
|
|
"knowledge.questionLabelExchange",
|
|
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
|
]);
|
|
|
|
function isTranslatableProse(value: unknown): value is string {
|
|
if (typeof value !== "string") return false;
|
|
if (value.trim().length < 8) return false;
|
|
// Strip interpolation placeholders and non-letter characters; if nothing substantial
|
|
// remains (pure numbers/punctuation/units), it's not "prose" that needs translating.
|
|
const stripped = value
|
|
.replace(/\{\{[^}]+\}\}/g, " ")
|
|
.replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, "");
|
|
return stripped.trim().length >= 3;
|
|
}
|
|
|
|
test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => {
|
|
for (const namespace of namespaces) {
|
|
const en = loadNamespace("en-GB", namespace);
|
|
const nl = loadNamespace("nl-BE", namespace);
|
|
const fr = loadNamespace("fr-BE", namespace);
|
|
const keys = collectKeyPaths(en);
|
|
|
|
for (const keyPath of keys) {
|
|
if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue;
|
|
const at = (data: Record<string, unknown>) =>
|
|
keyPath.split(".").reduce<unknown>((acc, part) => {
|
|
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
|
return undefined;
|
|
}, data);
|
|
|
|
const enValue = at(en);
|
|
if (!isTranslatableProse(enValue)) continue;
|
|
const nlValue = at(nl);
|
|
const frValue = at(fr);
|
|
|
|
expect(
|
|
nlValue,
|
|
`${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` +
|
|
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
|
).not.toBe(enValue);
|
|
expect(
|
|
frValue,
|
|
`${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` +
|
|
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
|
).not.toBe(enValue);
|
|
expect(
|
|
frValue,
|
|
`${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` +
|
|
`looks like Dutch text was copy-pasted into the French locale`,
|
|
).not.toBe(nlValue);
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
|
|
// The whole-string identity check above only catches a value that is IDENTICAL to
|
|
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
|
|
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
|
|
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
|
|
// goedgekeurd." This scan flags known English fragments appearing literally inside any
|
|
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
|
|
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
|
|
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
|
|
const FORBIDDEN_ENGLISH_FRAGMENTS = [
|
|
"Operations Manager",
|
|
"Operations Managers",
|
|
"Rental Employee",
|
|
"Rental Employees",
|
|
"Audit trail",
|
|
"Start scenario",
|
|
];
|
|
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
|
|
"Operationsmanager",
|
|
"Verhuurmedewerker",
|
|
"Auditgeschiedenis",
|
|
"Scenario starten",
|
|
];
|
|
|
|
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
|
|
if (typeof value === "string") return [{ path: prefix, value }];
|
|
if (value === null || typeof value !== "object") return [];
|
|
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
|
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
|
|
);
|
|
}
|
|
|
|
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
|
|
const findings: string[] = [];
|
|
for (const namespace of namespaces) {
|
|
const nl = loadNamespace("nl-BE", namespace);
|
|
const fr = loadNamespace("fr-BE", namespace);
|
|
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
|
|
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
|
|
if (value.includes(fragment)) {
|
|
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
|
|
}
|
|
}
|
|
}
|
|
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
|
|
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
|
|
if (value.includes(fragment)) {
|
|
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(findings, findings.join("\n")).toEqual([]);
|
|
});
|
|
|
|
// --- Hardcoded JSX text (section 11D) ---
|
|
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
|
// `{...}` expression) containing two or more real words are almost always user-facing
|
|
// prose that should go through t(...). This is not a full parser, so a short, explicit
|
|
// allowlist covers technical tokens/proper nouns that are correctly never translated
|
|
// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE
|
|
// expected to appear literally in JSX).
|
|
const SRC_DIR = path.resolve(__dirname, "../src");
|
|
const SCAN_DIRS = ["pages", "components"];
|
|
|
|
const ALLOWED_LITERAL_TEXT = new Set([
|
|
"Fleet Ops", // the non-localizable brand name (frontend/src/product.ts)
|
|
"Northstar Mobility", // fictional demo org, a proper noun
|
|
"ITWorx MCP Hub", // proper noun
|
|
]);
|
|
|
|
function collectTsxFiles(dir: string): string[] {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
return entries.flatMap((entry) => {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) return collectTsxFiles(full);
|
|
return entry.name.endsWith(".tsx") ? [full] : [];
|
|
});
|
|
}
|
|
|
|
function findHardcodedJsxText(filePath: string): string[] {
|
|
const source = fs.readFileSync(filePath, "utf-8");
|
|
const findings: string[] = [];
|
|
// Matches `<Tag ...>text</Tag>` where the closing tag name backreferences the
|
|
// opening one -- this specifically excludes TypeScript generics like
|
|
// `useState<string | null>(null)`, which have no matching `</string | null>` closer,
|
|
// unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly
|
|
// puts JSX text on its own line) and does not attempt to parse JSX properly -- it is
|
|
// a fast, approximate net for the common mistake, not a compiler.
|
|
const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = jsxTextPattern.exec(source)) !== null) {
|
|
const text = match[2].trim();
|
|
if (!text) continue;
|
|
if (ALLOWED_LITERAL_TEXT.has(text)) continue;
|
|
// Needs at least two alphabetic words to count as "prose" -- filters out numbers,
|
|
// single technical words, units (km, %), punctuation-only fragments, and JSX
|
|
// whitespace artifacts.
|
|
const words = text.match(/[A-Za-z]+/g) ?? [];
|
|
if (words.length < 2) continue;
|
|
// Skip anything that is itself an i18next interpolation artifact leaking through
|
|
// (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL
|
|
// or path.
|
|
if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue;
|
|
findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`);
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => {
|
|
const allFindings: string[] = [];
|
|
for (const dir of SCAN_DIRS) {
|
|
const files = collectTsxFiles(path.join(SRC_DIR, dir));
|
|
for (const file of files) {
|
|
allFindings.push(...findHardcodedJsxText(file));
|
|
}
|
|
}
|
|
expect(
|
|
allFindings,
|
|
`Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` +
|
|
`Either route it through the translation system, or add the exact literal to ` +
|
|
`ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` +
|
|
allFindings.join("\n"),
|
|
).toEqual([]);
|
|
});
|