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

+10 -16
View File
@@ -10,13 +10,7 @@ import {
describe('AI provider presets grouping', () => {
it('matches the expected UI group order', () => {
expect(AI_PROVIDER_FAMILY_ORDER).toEqual([
'openai_compatible',
'standalone',
'plan',
'local',
'custom',
])
expect(AI_PROVIDER_FAMILY_ORDER).toEqual(['openai_compatible', 'standalone', 'plan', 'local', 'custom'])
expect(AI_PROVIDER_FAMILY_META.openai_compatible.label).toBe('OpenAI compatible')
expect(AI_PROVIDER_FAMILY_META.plan.label).toBe('Subscription plans')
expect(AI_PROVIDER_FAMILY_META.local.label).toBe('Self-hosted')
@@ -35,34 +29,34 @@ describe('AI provider presets grouping', () => {
})
it('lists Anthropic and Google Gemini after the separator group', () => {
expect(presetsByFamily('standalone').map((item) => item.name)).toEqual([
'Anthropic',
'Google Gemini',
])
expect(presetsByFamily('standalone').map((item) => item.name)).toEqual(['Anthropic', 'Google Gemini'])
})
it('lists Plan presets as GLM / MiniMax / Kimi', () => {
expect(presetsByFamily('plan').map((item) => item.name)).toEqual(['GLM', 'MiniMax', 'Kimi'])
expect(createProviderFromPreset('glm').defaultModelId).toBe('glm-5.2-plan')
it('lists Plan presets as GLM / MiniMax / Kimi / MiMo', () => {
expect(presetsByFamily('plan').map((item) => item.name)).toEqual(['GLM', 'MiniMax', 'Kimi', 'Xiaomi MiMo'])
expect(createProviderFromPreset('glm').defaultModelId).toBe('glm-5.3-plan')
expect(createProviderFromPreset('minimax-plan').defaultModelId).toBe('MiniMax-M3-plan')
expect(createProviderFromPreset('kimi-plan').defaultModelId).toBe('kimi-k3-plan')
expect(createProviderFromPreset('mimo-plan').defaultModelId).toBe('mimo-v2.5-pro-plan')
expect(createProviderFromPreset('mimo-plan').baseUrl).toBe('https://token-plan-cn.xiaomimimo.com/v1')
expect(createProviderFromPreset('zai').presetId).toBe('glm')
})
it('defaults DeepSeek to V4 Pro / Flash', () => {
const deepseek = createProviderFromPreset('deepseek')
expect(deepseek.defaultModelId).toBe('deepseek-v4-pro')
expect(deepseek.models.map((item) => item.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-flash'])
expect(deepseek.models.map((item) => item.model)).toEqual(['deepseek-v4-pro', 'deepseek-flash'])
})
it('uses current frontier model IDs from official catalogs', () => {
expect(createProviderFromPreset('openai').models.map((item) => item.model)).toEqual([
'gpt-6-astra',
'gpt-5.6-sol',
'gpt-5.6-terra',
'gpt-5.6-luna',
'gpt-5.5',
])
expect(createProviderFromPreset('anthropic').defaultModelId).toBe('claude-opus-5')
expect(createProviderFromPreset('anthropic').defaultModelId).toBe('claude-fable-5-1')
expect(createProviderFromPreset('google').defaultModelId).toBe('gemini-3.1-pro-preview')
expect(createProviderFromPreset('qwen').defaultModelId).toBe('qwen3.8-max')
expect(createProviderFromPreset('kimi').defaultModelId).toBe('kimi-k3')
+1 -1
View File
@@ -27,7 +27,7 @@ describe('AI entry points', () => {
const entry = screen.getByTestId('settings-tab-ai')
expect(entry).toBeVisible()
expect(entry).toHaveTextContent('大模型')
expect(entry).toHaveTextContent('Provider')
expect(entry).toHaveTextContent('供应商')
expect(entry).not.toHaveTextContent('模型与测试')
fireEvent.click(entry)
+3 -3
View File
@@ -32,7 +32,7 @@ describe('AI provider settings', () => {
const save = screen.getByRole('button', { name: '保存' })
expect(save).toBeDisabled()
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'sk-test-only' } })
fireEvent.change(screen.getByLabelText('API 密钥'), { target: { value: 'sk-test-only' } })
fireEvent.click(screen.getByRole('button', { name: '测试' }))
await waitFor(() => expect(mockDesktopApi.aiTestProvider).toHaveBeenCalled())
@@ -49,13 +49,13 @@ describe('AI provider settings', () => {
it('invalidates a successful test whenever a connection field changes', async () => {
render(<AiProviderSettings />)
fireEvent.click(screen.getByRole('button', { name: '添加' }))
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'sk-test-only' } })
fireEvent.change(screen.getByLabelText('API 密钥'), { target: { value: 'sk-test-only' } })
fireEvent.click(screen.getByRole('button', { name: '测试' }))
const save = screen.getByRole('button', { name: '保存' })
await waitFor(() => expect(save).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '高级' }))
fireEvent.change(screen.getByLabelText('Endpoint'), { target: { value: 'https://proxy.example.com/v1' } })
fireEvent.change(screen.getByLabelText('接口地址'), { target: { value: 'https://proxy.example.com/v1' } })
expect(save).toBeDisabled()
expect(screen.getByText('请重新测试')).toBeVisible()
})
+63 -2
View File
@@ -130,7 +130,7 @@ describe('terminal Agent CLI panel', () => {
expect(within(dialog).getByText(/宽松/)).toBeVisible()
expect(within(dialog).getByText(/systemctl restart nginx/)).toBeVisible()
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: 'Once' }))
fireEvent.click(within(dialog).getByRole('button', { name: '执行一次' }))
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1))
})
@@ -167,7 +167,7 @@ describe('terminal Agent CLI panel', () => {
fireEvent.click(screen.getByRole('button', { name: '发送' }))
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Always' }))
fireEvent.click(within(dialog).getByRole('button', { name: '本会话允许' }))
await waitFor(() =>
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.AAA111', { fromAgent: true })
)
@@ -388,4 +388,65 @@ describe('terminal Agent CLI panel', () => {
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeVisible())
expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(1)
})
it('splits model and SSH target in the header and hides extra chrome until needed', () => {
useAiProviderStore.setState({
providers: [
{
id: 'deepseek',
name: 'DeepSeek',
protocol: 'openai-chat',
authStyle: 'bearer',
baseUrl: 'https://api.deepseek.com',
models: [
{ id: 'v4', model: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro', capability: 'chat', billing: 'payg' },
],
defaultModelId: 'v4',
enabled: true,
hasApiKey: true,
allowInsecureHttp: false,
},
],
activeProviderId: 'deepseek',
isLoaded: true,
catalogProviders: [],
catalogUpdatedAt: null,
})
render(
<AiTerminalPanel
capture={() => ({ selection: '', viewport: '' })}
onClose={vi.fn()}
connectionId="ssh-active-1"
targetLabel="pre-jenkins"
/>
)
expect(screen.getByText('DeepSeek V4 Pro')).toBeVisible()
expect(screen.getByText('SSH · pre-jenkins')).toBeVisible()
expect(screen.queryByRole('button', { name: '所有命令都需确认(只读白名单除外)' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '新话题' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '关闭' })).toBeVisible()
})
it('compacts oversized terminal dumps before summarizing', async () => {
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
success: true,
text: 'FINAL: 日志过长,已按摘要阅读。',
model: 'test',
redactedCount: 0,
})
const viewport = Array.from({ length: 200 }, (_, index) => `line-${index} ${'x'.repeat(48)}`).join('\n')
render(
<AiTerminalPanel
capture={() => ({ selection: '', viewport })}
onClose={vi.fn()}
connectionId="ssh-active-1"
targetLabel="root@opsbase.cn"
/>
)
fireEvent.click(screen.getByRole('button', { name: '总结输出' }))
await waitFor(() => expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(1))
const context = String(vi.mocked(mockDesktopApi.aiComplete).mock.calls[0][0].context)
expect(context.length).toBeLessThan(viewport.length)
expect(context).toMatch(/总行数|已省略/)
})
})
+4 -6
View File
@@ -61,6 +61,7 @@ describe('App foundation acceptance', () => {
fireEvent.doubleClick(titleBar!)
expect(mockDesktopApi.toggleMaximize).not.toHaveBeenCalled()
expect(document.querySelector('[data-app-overlay]')).toBeNull()
})
it('writes encrypted exported sessions to the selected backup file', async () => {
@@ -88,7 +89,7 @@ describe('App foundation acceptance', () => {
})
vi.mocked(mockDesktopApi.showSaveDialog).mockResolvedValueOnce({
canceled: false,
filePath: '/tmp/opsxshell-sessions.bak',
filePath: '/tmp/opsxshell-hosts.oxs',
})
render(<App />)
@@ -108,13 +109,10 @@ describe('App foundation acceptance', () => {
expect(mockDesktopApi.showSaveDialog).toHaveBeenCalledWith(
expect.objectContaining({
defaultPath: 'opsxshell-sessions.bak',
defaultPath: 'opsxshell-hosts.oxs',
})
)
expect(mockDesktopApi.writeFile).toHaveBeenCalledWith(
'/tmp/opsxshell-sessions.bak',
expect.any(String)
)
expect(mockDesktopApi.writeFile).toHaveBeenCalledWith('/tmp/opsxshell-hosts.oxs', expect.any(String))
const [, encoded] = vi.mocked(mockDesktopApi.writeFile).mock.calls[0]
const decoded = decodeBase64ToText(String(encoded))
expect(decoded).toContain('"format": "opsxshell-backup"')
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import AppOverlay from '../../src/components/common/AppOverlay'
describe('AppOverlay', () => {
it('does not leave a full-window layer in the DOM when closed', () => {
const { rerender } = render(
<AppOverlay open={false}>
<div>blocked</div>
</AppOverlay>
)
expect(document.querySelector('[data-app-overlay]')).toBeNull()
rerender(
<AppOverlay open className="z-[100]">
<div>panel</div>
</AppOverlay>
)
const overlay = document.querySelector('[data-app-overlay]')
expect(overlay).toBeTruthy()
expect(overlay).toHaveClass('app-overlay')
expect(screen.getByText('panel')).toBeVisible()
})
it('portals onto document.body so layout overflow cannot trap it', () => {
render(
<div id="trap" className="overflow-hidden">
<AppOverlay open>
<span>layer</span>
</AppOverlay>
</div>
)
const overlay = document.querySelector('[data-app-overlay]')
expect(overlay?.parentElement).toBe(document.body)
expect(document.getElementById('trap')?.contains(overlay)).toBe(false)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ErrorBoundary from '../../src/components/common/ErrorBoundary'
import i18n from '../../src/i18n'
function Boom(): never {
throw new Error('pane-crash')
}
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(async () => {
await i18n.changeLanguage('zh-CN')
vi.restoreAllMocks()
})
it('shows compact pane copy in Chinese', async () => {
await i18n.changeLanguage('zh-CN')
render(
<ErrorBoundary compact>
<Boom />
</ErrorBoundary>
)
expect(screen.getByText(i18n.t('common:error.bodyPane'))).toBeVisible()
expect(screen.getByRole('button', { name: i18n.t('common:error.retryPane') })).toBeVisible()
})
it('shows compact pane copy in English', async () => {
await i18n.changeLanguage('en')
render(
<ErrorBoundary compact>
<Boom />
</ErrorBoundary>
)
expect(screen.getByText('This tab hit an unexpected problem. Other tabs still work.')).toBeVisible()
expect(screen.getByRole('button', { name: 'Retry this tab' })).toBeVisible()
})
it('clears the captured error when resetKey changes', async () => {
await i18n.changeLanguage('zh-CN')
const { rerender } = render(
<ErrorBoundary compact resetKey="tab-a">
<Boom />
</ErrorBoundary>
)
expect(screen.getByText(i18n.t('common:error.bodyPane'))).toBeVisible()
rerender(
<ErrorBoundary compact resetKey="tab-b">
<div>ok-pane</div>
</ErrorBoundary>
)
expect(screen.getByText('ok-pane')).toBeVisible()
})
})
@@ -25,7 +25,7 @@ describe('Session workflow acceptance', () => {
const onClose = vi.fn()
render(<SessionDialog isOpen onClose={onClose} editSession={null} />)
await user.type(screen.getByPlaceholderText('自动用 user@host 或 vnc:host'), 'OpsBase 验收')
await user.type(screen.getByPlaceholderText('自动用 user@host'), 'OpsBase 验收')
await user.clear(screen.getByPlaceholderText('192.168.1.10'))
await user.type(screen.getByPlaceholderText('192.168.1.10'), 'root@opsbase.cn:55555')
expect(screen.getByLabelText('端口')).toHaveValue(55555)
+23 -2
View File
@@ -1,13 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ShellClosedOverlay } from '../../src/components/terminal/TerminalOverlay'
import TerminalOverlay, { ShellClosedOverlay } from '../../src/components/terminal/TerminalOverlay'
describe('remote shell close recovery', () => {
it('explains remote TMOUT without claiming the pooled SSH connection is dead', () => {
const reopen = vi.fn()
render(<ShellClosedOverlay reason="远端 Shell 因空闲策略(TMOUT)自动退出" idleTimeout onReopen={reopen} />)
render(<ShellClosedOverlay reason="shell.idleTimeout" idleTimeout onReopen={reopen} />)
expect(screen.getByText('空闲超时,Shell 已退出')).toBeVisible()
expect(screen.getByText('远端 Shell 因空闲超时退出(TMOUT)')).toBeVisible()
expect(screen.getByText(/SSH 连接仍可用/)).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '重新打开 Shell' }))
expect(reopen).toHaveBeenCalledTimes(1)
@@ -22,3 +23,23 @@ describe('remote shell close recovery', () => {
expect(screen.queryByText(/SSH 连接仍可用/)).toBeNull()
})
})
describe('connection disconnect overlay', () => {
it('explains keepalive failure without calling it a user close', () => {
render(
<TerminalOverlay
connectionState="disconnected"
statusMessage=""
showSuspendedOverlay={false}
needsReconnect
disconnectKind="keepalive"
onReconnect={vi.fn()}
onResume={vi.fn()}
/>
)
expect(screen.getByText('心跳失败,连接已断开')).toBeVisible()
expect(screen.getByText(/不会向 Shell 发送按键/)).toBeVisible()
expect(screen.queryByText(/closed by user/i)).toBeNull()
})
})
+38
View File
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TabContextMenu } from '../../src/components/layout/tabbar/TabContextMenu'
import { tabSupportsReconnect } from '../../src/utils/tabReconnect'
describe('TabContextMenu', () => {
it('lists SFTP, clone, reconnect, then close actions', () => {
const onReconnect = vi.fn()
render(
<TabContextMenu
isOpen
position={{ x: 8, y: 8 }}
onClose={vi.fn()}
onCloseTab={vi.fn()}
onCloseOthers={vi.fn()}
onCloseRight={vi.fn()}
onCloseLeft={vi.fn()}
onCloseAll={vi.fn()}
onClone={vi.fn()}
onOpenSftp={vi.fn()}
onReconnect={onReconnect}
/>
)
const labels = screen.getAllByRole('button').map((button) => button.textContent)
expect(labels.slice(0, 4)).toEqual(['打开 SFTP', '克隆标签', '重新连接', '关闭'])
fireEvent.click(screen.getByRole('button', { name: '重新连接' }))
expect(onReconnect).toHaveBeenCalledTimes(1)
})
it('offers reconnect for SSH / SFTP / desktop tabs only', () => {
expect(tabSupportsReconnect({ type: 'terminal' })).toBe(true)
expect(tabSupportsReconnect({ type: 'sftp' })).toBe(true)
expect(tabSupportsReconnect({ type: 'rdp' })).toBe(true)
expect(tabSupportsReconnect({ type: 'welcome' })).toBe(false)
expect(tabSupportsReconnect({ type: 'settings' })).toBe(false)
})
})
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startNativeForward, stopNativeForward } from '../../../src/components/connection/forwardRuntime'
import type { ForwardRule } from '../../../src/components/connection/forwardRules'
const localRule: ForwardRule = {
id: 'pf-local',
type: 'local',
name: 'db',
localPort: 3306,
localBindHost: '127.0.0.1',
remoteHost: '127.0.0.1',
remotePort: 3306,
}
describe('forwardRuntime', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('starts a local forward through the native API', async () => {
const sshForwardLocal = vi
.spyOn(window.opsxApi, 'sshForwardLocal')
.mockResolvedValue({ success: true, forwardId: 'pf-local' })
await expect(startNativeForward('conn-1', localRule)).resolves.toEqual({
success: true,
error: undefined,
errorCode: undefined,
})
expect(sshForwardLocal).toHaveBeenCalledWith('conn-1', 3306, '127.0.0.1', 3306, 'pf-local', '127.0.0.1')
})
it('stops by runtime tunnel id when the persisted rule id misses', async () => {
const sshForwardStop = vi
.spyOn(window.opsxApi, 'sshForwardStop')
.mockResolvedValueOnce({ success: false, error: 'missing' })
.mockResolvedValueOnce({ success: true })
vi.spyOn(window.opsxApi, 'sshForwardList').mockResolvedValue({
success: true,
tunnels: [
{
forwardId: 'runtime-9',
connectionId: 'conn-1',
kind: 'local',
bindHost: '127.0.0.1',
boundPort: 3306,
startedAt: 1,
running: true,
activeConnections: 0,
bytesUp: 0,
bytesDown: 0,
},
],
})
await expect(stopNativeForward('conn-1', localRule)).resolves.toMatchObject({ success: true })
expect(sshForwardStop).toHaveBeenNthCalledWith(1, 'pf-local')
expect(sshForwardStop).toHaveBeenNthCalledWith(2, 'runtime-9')
})
})
+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'))
})
})
+7 -9
View File
@@ -20,9 +20,7 @@ describe('patrol / desktop (rdp + vnc)', () => {
it('RDP connect dialog submits host/port/username/password', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(
<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />
)
render(<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />)
expect(screen.getByText('连接 RDP')).toBeVisible()
expect(screen.getByLabelText('端口')).toHaveValue(3389)
@@ -48,9 +46,7 @@ describe('patrol / desktop (rdp + vnc)', () => {
it('VNC connect dialog defaults to port 5900 and allows password-only auth', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(
<DesktopConnectDialog protocol="vnc" isOpen onClose={vi.fn()} onConnect={onConnect} />
)
render(<DesktopConnectDialog protocol="vnc" isOpen onClose={vi.fn()} onConnect={onConnect} />)
expect(screen.getByText('连接 VNC')).toBeVisible()
expect(screen.getByLabelText('端口')).toHaveValue(5900)
@@ -73,9 +69,7 @@ describe('patrol / desktop (rdp + vnc)', () => {
it('requires host before connecting', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(
<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />
)
render(<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />)
await user.click(screen.getByRole('button', { name: '连接' }))
expect(await screen.findByText(/请输入主机/)).toBeVisible()
expect(onConnect).not.toHaveBeenCalled()
@@ -103,6 +97,10 @@ describe('patrol / desktop (rdp + vnc)', () => {
await waitFor(() => {
expect(screen.getByLabelText('端口')).toHaveValue(3389)
})
expect(screen.getAllByLabelText('密码')).toHaveLength(1)
await user.click(screen.getByText('高级选项'))
expect(screen.queryByLabelText('跳板机(可选)')).not.toBeInTheDocument()
expect(screen.queryByLabelText('启动命令(可选)')).not.toBeInTheDocument()
await user.clear(screen.getByLabelText('主机'))
await user.type(screen.getByLabelText('主机'), 'win.lab.local')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
+22 -20
View File
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import SFTPView from '../../src/components/sftp/SFTPView'
import { useTabStore } from '../../src/stores/tabStore'
import { useToastStore } from '../../src/stores/toastStore'
import { useSettingsStore } from '../../src/stores/settingsStore'
import { mockDesktopApi } from '../setup'
import { resetPatrolStores, SSH_SESSION } from '../helpers/patrol'
@@ -109,10 +110,7 @@ describe('patrol / sftp', () => {
await user.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => {
expect(mockDesktopApi.sftpMkdir).toHaveBeenCalledWith(
'sftp-native',
expect.stringMatching(/\/tests$/)
)
expect(mockDesktopApi.sftpMkdir).toHaveBeenCalledWith('sftp-native', expect.stringMatching(/\/tests$/))
})
})
@@ -202,9 +200,7 @@ describe('patrol / sftp', () => {
const remotePanel = screen.getByLabelText('远程文件面板')
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
expect(
within(remotePanel).getAllByText('权限不足,无法访问该文件或目录').length
).toBeGreaterThan(0)
expect(within(remotePanel).getAllByText('权限不足,无法访问该文件或目录').length).toBeGreaterThan(0)
await waitFor(() => {
expect(useToastStore.getState().toasts.some((item) => item.message.includes('权限不足'))).toBe(true)
})
@@ -261,9 +257,7 @@ describe('patrol / sftp', () => {
await user.click(await screen.findByText('编辑'))
await waitFor(() => {
expect(
useToastStore.getState().toasts.some((item) => item.message.includes('2MB'))
).toBe(true)
expect(useToastStore.getState().toasts.some((item) => item.message.includes('2MB'))).toBe(true)
})
})
@@ -298,9 +292,7 @@ describe('patrol / sftp', () => {
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
// Channel failure also tears down the browse session → reconnect strip.
expect(
await screen.findByText('无法打开 SFTP 通道(服务器限制或会话繁忙),请重连后再试')
).toBeVisible()
expect(await screen.findByText('无法打开 SFTP 通道(服务器限制或会话繁忙),请重连后再试')).toBeVisible()
expect(screen.getByRole('button', { name: '重新连接' })).toBeVisible()
})
@@ -373,17 +365,27 @@ describe('patrol / sftp', () => {
expect(await within(remotePanel).findByText('root')).toBeVisible()
expect(onClosed).toBeTypeOf('function')
useSettingsStore.setState((state) => ({
settings: { ...state.settings, reconnectBaseDelayMs: 10 },
}))
let releaseConnect: ((value: { success: true; sftpId: string }) => void) | undefined
vi.mocked(mockDesktopApi.sftpConnect).mockImplementation(
() =>
new Promise((resolve) => {
releaseConnect = resolve
})
)
onClosed?.({}, 'conn-sftp', 'network reset')
expect(await screen.findByText(/连接已关闭|Connection closed/i)).toBeVisible()
expect(screen.getByText(/重连后将回到 \//)).toBeVisible()
vi.mocked(mockDesktopApi.sftpListDirectory).mockClear()
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-resumed' })
await user.click(screen.getByRole('button', { name: '重新连接' }))
expect(await screen.findByText(/正在重连并恢复 \//)).toBeVisible()
await waitFor(() => {
expect(mockDesktopApi.sftpConnect).toHaveBeenCalled()
expect(releaseConnect).toBeTypeOf('function')
})
vi.mocked(mockDesktopApi.sftpListDirectory).mockClear()
releaseConnect?.({ success: true, sftpId: 'sftp-resumed' })
await waitFor(() => {
const paths = vi.mocked(mockDesktopApi.sftpListDirectory).mock.calls.map((call) => call[1])
expect(paths[0]).toBe('/')
+2 -2
View File
@@ -39,13 +39,13 @@ describe('Tauri event subscriptions', () => {
payload: {
connectionId: 'connection-1',
shellId: 'shell-2',
reason: '远端 Shell 因空闲策略(TMOUT)自动退出',
reason: 'shell.idleTimeout',
idleTimeout: true,
},
})
expect(listener).toHaveBeenCalledWith({}, 'connection-1', 'shell-2', {
reason: '远端 Shell 因空闲策略(TMOUT)自动退出',
reason: 'shell.idleTimeout',
idleTimeout: true,
})
})
+2 -2
View File
@@ -42,7 +42,7 @@ describe('native connection close', () => {
retainForwards: true,
})
expect(disconnect).toHaveBeenCalledWith('conn-1', { retainForwards: true })
expect(disconnect).toHaveBeenCalledWith('conn-1', { retainForwards: true, reason: 'auto reconnect cleanup' })
})
it('stops tunnel listeners when the last tab releases the pooled SSH connection', async () => {
@@ -52,6 +52,6 @@ describe('native connection close', () => {
await usePoolStore.getState().disconnectConnection('conn-1', 'no refs')
expect(disconnect).toHaveBeenCalledWith('conn-1', { retainForwards: false })
expect(disconnect).toHaveBeenCalledWith('conn-1', { retainForwards: false, reason: 'no refs' })
})
})
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
BUG_REPORT_ISSUE_URL,
buildBugReportUrl,
formatRuntimeSystem,
githubBugPlatform,
} from '../../src/utils/bugReport'
describe('bugReport', () => {
it('maps rust OS names onto the GitHub form dropdown', () => {
expect(githubBugPlatform('macos')).toBe('macOS')
expect(githubBugPlatform('windows')).toBe('Windows')
expect(githubBugPlatform('linux')).toBe('Linux')
expect(githubBugPlatform('freebsd')).toBeUndefined()
})
it('formats the About system line for people, not rust consts', () => {
expect(formatRuntimeSystem('macos', 'aarch64')).toBe('macOS · aarch64')
expect(formatRuntimeSystem('windows', 'x86_64')).toBe('Windows · x86_64')
})
it('pre-fills the issue form the way Settings → About should', () => {
const url = buildBugReportUrl({
appVersion: '2.1.1',
os: 'macos',
arch: 'aarch64',
logPath: '/tmp/opsxshell.log',
userAgent: 'Mozilla/5.0 Test',
})
const parsed = new URL(url)
expect(`${parsed.origin}${parsed.pathname}`).toBe(BUG_REPORT_ISSUE_URL)
expect(parsed.searchParams.get('template')).toBe('bug_report.yml')
expect(parsed.searchParams.get('title')).toBe('[Bug] ')
expect(parsed.searchParams.get('platform')).toBe('macOS')
expect(parsed.searchParams.get('version')).toBe('2.1.1')
expect(parsed.searchParams.get('install_source')).toBeNull()
expect(parsed.searchParams.get('logs')).toContain('Reported from OpsXShell Settings (opsxshell 2.1.1).')
expect(parsed.searchParams.get('logs')).toContain('OS: macos aarch64')
expect(parsed.searchParams.get('logs')).toContain('Log file: /tmp/opsxshell.log')
expect(parsed.searchParams.get('logs')).not.toContain('Tauri')
expect(parsed.searchParams.get('logs')).not.toContain('libssh2')
expect(parsed.searchParams.get('logs')).not.toContain('password')
})
})
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { desktopCanvasDisplaySize, resolutionFromPane } from '../../src/utils/desktopResolution'
describe('desktopCanvasDisplaySize', () => {
it('lets RDP fill a taller pane instead of letterboxing 1280×800', () => {
expect(desktopCanvasDisplaySize('rdp', 1100, 980, 1280, 800)).toEqual({
width: 1100,
height: 980,
})
})
it('keeps VNC aspect ratio so the framebuffer is not stretched', () => {
expect(desktopCanvasDisplaySize('vnc', 1100, 980, 1280, 800)).toEqual({
width: 1100,
height: 687,
})
})
})
describe('resolutionFromPane', () => {
it('uses the pane when it is large enough for a desktop session', () => {
expect(resolutionFromPane(1420, 900, { width: 1280, height: 800 })).toEqual({
width: 1420,
height: 900,
})
})
it('falls back when the pane is still collapsing or hidden', () => {
expect(resolutionFromPane(0, 0, { width: 1280, height: 800 })).toEqual({
width: 1280,
height: 800,
})
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
classifyDisconnectReason,
reconnectDelayMs,
shouldAutoReconnect,
shouldForgetNativeConnection,
} from '../../src/utils/disconnectReason'
describe('classifyDisconnectReason', () => {
it('treats keepalive failure as a link drop that should reconnect', () => {
expect(classifyDisconnectReason('keepalive failed')).toBe('keepalive')
expect(shouldAutoReconnect('keepalive')).toBe(true)
})
it('does not treat the legacy native default as a user click', () => {
expect(classifyDisconnectReason('closed by user')).toBe('unexpected')
expect(classifyDisconnectReason('connection closed')).toBe('unexpected')
expect(shouldAutoReconnect('unexpected')).toBe(true)
})
it('skips auto-reconnect for local policy and teardown', () => {
expect(classifyDisconnectReason('idle suspend')).toBe('idle-suspend')
expect(shouldAutoReconnect('idle-suspend')).toBe(false)
expect(shouldForgetNativeConnection('idle-suspend')).toBe(false)
expect(classifyDisconnectReason('manual')).toBe('user')
expect(classifyDisconnectReason('no refs')).toBe('user')
expect(classifyDisconnectReason('auto reconnect cleanup')).toBe('cleanup')
expect(shouldAutoReconnect('user')).toBe(false)
expect(shouldAutoReconnect('cleanup')).toBe(false)
})
it('backs off reconnect delay', () => {
expect(reconnectDelayMs(1, 1000)).toBe(1000)
expect(reconnectDelayMs(2, 1000)).toBe(1600)
expect(reconnectDelayMs(20, 1000, 5000)).toBe(5000)
})
})
+48 -4
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
panesAfterConnectionStateChange,
resolveTerminalInputTargets,
shouldBroadcastTerminalInput,
shouldCollapseTerminalSplitForOverlay,
} from '../../src/utils/terminalPanes'
const panes = [
@@ -16,10 +18,7 @@ describe('terminal pane input routing', () => {
})
it('routes broadcast input to every ready shell in pane order', () => {
expect(resolveTerminalInputTargets(panes, 'left', true)).toEqual([
'shell-1',
'shell-3',
])
expect(resolveTerminalInputTargets(panes, 'left', true)).toEqual(['shell-1', 'shell-3'])
})
it('does not write when the source pane has no ready shell', () => {
@@ -34,3 +33,48 @@ describe('terminal pane input routing', () => {
expect(shouldBroadcastTerminalInput(false, false, 'ssh-connection')).toBe(false)
})
})
describe('panesAfterConnectionStateChange', () => {
const split = [
{ id: 'pane-a', shellId: 'shell-old', closed: { reason: 'stale' } },
{ id: 'pane-b', shellId: 'shell-2' },
{ id: 'pane-c', shellId: 'shell-3' },
]
it('clears every split shell when the pooled session drops', () => {
const next = panesAfterConnectionStateChange(split, 'reconnecting', 'shell-new')
expect(next.map((pane) => pane.shellId)).toEqual([null, null, null])
expect(next.every((pane) => pane.closed == null)).toBe(true)
expect(next.map((pane) => pane.id)).toEqual(['pane-a', 'pane-b', 'pane-c'])
})
it('rebinds the primary pane to the new shell after reconnect', () => {
const cleared = panesAfterConnectionStateChange(split, 'disconnected', null)
const next = panesAfterConnectionStateChange(cleared, 'connected', 'shell-new')
expect(next[0].shellId).toBe('shell-new')
expect(next[1].shellId).toBeNull()
expect(next[2].shellId).toBeNull()
})
it('keeps the split layout in memory while collapsing the overlay visually', () => {
expect(
shouldCollapseTerminalSplitForOverlay({
connectionState: 'disconnected',
needsReconnect: true,
showSuspendedOverlay: false,
})
).toBe(true)
expect(
shouldCollapseTerminalSplitForOverlay({
connectionState: 'connected',
needsReconnect: false,
showSuspendedOverlay: false,
})
).toBe(false)
expect(panesAfterConnectionStateChange(split, 'disconnected', null).map((pane) => pane.id)).toEqual([
'pane-a',
'pane-b',
'pane-c',
])
})
})