Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ const withMakaRoot: Decorator = (Story, context) => {
? 'en'
: context.globals.locale === 'zh-TW'
? 'zh-TW'
: 'zh-CN';
: context.globals.locale === 'ko'
? 'ko'
: 'zh-CN';

root.classList.toggle('dark', colorScheme === 'dark');
root.style.colorScheme = colorScheme;
Expand Down Expand Up @@ -103,6 +105,7 @@ const preview: Preview = {
items: [
{ title: '简体中文', value: 'zh-CN' },
{ title: '繁體中文', value: 'zh-TW' },
{ title: '한국어', value: 'ko' },
{ title: 'English', value: 'en' },
],
},
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ async function seedE2eConnection(userDataDir: string): Promise<void> {
}
}

async function seedE2eLocale(userDataDir: string, locale: 'zh-CN' | 'zh-TW' | 'en'): Promise<void> {
async function seedE2eLocale(userDataDir: string, locale: 'zh-CN' | 'zh-TW' | 'en' | 'ko'): Promise<void> {
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
await createSettingsStore(workspaceRoot).update({
personalization: { uiLocale: locale },
Expand Down Expand Up @@ -408,7 +408,7 @@ export async function withE2eWindow(
seed: boolean;
readinessSelector: string;
e2eFixtureScenario?: string;
locale?: 'zh-CN' | 'zh-TW' | 'en';
locale?: 'zh-CN' | 'zh-TW' | 'en' | 'ko';
/** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */
platform?: 'darwin' | 'win32' | 'linux';
/** Show fixtures whose contract depends on compositor-paced frames. */
Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/e2e/ko-locale.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { ensureSidebarExpanded, expect, test } from './fixtures';

test('persists Korean locale preference through reload', async ({ window: page }) => {
await ensureSidebarExpanded(page);
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await expect(page.getByRole('main', { name: 'Settings content' })).toBeVisible();
await page.getByRole('button', { name: 'General', exact: true }).click();
// The Settings → General row labels itself `interfaceLanguage`
// (`Interface language` in en) — main-process confirmation copy uses
// `UI language`, which is not on this surface.
await expect(page.getByText('Interface language', { exact: true }).first()).toBeVisible();
await page.keyboard.press('Escape');

await page.evaluate(async () => {
await window.maka.settings.update({ personalization: { uiLocale: 'ko' } });
});
await page.reload();
await page.waitForSelector('.maka-composer-editor');
await ensureSidebarExpanded(page);

const locale = await page.evaluate(async () => {
const settings = await window.maka.settings.read();
return settings.personalization.uiLocale;
});
expect(locale).toBe('ko');

// The preference must not only persist — it has to be applied. Core locale
// resolution surfaces the resolved locale on the document root, so assert it
// actually took effect instead of just being stored.
await expect(page.locator('html')).toHaveAttribute('lang', 'ko');
await expect(page.locator('html')).toHaveAttribute('data-maka-locale', 'ko');

// Renderer chrome labels are English stubs by design in this PR (the real
// renderer catalogs land with the renderer slices #3977–#3979). Keep a smoke
// check that Settings → General still renders after the switch so a future
// real Korean translation flips this assertion instead of silently passing.
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByRole('button', { name: 'General', exact: true }).click();
await expect(page.getByRole('main', { name: 'Settings content' })).toBeVisible();
});
94 changes: 88 additions & 6 deletions apps/desktop/src/main/__tests__/bot-chat-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type { EventEmitter } from 'node:events';
import { pathToFileURL } from 'node:url';
import { createDefaultBotChannel } from '@maka/core/bot-chat-settings';
import { MAX_ALLOWED_USER_IDS, createDefaultSettings } from '@maka/core/settings';
import { BotRegistry, SlackBotBridge, WechatBridge, type BotStatus } from '@maka/runtime/bots';
import { BotRegistry, SlackBotBridge, WechatBridge, type BotStatus, type BotTestErrorCode } from '@maka/runtime/bots';
import { UI_LOCALES, type UiCatalog, type UiLocale } from '@maka/core/ui-locale';
import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui';
import { build } from 'esbuild';
Expand Down Expand Up @@ -100,12 +100,94 @@ const expectedCopy = {
[5, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol and 2 more'],
],
},
ko: {
help: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored.',
cappedHelp: 'Telegram user IDs are 64-bit integers. When set, only messages from these IDs are accepted; all others are silently ignored. (limit reached)',
warnings: [
[1, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice'],
[3, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol'],
[4, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol and 1 more'],
[5, 'These entries are not numeric IDs and may be usernames, so they will not match anyone: @alice, @bob, @carol and 2 more'],
],
},
} satisfies UiCatalog<{
help: string;
cappedHelp: string;
warnings: [number, string][];
}>;

// Spelled out, not read back via `getBotSettingsCopy(locale)`: an expectation
// pulled from the same catalog the component renders can only prove the lookup
// ran, so these literals are the only real check on the copy that comes out.
const assertedTestErrorCodes = [
'slack_tokens_missing',
'wecom_credentials_missing',
'dingtalk_credentials_missing',
'qq_credentials_missing',
'wechat_bridge_url_invalid',
'wechat_ilink_credentials_incomplete',
] as const satisfies readonly BotTestErrorCode[];

const expectedStatusCopy = {
'zh-CN': {
'slack-disconnected': 'Slack 连接已断开,正在等待重新连接',
network_error: '网络错误,请检查网络和代理设置',
detailsInLogs: '运行态详情请见日志',
testErrors: {
slack_tokens_missing: '请填写 Slack Bot Token 和 App-Level Token 后再测试。',
wecom_credentials_missing: '请填写企业微信 Bot ID 和 Secret 后再测试。',
dingtalk_credentials_missing: '请填写钉钉 Client ID(AppKey)和 Client Secret 后再测试。',
qq_credentials_missing: '请填写 QQ App ID 和 AppSecret 后再测试。',
wechat_bridge_url_invalid: '微信本地桥接只允许访问本机 wechat-bridge,不能指向远端 URL。',
wechat_ilink_credentials_incomplete: '请先完成微信扫码登录,保存 iLink bot token 与 base URL。',
},
},
'zh-TW': {
'slack-disconnected': 'Slack 連線已中斷,正在等待重新連線',
network_error: '網路錯誤,請檢查網路和代理設定',
detailsInLogs: '執行狀態詳情請見記錄',
testErrors: {
slack_tokens_missing: '請填寫 Slack Bot Token 和 App-Level Token 後再測試。',
wecom_credentials_missing: '請填寫企業微信 Bot ID 和 Secret 後再測試。',
dingtalk_credentials_missing: '請填寫釘釘 Client ID(AppKey)和 Client Secret 後再測試。',
qq_credentials_missing: '請填寫 QQ App ID 和 AppSecret 後再測試。',
wechat_bridge_url_invalid: '微信本機橋接只允許存取本機 wechat-bridge,不能指向遠端 URL。',
wechat_ilink_credentials_incomplete: '請先完成微信掃碼登入,儲存 iLink bot token 與 base URL。',
},
},
ko: {
'slack-disconnected': 'Slack disconnected; waiting to reconnect',
network_error: 'Network error. Check the network and proxy settings',
detailsInLogs: 'See logs for runtime details',
testErrors: {
slack_tokens_missing: 'Enter a Slack Bot Token and App-Level Token before testing the connection.',
wecom_credentials_missing: 'Enter a WeCom Bot ID and Secret before testing the connection.',
dingtalk_credentials_missing: 'Enter a DingTalk Client ID (AppKey) and Client Secret before testing the connection.',
qq_credentials_missing: 'Enter a QQ App ID and AppSecret before testing the connection.',
wechat_bridge_url_invalid: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.',
wechat_ilink_credentials_incomplete: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.',
},
},
en: {
'slack-disconnected': 'Slack disconnected; waiting to reconnect',
network_error: 'Network error. Check the network and proxy settings',
detailsInLogs: 'See logs for runtime details',
testErrors: {
slack_tokens_missing: 'Enter a Slack Bot Token and App-Level Token before testing the connection.',
wecom_credentials_missing: 'Enter a WeCom Bot ID and Secret before testing the connection.',
dingtalk_credentials_missing: 'Enter a DingTalk Client ID (AppKey) and Client Secret before testing the connection.',
qq_credentials_missing: 'Enter a QQ App ID and AppSecret before testing the connection.',
wechat_bridge_url_invalid: 'The local WeChat bridge only accepts the local wechat-bridge, not a remote URL.',
wechat_ilink_credentials_incomplete: 'Complete WeChat QR sign-in first to save the iLink bot token and base URL.',
},
},
} satisfies UiCatalog<{
'slack-disconnected': string;
network_error: string;
detailsInLogs: string;
testErrors: Record<(typeof assertedTestErrorCodes)[number], string>;
}>;

for (const locale of UI_LOCALES) {
const expected = expectedCopy[locale];
for (const [count, warning] of expected.warnings) {
Expand Down Expand Up @@ -194,30 +276,30 @@ test('real bridge failures render localized detail and overview output in all lo
await slack.start();
assert.ok(socket);
socket.emit('disconnected');
statuses.push([slack.getStatus(), ['Slack 连接已断开,正在等待重新连接', 'Slack 連線已中斷,正在等待重新連線', 'Slack disconnected; waiting to reconnect']]);
statuses.push([slack.getStatus(), UI_LOCALES.map((locale) => expectedStatusCopy[locale]['slack-disconnected'])]);
await slack.stop();
auth.mock.mockImplementation(async () => { throw new Error('Network error bot-secret app-secret'); });
await assert.rejects(slack.start());
assert.equal(slack.getStatus().reason, 'network_error');
statuses.push([slack.getStatus(), UI_LOCALES.map((locale) => getBotSettingsCopy(locale).statusReasons.codes.network_error)]);
statuses.push([slack.getStatus(), UI_LOCALES.map((locale) => expectedStatusCopy[locale].network_error)]);
const diagnostic = log.mock.calls.map((call) => call.arguments.join(' ')).join('\n');
assert.match(diagnostic, /Network error \[redacted\] \[redacted\]/);
for (const [url, code] of [['https://remote.invalid', 'wechat_bridge_url_invalid'], ['https://ilinkai.weixin.qq.com', 'wechat_ilink_credentials_incomplete']] as const) {
const bridge = new WechatBridge({ ...createDefaultBotChannel('wechat'), enabled: true, webhookUrl: url });
await bridge.start();
assert.equal(bridge.getStatus().reason, code);
statuses.push([bridge.getStatus(), UI_LOCALES.map((locale) => getBotSettingsCopy(locale).testErrors[code])]);
statuses.push([bridge.getStatus(), UI_LOCALES.map((locale) => expectedStatusCopy[locale].testErrors[code])]);
}
for (const [provider, code] of [['slack', 'slack_tokens_missing'], ['wecom', 'wecom_credentials_missing'], ['dingtalk', 'dingtalk_credentials_missing'], ['qq', 'qq_credentials_missing']] as const) {
const registry = new BotRegistry({ onIncomingMessage() {}, onStatusChange() {} });
const settings = createDefaultSettings().botChat;
settings.channels[provider].enabled = true;
await registry.applySettings(settings);
statuses.push([registry.getStatus(provider), UI_LOCALES.map((locale) => getBotSettingsCopy(locale).testErrors[code])]);
statuses.push([registry.getStatus(provider), UI_LOCALES.map((locale) => expectedStatusCopy[locale].testErrors[code])]);
await registry.stopAll();
}
for (const reason of ['Network error', '外部错误 token=secret', 'constructor', '__proto__', 'toString', 'future-code']) {
statuses.push([{ ...slack.getStatus(), reason }, UI_LOCALES.map((locale) => getBotSettingsCopy(locale).status.detailsInLogs)]);
statuses.push([{ ...slack.getStatus(), reason }, UI_LOCALES.map((locale) => expectedStatusCopy[locale].detailsInLogs)]);
}
for (const [status, expected] of statuses) {
for (const [index, locale] of UI_LOCALES.entries()) {
Expand Down
12 changes: 10 additions & 2 deletions apps/desktop/src/main/__tests__/e2e-fixture-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolveE2eFixture } from '../e2e-fixture.js';

test('preserves both canonical Chinese locale fixture flags', () => {
for (const locale of ['zh-CN', 'zh-TW', 'en'] as const) {
test('preserves canonical Chinese and Korean locale fixture flags', () => {
for (const locale of ['zh-CN', 'zh-TW', 'en', 'ko'] as const) {
const fixture = resolveE2eFixture(
'settings-general',
false,
Expand All @@ -39,6 +39,14 @@ test('normalizes locale fixture flag casing without widening the contract', () =
resolveE2eFixture('settings-general', false, undefined, undefined, 'ZH-tw')?.locale,
'zh-TW',
);
assert.equal(
resolveE2eFixture('settings-general', false, undefined, undefined, 'KO')?.locale,
'ko',
);
assert.equal(
resolveE2eFixture('settings-general', false, undefined, undefined, 'ko-kr')?.locale,
'ko',
);
assert.equal(
resolveE2eFixture('settings-general', false, undefined, undefined, 'zh-Hant')?.locale,
null,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/__tests__/health-center-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ test('capability reason codes have copy in every locale, unknown reasons keep th
'zh-CN': '状态详情请见对应设置页。',
'zh-TW': '狀態詳細資料請參閱對應的設定頁。',
en: 'See the corresponding settings page for details.',
ko: 'See the corresponding settings page for details.',
} satisfies UiCatalog<string>;
assert.equal(getCapabilityReasonCopy('zh-CN').platform_credentials_missing, '未配置平台凭据');
for (const locale of UI_LOCALES) {
Expand Down
106 changes: 106 additions & 0 deletions apps/desktop/src/main/__tests__/native-locale-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import test from 'node:test';
import { clientSettingsConfirmation } from '../client-settings-confirmation-copy.js';
import { getNativeDiagnosticDialogCopy } from '../native-diagnostic-dialog-copy.js';
import { getPermissionOverlayCopy } from '../permission-overlay/permission-overlay-copy.js';
import { buildRuntimeHostActiveQuitDialog } from '../runtime-host-quit-copy.js';
import { renderStartupProgressHtml } from '../startup-progress-window.js';

function stringify(value: unknown): string {
return JSON.stringify(value, (_key, item) =>
typeof item === 'function' ? item.toString() : item,
);
}

test('native copy ships real Korean for every translated surface', () => {
const surfaces: ReadonlyArray<{
name: string;
en: () => unknown;
ko: () => unknown;
}> = [
{
name: 'diagnostic dialog',
en: () => getNativeDiagnosticDialogCopy('en'),
ko: () => getNativeDiagnosticDialogCopy('ko'),
},
{
name: 'permission overlay',
en: () => getPermissionOverlayCopy('en', 'screen_recording'),
ko: () => getPermissionOverlayCopy('ko', 'screen_recording'),
},
{
name: 'runtime host quit dialog',
en: () => buildRuntimeHostActiveQuitDialog('en'),
ko: () => buildRuntimeHostActiveQuitDialog('ko'),
},
{
name: 'startup progress window',
en: () => renderStartupProgressHtml('en', false),
ko: () => renderStartupProgressHtml('ko', false),
},
{
name: 'client settings confirmation',
en: () =>
clientSettingsConfirmation(
[{ key: 'keepSystemAwake', current: false, next: true }],
'en',
),
ko: () =>
clientSettingsConfirmation(
[{ key: 'keepSystemAwake', current: false, next: true }],
'ko',
),
},
];

for (const surface of surfaces) {
const en = stringify(surface.en());
const ko = stringify(surface.ko());
assert.match(
ko,
/[\uAC00-\uD7A3]/,
`${surface.name}: ko copy must contain Hangul`,
);
assert.notEqual(
ko,
en,
`${surface.name}: ko copy must differ from the en stub`,
);
}
});

test('client settings confirmation keeps the keepSystemAwake label non-reversed', () => {
const ko = clientSettingsConfirmation(
[{ key: 'keepSystemAwake', current: false, next: true }],
'ko',
);
assert.match(
ko.detail,
/시스템 절전 방지/,
'ko keepSystemAwake label must read 시스템 절전 방지 (prevent sleep), not 시스템 절전 모드 유지 (keep sleeping)',
);
assert.doesNotMatch(
ko.detail,
/시스템 절전 모드 유지/,
'ko keepSystemAwake label must not reverse the meaning to keep the system in sleep mode',
);
});
Loading