feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

@@ -0,0 +1,61 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AiProviderSettings from '../../src/components/settings/AiProviderSettings'
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
import { mockDesktopApi } from '../setup'
describe('AI provider settings', () => {
beforeEach(() => {
vi.clearAllMocks()
useAiProviderStore.setState({
providers: [],
activeProviderId: '',
isLoaded: true,
catalogProviders: [],
catalogUpdatedAt: null,
})
vi.mocked(mockDesktopApi.aiTestProvider).mockResolvedValue({
success: true,
status: 200,
model: 'deepseek-chat',
latencyMs: 92,
answer: 'OK',
})
})
it('keeps a new provider transient until a real connection test passes', async () => {
render(<AiProviderSettings />)
fireEvent.click(screen.getByRole('button', { name: '添加' }))
expect(useAiProviderStore.getState().providers).toHaveLength(0)
expect(mockDesktopApi.settingsSet).not.toHaveBeenCalled()
const save = screen.getByRole('button', { name: '保存' })
expect(save).toBeDisabled()
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'sk-test-only' } })
fireEvent.click(screen.getByRole('button', { name: '测试连接' }))
await waitFor(() => expect(mockDesktopApi.aiTestProvider).toHaveBeenCalled())
expect(mockDesktopApi.aiProviderSaveKey).not.toHaveBeenCalled()
expect(await screen.findByText(/测试成功 · HTTP 200/)).toBeVisible()
expect(save).toBeEnabled()
fireEvent.click(save)
await waitFor(() => expect(useAiProviderStore.getState().providers).toHaveLength(1))
expect(mockDesktopApi.aiProviderSaveKey).toHaveBeenCalledWith(expect.any(String), 'sk-test-only')
expect(JSON.stringify(vi.mocked(mockDesktopApi.settingsSet).mock.calls)).not.toContain('sk-test-only')
})
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.click(screen.getByRole('button', { name: '测试连接' }))
const save = screen.getByRole('button', { name: '保存' })
await waitFor(() => expect(save).toBeEnabled())
fireEvent.change(screen.getByLabelText('Endpoint'), { target: { value: 'https://proxy.example.com/v1' } })
expect(save).toBeDisabled()
expect(screen.getByText('连接字段已变更,请重新测试')).toBeVisible()
})
})
+91
View File
@@ -0,0 +1,91 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AiTerminalPanel from '../../src/components/ai/AiTerminalPanel'
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
import { mockDesktopApi } from '../setup'
describe('terminal AI panel', () => {
beforeEach(() => {
vi.clearAllMocks()
useAiProviderStore.setState({
providers: [
{
id: 'local-test',
name: 'Local Test',
protocol: 'openai-chat',
authStyle: 'none',
baseUrl: 'http://127.0.0.1:11434/v1',
allowInsecureHttp: true,
models: [{ id: 'test', model: 'test', label: 'Test', capability: 'chat', billing: 'local' }],
defaultModelId: 'test',
enabled: true,
hasApiKey: true,
},
],
activeProviderId: 'local-test',
isLoaded: true,
catalogProviders: [],
catalogUpdatedAt: null,
})
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValue({
success: true,
text: '建议:\n```bash\ndate\n```',
model: 'test',
redactedCount: 0,
})
})
it('prefers selection and fills one line without pressing enter', async () => {
const insert = vi.fn()
render(
<AiTerminalPanel
capture={() => ({ selection: 'selected error', viewport: 'whole screen' })}
insert={insert}
onClose={vi.fn()}
/>
)
fireEvent.change(screen.getByPlaceholderText('例如:分析这段报错,并给出最少的只读检查命令'), {
target: { value: '怎么排查' },
})
fireEvent.click(screen.getByRole('button', { name: '生成建议' }))
await waitFor(() => expect(mockDesktopApi.aiComplete).toHaveBeenCalled())
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[0][0].context).toBe('selected error')
fireEvent.click(await screen.findByRole('button', { name: '填入终端' }))
expect(insert).toHaveBeenCalledWith('date')
})
it('executes one confirmed command through the current SSH connection and analyzes real output', async () => {
vi.mocked(mockDesktopApi.aiComplete)
.mockResolvedValueOnce({ success: true, text: '```bash\nuptime\n```', model: 'test', redactedCount: 0 })
.mockResolvedValueOnce({ success: true, text: '负载正常。', model: 'test', redactedCount: 0 })
vi.mocked(mockDesktopApi.sshExec).mockResolvedValueOnce({ success: true, result: 'load average: 0.01' })
render(
<AiTerminalPanel
capture={() => ({ selection: '', viewport: '' })}
insert={vi.fn()}
onClose={vi.fn()}
connectionId="ssh-active-1"
targetLabel="root@opsbase.cn"
/>
)
fireEvent.click(screen.getByRole('button', { name: '执行任务' }))
fireEvent.change(screen.getByPlaceholderText('例如:分析这段报错,并给出最少的只读检查命令'), {
target: { value: '检查服务器负载' },
})
fireEvent.click(screen.getByRole('button', { name: '准备执行方案' }))
fireEvent.click(await screen.findByRole('button', { name: '执行一次' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText(/root@opsbase.cn/)).toBeVisible()
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: '执行一次' }))
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'uptime'))
expect(await screen.findByText('load average: 0.01')).toBeVisible()
expect(await screen.findByText('负载正常。')).toBeVisible()
})
})
+91
View File
@@ -0,0 +1,91 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import App from '../../src/app/App'
import { useSessionStore } from '../../src/stores/sessionStore'
import { mockDesktopApi } from '../setup'
describe('App foundation acceptance', () => {
afterEach(() => {
window.opsxApi = mockDesktopApi
})
it('renders the application shell with the native desktop contract', async () => {
render(<App />)
expect(await screen.findByRole('heading', { name: 'OpsXShell' })).toBeVisible()
expect(mockDesktopApi.onMenuOpenSettings).toHaveBeenCalled()
const title = screen.getByText('OpsXShell', { selector: 'span' })
expect(title.closest('header')).toHaveAttribute('data-tauri-drag-region')
})
it('renders the shell while the native bridge is unavailable', async () => {
Object.defineProperty(window, 'opsxApi', {
configurable: true,
value: undefined,
writable: true,
})
render(<App />)
expect(await screen.findByRole('heading', { name: 'OpsXShell' })).toBeVisible()
})
it('opens quick connect with a queued ssh:// target', async () => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
vi.mocked(mockDesktopApi.takePendingSshLinks).mockResolvedValueOnce([
{ host: 'opsbase.cn', port: 55555, username: 'root' },
])
render(<App />)
expect(await screen.findByDisplayValue('root@opsbase.cn:55555')).toBeVisible()
expect(mockDesktopApi.onSshLinkAvailable).toHaveBeenCalled()
})
it('delegates title-bar double click to a single native drag-region handler', async () => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
render(<App />)
const title = await screen.findByText('OpsXShell', { selector: 'span' })
const titleBar = title.closest('header')
expect(titleBar).toHaveAttribute('data-tauri-drag-region')
expect(titleBar).not.toHaveClass('titlebar-drag')
fireEvent.doubleClick(titleBar!)
expect(mockDesktopApi.toggleMaximize).not.toHaveBeenCalled()
})
it('writes exported sessions to the selected JSON file', async () => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
useSessionStore.setState({
sessions: [
{
id: 'session-1',
name: 'Production',
host: 'prod.example.com',
port: 22,
username: 'root',
authType: 'agent',
},
],
})
vi.mocked(mockDesktopApi.showSaveDialog).mockResolvedValueOnce({
canceled: false,
filePath: '/tmp/opsxshell-sessions.json',
})
render(<App />)
await waitFor(() => expect(mockDesktopApi.onMenuExportSessions).toHaveBeenCalled())
const callback = vi.mocked(mockDesktopApi.onMenuExportSessions).mock.calls[0][0]
await callback()
expect(mockDesktopApi.writeFile).toHaveBeenCalledWith(
'/tmp/opsxshell-sessions.json',
expect.stringContaining('"prod.example.com"')
)
})
})
@@ -0,0 +1,46 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BatchCommandDialog from '../../src/components/sessions/BatchCommandDialog'
import type { Session } from '../../src/stores/sessionStore'
import { mockDesktopApi } from '../setup'
const sessions: Session[] = [
{ id: 'host-a', name: 'Host A', host: '10.0.0.1', port: 22, username: 'root', authType: 'key' },
{ id: 'host-b', name: 'Host B', host: '10.0.0.2', port: 2222, username: 'ops', authType: 'password' },
]
describe('BatchCommandDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
vi.mocked(mockDesktopApi.sessionGetById).mockImplementation(async (id) => ({
success: true,
session: sessions.find((session) => session.id === id),
}))
vi.mocked(mockDesktopApi.sshConnect).mockImplementation(async (config) => ({
success: true,
connectionId: `connection-${config.host}`,
}))
vi.mocked(mockDesktopApi.sshExec).mockImplementation(async (connectionId, command) => ({
success: true,
result: `${connectionId}: ${command}`,
}))
})
it('executes the same command on every selected host and disconnects transient sessions', async () => {
const user = userEvent.setup()
render(<BatchCommandDialog isOpen sessions={sessions} onClose={vi.fn()} />)
await user.type(screen.getByLabelText('远程命令'), 'date')
await user.click(screen.getByRole('button', { name: '执行命令' }))
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('connection-10.0.0.1', 'date')
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('connection-10.0.0.2', 'date')
await waitFor(() => expect(mockDesktopApi.sshDisconnect).toHaveBeenCalledTimes(2))
expect(await screen.findByText('成功 2')).toBeVisible()
expect(screen.getByText('connection-10.0.0.1: date')).toBeVisible()
expect(screen.getByText('connection-10.0.0.2: date')).toBeVisible()
})
})
@@ -0,0 +1,71 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Sidebar from '../../src/components/layout/Sidebar'
import { useSessionStore, type Session } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
const sessions: Session[] = [
{ id: 'prod-a', name: 'Prod A', host: '10.0.0.1', port: 22, username: 'root', authType: 'key' },
{ id: 'prod-b', name: 'Prod B', host: '10.0.0.2', port: 22, username: 'root', authType: 'key' },
]
describe('batch session operations', () => {
beforeEach(() => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
vi.mocked(mockDesktopApi.sessionGetAll).mockResolvedValueOnce({ success: true, sessions })
useSessionStore.setState({ sessions: [] })
useTabStore.setState({
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
activeTabId: 'welcome',
})
})
it('selects multiple saved sessions and opens connected terminal tabs', async () => {
const user = userEvent.setup()
render(
<Sidebar
onNewSession={vi.fn()}
onQuickConnect={vi.fn()}
onEditSession={vi.fn()}
/>
)
await screen.findByText('Prod A')
await user.click(screen.getByRole('button', { name: '批量操作' }))
await user.click(screen.getByText('Prod A'))
await user.click(screen.getByText('Prod B'))
expect(screen.getByText('已选 2 个会话')).toBeVisible()
await user.click(screen.getByRole('button', { name: /打开终端/ }))
expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([
expect.objectContaining({ sessionId: 'prod-a', type: 'terminal', connectOnMount: true }),
expect.objectContaining({ sessionId: 'prod-b', type: 'terminal', connectOnMount: true }),
]))
})
it('opens SFTP only from the saved session context menu', async () => {
const user = userEvent.setup()
render(
<Sidebar
onNewSession={vi.fn()}
onQuickConnect={vi.fn()}
onEditSession={vi.fn()}
/>
)
const sessionName = await screen.findByText('Prod A')
expect(screen.queryByTitle('SFTP')).not.toBeInTheDocument()
expect(screen.queryByText('SFTP 文件')).not.toBeInTheDocument()
fireEvent.contextMenu(sessionName)
await user.click(screen.getByRole('button', { name: 'SFTP 文件' }))
expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([
expect.objectContaining({ sessionId: 'prod-a', type: 'sftp' }),
]))
})
})
@@ -0,0 +1,107 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from '../../src/app/App'
import QuickLauncher from '../../src/components/sessions/QuickLauncher'
import SerialConnectDialog from '../../src/components/serial/SerialConnectDialog'
import { useSessionStore } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
const serialMocks = vi.hoisted(() => ({
listSerialPorts: vi.fn(),
}))
vi.mock('../../src/platform/serial', () => ({
listSerialPorts: serialMocks.listSerialPorts,
}))
describe('Quick launcher and serial acceptance', () => {
beforeEach(() => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
serialMocks.listSerialPorts.mockResolvedValue([
{ path: '/dev/tty.debug-console', portType: 'Serial' },
{ path: '/dev/tty.Bluetooth-Incoming-Port', portType: 'Bluetooth' },
])
useSessionStore.setState({ sessions: [] })
useTabStore.setState({
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
activeTabId: 'welcome',
})
})
it('opens the unified launcher from the tab-bar plus button', async () => {
const user = userEvent.setup()
render(<App />)
await user.click(screen.getByRole('button', { name: '快速启动' }))
expect(await screen.findByRole('heading', { name: '快速启动' })).toBeVisible()
const launcher = within(screen.getByRole('dialog'))
expect(launcher.getByRole('button', { name: /串口终端/ })).toBeVisible()
expect(launcher.getByRole('button', { name: /快速连接/ })).toBeVisible()
})
it('starts the system-default zsh directly from the launcher', async () => {
const user = userEvent.setup()
render(
<QuickLauncher
isOpen
onClose={vi.fn()}
onNewSession={vi.fn()}
onQuickConnect={vi.fn()}
onSerialConnect={vi.fn()}
/>
)
const zshButton = await screen.findByRole('button', {
name: /Zsh.*\/bin\/zsh.*系统默认/,
})
await user.click(zshButton)
expect(useTabStore.getState().tabs).toContainEqual(
expect.objectContaining({
title: 'Zsh',
type: 'terminal',
isLocal: true,
localShellPath: '/bin/zsh',
localShellKind: 'zsh',
})
)
})
it('maps the selected serial device and line settings into a serial config', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(<SerialConnectDialog isOpen onClose={vi.fn()} onConnect={onConnect} />)
await user.click(
await screen.findByRole('button', { name: /\/dev\/tty\.Bluetooth-Incoming-Port/ })
)
await user.selectOptions(screen.getByLabelText('波特率'), '921600')
await user.selectOptions(screen.getByLabelText('数据位'), '7')
await user.selectOptions(screen.getByLabelText('停止位'), '2')
await user.selectOptions(screen.getByLabelText('校验'), 'even')
await user.selectOptions(screen.getByLabelText('流控'), 'hardware')
await user.click(screen.getByRole('button', { name: '打开串口' }))
expect(onConnect).toHaveBeenCalledWith({
path: '/dev/tty.Bluetooth-Incoming-Port',
baudRate: 921600,
dataBits: 7,
stopBits: 2,
parity: 'even',
flowControl: 'hardware',
})
})
it('keeps the documented manual serial path when enumeration returns no devices', async () => {
serialMocks.listSerialPorts.mockResolvedValue([])
render(<SerialConnectDialog isOpen onClose={vi.fn()} onConnect={vi.fn()} />)
await waitFor(() => expect(serialMocks.listSerialPorts).toHaveBeenCalled())
expect(screen.getByLabelText('设备')).toHaveValue('/dev/tty.debug-console')
expect(screen.getByText(/\/dev\/tty\.Bluetooth-Incoming-Port/)).toBeVisible()
})
})
@@ -0,0 +1,161 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SFTPView from '../../src/components/sftp/SFTPView'
import { usePoolStore } from '../../src/stores/connection/poolStore'
import { useSessionStore } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
const session = {
id: 'opsbase',
name: 'OpsBase',
host: 'opsbase.cn',
port: 55555,
username: 'root',
authType: 'agent' as const,
}
const remoteFile = {
filename: 'remote.txt',
longname: '-rw-r--r-- 1 root root 12 Jul 30 12:00 remote.txt',
attrs: { size: 12, mtime: 1_700_000_000, mode: 0o100644 },
}
function createDataTransfer(): DataTransfer {
const values = new Map<string, string>()
const transfer = {
effectAllowed: 'all',
dropEffect: 'none',
files: [] as unknown as FileList,
types: [] as string[],
setData(type: string, value: string) {
values.set(type, value)
if (!this.types.includes(type)) this.types.push(type)
},
getData(type: string) {
return values.get(type) || ''
},
}
return transfer as unknown as DataTransfer
}
describe('SFTP basic transfer acceptance', () => {
beforeEach(() => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
usePoolStore.setState({ connections: new Map() })
useSessionStore.setState({ sessions: [session] })
useTabStore.setState({
tabs: [{ id: 'sftp-tab', title: 'SFTP: OpsBase', type: 'sftp', sessionId: session.id, isActive: true }],
activeTabId: 'sftp-tab',
})
vi.mocked(mockDesktopApi.sshConnect).mockResolvedValue({ success: true, connectionId: 'conn-sftp' })
vi.mocked(mockDesktopApi.sessionGetById).mockResolvedValue({ success: true, session })
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-native' })
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({ success: true, files: [remoteFile] })
vi.mocked(mockDesktopApi.getHomePath).mockResolvedValue({ success: true, path: '/Users/test' })
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({ success: true, files: [] })
})
it('uploads the absolute path returned by the native file picker', async () => {
const user = userEvent.setup()
vi.mocked(mockDesktopApi.showOpenDialog).mockResolvedValueOnce({
canceled: false,
filePaths: ['/tmp/actual-selected.txt'],
})
vi.mocked(mockDesktopApi.localGetStats).mockResolvedValueOnce({
success: true,
stats: { name: 'actual-selected.txt', path: '/tmp/actual-selected.txt', type: 'file', size: 17 },
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByText('已连接')
await user.click(screen.getByRole('button', { name: '上传' }))
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/tmp/actual-selected.txt',
'/root/actual-selected.txt'
)
})
})
it('downloads a selected remote file into the current local directory', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByText('已连接')
await user.click(await screen.findByText('remote.txt'))
await user.click(screen.getByRole('button', { name: '下载' }))
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
'/root/remote.txt',
'/Users/test/remote.txt'
)
})
})
it('shows the native upload failure instead of reporting success', async () => {
const user = userEvent.setup()
vi.mocked(mockDesktopApi.showOpenDialog).mockResolvedValueOnce({
canceled: false,
filePaths: ['/tmp/denied.txt'],
})
vi.mocked(mockDesktopApi.localGetStats).mockResolvedValueOnce({
success: true,
stats: { name: 'denied.txt', path: '/tmp/denied.txt', type: 'file', size: 4 },
})
vi.mocked(mockDesktopApi.sftpUpload).mockResolvedValueOnce({
success: false,
error: 'Permission denied',
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByText('已连接')
await user.click(screen.getByRole('button', { name: '上传' }))
expect(await screen.findByText(/Permission denied/)).toBeVisible()
})
it('uploads by dragging a local row onto the remote panel', async () => {
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
success: true,
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByText('已连接')
const transfer = createDataTransfer()
fireEvent.dragStart(await screen.findByText('local.txt'), { dataTransfer: transfer })
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
'/root/local.txt'
)
})
})
it('downloads by dragging a remote row onto the local panel', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByText('已连接')
const transfer = createDataTransfer()
fireEvent.dragStart(await screen.findByText('remote.txt'), { dataTransfer: transfer })
fireEvent.drop(screen.getByLabelText('本地文件面板'), { dataTransfer: transfer })
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
'/root/remote.txt',
'/Users/test/remote.txt'
)
})
})
})
@@ -0,0 +1,208 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SessionDialog from '../../src/components/sessions/SessionDialog'
import QuickConnectDialog from '../../src/components/sessions/QuickConnectDialog'
import { useSessionStore } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
describe('Session workflow acceptance', () => {
beforeEach(() => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
useSessionStore.setState({ sessions: [] })
useTabStore.setState({
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
activeTabId: 'welcome',
})
})
it('creates and saves a session in the selected group', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<SessionDialog isOpen onClose={onClose} editSession={null} />)
await user.type(screen.getByPlaceholderText('自动用 user@host'), 'OpsBase 验收')
await user.clear(screen.getByPlaceholderText('root@192.168.1.10'))
await user.type(screen.getByPlaceholderText('root@192.168.1.10'), 'root@opsbase.cn:55555')
expect(screen.getByLabelText('端口')).toHaveValue(55555)
await user.click(screen.getByRole('button', { name: /高级选项/ }))
await user.clear(screen.getByPlaceholderText('default'))
await user.type(screen.getByPlaceholderText('default'), '验收环境')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
host: 'opsbase.cn',
port: 55555,
username: 'root',
group: '验收环境',
})
)
})
expect(useSessionStore.getState().sessions).toEqual([
expect.objectContaining({
name: 'OpsBase 验收',
host: 'opsbase.cn',
port: 55555,
group: '验收环境',
}),
])
expect(onClose).toHaveBeenCalledOnce()
})
it('defaults new sessions to port 22 and password authentication', async () => {
const user = userEvent.setup()
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
expect(screen.getByLabelText('端口')).toHaveValue(22)
expect(screen.getByRole('button', { name: '密码' })).toBeVisible()
expect(screen.getByRole('button', { name: '密钥' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Agent' })).not.toBeInTheDocument()
await user.clear(screen.getByLabelText('目标'))
await user.type(screen.getByLabelText('目标'), 'admin@example.com')
await user.type(screen.getByLabelText('密码'), 'session-secret')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
host: 'example.com',
port: 22,
username: 'admin',
authType: 'password',
password: 'session-secret',
privateKey: undefined,
passphrase: undefined,
})
)
})
})
it('saves key authentication without retaining a password', async () => {
const user = userEvent.setup()
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
await user.clear(screen.getByLabelText('目标'))
await user.type(screen.getByLabelText('目标'), 'root@opsbase.cn')
await user.click(screen.getByRole('button', { name: '密钥' }))
await user.clear(screen.getByLabelText('私钥路径'))
await user.type(screen.getByLabelText('私钥路径'), '~/.ssh/id_ed25519')
await user.type(screen.getByLabelText('密钥口令(可选)'), 'key-secret')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
port: 22,
authType: 'key',
password: undefined,
privateKey: '~/.ssh/id_ed25519',
passphrase: 'key-secret',
})
)
})
})
it('saves a SOCKS5 connection proxy with optional credentials', async () => {
const user = userEvent.setup()
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
await user.clear(screen.getByLabelText('目标'))
await user.type(screen.getByLabelText('目标'), 'admin@internal.example.com')
await user.click(screen.getByRole('button', { name: /高级选项/ }))
await user.click(screen.getByRole('button', { name: 'SOCKS5' }))
await user.type(screen.getByPlaceholderText('代理地址'), '127.0.0.1')
await user.type(screen.getByPlaceholderText('用户名(可选)'), 'proxy-user')
await user.type(screen.getByPlaceholderText('密码(可选)'), 'proxy-secret')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
proxy: {
type: 'socks5',
host: '127.0.0.1',
port: 1080,
username: 'proxy-user',
password: 'proxy-secret',
},
})
)
})
})
it('saves one existing session as a ProxyJump host', async () => {
const user = userEvent.setup()
useSessionStore.setState({
sessions: [
{
id: 'jump-1',
name: 'Bastion',
host: 'gateway.example.com',
port: 22,
username: 'ops',
authType: 'agent',
},
],
})
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
await user.clear(screen.getByLabelText('目标'))
await user.type(screen.getByLabelText('目标'), 'root@internal.example.com')
await user.click(screen.getByRole('button', { name: /高级选项/ }))
await user.selectOptions(screen.getByLabelText('跳板机(可选)'), 'jump-1')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
jumpSessionId: 'jump-1',
jumpSessionIds: ['jump-1'],
proxy: undefined,
})
)
})
})
it('creates a quick-connect terminal tab with the parsed target', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<QuickConnectDialog isOpen onClose={onClose} />)
await user.type(screen.getByPlaceholderText('root@192.168.1.10:22'), 'root@opsbase.cn:55555')
await user.click(screen.getByRole('button', { name: '连接' }))
const terminalTab = useTabStore
.getState()
.tabs.find((tab) => tab.type === 'terminal' && tab.title === 'root@opsbase.cn')
expect(terminalTab?.connectionConfig).toEqual({
host: 'opsbase.cn',
port: 55555,
username: 'root',
password: undefined,
})
expect(onClose).toHaveBeenCalledOnce()
})
it('accepts pasted targets in quick connect and new session inputs', async () => {
const user = userEvent.setup()
const quick = render(<QuickConnectDialog isOpen onClose={vi.fn()} />)
const quickTarget = screen.getByPlaceholderText('root@192.168.1.10:22')
await user.click(quickTarget)
await user.paste('root@opsbase.cn:55555')
expect(quickTarget).toHaveValue('root@opsbase.cn:55555')
quick.unmount()
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
const sessionTarget = screen.getByLabelText('目标')
await user.clear(sessionTarget)
await user.paste('admin@example.com:2200')
expect(sessionTarget).toHaveValue('admin@example.com:2200')
expect(screen.getByLabelText('端口')).toHaveValue(2200)
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import {
duplicateForwardRule,
formatTransferBytes,
parseForwardRules,
portForwardStorageKey,
runtimeMap,
} from '../../../src/components/connection/forwardRules'
describe('forward rule persistence', () => {
it('uses stable session ids instead of runtime connection ids', () => {
expect(portForwardStorageKey('saved-1', 'tab-1')).toBe('portForwards_session_saved-1')
expect(portForwardStorageKey(undefined, 'tab-1')).toBe('portForwards_tab_tab-1')
})
it('migrates JSON strings and drops runtime state', () => {
const rules = parseForwardRules(
JSON.stringify([
{
id: 'proxy-1',
type: 'dynamic',
name: 'SOCKS5',
localPort: 1080,
isActive: true,
},
])
)
expect(rules).toEqual([{ id: 'proxy-1', type: 'dynamic', name: 'SOCKS5', localPort: 1080 }])
expect(rules[0]).not.toHaveProperty('isActive')
})
it('indexes process-local tunnel status independently', () => {
const map = runtimeMap([
{
forwardId: 'proxy-1',
connectionId: 'conn-1',
kind: 'dynamic',
bindHost: '127.0.0.1',
boundPort: 1080,
startedAt: 1,
running: true,
activeConnections: 2,
bytesUp: 1024,
bytesDown: 2048,
},
])
expect(map.get('proxy-1')?.activeConnections).toBe(2)
expect(formatTransferBytes(1536)).toBe('1.5 KB')
})
it('duplicates configuration with a fresh id and no runtime state', () => {
const original = { id: 'proxy-1', type: 'dynamic' as const, name: 'SOCKS5', localPort: 1080 }
const first = duplicateForwardRule(original, [original], 'proxy-2')
const second = duplicateForwardRule(original, [original, first], 'proxy-3')
expect(first).toEqual({ ...original, id: 'proxy-2', name: 'SOCKS5 副本' })
expect(second.name).toBe('SOCKS5 副本 2')
expect(second).not.toHaveProperty('isActive')
})
})
+73 -1
View File
@@ -3,7 +3,16 @@
*/
import { describe, it, expect } from 'vitest'
import { formatSize, formatSpeed, formatDate, getFileExtension, sortFiles } from '../../../src/components/sftp/utils'
import {
formatSize,
formatSpeed,
formatDate,
getFileExtension,
sortFiles,
filterFilesByName,
isHiddenFileName,
filterHiddenFiles,
} from '../../../src/components/sftp/utils'
import type { FileItem } from '../../../src/components/sftp/types'
describe('SFTP Utils', () => {
@@ -111,4 +120,67 @@ describe('SFTP Utils', () => {
expect(mockFiles).toEqual(original)
})
})
describe('filterFilesByName', () => {
const mockFiles: FileItem[] = [
{ name: 'test.txt', path: '/test.txt', type: 'file', size: 100, modified: 1000, permissions: '-rw-r--r--' },
{ name: 'docs', path: '/docs', type: 'directory', size: 0, modified: 2000, permissions: 'drwxr-xr-x' },
{ name: 'image.png', path: '/image.png', type: 'file', size: 500, modified: 500, permissions: '-rw-r--r--' },
{ name: 'backup.zip', path: '/backup.zip', type: 'file', size: 1000, modified: 1500, permissions: '-rw-r--r--' },
{ name: 'Test_Report.PDF', path: '/Test_Report.PDF', type: 'file', size: 200, modified: 1200, permissions: '-rw-r--r--' },
]
it('空查询应返回原列表引用', () => {
expect(filterFilesByName(mockFiles, '')).toBe(mockFiles)
expect(filterFilesByName(mockFiles, ' ')).toBe(mockFiles)
})
it('应按子串匹配文件名', () => {
const result = filterFilesByName(mockFiles, 'image')
expect(result.map((f) => f.name)).toEqual(['image.png'])
})
it('应不区分大小写', () => {
const result = filterFilesByName(mockFiles, 'test')
expect(result.map((f) => f.name).sort()).toEqual(['Test_Report.PDF', 'test.txt'])
})
it('可匹配扩展名', () => {
const result = filterFilesByName(mockFiles, '.png')
expect(result).toHaveLength(1)
expect(result[0].name).toBe('image.png')
})
it('无匹配时返回空数组', () => {
expect(filterFilesByName(mockFiles, 'xyz-not-exist')).toEqual([])
})
})
describe('isHiddenFileName / filterHiddenFiles', () => {
const mockFiles: FileItem[] = [
{ name: 'readme.md', path: '/readme.md', type: 'file', size: 10, modified: 1, permissions: '-rw-r--r--' },
{ name: '.gitignore', path: '/.gitignore', type: 'file', size: 20, modified: 2, permissions: '-rw-r--r--' },
{ name: '.config', path: '/.config', type: 'directory', size: 0, modified: 3, permissions: 'drwxr-xr-x' },
{ name: '.', path: '/.', type: 'directory', size: 0, modified: 4, permissions: 'drwxr-xr-x' },
{ name: '..', path: '/..', type: 'directory', size: 0, modified: 5, permissions: 'drwxr-xr-x' },
{ name: 'src', path: '/src', type: 'directory', size: 0, modified: 6, permissions: 'drwxr-xr-x' },
]
it('应识别点文件与 . / ..', () => {
expect(isHiddenFileName('.gitignore')).toBe(true)
expect(isHiddenFileName('.')).toBe(true)
expect(isHiddenFileName('..')).toBe(true)
expect(isHiddenFileName('readme.md')).toBe(false)
})
it('默认应隐藏点文件,并始终排除 . / ..', () => {
const result = filterHiddenFiles(mockFiles, false)
expect(result.map((f) => f.name).sort()).toEqual(['readme.md', 'src'])
})
it('显示隐藏文件时仍排除 . / ..', () => {
const result = filterHiddenFiles(mockFiles, true)
expect(result.map((f) => f.name).sort()).toEqual(['.config', '.gitignore', 'readme.md', 'src'])
})
})
})