mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-10-02 07:34:35 +08:00
Merge pull request #161 from dr-coton/feat/korean-localization
feat(i18n): add Korean localization
This commit is contained in:
@@ -92,6 +92,9 @@ jobs:
|
||||
- name: Compile extension TypeScript
|
||||
run: pnpm --filter @browser-skill/extension compile
|
||||
|
||||
- name: Run localization tests
|
||||
run: pnpm --filter @browser-skill/i18n test
|
||||
|
||||
- name: Run extension tests
|
||||
run: pnpm ext:test
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ The repository is a Cargo + pnpm workspace:
|
||||
- `crates/bsk-cli` — `bsk` CLI and local daemon
|
||||
- `crates/bsk-protocol` — shared wire types and JSON schemas
|
||||
- `apps/extension` — browser extension
|
||||
- `packages/ui` and `packages/i18n` — shared extension UI support
|
||||
- `packages/ui` and [`packages/i18n`](packages/i18n/README.md) — shared extension UI support, including English, Simplified Chinese and Korean localization
|
||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness plugin (`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||
- [`evals/browser`](evals/browser/README.md) — deterministic local pages and agent-neutral browser capability evaluation
|
||||
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ Agent 不直接与浏览器通信。它通过 `bsk` CLI 下发浏览器任务;
|
||||
- `crates/bsk-cli` — `bsk` CLI 与本地 daemon
|
||||
- `crates/bsk-protocol` — 共享协议类型与 JSON Schema
|
||||
- `apps/extension` — 浏览器扩展
|
||||
- `packages/ui` 和 `packages/i18n` — 扩展 UI 共享支持
|
||||
- `packages/ui` 和 [`packages/i18n`](packages/i18n/README.md) — 扩展 UI 共享支持,包含英文、简体中文和韩语本地化
|
||||
- `packages/dsh-plugin-browserskill` — DeepSeek Harness 插件(`@wxg-prc-cpg/browser-skill-dsh-plugin`)
|
||||
- [`evals/browser`](evals/browser/README.zh-CN.md) — 确定性本地页面与 Agent 无关的浏览器能力测试台
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { i18n } from "@browser-skill/i18n";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SnapshotInfo } from "@/lib/connection-controller";
|
||||
import { STORAGE_KEYS } from "@/lib/instance-id";
|
||||
import { EXTENSION_VERSION } from "@/transport/handshake";
|
||||
import { EXTENSION_VERSION, PROTOCOL_VERSION } from "@/transport/handshake";
|
||||
import { App } from "./App";
|
||||
import { useConnectionState } from "./use-connection-state";
|
||||
|
||||
@@ -57,8 +58,9 @@ describe("App", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -282,6 +284,52 @@ describe("App", () => {
|
||||
fireEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders Korean upgrade guidance and copies usable recording instructions", async () => {
|
||||
await i18n.changeLanguage("ko-KR");
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "version_skew",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "version_skew",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByText("연결됨")).toBeTruthy();
|
||||
expect(screen.getByText("업그레이드 가능")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
`확장 프로그램 프로토콜 v${PROTOCOL_VERSION}, CLI 프로토콜 v1.0. 프로토콜 버전이 다릅니다. 업그레이드해 주세요.`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "빠른 작업" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /작업 기록/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText("예: 게시물 작성"), {
|
||||
target: { value: "게시물 작성" },
|
||||
});
|
||||
const copyButton = screen.getByRole("button", { name: "기록 지침 복사" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain('bsk record start --browser 03c3e47f --purpose "게시물 작성"');
|
||||
expect(copied).toContain("\n\n단계:\n1.");
|
||||
expect(copied).toContain("`./trace`");
|
||||
expect(copied).toContain("`trace.json` + `states/`");
|
||||
expect(copied).not.toContain("{{");
|
||||
await waitFor(() => expect(screen.getByRole("status").textContent).toBe("복사됨"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("control hints toggle", () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Extension localization
|
||||
|
||||
The extension ships English (`en-US`), Simplified Chinese (`zh-CN`) and Korean
|
||||
(`ko-KR`) in the `common` and `extension` namespaces. These resources cover the
|
||||
popup, page overlays and extension notifications; they do not translate CLI
|
||||
output or extension store metadata.
|
||||
|
||||
## Language selection
|
||||
|
||||
The extension prefers `chrome.i18n.getUILanguage()` and uses `navigator` when
|
||||
that API is unavailable. Detected language tags resolve against the registered
|
||||
resources: for example, `ko`, `ko-KR` and `KO-kr` select Korean. Languages without
|
||||
a translation fall back to English. Chinese variants use Simplified Chinese
|
||||
until a Traditional Chinese resource is registered.
|
||||
|
||||
A saved `chrome.storage.local.i18nextLng` preference takes precedence over the
|
||||
detected language. Both restoring that preference and receiving a storage change
|
||||
normalize the tag against the same resources, so a stored `ko` also selects
|
||||
`ko-KR`. Matching aliases do not trigger another language change or storage write.
|
||||
|
||||
## Adding or updating translations
|
||||
|
||||
1. Add `src/locales/<locale>/common.json` and `extension.json`, preserving the
|
||||
English key structure and interpolation variables such as `{{cliProtocol}}`.
|
||||
2. Register both namespaces in `src/i18n.ts`. Language normalization derives its
|
||||
mappings from this registry; no separate language list needs updating.
|
||||
3. Preserve commands, line breaks and trace paths in recording instructions.
|
||||
Protocol version differences should say the connection remains available and
|
||||
an upgrade is recommended. Help prompts must make clear that the agent is
|
||||
waiting for the user to complete a step.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
pnpm --filter @browser-skill/i18n test
|
||||
pnpm ext:test
|
||||
pnpm lint
|
||||
pnpm --filter @browser-skill/extension compile
|
||||
pnpm ext:build
|
||||
```
|
||||
|
||||
CI runs both test suites. The localization tests check actual registered
|
||||
resources, detection, storage synchronization, key parity and interpolation
|
||||
variables. The popup tests also cover Korean upgrade guidance and copied
|
||||
recording instructions. When changing copy, visually check the popup and page
|
||||
overlays for wrapping and clipped text in the target language.
|
||||
@@ -154,11 +154,19 @@ export function bindChromeStorageLanguageSync(i18n: I18nType): void {
|
||||
return;
|
||||
}
|
||||
|
||||
storage.get(STORAGE_KEY, (items) => {
|
||||
const saved = items[STORAGE_KEY];
|
||||
if (typeof saved === "string" && saved !== i18n.language) {
|
||||
void i18n.changeLanguage(saved);
|
||||
const normalize = createLanguageNormalizer(Object.keys(i18n.options.resources ?? {}));
|
||||
const applyStoredLanguage = (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
const language = normalize(value);
|
||||
if (language !== i18n.language) {
|
||||
void i18n.changeLanguage(language);
|
||||
}
|
||||
};
|
||||
|
||||
storage.get(STORAGE_KEY, (items) => {
|
||||
applyStoredLanguage(items[STORAGE_KEY]);
|
||||
});
|
||||
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
@@ -169,9 +177,6 @@ export function bindChromeStorageLanguageSync(i18n: I18nType): void {
|
||||
if (area !== "local" || !(STORAGE_KEY in changes)) {
|
||||
return;
|
||||
}
|
||||
const next = changes[STORAGE_KEY]?.newValue;
|
||||
if (typeof next === "string" && next !== i18n.language) {
|
||||
void i18n.changeLanguage(next);
|
||||
}
|
||||
applyStoredLanguage(changes[STORAGE_KEY]?.newValue);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "./chrome-storage-sync";
|
||||
import enUSCommon from "./locales/en-US/common.json";
|
||||
import enUSExtension from "./locales/en-US/extension.json";
|
||||
import koKRCommon from "./locales/ko-KR/common.json";
|
||||
import koKRExtension from "./locales/ko-KR/extension.json";
|
||||
import zhCNCommon from "./locales/zh-CN/common.json";
|
||||
import zhCNExtension from "./locales/zh-CN/extension.json";
|
||||
|
||||
@@ -21,6 +23,10 @@ const resources = {
|
||||
common: enUSCommon,
|
||||
extension: enUSExtension,
|
||||
},
|
||||
"ko-KR": {
|
||||
common: koKRCommon,
|
||||
extension: koKRExtension,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "browser-skill",
|
||||
"description": "bsk CLI를 통해 AI 에이전트가 로그인된 브라우저를 제어할 수 있습니다"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"popup": {
|
||||
"brandCategory": "브라우저 자동화",
|
||||
"brandName": "BrowserSkill",
|
||||
"stateLabel": {
|
||||
"disconnected": "연결되지 않음",
|
||||
"connecting": "연결 중…",
|
||||
"connected": "연결됨",
|
||||
"version_skew": "연결됨",
|
||||
"disabled": "연결 꺼짐"
|
||||
},
|
||||
"stateDetail": {
|
||||
"disconnected": "BrowserSkill을 열어 연결하세요.",
|
||||
"connecting": "BrowserSkill에 연결 중…",
|
||||
"connected": "자동화 작업을 수행할 준비가 되었습니다.",
|
||||
"version_skew": "프로토콜 버전이 다릅니다. 업그레이드해 주세요."
|
||||
},
|
||||
"stateBadge": {
|
||||
"disconnected": "대기",
|
||||
"connecting": "시작 중",
|
||||
"connected": "준비됨",
|
||||
"version_skew": "업그레이드 가능",
|
||||
"disabled": "꺼짐"
|
||||
},
|
||||
"connectionToggleTitle": "BrowserSkill 연결",
|
||||
"controlHintsToggleTitle": "제어 표시",
|
||||
"controlHintsToggleHint": "에이전트가 페이지를 제어하는 동안 상태 표시와 주황색 테두리를 표시합니다.",
|
||||
"controlHintsInfoLabel": "제어 표시에 관하여",
|
||||
"versionSkewWarning": "확장 프로그램 프로토콜 v{{extensionProtocol}}, CLI 프로토콜 v{{cliProtocol}}. 프로토콜 버전이 다릅니다. 업그레이드해 주세요.",
|
||||
"upgradeAvailable": "업그레이드 가능",
|
||||
"launcher": {
|
||||
"title": "빠른 작업"
|
||||
},
|
||||
"back": "뒤로",
|
||||
"runtimeTitle": "런타임",
|
||||
"daemonVersion": "데몬 v{{version}}",
|
||||
"extensionVersion": "확장 프로그램 v{{version}}",
|
||||
"extensionVersionHint": "확장 프로그램 버전",
|
||||
"daemonVersionHint": "bsk 버전",
|
||||
"protocolVersion": "프로토콜 v{{version}}",
|
||||
"extensionProtocol": "확장 프로그램 프로토콜 v{{version}}",
|
||||
"daemonProtocol": "데몬 프로토콜 v{{version}}",
|
||||
"instanceTitle": "인스턴스 ID",
|
||||
"instanceDescription": "이 브라우저의 고유 연결 식별자",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"copyInstanceId": "인스턴스 ID 복사",
|
||||
"record": {
|
||||
"sectionTitle": "작업 기록",
|
||||
"cardDesc": "에이전트가 참고할 수 있도록 작업 기록",
|
||||
"sectionHint": "기록 지침을 생성합니다. 에이전트에게 보내 작업 기록을 시작하면, 에이전트가 생성된 trace 번들을 참고하여 유사한 작업을 자동화할 수 있습니다.",
|
||||
"topicLabel": "작업 주제(선택 사항)",
|
||||
"topicPlaceholder": "예: 게시물 작성",
|
||||
"startUrlLabel": "시작 URL(선택 사항)",
|
||||
"startUrlPlaceholder": "https://…",
|
||||
"copyButton": "기록 지침 복사",
|
||||
"copied": "복사됨",
|
||||
"hintDisconnected": "연결 후 사용할 수 있습니다",
|
||||
"promptTemplate": "BrowserSkill의 `bsk` CLI를 사용해 지금부터 제가 수행할 브라우저 작업을 기록하세요.\n\n단계:\n1. 다음을 실행하세요: {{command}}\n2. 기록이 시작됐다고 알려주면, 열린 창에서 제가 직접 작업하겠습니다.\n3. 제가 완료를 누른 후 생성된 `./trace` 번들(`trace.json` + `states/`)을 읽고 제가 수행한 작업을 요약하세요."
|
||||
}
|
||||
},
|
||||
"controlOverlay": {
|
||||
"status": "에이전트가 제어 중",
|
||||
"interrupt": "중단",
|
||||
"interrupting": "중단 중…"
|
||||
},
|
||||
"borrowConfirmation": {
|
||||
"title": "탭 사용을 허용할까요?",
|
||||
"titleInactive": "에이전트가 탭 사용을 요청했습니다",
|
||||
"body": "AI 에이전트가 이 탭을 작업 공간으로 옮기려고 합니다. 요청을 거절하거나 허용할 수 있습니다.",
|
||||
"targetTab": "대상 탭:",
|
||||
"autoDeny": "{{count}}초 후 요청이 만료됩니다",
|
||||
"deny": "거절",
|
||||
"allow": "허용",
|
||||
"notificationTitle": "BrowserSkill: 에이전트가 탭 사용을 요청했습니다",
|
||||
"notificationBody": "브라우저에서 \"{{tabTitle}}\" 탭 사용 요청을 허용하거나 거절하세요."
|
||||
},
|
||||
"helpRequest": {
|
||||
"title": "에이전트가 도움을 요청했습니다",
|
||||
"compactStatus": "이 단계를 완료해 주세요. 에이전트가 기다리고 있습니다.",
|
||||
"noteLabel": "에이전트에게 남길 메모(선택 사항)",
|
||||
"notePlaceholder": "에이전트에게 메모 남기기(선택 사항)",
|
||||
"continue": "완료하고 제어권 돌려주기",
|
||||
"cancel": "요청 취소",
|
||||
"collapse": "접기",
|
||||
"expand": "펼치기",
|
||||
"dragHandle": "드래그하여 이동",
|
||||
"notificationTitle": "BrowserSkill: 에이전트가 도움을 요청했습니다"
|
||||
},
|
||||
"recordOverlay": {
|
||||
"recording": "작업 기록 중",
|
||||
"finish": "완료"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type StorageItems = Record<string, unknown>;
|
||||
type StorageChanges = Record<string, { newValue?: unknown }>;
|
||||
|
||||
async function loadI18n(browserLanguage: string | null = "ko", navigatorLanguage = "en-US") {
|
||||
const get = vi.fn<(key: string, callback: (items: StorageItems) => void) => void>();
|
||||
const set = vi.fn();
|
||||
const addListener = vi.fn<(callback: (changes: StorageChanges, area: string) => void) => void>();
|
||||
vi.stubGlobal("navigator", { language: navigatorLanguage, languages: [navigatorLanguage] });
|
||||
vi.stubGlobal("chrome", {
|
||||
i18n: browserLanguage === null ? undefined : { getUILanguage: () => browserLanguage },
|
||||
storage: { local: { get, set }, onChanged: { addListener } },
|
||||
});
|
||||
const { default: i18n } = await import("../src/i18n");
|
||||
return { i18n, set, restore: get.mock.calls[0][1], change: addListener.mock.calls[0][0] };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.resetModules());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("shipped language detection", () => {
|
||||
it.each([
|
||||
"ko",
|
||||
"ko-KR",
|
||||
"KO-kr",
|
||||
"ko_KR",
|
||||
])("loads both Korean namespaces for Chrome UI language %s", async (language) => {
|
||||
const { i18n } = await loadI18n(language);
|
||||
expect(i18n.resolvedLanguage).toBe("ko-KR");
|
||||
expect(i18n.t("app.description")).toContain("AI 에이전트");
|
||||
expect(i18n.t("popup.stateLabel.connected", { ns: "extension" })).toBe("연결됨");
|
||||
});
|
||||
|
||||
it("uses navigator when Chrome UI language is unavailable", async () => {
|
||||
const { i18n } = await loadI18n(null, "ko");
|
||||
expect(i18n.resolvedLanguage).toBe("ko-KR");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["en-GB", "en-US", "Connected"],
|
||||
["zh-Hant", "zh-CN", "已连接"],
|
||||
["fr-FR", "en-US", "Connected"],
|
||||
])("preserves the fallback for %s", async (language, resolved, label) => {
|
||||
const { i18n } = await loadI18n(language);
|
||||
expect(i18n.resolvedLanguage).toBe(resolved);
|
||||
expect(i18n.t("popup.stateLabel.connected", { ns: "extension" })).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stored language synchronization", () => {
|
||||
it.each(["ko", "KO-kr", "ko_KR"])("restores %s as Korean", async (language) => {
|
||||
const { i18n, restore } = await loadI18n("en-US");
|
||||
restore({ i18nextLng: language });
|
||||
expect(i18n.language).toBe("ko-KR");
|
||||
expect(i18n.resolvedLanguage).toBe("ko-KR");
|
||||
});
|
||||
|
||||
it("normalizes cross-context changes and avoids echo writes", async () => {
|
||||
const { i18n, change, set } = await loadI18n("en-US");
|
||||
change({ i18nextLng: { newValue: "ko" } }, "local");
|
||||
expect(i18n.resolvedLanguage).toBe("ko-KR");
|
||||
expect(set).toHaveBeenCalledExactlyOnceWith({ i18nextLng: "ko-KR" });
|
||||
|
||||
change({ i18nextLng: { newValue: "ko-KR" } }, "local");
|
||||
change({ i18nextLng: { newValue: "ko" } }, "local");
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps Korean when a saved alias matches the detected language", async () => {
|
||||
const { i18n, restore, set } = await loadI18n("ko-KR");
|
||||
restore({ i18nextLng: "ko" });
|
||||
expect(i18n.resolvedLanguage).toBe("ko-KR");
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a stored preference over the browser language", async () => {
|
||||
const { i18n, restore } = await loadI18n();
|
||||
restore({ i18nextLng: "en-GB" });
|
||||
expect(i18n.resolvedLanguage).toBe("en-US");
|
||||
});
|
||||
|
||||
it("ignores missing or invalid values and unrelated storage changes", async () => {
|
||||
const { i18n, restore, change, set } = await loadI18n();
|
||||
restore({});
|
||||
restore({ i18nextLng: 42 });
|
||||
change({ i18nextLng: {} }, "local");
|
||||
change({ i18nextLng: { newValue: null } }, "local");
|
||||
change({ other: { newValue: "en-US" } }, "local");
|
||||
change({ i18nextLng: { newValue: "en-US" } }, "sync");
|
||||
expect(i18n.resolvedLanguage).toBe("ko-KR");
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function flattenMessages(resource: Record<string, unknown>, prefix = ""): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(resource).flatMap(([key, value]) =>
|
||||
typeof value === "string"
|
||||
? [[prefix + key, value]]
|
||||
: Object.entries(flattenMessages(value as Record<string, unknown>, `${prefix}${key}.`)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function interpolationVariables(message: string): string[] {
|
||||
return [...message.matchAll(/{{\s*([^{}]+?)\s*}}/g)].map((match) => match[1]).sort();
|
||||
}
|
||||
|
||||
describe("shipped translations", () => {
|
||||
it.each([
|
||||
"common",
|
||||
"extension",
|
||||
])("keeps %s keys and interpolation variables aligned", async (ns) => {
|
||||
const { i18n } = await loadI18n();
|
||||
const english = flattenMessages(i18n.getResourceBundle("en-US", ns));
|
||||
for (const language of Object.keys(i18n.options.resources ?? {})) {
|
||||
const translated = flattenMessages(i18n.getResourceBundle(language, ns));
|
||||
expect(Object.keys(translated).sort(), `${language}/${ns}`).toEqual(
|
||||
Object.keys(english).sort(),
|
||||
);
|
||||
for (const [key, message] of Object.entries(english)) {
|
||||
expect(translated[key].trim(), `${language}/${ns}/${key}`).not.toBe("");
|
||||
expect(interpolationVariables(translated[key]), `${language}/${ns}/${key}`).toEqual(
|
||||
interpolationVariables(message),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
chromeUiLanguageDetector,
|
||||
createLanguageNormalizer,
|
||||
getLanguageDetectionOptions,
|
||||
} from "../src/chrome-storage-sync";
|
||||
import i18n from "../src/i18n";
|
||||
|
||||
/** What `packages/i18n` ships today — kept in sync with `resources` in i18n.ts. */
|
||||
const SHIPPED_RESOURCE_KEYS = ["zh-CN", "en-US"];
|
||||
const SHIPPED_RESOURCE_KEYS = Object.keys(i18n.options.resources ?? {});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
/**
|
||||
* Locks the locale-resolution table from issue #168: the UI must follow the
|
||||
@@ -24,6 +26,11 @@ describe("createLanguageNormalizer", () => {
|
||||
["en-CN", "en-US"],
|
||||
["en-AU", "en-US"],
|
||||
|
||||
["ko", "ko-KR"],
|
||||
["ko-KR", "ko-KR"],
|
||||
["KO-kr", "ko-KR"],
|
||||
["ko_KR", "ko-KR"],
|
||||
|
||||
// Chinese — only `zh-CN` ships today, so every variant resolves to it
|
||||
// directly rather than leaking into the fallback chain. Traditional picks
|
||||
// up `zh-TW` automatically once that bundle is registered (see below).
|
||||
@@ -64,9 +71,9 @@ describe("createLanguageNormalizer", () => {
|
||||
*/
|
||||
describe("createLanguageNormalizer extensibility", () => {
|
||||
it("auto-resolves a newly registered language", () => {
|
||||
const normalize = createLanguageNormalizer(["en-US", "zh-CN", "ko-KR"]);
|
||||
expect(normalize("ko")).toBe("ko-KR");
|
||||
expect(normalize("ko-KR")).toBe("ko-KR");
|
||||
const normalize = createLanguageNormalizer([...SHIPPED_RESOURCE_KEYS, "ja-JP"]);
|
||||
expect(normalize("ja")).toBe("ja-JP");
|
||||
expect(normalize("ja-JP")).toBe("ja-JP");
|
||||
});
|
||||
|
||||
it("disambiguates zh once a Traditional bundle ships", () => {
|
||||
|
||||
Reference in New Issue
Block a user