Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/i18n-check.test.ts
T

470 lines
20 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 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("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);
});
});