Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/i18n-check.test.ts
T

645 lines
28 KiB
TypeScript

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<Parameters<typeof checkI18nParity>[0]> = {},
): ReturnType<typeof checkI18nParity> {
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 high-confidence German UI text leaks into the English catalog", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { leads: { status: { new: "Neu" } } });
await writeTranslations(root, "en", { leads: { status: { new: "Neu" } } });
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "language-leak",
locale: "en",
namespace: "translations",
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
findings: [{ key: "leads.status.new", words: ["Neu"] }],
});
expect(report).toContain("German UI text in English catalog [en/translations]");
expect(report).toContain("leads.status.new [Neu]");
});
test("does not flag lower-case technical identifiers in English examples", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { audit: { example: "z. B. kontakt.created" } });
await writeTranslations(root, "en", { audit: { example: "e.g. kontakt.created" } });
expect(checkFixtureParity(root).ok).toBe(true);
});
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 <Trans i18nKey="common.banner.title" descriptionKey="common.description" />; }\n',
);
const result = checkFixtureParity(root);
expect(result.ok).toBe(true);
});
test("extracts semantic translation keys from view-model and registry fields", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {
registry: {
breadcrumb: "Archiv",
empty: "Keine Einträge",
health: "Verbunden",
status: "Bereit",
},
});
await writeTranslations(root, "en", {
registry: {
breadcrumb: "Archive",
empty: "No entries",
health: "Connected",
status: "Ready",
},
});
await writeSource(
root,
"packages/view-models/src/registry.ts",
[
"export const vm = {",
' breadcrumbKey: "registry.breadcrumb",',
' emptyHeadlineKey: "registry.empty",',
' healthMessageKey: "registry.health",',
' statusLabelKey: "registry.status",',
"};",
].join("\n"),
);
expect(checkFixtureParity(root).ok).toBe(true);
});
test("fails for missing semantic registry keys that would otherwise render raw", 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/services/registry.ts", 'export const result = { errorKey: "registry.errors.typo" };\n');
const result = checkFixtureParity(root);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "registry.errors.typo",
namespace: null,
fallback: "no-fallback",
files: ["apps/web/app/services/registry.ts"],
});
});
test("scans feature-specific view-model key fields beyond generic labels", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
await writeTranslations(root, "en", { common: { save: "Save" } });
await writeSource(
root,
"packages/view-models/src/feature.vm.ts",
[
"export const vm = {",
' anonymousHintKey: "feature.hints.anonymous",',
' candidatesEmptyKey: "feature.candidates.empty",',
' dropzoneHeadlineKey: "feature.dropzone.headline",',
' noMailboxKey: "feature.mailbox.missing",',
' usersLoadingKey: "feature.users.loading",',
"};",
].join("\n"),
);
const missingKeys = checkFixtureParity(root).issues
.filter((issue) => issue.type === "missing-source-key")
.map((issue) => issue.key)
.sort();
expect(missingKeys).toEqual([
"feature.candidates.empty",
"feature.dropzone.headline",
"feature.hints.anonymous",
"feature.mailbox.missing",
"feature.users.loading",
]);
});
test("does not treat stable protocol registry identifiers as translation keys", 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/services/protocol-registry.ts",
[
'export const feature = { featureFlagKey: "tenant.subscription.active" };',
'export const trigger = { triggerKey: "lead.qualified" };',
'export const template = { templateKey: "shk.order.v1" };',
].join("\n"),
);
expect(checkFixtureParity(root).ok).toBe(true);
});
test("does not treat local variables named like translation fields as translation keys", 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/services/registry.ts", 'export const value = () => { const translationKey = "configTranslationKeys"; return translationKey; };\n');
expect(checkFixtureParity(root).ok).toBe(true);
});
test("accepts dynamic translation key families backed by catalog entries", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { workflow: { status: { active: "Aktiv", paused: "Pausiert" } } });
await writeTranslations(root, "en", { workflow: { status: { active: "Active", paused: "Paused" } } });
await writeSource(root, "apps/web/app/components/Status.tsx", "export function Status({ t, status }) { return t(`workflow.status.${status}`); }\n");
expect(checkFixtureParity(root).ok).toBe(true);
});
test("fails for dynamic translation key families with a misspelled catalog prefix", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { forms: { editor: { operators: { equals: "Ist gleich" } } } });
await writeTranslations(root, "en", { forms: { editor: { operators: { equals: "Equals" } } } });
await writeSource(root, "apps/web/app/components/Operator.tsx", "export function Operator({ t, op }) { return t(`forms.builder.operators.${op}`); }\n");
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "forms.builder.operators.*",
namespace: null,
fallback: "no-fallback",
files: ["apps/web/app/components/Operator.tsx"],
});
expect(report).toContain("missing source translation key family (no-fallback): forms.builder.operators.*");
});
test("handles expressions with quoted fallbacks inside dynamic key families", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { dialog: { confirm: { leave: { title: "Verlassen?" } } } });
await writeTranslations(root, "en", { dialog: { confirm: { leave: { title: "Leave?" } } } });
await writeSource(root, "apps/web/app/components/Dialog.tsx", 'export function Dialog({ t, action }) { return t(`dialog.confirm.${action ?? "leave"}.title`); }\n');
expect(checkFixtureParity(root).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 <Trans ns="common" i18nKey="shared.save" />; }\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);
});
});