import { mkdir, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { checkI18nParity, collectLeafKeys, formatI18nParityReport } from "./i18n-check.ts"; async function makeFixture() { const root = await mkdir(path.join(os.tmpdir(), `i18n-parity-${crypto.randomUUID()}`), { recursive: true }); if (!root) throw new Error("Failed to create i18n parity fixture"); return { root }; } async function writeTranslations(root: string, locale: string, translations: unknown) { await writeNamespace(root, locale, "translations", translations); } async function writeNamespace(root: string, locale: string, namespace: string, translations: unknown) { const localeRoot = path.join(root, "apps", "web", "public", "locales", locale); await mkdir(localeRoot, { recursive: true }); await writeFile(path.join(localeRoot, `${namespace}.json`), `${JSON.stringify(translations, null, 2)}\n`, "utf8"); } async function writeSource(root: string, relativePath: string, source: string) { const filePath = path.join(root, relativePath); await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, source, "utf8"); } function checkFixtureParity( root: string, options: Partial[0]> = {}, ): ReturnType { return checkI18nParity({ cwd: root, locales: ["de", "en"], referenceLocale: "de", namespaces: ["translations"], ...options }); } describe("i18n parity guard", () => { test("collects nested translation leaf keys deterministically", () => { expect( collectLeafKeys({ dashboard: { title: "Dashboard", stats: { empty: "No data" } }, common: { cancel: "Cancel" }, }), ).toEqual(["common.cancel", "dashboard.stats.empty", "dashboard.title"]); }); test("passes when supported locale files share identical nested leaf keys", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { cancel: "Abbrechen", save: "Speichern" }, dashboard: { stats: { empty: "Keine Daten" }, title: "Dashboard" }, }); await writeTranslations(root, "en", { common: { cancel: "Cancel", save: "Save" }, dashboard: { stats: { empty: "No data" }, title: "Dashboard" }, }); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(true); expect(result.keyCount).toBe(4); expect(report).toBe("PASS i18n-parity: 2 locales (de, en) share 4 translation keys across 1 namespaces"); }); test("fails with a clear report when a configured locale file is missing", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { save: "Speichern" } }); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toEqual([ { type: "missing-file", locale: "en", namespace: "translations", filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"), }, ]); expect(report).toContain("FAIL i18n-parity"); expect(report).toContain(path.join("apps", "web", "public", "locales", "en", "translations.json")); }); test("fails when the reference locale contains zero translation keys", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", {}); await writeTranslations(root, "en", {}); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.keyCount).toBe(0); expect(result.issues).toEqual([ { type: "empty-reference", locale: "de", namespace: "translations", filePath: path.join(root, "apps", "web", "public", "locales", "de", "translations.json"), }, ]); expect(report).toContain("empty reference namespace [de/translations]"); expect(report).toContain("expected at least one translation key"); }); test("fails with missing and extra key diagnostics when English diverges from German", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { cancel: "Abbrechen", save: "Speichern" }, dashboard: { title: "Dashboard" }, }); await writeTranslations(root, "en", { common: { cancel: "Cancel", search: "Search" }, dashboard: { title: "Dashboard" }, }); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toEqual([ { type: "key-divergence", locale: "en", namespace: "translations", filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"), referenceLocale: "de", missingKeys: ["common.save"], extraKeys: ["common.search"], }, ]); expect(report).toContain("key divergence [en/translations] vs [de/translations]"); expect(report).toContain("missing keys:\n - common.save"); expect(report).toContain("extra keys:\n - common.search"); }); test("fails when locale values are blank even when keys are present", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { cancel: "Abbrechen", save: " " }, dashboard: { title: "Dashboard" }, }); await writeTranslations(root, "en", { common: { cancel: "Cancel", save: "Save" }, dashboard: { title: "" }, }); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toContainEqual({ type: "empty-translation", locale: "de", namespace: "translations", filePath: path.join(root, "apps", "web", "public", "locales", "de", "translations.json"), keys: ["common.save"], }); expect(result.issues).toContainEqual({ type: "empty-translation", locale: "en", namespace: "translations", filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"), keys: ["dashboard.title"], }); expect(report).toContain("empty translation values [de/translations]"); expect(report).toContain("keys:\n - common.save"); }); test("fails when an unconfigured locale directory is present", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { save: "Speichern" } }); await writeTranslations(root, "en", { common: { save: "Save" } }); await writeTranslations(root, "fr", { common: { save: "Enregistrer" } }); const result = checkFixtureParity(root); expect(result.issues).toContainEqual({ type: "unexpected-locale-dir", locale: "fr", directoryPath: path.join(root, "apps", "web", "public", "locales", "fr"), }); }); test("passes static source keys from packages when the reference locale contains them", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { package: { example: { title: "Paket" } }, models: { role: { viewPermissions_one: "Eine Berechtigung", viewPermissions_other: "{{count}} Berechtigungen" } }, }); await writeTranslations(root, "en", { package: { example: { title: "Package" } }, models: { role: { viewPermissions_one: "One permission", viewPermissions_other: "{{count}} permissions" } }, }); await writeSource( root, "packages/example/src/component.tsx", 'export function Component({ t, count }) { return <>{t("package.example.title")}{t("models.role.viewPermissions", { count })}; }\n', ); const result = checkFixtureParity(root); expect(result.ok).toBe(true); }); test("accepts static source keys from non-default locale namespaces", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { save: "Speichern" } }); await writeTranslations(root, "en", { common: { save: "Save" } }); await writeNamespace(root, "de", "errors", { "404": { title: "Nicht gefunden" }, notFound: "Nicht gefunden" }); await writeNamespace(root, "en", "errors", { "404": { title: "Not found" }, notFound: "Not found" }); await writeNamespace(root, "de", "mail", { welcome: { subject: "Willkommen" } }); await writeNamespace(root, "en", "mail", { welcome: { subject: "Welcome" } }); await writeSource(root, "apps/web/app/routes/404.tsx", 'export async function loader({ t }) { return t("404.title") + t("notFound"); }\n'); await writeSource(root, "packages/mail/src/template.ts", 'export function mail(t) { return t("welcome.subject"); }\n'); const result = checkFixtureParity(root, { namespaces: ["translations", "errors", "mail"] }); expect(result.ok).toBe(true); }); test("extracts static translation keys from JSX attributes", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { banner: { title: "Titel" }, description: "Beschreibung" }, }); await writeTranslations(root, "en", { common: { banner: { title: "Title" }, description: "Description" }, }); await writeSource( root, "apps/web/app/components/Banner.tsx", 'export function Banner() { return ; }\n', ); const result = checkFixtureParity(root); expect(result.ok).toBe(true); }); test("fails when static package source keys are missing from the reference locale", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { save: "Speichern" } }); await writeTranslations(root, "en", { common: { save: "Save" } }); await writeSource(root, "packages/example/src/component.tsx", 'export function Component({ t }) { return t("package.example.title"); }\n'); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "package.example.title", namespace: null, fallback: "no-fallback", files: ["packages/example/src/component.tsx"], }); expect(report).toContain("missing source translation key (no-fallback): package.example.title"); }); test("checks every configured namespace file and key parity per locale", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { shared: { save: "Save" } }); await writeNamespace(root, "de", "common", { actions: { cancel: "Abbrechen", save: "Speichern" } }); await writeNamespace(root, "en", "common", { actions: { cancel: "Cancel", search: "Search" } }); const result = checkFixtureParity(root, { namespaces: ["translations", "common"] }); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.keyCount).toBe(3); expect(result.issues).toContainEqual({ type: "key-divergence", locale: "en", namespace: "common", filePath: path.join(root, "apps", "web", "public", "locales", "en", "common.json"), referenceLocale: "de", missingKeys: ["actions.save"], extraKeys: ["actions.search"], }); expect(report).toContain("key divergence [en/common] vs [de/common]"); }); test("reports missing configured namespace files", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { shared: { save: "Save" } }); await writeNamespace(root, "de", "common", { actions: { save: "Speichern" } }); const result = checkFixtureParity(root, { namespaces: ["translations", "common"] }); expect(result.issues).toContainEqual({ type: "missing-file", locale: "en", namespace: "common", filePath: path.join(root, "apps", "web", "public", "locales", "en", "common.json"), }); }); test("uses explicit source namespaces instead of accepting keys from another namespace", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { actions: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { actions: { save: "Save" } }); await writeNamespace(root, "de", "common", { actions: { cancel: "Abbrechen" } }); await writeNamespace(root, "en", "common", { actions: { cancel: "Cancel" } }); await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form({ t }) { return t("actions.save", { ns: "common" }); }\n'); const result = checkFixtureParity(root, { namespaces: ["translations", "common"] }); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "actions.save", namespace: "common", fallback: "no-fallback", files: ["apps/web/app/components/Form.tsx"], }); expect(report).toContain("missing source translation key (no-fallback): common:actions.save"); }); test("flags namespace keys that use a dot instead of the namespace colon separator", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { shared: { save: "Save" } }); // settings.json nutzt flache, gepunktete Keys (erreichbar nur via "settings:tenant.theme.title"). await writeNamespace(root, "de", "settings", { "tenant.theme.title": "Darstellung" }); await writeNamespace(root, "en", "settings", { "tenant.theme.title": "Appearance" }); // Bug-Muster: Punkt statt Doppelpunkt -> adressiert den defaultNS, nicht den settings-Namespace, // und erscheint zur Laufzeit als nackter Key. Korrekt waere t("settings:tenant.theme.title"). await writeSource(root, "apps/web/app/components/Theme.tsx", 'export function Theme({ t }) { return t("settings.tenant.theme.title"); }\n'); const result = checkFixtureParity(root, { namespaces: ["translations", "settings"] }); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "settings.tenant.theme.title", namespace: null, fallback: "no-fallback", files: ["apps/web/app/components/Theme.tsx"], }); expect(report).toContain("missing source translation key (no-fallback): settings.tenant.theme.title"); }); test("classifies missing source keys with fallback separately from missing source keys without fallback", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { save: "Speichern" } }); await writeTranslations(root, "en", { common: { save: "Save" } }); await writeSource( root, "apps/web/app/routes/example.tsx", 'export function Route({ t }) { return t("missing.withFallback", { defaultValue: "Fallback" }) + t("missing.withoutFallback"); }\n', ); const result = checkFixtureParity(root); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "missing.withFallback", namespace: null, fallback: "fallback", files: ["apps/web/app/routes/example.tsx"], }); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "missing.withoutFallback", namespace: null, fallback: "no-fallback", files: ["apps/web/app/routes/example.tsx"], }); }); test("does not fail the gate when missing source keys have fallback text", async () => { const { root } = await makeFixture(); await writeTranslations(root, "de", { common: { save: "Speichern" } }); await writeTranslations(root, "en", { common: { save: "Save" } }); await writeSource(root, "apps/web/app/routes/example.tsx", 'export function Route({ t }) { return t("missing.withFallback", "Fallback"); }\n'); const result = checkFixtureParity(root); const report = formatI18nParityReport(result); expect(result.ok).toBe(true); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "missing.withFallback", namespace: null, fallback: "fallback", files: ["apps/web/app/routes/example.tsx"], }); expect(report).toContain("PASS i18n-parity"); expect(report).toContain("missing source translation key (fallback): missing.withFallback"); }); test("infers source namespace from namespace-specific translation loaders", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { notFound: "Nicht gefunden" }); await writeNamespace(root, "en", "translations", { notFound: "Not found" }); await writeNamespace(root, "de", "errors", { notFound: "Nicht gefunden" }); await writeNamespace(root, "en", "errors", { notFound: "Not found" }); await writeSource(root, "apps/web/app/routes/404.tsx", 'export async function loader(request) { const { t } = await getTranslations(request, "errors"); return t("notFound"); }\n'); const result = checkFixtureParity(root, { namespaces: ["translations", "errors"] }); expect(result.ok).toBe(true); }); test("applies static key prefixes from translation hooks and fixed translators", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { shared: { save: "Save" } }); await writeNamespace(root, "de", "common", { actions: { save: "Speichern" }, admin: { title: "Admin" } }); await writeNamespace(root, "en", "common", { actions: { save: "Save" }, admin: { title: "Admin" } }); await writeSource( root, "apps/web/app/components/Form.tsx", 'export function Form() { const { t } = useTranslation("common", { keyPrefix: "actions" }); return t("save"); }\n', ); await writeSource( root, "apps/web/app/routes/Admin.tsx", 'export async function loader(i18n) { const t = await i18n.getFixedT("de", "common", "admin"); return t("title"); }\n', ); const result = checkFixtureParity(root, { namespaces: ["translations", "common"] }); expect(result.ok).toBe(true); }); test("uses nearby JSX namespaces for Trans i18n keys instead of accepting matching default keys", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { shared: { save: "Save" } }); await writeNamespace(root, "de", "common", { shared: { cancel: "Abbrechen" } }); await writeNamespace(root, "en", "common", { shared: { cancel: "Cancel" } }); await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form() { return ; }\n'); const result = checkFixtureParity(root, { namespaces: ["translations", "common"] }); const report = formatI18nParityReport(result); expect(result.ok).toBe(false); expect(result.issues).toContainEqual({ type: "missing-source-key", key: "shared.save", namespace: "common", fallback: "no-fallback", files: ["apps/web/app/components/Form.tsx"], }); expect(report).toContain("missing source translation key (no-fallback): common:shared.save"); }); test("extracts selector-style translation keys", async () => { const { root } = await makeFixture(); await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } }); await writeNamespace(root, "en", "translations", { shared: { save: "Save" } }); await writeNamespace(root, "de", "common", { actions: { save: "Speichern" } }); await writeNamespace(root, "en", "common", { actions: { save: "Save" } }); await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form() { const { t } = useTranslation("common"); return t(($) => $.actions.save); }\n'); const result = checkFixtureParity(root, { namespaces: ["translations", "common"] }); expect(result.ok).toBe(true); }); });