chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(/总行数|已省略/)
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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: '创建并保存' }))
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user