diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts index a24ea23..2325394 100644 --- a/frontend/e2e/fleet-ops-correction.spec.ts +++ b/frontend/e2e/fleet-ops-correction.spec.ts @@ -295,3 +295,58 @@ test("automation shows a localized error explanation with the raw error only und await page.getByText("Technische details").first().click(); await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible(); }); + +test.describe("route matrix (section 11F)", () => { + // Opens every main route in all 3 languages: no console errors, correct html[lang], + // and a real, non-empty page heading (proving the route actually rendered content + // instead of silently falling back to a raw i18next key or a blank screen). Key + // parity across locale files is already proven structurally by i18n-coverage.spec.ts + // (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this + // matrix focuses on what only a live render can catch. + const routes = [ + "/dashboard", + "/vehicles", + "/vehicles/MO-001", + "/bookings", + "/bookings/BK-DEMO-RETURN", + "/data-quality", + "/data-quality/DQ-DEMO-STATUS", + "/automation", + "/knowledge", + "/audit", + "/scenarios", + "/about", + ]; + + for (const lang of ["nl-BE", "en-GB", "fr-BE"]) { + test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => { + await resetDemoData(request); + + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() !== "error") return; + if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return; + errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + await loginAsOpsManager(page, lang); + + for (const route of routes) { + await page.goto(route); + await expect(page.locator("html")).toHaveAttribute("lang", lang); + const heading = page.getByRole("heading", { level: 1 }); + await expect(heading, `${route} (${lang})`).toBeVisible(); + const headingText = (await heading.first().textContent())?.trim() ?? ""; + expect(headingText, `${route} (${lang}) heading text`).not.toBe(""); + // A raw, unresolved i18next key looks like "namespace:some.key.path" -- real + // page headings never contain a colon followed by a dotted identifier. + expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch( + /^[a-zA-Z]+:[\w.]+$/, + ); + } + + expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]); + }); + } +}); diff --git a/frontend/e2e/i18n-coverage.spec.ts b/frontend/e2e/i18n-coverage.spec.ts index e178b58..c83e01c 100644 --- a/frontend/e2e/i18n-coverage.spec.ts +++ b/frontend/e2e/i18n-coverage.spec.ts @@ -184,3 +184,74 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea } } }); + +// --- 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([]); +});