From 3f44b45fa20b4cdf82cd127a811b62a152b4964b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 22 May 2026 00:06:20 +0900 Subject: [PATCH] feat(i18n): wire initI18n into production plugin startup 6ffea1bc3 added i18n with en/zh locales and plugin config support, but the initI18n() call lived in the original src/index.ts. When src/index.ts became an 18-line wrapper that delegates to src/testing/create-plugin-module.ts createPluginModule(), the call site was dropped on the floor. Result: i18n.locale config and LANG env both ignored at runtime, every toast stayed English regardless of user setting. Inject initI18n as a managed dependency and call it in createPluginModule() immediately after loadPluginConfig(), passing pluginConfig.i18n?.locale through. Add an integration test that boots the plugin with i18n.locale='zh' and asserts getLocale() returns 'zh' and t('toast.task_completed') returns the Chinese string. Regression locked - subsequent moves of the startup path will fail loudly. --- src/testing/create-plugin-module.test.ts | 103 +++++++++++++++++++++++ src/testing/create-plugin-module.ts | 4 + 2 files changed, 107 insertions(+) create mode 100644 src/testing/create-plugin-module.test.ts diff --git a/src/testing/create-plugin-module.test.ts b/src/testing/create-plugin-module.test.ts new file mode 100644 index 000000000..94163ee3f --- /dev/null +++ b/src/testing/create-plugin-module.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" +import { getLocale, initI18n, t } from "../shared/i18n" +import { createPluginModule } from "./create-plugin-module" + +const mockInitConfigContext = mock(() => {}) +const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null, allPlugins: [] })) +const mockGetSkillPluginConflictWarning = mock(() => "") +const mockInjectServerAuthIntoClient = mock(() => {}) +const mockLogLegacyPluginStartupWarning = mock(() => {}) +const mockMigrateLegacyWorkspaceDirectory = mock(() => ({ migrated: false, skipped: [] })) +const mockLoadPluginConfig = mock(() => ({})) +const mockIsTmuxIntegrationEnabled = mock( + (pluginConfig: { tmux?: { enabled?: boolean } | undefined }) => pluginConfig.tmux?.enabled ?? false, +) +const mockCreateRuntimeTmuxConfig = mock(() => ({ + enabled: false, + layout: "tiled" as const, + main_pane_size: 60, + main_pane_min_width: 80, + agent_pane_min_width: 40, + isolation: "inline" as const, +})) +const mockCreateManagers = mock(() => ({ + backgroundManager: { shutdown: async () => {} }, + skillMcpManager: { disconnectAll: async () => {} }, + configHandler: async () => {}, +})) +const mockCreateTools = mock(async () => ({ + mergedSkills: [], + availableSkills: [], + filteredTools: {}, +})) +const mockCreateHooks = mock(() => ({ + disposeHooks: () => {}, + compactionContextInjector: undefined, + compactionTodoPreserver: undefined, + claudeCodeHooks: undefined, +})) +const mockCreatePluginInterface = mock(() => ({})) +const mockInitializeOpenClaw = mock(async () => {}) +const mockStartTmuxCheck = mock(() => {}) +const mockInstallAgentSortShim = mock(() => {}) +const mockSetAgentSortOrder = mock(() => {}) +const mockLog = mock(() => {}) +const mockCreateModelCacheState = mock(() => ({})) +const mockCreateFirstMessageVariantGate = mock(() => ({ + shouldOverride: () => false, + markApplied: () => {}, + markSessionCreated: () => {}, + clear: () => {}, +})) + +function createTestPluginModule(): ReturnType { + return createPluginModule({ + initConfigContext: mockInitConfigContext, + detectExternalSkillPlugin: mockDetectExternalSkillPlugin, + getSkillPluginConflictWarning: mockGetSkillPluginConflictWarning, + injectServerAuthIntoClient: mockInjectServerAuthIntoClient, + logLegacyPluginStartupWarning: mockLogLegacyPluginStartupWarning, + migrateLegacyWorkspaceDirectory: mockMigrateLegacyWorkspaceDirectory, + loadPluginConfig: mockLoadPluginConfig as never, + isTmuxIntegrationEnabled: mockIsTmuxIntegrationEnabled as never, + createRuntimeTmuxConfig: mockCreateRuntimeTmuxConfig as never, + createManagers: mockCreateManagers as never, + createTools: mockCreateTools as never, + createHooks: mockCreateHooks as never, + createPluginInterface: mockCreatePluginInterface as never, + initializeOpenClaw: mockInitializeOpenClaw as never, + startTmuxCheck: mockStartTmuxCheck, + installAgentSortShim: mockInstallAgentSortShim, + setAgentSortOrder: mockSetAgentSortOrder, + log: mockLog, + createModelCacheState: mockCreateModelCacheState as never, + createFirstMessageVariantGate: mockCreateFirstMessageVariantGate as never, + }) +} + +describe("createPluginModule()", () => { + beforeEach(() => { + mockLoadPluginConfig.mockClear() + initI18n({ locale: "en", fallback: "en" }) + }) + + describe("#given plugin config sets i18n.locale to zh", () => { + it("#then production startup applies the configured locale", async () => { + // given + const pluginModule = createTestPluginModule() + mockLoadPluginConfig.mockReturnValue({ + i18n: { locale: "zh" }, + }) + + // when + await pluginModule.server({ + directory: "/tmp/project", + client: {}, + } as Parameters[0]) + + // then + expect(getLocale()).toBe("zh") + expect(t("toast.task_completed")).toBe("任务完成") + }) + }) +}) diff --git a/src/testing/create-plugin-module.ts b/src/testing/create-plugin-module.ts index 684890976..b7f58bcda 100644 --- a/src/testing/create-plugin-module.ts +++ b/src/testing/create-plugin-module.ts @@ -18,6 +18,7 @@ import { import { installAgentSortShim, setAgentSortOrder } from "../shared/agent-sort-shim" import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "../shared/external-plugin-detector" import { createFirstMessageVariantGate } from "../shared/first-message-variant" +import { initI18n } from "../shared/i18n" import { log } from "../shared/logger" import { logLegacyPluginStartupWarning } from "../shared/log-legacy-plugin-startup-warning" import { migrateLegacyWorkspaceDirectory } from "../shared/legacy-workspace-migration" @@ -39,6 +40,7 @@ export type PluginModuleDeps = { getSkillPluginConflictWarning: typeof getSkillPluginConflictWarning injectServerAuthIntoClient: typeof injectServerAuthIntoClient loadPluginConfig: typeof loadPluginConfig + initI18n: typeof initI18n initializeOpenClaw: typeof initializeOpenClaw isTmuxIntegrationEnabled: typeof isTmuxIntegrationEnabled startTmuxCheck: typeof startTmuxCheck @@ -62,6 +64,7 @@ const defaultPluginModuleDeps: PluginModuleDeps = { getSkillPluginConflictWarning, injectServerAuthIntoClient, loadPluginConfig, + initI18n, initializeOpenClaw, isTmuxIntegrationEnabled, startTmuxCheck, @@ -93,6 +96,7 @@ export function createPluginModule(overrides: Partial = {}): P deps.injectServerAuthIntoClient(input.client) const pluginConfig = deps.loadPluginConfig(input.directory, input) + deps.initI18n(pluginConfig.i18n?.locale ? { locale: pluginConfig.i18n.locale } : undefined) deps.setAgentSortOrder(pluginConfig.agent_order) if (pluginConfig.openclaw) {