470 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|