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).flatMap(([key, nested]) => collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key), ); } function loadNamespace(language: string, namespace: string): Record { 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((acc, part) => { if (acc && typeof acc === "object") return (acc as Record)[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) => keyPath.split(".").reduce((acc, part) => { if (acc && typeof acc === "object") return (acc as Record)[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).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 `text` where the closing tag name backreferences the // opening one -- this specifically excludes TypeScript generics like // `useState(null)`, which have no matching `` 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([]); });