Ran a real AI Operations Brief via the live ITWorx MCP Hub connector's own MobilityOpsClient against production Fleet Ops: real operations summary, real most-pressing vehicle, real grounded knowledge answer with citations, real correlation IDs verified end-to-end in Fleet Ops's own audit log. No write actions performed. Runbook and full output in docs/final-integrations/ai-operations-brief-runbook.md. Ran the full Playwright e2e suite against the live deployed instance and fixed two pre-existing fragile locators unrelated to this session's feature work (both broke because Automation now legitimately has two tables sharing the same generic selectors, exposed by running the full suite rather than individual files) plus one pre-existing untranslated-loanword false positive. All specs pass. artifacts/final-integrations/final-summary.md has the complete evidence write-up: repository/deployment state, what was fixed vs. handed off, test results, and known limitations stated plainly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
325 lines
14 KiB
TypeScript
325 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
|
|
"integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and 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([]);
|
|
});
|