fix(ui): bootstrap source-only locales (#131015)

Allow source-only locale contributions to load intentional English bootstrap copy until post-merge generated memory exists. Derive refresh targets from canonical locale data while preserving strict validation.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-27 22:43:35 +05:30
committed by GitHub
parent 608e365ce4
commit c85be96d6e
12 changed files with 115 additions and 82 deletions
+12 -23
View File
@@ -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
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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))$/;
+9 -9
View File
@@ -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")) ?? {};
+22
View File
@@ -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" }
]
+2 -22
View File
@@ -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[];
@@ -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,
+1
View File
@@ -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);
});
+7 -3
View File
@@ -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");
+6
View File
@@ -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) {
+46
View File
@@ -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<typeof import("node:child_process")>();
@@ -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<typeof import("node:fs")>();
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);
},
);
});
});
+2 -23
View File
@@ -6,29 +6,6 @@ type LocaleModule = Record<string, TranslationMap>;
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<LazyLocale, () => Promise<LocaleModule>> = {
"zh-CN": () => import("../locales/zh-CN.ts"),
"zh-TW": () => import("../locales/zh-TW.ts"),
@@ -51,6 +28,8 @@ const LAZY_LOCALE_REGISTRY: Record<LazyLocale, () => Promise<LocaleModule>> = {
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<Locale> = [DEFAULT_LOCALE, ...LAZY_LOCALES];