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

+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')
})
})