From b2f564c2be9a11b3e3c1024b2b117cb23b82f098 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 11:02:55 -0700 Subject: [PATCH] FN-6791: add i18n regression guardrails Add focused i18n regression coverage for dashboard localization guardrails. - Assert dashboard hardcoded-string lint ignores stay limited to non-shipping patterns. - Verify real locale catalogs retain key parity across supported locales and namespaces. - Cover fallback behavior for empty secondary translations, missing keys, unsupported locale tags, and Chinese locale normalization. - Document that @fusion/i18n tests now protect lint-ignore scope and catalog parity drift. Files changed: docs/i18n-contributing.md | 2 + .../i18n/src/__tests__/i18n-gate-coverage.test.ts | 65 +++++++++++++++++++ .../i18n/src/__tests__/locale-fallback.test.ts | 72 ++++++++++++++++++++++ 3 files changed, 139 insertions(+) Fusion-Task-Id: FN-6791 Fusion-Task-Lineage: 7f52113e-237a-415d-a109-51295fdc0372 --- docs/i18n-contributing.md | 2 + .../src/__tests__/i18n-gate-coverage.test.ts | 65 +++++++++++++++++ .../src/__tests__/locale-fallback.test.ts | 72 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 packages/i18n/src/__tests__/i18n-gate-coverage.test.ts create mode 100644 packages/i18n/src/__tests__/locale-fallback.test.ts diff --git a/docs/i18n-contributing.md b/docs/i18n-contributing.md index 4a58e114d2..35e309bc1a 100644 --- a/docs/i18n-contributing.md +++ b/docs/i18n-contributing.md @@ -54,6 +54,8 @@ Any remaining user-facing copy must be localized with `t()` / `` and an specific files or a small cluster in `lint.ignore`, includes an `FNXC` rationale, and has a filed follow-up task that removes the ignore. The settings sections cluster is no longer deferred as of FN-6771; keep those files covered by lint. +The `@fusion/i18n` regression tests also assert the lint-ignore scope and live +catalog key parity so those guardrails cannot silently drift. ## Translating an existing language diff --git a/packages/i18n/src/__tests__/i18n-gate-coverage.test.ts b/packages/i18n/src/__tests__/i18n-gate-coverage.test.ts new file mode 100644 index 0000000000..882ef558d6 --- /dev/null +++ b/packages/i18n/src/__tests__/i18n-gate-coverage.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { SUPPORTED_LOCALES } from "@fusion/core"; +import { describe, expect, it } from "vitest"; +import config from "../../../../i18next.config.ts"; +import namespaces from "../../namespaces.json"; +import { findParityViolations, type CatalogObject, type NamespaceCatalogs } from "../parity.js"; + +/** + * FNXC:i18n-GateRegression 2026-06-20-00:00: + * The post-localization dashboard must stay inside both i18n guardrails: future hardcoded dashboard copy cannot be hidden in lint.ignore, and future en keys must be synced structurally across every locale/namespace. + * This test duplicates the gate invariants in fast Vitest coverage without shelling out, so CI catches drift even before a human reruns the i18n CLI commands. + */ + +type Locale = (typeof SUPPORTED_LOCALES)[number]; +type Namespace = (typeof namespaces.all)[number]; + +const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); +const expectedNonShippingLintIgnores = ["**/__tests__/**", "**/*.test.*", "**/*.stories.*"]; + +const representativeDashboardFiles = { + plugin: "packages/dashboard/app/components/PiExtensionsManager.tsx", + agent: "packages/dashboard/app/components/AgentDetailView.tsx", + mission: "packages/dashboard/app/components/MissionManager.tsx", + node: "packages/dashboard/app/components/AddNodeModal.tsx", + research: "packages/dashboard/app/components/ResearchView.tsx", + document: "packages/dashboard/app/components/DocumentsView.tsx", + activity: "packages/dashboard/app/components/ActivityFeed.tsx", + workflow: "packages/dashboard/app/components/WorkflowSelector.tsx", + task: "packages/dashboard/app/components/TaskDetailModal.tsx", + setup: "packages/dashboard/app/components/SetupWizardModal.tsx", + pr: "packages/dashboard/app/components/PullRequestView.tsx", + settings: "packages/dashboard/app/components/settings/sections/GeneralSection.tsx", +} as const; + +function readCatalog(locale: Locale, namespace: Namespace): CatalogObject { + const path = `${repoRoot}/packages/i18n/locales/${locale}/${namespace}.json`; + return JSON.parse(readFileSync(path, "utf8")) as CatalogObject; +} + +function readCatalogs(locale: Locale): NamespaceCatalogs { + return Object.fromEntries(namespaces.all.map((namespace) => [namespace, readCatalog(locale, namespace)])); +} + +describe("i18n gate regression coverage", () => { + it("keeps dashboard source files under the hardcoded-string lint gate", () => { + expect(config.lint?.ignore).toEqual(expectedNonShippingLintIgnores); + expect(config.lint?.ignoredTags).toEqual(["kbd"]); + + for (const [area, file] of Object.entries(representativeDashboardFiles)) { + expect(config.lint?.ignore, `${area} representative must not be ignored`).not.toContain(file); + } + expect(config.lint?.ignore?.filter((entry) => entry.includes("packages/dashboard/app/"))).toEqual([]); + }); + + it("keeps real catalogs in key parity across every supported locale and namespace", () => { + expect(config.locales).toEqual([...SUPPORTED_LOCALES]); + expect(namespaces.all).toEqual(["common", "app", "errors", "cli"]); + + const enCatalogs = readCatalogs("en"); + for (const locale of SUPPORTED_LOCALES.filter((locale) => locale !== "en")) { + expect(findParityViolations(enCatalogs, readCatalogs(locale), { locale })).toEqual([]); + } + }); +}); diff --git a/packages/i18n/src/__tests__/locale-fallback.test.ts b/packages/i18n/src/__tests__/locale-fallback.test.ts new file mode 100644 index 0000000000..ff6dd5fa5d --- /dev/null +++ b/packages/i18n/src/__tests__/locale-fallback.test.ts @@ -0,0 +1,72 @@ +import i18next, { type Resource } from "i18next"; +import { describe, expect, it } from "vitest"; +import { baseInitOptions, FALLBACK_LNG, normalizeToSupportedLocale } from "../config.js"; + +/** + * FNXC:i18n-Fallback 2026-06-20-00:00: + * Secondary locale catalogs intentionally keep untranslated entries as empty strings, so runtime fallback must prefer the en source instead of rendering blanks. + * These in-memory fixtures exercise real i18next options for translated, empty, missing, and unsupported-locale boundary cases without loading catalogs or spawning gate commands. + */ + +const resources = { + en: { + common: { + translated: "English translated source", + empty: "English empty fallback", + absent: "English absent fallback", + defaultFallback: "English default fallback", + }, + }, + fr: { + common: { + translated: "Valeur française", + empty: "", + }, + }, +} satisfies Resource; + +async function createFixtureInstance(lng: string) { + const instance = i18next.createInstance(); + await instance.init({ + ...baseInitOptions(), + resources, + lng, + ns: ["common"], + defaultNS: "common", + }); + return instance; +} + +describe("locale fallback boundary cases", () => { + it("uses translated values and falls back to en for empty or absent secondary values", async () => { + const instance = await createFixtureInstance("fr"); + + expect(instance.t("translated")).toBe("Valeur française"); + expect(instance.t("empty")).toBe("English empty fallback"); + expect(instance.t("absent")).toBe("English absent fallback"); + }); + + it("normalizes known locale tags and rejects unsupported tags", () => { + expect(normalizeToSupportedLocale("en-US")).toBe("en"); + expect(normalizeToSupportedLocale("zh-Hant-HK")).toBe("zh-TW"); + expect(normalizeToSupportedLocale("zh-Hans")).toBe("zh-CN"); + expect(normalizeToSupportedLocale("xx")).toBeUndefined(); + }); + + it("resolves unsupported runtime languages through the default en fallback chain", async () => { + const instance = await createFixtureInstance("xx"); + + expect(FALLBACK_LNG.default).toEqual(["en"]); + expect(instance.t("defaultFallback")).toBe("English default fallback"); + }); + + it("keeps Simplified and Traditional Chinese catalogs from collapsing", () => { + const options = baseInitOptions(); + + expect(options.load).toBe("currentOnly"); + expect(options.nonExplicitSupportedLngs).toBe(false); + expect(normalizeToSupportedLocale("zh-CN")).toBe("zh-CN"); + expect(normalizeToSupportedLocale("zh-TW")).toBe("zh-TW"); + expect(normalizeToSupportedLocale("zh")).toBe("zh-CN"); + }); +});