chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import i18n from '../../src/i18n'
|
||||
import { translateErrorMessage } from '../../src/i18n/format'
|
||||
import { translateErrorMessage, translateIpcError } from '../../src/i18n/format'
|
||||
|
||||
describe('translateErrorMessage — SFTP real-device phrases', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -34,6 +34,13 @@ describe('translateErrorMessage — SFTP real-device phrases', () => {
|
||||
'建立远程端口转发失败: 远端拒绝监听特权端口 80(需 root 或改用 ≥1024;另请确认 AllowTcpForwarding / PermitListen,且端口未被占用)。可用命令行核对:ssh -R 80:127.0.0.1:<本地端口> user@host',
|
||||
'errors:forward.remoteDeniedPrivileged',
|
||||
],
|
||||
['closed by user', 'errors:connection.unexpectedDropped'],
|
||||
['connection closed', 'errors:connection.unexpectedDropped'],
|
||||
['keepalive failed', 'errors:connection.keepaliveFailed'],
|
||||
['shell.idleTimeout', 'errors:connection.idleKicked'],
|
||||
['shell.exited', 'errors:connection.shellExited'],
|
||||
['命令已终止', 'ai:step.terminated'],
|
||||
['command cancelled', 'ai:step.terminated'],
|
||||
])('maps %s', (raw, key) => {
|
||||
expect(translateErrorMessage(raw)).toBe(i18n.t(key))
|
||||
})
|
||||
@@ -42,3 +49,18 @@ describe('translateErrorMessage — SFTP real-device phrases', () => {
|
||||
expect(translateErrorMessage('weird native blip 0xdead')).toBe('weird native blip 0xdead')
|
||||
})
|
||||
})
|
||||
|
||||
describe('translateIpcError', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
})
|
||||
|
||||
it('maps a stable errorCode before the raw phrase', () => {
|
||||
expect(translateIpcError('ignored', 'native.taskJoin')).toBe(i18n.t('errors:native.taskJoin'))
|
||||
expect(translateIpcError('native.invalidBase64')).toBe(i18n.t('errors:native.invalidBase64'))
|
||||
})
|
||||
|
||||
it('falls back to phrase matching when the code is unknown', () => {
|
||||
expect(translateIpcError('Permission denied', 'not.a.real.code')).toBe(i18n.t('errors:sftp.permissionDenied'))
|
||||
})
|
||||
})
|
||||
@@ -29,16 +29,94 @@ describe('i18n locale key parity', () => {
|
||||
for (const namespace of I18N_NAMESPACES) {
|
||||
for (const language of APP_LANGUAGES) {
|
||||
it(`${language} keys match en for ${namespace}`, async () => {
|
||||
const [locale, en] = await Promise.all([
|
||||
loadLocale(namespace, language),
|
||||
loadLocale(namespace, 'en'),
|
||||
])
|
||||
const [locale, en] = await Promise.all([loadLocale(namespace, language), loadLocale(namespace, 'en')])
|
||||
|
||||
const localeKeys = flattenKeys(locale).sort()
|
||||
const enKeys = flattenKeys(en).sort()
|
||||
|
||||
expect(localeKeys).toEqual(enKeys)
|
||||
|
||||
for (const key of localeKeys) {
|
||||
const value = key.split('.').reduce<unknown>((node, part) => {
|
||||
if (node && typeof node === 'object' && part in node) {
|
||||
return (node as Record<string, unknown>)[part]
|
||||
}
|
||||
return undefined
|
||||
}, locale)
|
||||
if (typeof value === 'string') {
|
||||
expect(value.trim().length, `${language}:${namespace}.${key}`).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function flattenValues(value: unknown, prefix = ''): Record<string, string> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return prefix && typeof value === 'string' ? { [prefix]: value } : {}
|
||||
}
|
||||
|
||||
const out: Record<string, string> = {}
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
const next = prefix ? `${prefix}.${key}` : key
|
||||
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
Object.assign(out, flattenValues(nested, next))
|
||||
} else if (typeof nested === 'string') {
|
||||
out[next] = nested
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Product names, URLs, and format tokens that stay the same in every locale. */
|
||||
const SETTINGS_ALLOW_SAME_AS_EN = new Set([
|
||||
'language.zhCN',
|
||||
'language.zhTW',
|
||||
'language.en',
|
||||
'language.ja',
|
||||
'language.ko',
|
||||
'language.vi',
|
||||
'language.ru',
|
||||
'nav.ssh',
|
||||
'about.appVersion',
|
||||
'about.githubDesc',
|
||||
'about.license',
|
||||
'about.licenseLinkDesc',
|
||||
'about.wechat',
|
||||
])
|
||||
|
||||
describe('settings copy is translated', () => {
|
||||
it.each(APP_LANGUAGES.filter((language) => language !== 'en'))(
|
||||
'%s does not leave English settings/about strings',
|
||||
async (language) => {
|
||||
const [locale, en] = await Promise.all([loadLocale('settings', language), loadLocale('settings', 'en')])
|
||||
const localeValues = flattenValues(locale)
|
||||
const enValues = flattenValues(en)
|
||||
const leftover = Object.keys(enValues).filter((key) => {
|
||||
if (SETTINGS_ALLOW_SAME_AS_EN.has(key)) return false
|
||||
return localeValues[key] === enValues[key]
|
||||
})
|
||||
expect(leftover, leftover.join(', ')).toEqual([])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
/** Product name and shared layout templates in Agent CLI. */
|
||||
const AI_ALLOW_SAME_AS_EN = new Set(['panel.title', 'panel.modelConfigured', 'panel.sshReady', 'step.confirmMessage'])
|
||||
|
||||
describe('ai copy is translated', () => {
|
||||
it.each(APP_LANGUAGES.filter((language) => language !== 'en'))(
|
||||
'%s does not leave English Agent CLI strings',
|
||||
async (language) => {
|
||||
const [locale, en] = await Promise.all([loadLocale('ai', language), loadLocale('ai', 'en')])
|
||||
const localeValues = flattenValues(locale)
|
||||
const enValues = flattenValues(en)
|
||||
const leftover = Object.keys(enValues).filter((key) => {
|
||||
if (AI_ALLOW_SAME_AS_EN.has(key)) return false
|
||||
return localeValues[key] === enValues[key]
|
||||
})
|
||||
expect(leftover, leftover.join(', ')).toEqual([])
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import i18n from '../../src/i18n'
|
||||
import { APP_LANGUAGES } from '../../src/i18n/detectLocale'
|
||||
import { translateIpcError } from '../../src/i18n/format'
|
||||
|
||||
/** User-visible keys added or changed in this round. Must resolve in every locale. */
|
||||
const ROUND_KEYS = [
|
||||
'errors:native.taskJoin',
|
||||
'errors:native.invalidBase64',
|
||||
'errors:native.knownHostsPath',
|
||||
'errors:native.invalidHostKeyDecision',
|
||||
'errors:connection.unexpectedDropped',
|
||||
'errors:connection.shellExited',
|
||||
'errors:connection.shellReadFailed',
|
||||
'common:error.bodyPane',
|
||||
'common:error.retryPane',
|
||||
'terminal:overlay.disconnectedKeepalive',
|
||||
'settings:ssh.sessionPolicyHint',
|
||||
'sftp:connection.autoReconnecting',
|
||||
'desktop:toolbar.reconnect',
|
||||
'desktop:status.reconnecting',
|
||||
'common:tab.menu.reconnect',
|
||||
'sessions:dialog.displayNamePlaceholderVnc',
|
||||
'sessions:importExport.exportFilterOxs',
|
||||
'sessions:importExport.importFilterOxs',
|
||||
'settings:nav.aboutDesc',
|
||||
'settings:about.wechat',
|
||||
'settings:about.copyWechat',
|
||||
'settings:about.resources',
|
||||
'settings:ai.apiKey',
|
||||
'ai:confirm.reject',
|
||||
'ai:confirm.once',
|
||||
'ai:confirm.always',
|
||||
'ai:confirm.hint',
|
||||
'ai:error.noProvider',
|
||||
'ai:error.execFailed',
|
||||
] as const
|
||||
|
||||
describe('new copy resolves in every locale', () => {
|
||||
afterEach(async () => {
|
||||
await i18n.changeLanguage('zh-CN')
|
||||
})
|
||||
|
||||
it.each(APP_LANGUAGES)('%s returns real text, not the key', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
for (const key of ROUND_KEYS) {
|
||||
const text = i18n.t(key)
|
||||
expect(text.trim().length, key).toBeGreaterThan(0)
|
||||
expect(text).not.toBe(key)
|
||||
expect(text).not.toBe(key.replace(/^[a-z]+:/, ''))
|
||||
}
|
||||
})
|
||||
|
||||
it.each(APP_LANGUAGES.filter((language) => language !== 'en'))(
|
||||
'%s does not leave English copy for native errors and pane fallback',
|
||||
async (language) => {
|
||||
await i18n.changeLanguage('en')
|
||||
const english = Object.fromEntries(ROUND_KEYS.map((key) => [key, i18n.t(key)]))
|
||||
await i18n.changeLanguage(language)
|
||||
for (const key of [
|
||||
'errors:native.taskJoin',
|
||||
'errors:native.invalidBase64',
|
||||
'errors:native.knownHostsPath',
|
||||
'errors:native.invalidHostKeyDecision',
|
||||
'errors:connection.unexpectedDropped',
|
||||
'terminal:overlay.disconnectedKeepalive',
|
||||
'settings:ssh.sessionPolicyHint',
|
||||
'common:error.bodyPane',
|
||||
'common:error.retryPane',
|
||||
'sessions:dialog.displayNamePlaceholderVnc',
|
||||
'settings:nav.aboutDesc',
|
||||
'settings:about.copyWechat',
|
||||
'settings:ai.apiKey',
|
||||
'ai:confirm.once',
|
||||
'ai:error.noProvider',
|
||||
'ai:error.execFailed',
|
||||
] as const) {
|
||||
expect(i18n.t(key), key).not.toBe(english[key])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(APP_LANGUAGES)('%s maps native.taskJoin through translateIpcError', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
expect(translateIpcError('ignored', 'native.taskJoin')).toBe(i18n.t('errors:native.taskJoin'))
|
||||
expect(translateIpcError('native.knownHostsPath')).toBe(i18n.t('errors:native.knownHostsPath'))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user