test: add route matrix (11F) and hardcoded-JSX-text check (11D)
- fleet-ops-correction.spec.ts: opens every main route in all 3 languages, asserting no console errors, correct html[lang], and a real non-empty page heading (key parity across locale files is already proven structurally elsewhere, so this focuses on what only a live render can catch). - i18n-coverage.spec.ts: a static scan for hardcoded JSX text bypassing t(...). A naive `>text<` regex falsely flagged TypeScript generics everywhere (`useState<string | null>(null)` was read as a "JSX tag" spanning to the next unrelated `>`) -- fixed by requiring the closing tag name to backreference the opening one (`<Tag>...</Tag>`), which generics can never satisfy. Verified against both false positives (passes clean on the current codebase) and false negatives (deliberately injected and reverted a hardcoded string to confirm it's caught). Known pre-existing flake (unrelated to this branch, not touched by it): "logout invalidates the server session so a refresh returns to login" in interactive-elements.spec.ts occasionally fails only in the full sequential run, never in isolation -- AuthContext.logout() clears local state and redirects before awaiting the server-side cookie-clearing POST, a narrow race no human interaction speed would ever hit. Noted as a known limitation, not fixed (out of this branch's scope). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a7ac5ed9d0
commit
7851e807fa
@@ -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 page.getByText("Technische details").first().click();
|
||||||
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
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([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 `<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([]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user