Merge pull request #161 from dr-coton/feat/korean-localization

feat(i18n): add Korean localization
This commit is contained in:
Zhang GH
2026-09-10 16:06:34 +08:00
committed by GitHub
11 changed files with 363 additions and 18 deletions
+3
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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", () => {
+46
View File
@@ -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.
+13 -8
View File
@@ -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);
});
}
+6
View File
@@ -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": "완료"
}
}
+130
View File
@@ -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),
);
}
}
});
});
+13 -6
View File
@@ -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", () => {