diff --git a/.github/workflows/control-ui-locale-refresh.yml b/.github/workflows/control-ui-locale-refresh.yml index ad2f4e33737e..e8921ad7659b 100644 --- a/.github/workflows/control-ui-locale-refresh.yml +++ b/.github/workflows/control-ui-locale-refresh.yml @@ -14,6 +14,7 @@ on: - scripts/control-ui-i18n.ts - scripts/control-ui-i18n-verify.ts - scripts/lib/control-ui-i18n-catalog.ts + - scripts/lib/control-ui-i18n-config.json - scripts/lib/control-ui-i18n-config.ts - scripts/lib/control-ui-i18n-raw-copy.ts - scripts/lib/control-ui-i18n-sync-plan.ts @@ -52,6 +53,7 @@ jobs: (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') runs-on: ubuntu-latest outputs: + locales: ${{ steps.base.outputs.locales }} sha: ${{ steps.base.outputs.sha }} steps: - name: Resolve source commit @@ -76,6 +78,14 @@ jobs: echo "Unable to resolve the locale refresh source to an exact commit." >&2 exit 1 fi + locale_config="$( + timeout --signal=TERM --kill-after=10s 60s \ + gh api --method GET \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/${REPOSITORY}/contents/scripts/lib/control-ui-i18n-config.json?ref=${sha}" + )" + locales="$(jq -ce '[.[].locale] | select(length > 0 and all(.[]; type == "string" and length > 0))' <<< "${locale_config}")" + echo "locales=${locales}" >> "${GITHUB_OUTPUT}" echo "sha=${sha}" >> "${GITHUB_OUTPUT}" publisher-preflight: @@ -130,29 +140,7 @@ jobs: fail-fast: false max-parallel: 4 matrix: - locale: - [ - zh-CN, - zh-TW, - pt-BR, - de, - es, - ja-JP, - ko, - fr, - hi, - ar, - it, - tr, - uk, - id, - pl, - th, - vi, - nl, - fa, - ru, - ] + locale: ${{ fromJSON(needs.resolve-base.outputs.locales) }} runs-on: ubuntu-latest name: Refresh ${{ matrix.locale }} steps: @@ -341,6 +329,7 @@ jobs: scripts/control-ui-i18n.ts scripts/control-ui-i18n-verify.ts scripts/lib/control-ui-i18n-catalog.ts + scripts/lib/control-ui-i18n-config.json scripts/lib/control-ui-i18n-config.ts scripts/lib/control-ui-i18n-raw-copy.ts scripts/lib/control-ui-i18n-sync-plan.ts diff --git a/scripts/check-changed.mts b/scripts/check-changed.mts index 3a6a29afeb02..f61d44802226 100644 --- a/scripts/check-changed.mts +++ b/scripts/check-changed.mts @@ -103,7 +103,7 @@ const DEPRECATION_HYGIENE_PATH_RE = const WRAPPER_SHADOWING_PATH_RE = /^(?:package\.json$|src\/|scripts\/(?:check-(?:export-name-collisions|wrapper-shadowing)\.mts$|lib\/ts-guard-utils\.mts$))/u; const CONTROL_UI_I18N_VERIFY_PATH_RE = - /^(?:package\.json$|ui\/(?:src\/|config\/control-ui-locales\.ts$)|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u; + /^(?:package\.json$|ui\/(?:src\/|config\/control-ui-locales\.ts$)|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/(?:control-ui-i18n-[^/]+\.ts|control-ui-i18n-config\.json))$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u; const SHRINK_RATCHET_OWNER_PATH = "scripts/lib/shrink-ratchet.mts"; const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json"; const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json"; diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index ffce99fef98d..9fdbebab8876 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -92,7 +92,7 @@ const WINDOWS_WORKER_BUNDLE_SCOPE_RE = const WINDOWS_WORKER_WORKSPACE_SCOPE_RE = /^src\/(?:node-host\/node-worker-transfer-client(?:\.test)?|gateway\/worker-environments\/(?:node-worker-tunnel(?:\.test)?|workspace-sync-(?:scripts|manifest\.test)))\.ts$/; const CONTROL_UI_I18N_SCOPE_RE = - /^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:catalog|config|raw-copy|sync-plan)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/; + /^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:(?:catalog|config|raw-copy|sync-plan)\.ts|config\.json))$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/; const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/; const CONTROL_UI_HARD_GENERATED_I18N_RE = /^ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl))$/; diff --git a/scripts/control-ui-i18n-verify.ts b/scripts/control-ui-i18n-verify.ts index f5958953819b..d0a9acab5cd8 100644 --- a/scripts/control-ui-i18n-verify.ts +++ b/scripts/control-ui-i18n-verify.ts @@ -306,15 +306,15 @@ export async function syncControlUiCatalogFallbackBaseline(options: { } export async function verifyRuntimeLocaleConfig() { - const registryRaw = await readFile( - path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts"), - "utf8", - ); - const typesRaw = await readFile(path.join(ROOT, "ui", "src", "i18n", "lib", "types.ts"), "utf8"); - for (const entry of CONTROL_UI_LOCALE_ENTRIES) { - if (!registryRaw.includes(`"${entry.locale}"`) || !typesRaw.includes(`| "${entry.locale}"`)) { - throw new Error(`runtime locale config is missing ${entry.locale}`); - } + const registryPath = path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts"); + const registry = (await import(pathToFileURL(registryPath).href)) as { + SUPPORTED_LOCALES: readonly string[]; + }; + const expectedLocales = ["en", ...CONTROL_UI_LOCALE_ENTRIES.map((entry) => entry.locale)]; + if (!compareStringArrays(registry.SUPPORTED_LOCALES, expectedLocales)) { + throw new Error( + `runtime locale config is out of sync: expected ${expectedLocales.join(", ")}, got ${registry.SUPPORTED_LOCALES.join(", ")}`, + ); } const enMap = (await loadControlUiLocaleCatalog(SOURCE_LOCALE_PATH, "en")) ?? {}; diff --git a/scripts/lib/control-ui-i18n-config.json b/scripts/lib/control-ui-i18n-config.json new file mode 100644 index 000000000000..e06b262c3d50 --- /dev/null +++ b/scripts/lib/control-ui-i18n-config.json @@ -0,0 +1,22 @@ +[ + { "locale": "zh-CN", "fileName": "zh-CN.ts", "exportName": "zh_CN", "languageKey": "zhCN" }, + { "locale": "zh-TW", "fileName": "zh-TW.ts", "exportName": "zh_TW", "languageKey": "zhTW" }, + { "locale": "pt-BR", "fileName": "pt-BR.ts", "exportName": "pt_BR", "languageKey": "ptBR" }, + { "locale": "de", "fileName": "de.ts", "exportName": "de", "languageKey": "de" }, + { "locale": "es", "fileName": "es.ts", "exportName": "es", "languageKey": "es" }, + { "locale": "ja-JP", "fileName": "ja-JP.ts", "exportName": "ja_JP", "languageKey": "jaJP" }, + { "locale": "ko", "fileName": "ko.ts", "exportName": "ko", "languageKey": "ko" }, + { "locale": "fr", "fileName": "fr.ts", "exportName": "fr", "languageKey": "fr" }, + { "locale": "hi", "fileName": "hi.ts", "exportName": "hi", "languageKey": "hi" }, + { "locale": "ar", "fileName": "ar.ts", "exportName": "ar", "languageKey": "ar" }, + { "locale": "it", "fileName": "it.ts", "exportName": "it", "languageKey": "it" }, + { "locale": "tr", "fileName": "tr.ts", "exportName": "tr", "languageKey": "tr" }, + { "locale": "uk", "fileName": "uk.ts", "exportName": "uk", "languageKey": "uk" }, + { "locale": "id", "fileName": "id.ts", "exportName": "id", "languageKey": "id" }, + { "locale": "pl", "fileName": "pl.ts", "exportName": "pl", "languageKey": "pl" }, + { "locale": "th", "fileName": "th.ts", "exportName": "th", "languageKey": "th" }, + { "locale": "vi", "fileName": "vi.ts", "exportName": "vi", "languageKey": "vi" }, + { "locale": "nl", "fileName": "nl.ts", "exportName": "nl", "languageKey": "nl" }, + { "locale": "fa", "fileName": "fa.ts", "exportName": "fa", "languageKey": "fa" }, + { "locale": "ru", "fileName": "ru.ts", "exportName": "ru", "languageKey": "ru" } +] diff --git a/scripts/lib/control-ui-i18n-config.ts b/scripts/lib/control-ui-i18n-config.ts index c36a0f408dfd..e8e7e761c440 100644 --- a/scripts/lib/control-ui-i18n-config.ts +++ b/scripts/lib/control-ui-i18n-config.ts @@ -1,24 +1,4 @@ +import controlUiLocaleEntries from "./control-ui-i18n-config.json" with { type: "json" }; import type { LocaleEntry } from "./control-ui-i18n-sync-plan.ts"; -export const CONTROL_UI_LOCALE_ENTRIES: readonly LocaleEntry[] = [ - { locale: "zh-CN", fileName: "zh-CN.ts", exportName: "zh_CN", languageKey: "zhCN" }, - { locale: "zh-TW", fileName: "zh-TW.ts", exportName: "zh_TW", languageKey: "zhTW" }, - { locale: "pt-BR", fileName: "pt-BR.ts", exportName: "pt_BR", languageKey: "ptBR" }, - { locale: "de", fileName: "de.ts", exportName: "de", languageKey: "de" }, - { locale: "es", fileName: "es.ts", exportName: "es", languageKey: "es" }, - { locale: "ja-JP", fileName: "ja-JP.ts", exportName: "ja_JP", languageKey: "jaJP" }, - { locale: "ko", fileName: "ko.ts", exportName: "ko", languageKey: "ko" }, - { locale: "fr", fileName: "fr.ts", exportName: "fr", languageKey: "fr" }, - { locale: "hi", fileName: "hi.ts", exportName: "hi", languageKey: "hi" }, - { locale: "ar", fileName: "ar.ts", exportName: "ar", languageKey: "ar" }, - { locale: "it", fileName: "it.ts", exportName: "it", languageKey: "it" }, - { locale: "tr", fileName: "tr.ts", exportName: "tr", languageKey: "tr" }, - { locale: "uk", fileName: "uk.ts", exportName: "uk", languageKey: "uk" }, - { locale: "id", fileName: "id.ts", exportName: "id", languageKey: "id" }, - { locale: "pl", fileName: "pl.ts", exportName: "pl", languageKey: "pl" }, - { locale: "th", fileName: "th.ts", exportName: "th", languageKey: "th" }, - { locale: "vi", fileName: "vi.ts", exportName: "vi", languageKey: "vi" }, - { locale: "nl", fileName: "nl.ts", exportName: "nl", languageKey: "nl" }, - { locale: "fa", fileName: "fa.ts", exportName: "fa", languageKey: "fa" }, - { locale: "ru", fileName: "ru.ts", exportName: "ru", languageKey: "ru" }, -]; +export const CONTROL_UI_LOCALE_ENTRIES = controlUiLocaleEntries satisfies readonly LocaleEntry[]; diff --git a/src/scripts/ci-changed-scope.control-ui.test.ts b/src/scripts/ci-changed-scope.control-ui.test.ts index a6193e21f705..e7822d597c4a 100644 --- a/src/scripts/ci-changed-scope.control-ui.test.ts +++ b/src/scripts/ci-changed-scope.control-ui.test.ts @@ -15,6 +15,12 @@ it("skips control-ui localization checks for test-only UI source", () => { ); }); +it("runs control-ui localization checks for the canonical locale config", () => { + expect(detectChangedScope(["scripts/lib/control-ui-i18n-config.json"]).runControlUiI18n).toBe( + true, + ); +}); + it("runs Chromium UI tests for browser copilot extension changes", () => { expect(detectChangedScope(["extensions/browser/chrome-extension/sidepanel.ts"]).runUiTests).toBe( true, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 33e2c737ed55..71cdc9394930 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -977,6 +977,7 @@ describe("scripts/changed-lanes", () => { expect(shouldRunControlUiI18nVerify(result.paths)).toBe(true); expect(plan.commands.map((command) => command.args[0])).toContain("lint:ui:i18n"); expect(shouldRunControlUiI18nVerify(["ui/config/control-ui-locales.ts"])).toBe(true); + expect(shouldRunControlUiI18nVerify(["scripts/lib/control-ui-i18n-config.json"])).toBe(true); expect(shouldRunControlUiI18nVerify(["scripts/lib/example.ts"])).toBe(false); }); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index eacf34da2e99..e15e38e8f4ca 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -27,7 +27,6 @@ import { } from "../../scripts/ci-changed-scope.mjs"; import { NATIVE_I18N_LOCALES } from "../../scripts/native-i18n-locales.ts"; import { resolvePnpmRunner } from "../../scripts/pnpm-runner.mts"; -import { SUPPORTED_LOCALES } from "../../ui/src/i18n/lib/registry.ts"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; const CHECKOUT_V6 = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"; @@ -2210,11 +2209,12 @@ NODE "${{ github.event_name == 'workflow_dispatch' && inputs.token_preflight_only && format('control-ui-locale-token-preflight-{0}', github.ref) || 'control-ui-locale-refresh' }}", ); expect(controlUiWorkflow.jobs.plan).toBeUndefined(); + expect(controlUiResolveBase.outputs.locales).toBe("${{ steps.base.outputs.locales }}"); expect(controlUiWorkflow.jobs.refresh.if).toBe( "needs.resolve-base.result == 'success' && needs.publisher-preflight.result == 'success' && !(github.event_name == 'workflow_dispatch' && inputs.token_preflight_only)", ); - expect(controlUiWorkflow.jobs.refresh.strategy.matrix.locale).toEqual( - SUPPORTED_LOCALES.filter((locale) => locale !== "en"), + expect(controlUiWorkflow.jobs.refresh.strategy.matrix.locale).toBe( + "${{ fromJSON(needs.resolve-base.outputs.locales) }}", ); expect(workflow.concurrency["cancel-in-progress"]).toBe(false); expect(workflow.concurrency.group).toBe("native-app-locale-refresh"); @@ -2382,6 +2382,10 @@ NODE 'if [[ "${TOKEN_PREFLIGHT_ONLY}" == "true" ]]; then', ); expect(controlUiResolveStep.run).toContain('sha="${WORKFLOW_SHA}"'); + expect(controlUiResolveStep.run).toContain( + "contents/scripts/lib/control-ui-i18n-config.json?ref=${sha}", + ); + expect(controlUiResolveStep.run).toContain("jq -ce '[.[].locale]"); for (const preflight of [controlUiPreflight, nativePreflight]) { expect(preflight.needs).toBe("resolve-base"); diff --git a/ui/config/control-ui-locales.ts b/ui/config/control-ui-locales.ts index 496ab242c12a..af630c9fa07b 100644 --- a/ui/config/control-ui-locales.ts +++ b/ui/config/control-ui-locales.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Plugin } from "vite"; @@ -47,6 +48,11 @@ export function controlUiLocaleModulesPlugin(): Plugin { return null; } const memoryPath = path.join(i18nAssetsDir, `${locale}.tm.jsonl`); + // Source PRs omit generated memory until the post-merge refresh runs. + // Existing empty or malformed memory stays fatal below so drift cannot hide. + if (!existsSync(memoryPath)) { + return `export default ${JSON.stringify(sourceCatalog)};`; + } this.addWatchFile(memoryPath); const memory = loadControlUiTranslationMemory(memoryPath); if (memory.size === 0) { diff --git a/ui/src/app/vite-config.node.test.ts b/ui/src/app/vite-config.node.test.ts index b42e7044eca2..8c65cf7d379a 100644 --- a/ui/src/app/vite-config.node.test.ts +++ b/ui/src/app/vite-config.node.test.ts @@ -15,6 +15,7 @@ import { } from "../../vite.config.ts"; const childProcessMocks = vi.hoisted(() => ({ execFileSync: vi.fn() })); +const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), readFileSync: vi.fn() })); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); @@ -22,6 +23,13 @@ vi.mock("node:child_process", async (importOriginal) => { return { ...actual, execFileSync: childProcessMocks.execFileSync }; }); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + fsMocks.existsSync.mockImplementation(actual.existsSync); + fsMocks.readFileSync.mockImplementation(actual.readFileSync); + return { ...actual, existsSync: fsMocks.existsSync, readFileSync: fsMocks.readFileSync }; +}); + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); type ResolveIdHandler = ( this: never, @@ -481,4 +489,42 @@ describe("Control UI Vite config", () => { expect(catalog.activity.title).toBeTypeOf("string"); expect(addWatchFile).toHaveBeenCalledWith(path.join(repoRoot, "ui/src/i18n/.i18n/fr.tm.jsonl")); }); + + it("bootstraps only an absent locale memory from the English catalog", async () => { + const loadHook = controlUiLocaleModulesPlugin().load; + const load = typeof loadHook === "function" ? loadHook : loadHook?.handler; + if (!load) { + throw new Error("Expected locale module loader"); + } + const id = "\0virtual:openclaw-control-ui-locale/fr"; + const addWatchFile = vi.fn(); + + await fsMocks.existsSync.withImplementation( + () => false, + async () => { + const result = await load.call({ addWatchFile } as never, id, {} as never); + if (typeof result !== "string") { + throw new Error("Expected locale module loader to return generated source"); + } + const catalog = JSON.parse(result.replace(/^export default /, "").replace(/;$/, "")); + expect(catalog.common.health).toBe("Health"); + expect(addWatchFile).not.toHaveBeenCalled(); + }, + ); + + await fsMocks.readFileSync.withImplementation( + () => "", + async () => { + expect(() => load.call({ addWatchFile } as never, id, {} as never)).toThrow( + "Control UI fr translation memory is missing or empty", + ); + }, + ); + await fsMocks.readFileSync.withImplementation( + () => "{", + async () => { + expect(() => load.call({ addWatchFile } as never, id, {} as never)).toThrow(SyntaxError); + }, + ); + }); }); diff --git a/ui/src/i18n/lib/registry.ts b/ui/src/i18n/lib/registry.ts index 2f7ebeea769e..3f2c91c0200a 100644 --- a/ui/src/i18n/lib/registry.ts +++ b/ui/src/i18n/lib/registry.ts @@ -6,29 +6,6 @@ type LocaleModule = Record; export const DEFAULT_LOCALE: Locale = "en"; -const LAZY_LOCALES: readonly LazyLocale[] = [ - "zh-CN", - "zh-TW", - "pt-BR", - "de", - "es", - "ja-JP", - "ko", - "fr", - "hi", - "ar", - "it", - "tr", - "uk", - "id", - "pl", - "th", - "vi", - "nl", - "fa", - "ru", -]; - const LAZY_LOCALE_REGISTRY: Record Promise> = { "zh-CN": () => import("../locales/zh-CN.ts"), "zh-TW": () => import("../locales/zh-TW.ts"), @@ -51,6 +28,8 @@ const LAZY_LOCALE_REGISTRY: Record Promise> = { fa: () => import("../locales/fa.ts"), ru: () => import("../locales/ru.ts"), }; +// SAFETY: The record contract guarantees every own key is a LazyLocale. +const LAZY_LOCALES = Object.keys(LAZY_LOCALE_REGISTRY) as LazyLocale[]; export const SUPPORTED_LOCALES: ReadonlyArray = [DEFAULT_LOCALE, ...LAZY_LOCALES];