chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+23 -1
View File
@@ -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'))
})
})
+82 -4
View File
@@ -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([])
}
)
})
+88
View File
@@ -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'))
})
})