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

+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { normalizeModelsDevCatalog } from '../../src/ai/modelCatalog'
describe('models.dev catalog normalization', () => {
it('normalizes the provider keyed api.json shape', () => {
const result = normalizeModelsDevCatalog([
{
source: 'https://models.dev/api.json',
data: {
deepseek: {
id: 'deepseek',
name: 'DeepSeek',
api: 'https://api.deepseek.com/v1',
models: {
'deepseek-chat': { id: 'deepseek-chat', name: 'DeepSeek Chat' },
'deepseek-reasoner': { id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', reasoning: true },
},
},
},
},
])
expect(result).toHaveLength(1)
expect(result[0].baseUrl).toBe('https://api.deepseek.com/v1')
expect(result[0].models).toEqual(
expect.arrayContaining([expect.objectContaining({ model: 'deepseek-reasoner', capability: 'reasoning' })])
)
})
it('groups a flat models.json shape by provider and tolerates partial documents', () => {
const result = normalizeModelsDevCatalog([
{ source: 'broken', data: { metadata: { updated: true } } },
{
source: 'models',
data: {
models: {
'vendor/model-a': { id: 'model-a', name: 'Model A', provider: 'vendor' },
'vendor/model-b': { id: 'model-b', name: 'Model B', provider: 'vendor', reasoning: true },
},
},
},
])
expect(result).toEqual([
expect.objectContaining({
id: 'vendor',
models: expect.arrayContaining([
expect.objectContaining({ model: 'model-a' }),
expect.objectContaining({ model: 'model-b', capability: 'reasoning' }),
]),
}),
])
})
})
@@ -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'])
})
})
})
+52
View File
@@ -0,0 +1,52 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeyboardShortcuts } from '../../src/hooks/useKeyboardShortcuts'
import { KeybindingAction } from '../../src/constants/keybindings'
import { useKeybindingStore } from '../../src/stores/keybindingStore'
import { useCommandPaletteStore } from '../../src/stores/commandPaletteStore'
function ShortcutHarness() {
useKeyboardShortcuts()
return <input aria-label="editable" />
}
describe('editable clipboard shortcut contract', () => {
beforeEach(() => {
vi.clearAllMocks()
useKeybindingStore.setState({ customKeybindings: {}, isLoaded: true })
useCommandPaletteStore.setState({ isOpen: false })
})
it.each(['a', 'c', 'v', 'x', 'z', 'y'])('does not prevent Cmd/Ctrl+%s in an input', (key) => {
render(<ShortcutHarness />)
const input = screen.getByLabelText('editable')
const event = new KeyboardEvent('keydown', {
key,
metaKey: true,
bubbles: true,
cancelable: true,
})
expect(input.dispatchEvent(event)).toBe(true)
expect(event.defaultPrevented).toBe(false)
})
it('keeps paste reserved even when a user shortcut conflicts with Cmd/Ctrl+V', () => {
useKeybindingStore.setState({
customKeybindings: { [KeybindingAction.TOOLS_COMMAND_PALETTE]: 'Ctrl+V' },
isLoaded: true,
})
render(<ShortcutHarness />)
const input = screen.getByLabelText('editable')
const event = new KeyboardEvent('keydown', {
key: 'v',
metaKey: true,
bubbles: true,
cancelable: true,
})
input.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
expect(useCommandPaletteStore.getState().isOpen).toBe(false)
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest'
const { listenMock } = vi.hoisted(() => ({
listenMock: vi.fn(),
}))
vi.mock('@tauri-apps/api/event', () => ({
listen: listenMock,
}))
import { desktopApi } from '../../src/platform/desktop'
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('Tauri event subscriptions', () => {
it('safely unregisters when React cleans up before listen resolves', async () => {
const pending = deferred<() => void>()
const unlisten = vi.fn()
listenMock.mockReturnValueOnce(pending.promise)
const listener = vi.fn()
desktopApi.onConnectionClosed(listener)
desktopApi.removeListener('ssh:connection-closed', listener as (...args: unknown[]) => void)
pending.resolve(unlisten)
await Promise.resolve()
await Promise.resolve()
expect(unlisten).toHaveBeenCalledTimes(1)
})
it('does not unregister an active listener more than once', async () => {
const unlisten = vi.fn()
listenMock.mockResolvedValueOnce(unlisten)
const listener = vi.fn()
desktopApi.onConnectionClosed(listener)
await Promise.resolve()
desktopApi.removeListener('ssh:connection-closed', listener as (...args: unknown[]) => void)
desktopApi.removeListener('ssh:connection-closed', listener as (...args: unknown[]) => void)
expect(unlisten).toHaveBeenCalledTimes(1)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadRemoteFile,
localPath,
readRemoteTextFile,
remotePath,
uploadRemoteFile,
writeRemoteTextFile,
} from '../../src/platform/sftp'
describe('SFTP path semantics', () => {
beforeEach(() => vi.clearAllMocks())
it('always sends local then remote paths for upload', async () => {
await uploadRemoteFile({
sftpId: 'sftp-1',
localPath: localPath('/Users/me/app.log'),
remotePath: remotePath('/var/log/app.log'),
})
expect(window.opsxApi.sftpUpload).toHaveBeenCalledWith('sftp-1', '/Users/me/app.log', '/var/log/app.log')
})
it('always sends remote then local paths for download', async () => {
await downloadRemoteFile({
sftpId: 'sftp-1',
remotePath: remotePath('/var/log/app.log'),
localPath: localPath('/Users/me/app.log'),
})
expect(window.opsxApi.sftpDownload).toHaveBeenCalledWith('sftp-1', '/var/log/app.log', '/Users/me/app.log')
})
it('keeps remote editor reads and writes separate from transfers', async () => {
await readRemoteTextFile({ sftpId: 'sftp-1', remotePath: remotePath('/etc/hosts') })
await writeRemoteTextFile({
sftpId: 'sftp-1',
remotePath: remotePath('/etc/hosts'),
content: '127.0.0.1 localhost',
})
expect(window.opsxApi.sftpReadText).toHaveBeenCalledWith('sftp-1', '/etc/hosts')
expect(window.opsxApi.sftpWriteText).toHaveBeenCalledWith('sftp-1', '/etc/hosts', '127.0.0.1 localhost')
expect(window.opsxApi.sftpUpload).not.toHaveBeenCalled()
expect(window.opsxApi.sftpDownload).not.toHaveBeenCalled()
})
})
-95
View File
@@ -1,95 +0,0 @@
/**
* Crypto Service Integration Tests
* 验证加密/解密核心逻辑的正确性和安全性
*/
import { describe, it, expect } from 'vitest'
import { encrypt, decrypt, encryptAsync, simpleEncrypt, simpleDecrypt } from '../../electron/services/crypto'
describe('Crypto Service', () => {
const testPassword = 'test-password-123'
const testData = 'Hello, OpsXShell! 你好,世界!'
const testKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
describe('encrypt / decrypt', () => {
it('应该能正确加密和解密数据', () => {
const encrypted = encrypt(testData, testPassword)
expect(encrypted).toBeTruthy()
expect(encrypted).toContain(':') // 格式: version:salt:iv:tag:ciphertext
const decrypted = decrypt(encrypted, testPassword)
expect(decrypted).toBe(testData)
})
it('密文应该包含版本标识', () => {
const encrypted = encrypt(testData, testPassword)
const parts = encrypted.split(':')
expect(parts.length).toBe(5)
expect(parts[0]).toBe('v1')
})
it('不同密码应该产生不同密文', () => {
const encrypted1 = encrypt(testData, testPassword)
const encrypted2 = encrypt(testData, 'different-password')
expect(encrypted1).not.toBe(encrypted2)
})
it('相同数据多次加密应该产生不同密文(随机 salt/iv)', () => {
const encrypted1 = encrypt(testData, testPassword)
const encrypted2 = encrypt(testData, testPassword)
expect(encrypted1).not.toBe(encrypted2)
// 但都能正确解密
expect(decrypt(encrypted1, testPassword)).toBe(testData)
expect(decrypt(encrypted2, testPassword)).toBe(testData)
})
it('错误密码应该抛出异常', () => {
const encrypted = encrypt(testData, testPassword)
expect(() => decrypt(encrypted, 'wrong-password')).toThrow('Decryption failed')
})
it('应该向后兼容旧格式密文(4部分,无版本标识)', () => {
// 构造一个旧格式密文: salt:iv:tag:ciphertext
const encrypted = encrypt(testData, testPassword)
const parts = encrypted.split(':')
const oldFormat = parts.slice(1).join(':') // 去掉版本标识
expect(decrypt(oldFormat, testPassword)).toBe(testData)
})
})
describe('encryptAsync / decrypt', () => {
it('异步加密应该产生可解密的结果', async () => {
const encrypted = await encryptAsync(testData, testPassword)
const decrypted = decrypt(encrypted, testPassword)
expect(decrypted).toBe(testData)
})
it('异步加密密文应该包含版本标识', async () => {
const encrypted = await encryptAsync(testData, testPassword)
const parts = encrypted.split(':')
expect(parts.length).toBe(5)
expect(parts[0]).toBe('v1')
})
})
describe('simpleEncrypt / simpleDecrypt', () => {
it('应该能正确加密和解密数据', () => {
const encrypted = simpleEncrypt(testData, testKey)
expect(encrypted).toBeTruthy()
const decrypted = simpleDecrypt(encrypted, testKey)
expect(decrypted).toBe(testData)
})
it('错误密钥应该抛出异常', () => {
const encrypted = simpleEncrypt(testData, testKey)
expect(() => simpleDecrypt(encrypted, 'wrong-key')).toThrow('Decryption failed')
})
it('无效密钥长度应该抛出异常', () => {
expect(() => simpleEncrypt(testData, 'short')).toThrow('Invalid key length')
})
})
})
+94 -25
View File
@@ -4,18 +4,33 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
import type { DesktopApiContract } from '../src/desktop'
// Mock Electron API
const mockElectronAPI = {
// Mock native desktop API
export const mockDesktopApi: DesktopApiContract = {
takePendingSshLinks: vi.fn().mockResolvedValue([]),
appRuntimeInfo: vi.fn().mockResolvedValue({
appVersion: '2.0.0',
os: 'macos',
arch: 'aarch64',
nativeBackend: 'Tauri 2 / Rust',
sshBackend: 'libssh2 / ssh2 0.9',
dataPath: '/tmp/opsxshell-data.json',
credentialPath: '/tmp/.opsxshsell/master.key',
}),
// SSH
sshConnect: vi.fn().mockResolvedValue({ success: true, connectionId: 'test-conn-1' }),
sshDisconnect: vi.fn().mockResolvedValue({ success: true }),
sshExec: vi.fn().mockResolvedValue({ success: true, stdout: '', stderr: '' }),
sshKeepalive: vi.fn().mockResolvedValue({ success: true }),
sshCreateShell: vi.fn().mockResolvedValue({ success: true, shellId: 'test-shell-1' }),
sshShellWrite: vi.fn().mockResolvedValue({ success: true }),
sshShellResize: vi.fn().mockResolvedValue({ success: true }),
sshForwardLocal: vi.fn().mockResolvedValue({ success: true }),
sshForwardRemote: vi.fn().mockResolvedValue({ success: true }),
sshForwardDynamic: vi.fn().mockResolvedValue({ success: true }),
sshForwardStop: vi.fn().mockResolvedValue({ success: true }),
sshForwardList: vi.fn().mockResolvedValue({ success: true, tunnels: [] }),
// SFTP
sftpConnect: vi.fn().mockResolvedValue({ success: true, sftpId: 'test-sftp-1' }),
@@ -25,20 +40,35 @@ const mockElectronAPI = {
sftpRename: vi.fn().mockResolvedValue({ success: true }),
sftpDelete: vi.fn().mockResolvedValue({ success: true }),
sftpMkdir: vi.fn().mockResolvedValue({ success: true }),
sftpStat: vi.fn().mockResolvedValue({ success: true, stats: undefined }),
sftpChmod: vi.fn().mockResolvedValue({ success: true }),
sftpReadText: vi.fn().mockResolvedValue({ success: true, content: '' }),
sftpWriteText: vi.fn().mockResolvedValue({ success: true }),
onSFTPProgress: vi.fn(),
// Session
getSessions: vi.fn().mockResolvedValue({ success: true, sessions: [] }),
getSession: vi.fn().mockResolvedValue({ success: true, session: null }),
createSession: vi.fn().mockResolvedValue({ success: true, id: 'test-session-1' }),
updateSession: vi.fn().mockResolvedValue({ success: true }),
deleteSession: vi.fn().mockResolvedValue({ success: true }),
sessionGetAll: vi.fn().mockResolvedValue({ success: true, sessions: [] }),
sessionGetById: vi.fn().mockResolvedValue({ success: true, session: undefined }),
sessionCreate: vi.fn().mockImplementation(async (session) => ({
success: true,
session: { ...session, id: 'test-session-1' },
})),
sessionUpdate: vi.fn().mockImplementation(async (id, updates) => ({
success: true,
session: { id, ...updates },
})),
sessionDelete: vi.fn().mockResolvedValue({ success: true }),
// Settings
getSettings: vi.fn().mockResolvedValue({ success: true, settings: {} }),
setSettings: vi.fn().mockResolvedValue({ success: true }),
settingsGet: vi.fn().mockResolvedValue({ success: true, value: undefined }),
settingsSet: vi.fn().mockResolvedValue({ success: true }),
aiProviderSaveKey: vi.fn().mockResolvedValue({ success: true }),
aiProviderHasKey: vi.fn().mockResolvedValue(false),
aiProviderDeleteKey: vi.fn().mockResolvedValue({ success: true }),
aiListModels: vi.fn().mockResolvedValue({ success: true, models: [] }),
aiComplete: vi.fn().mockResolvedValue({ success: true, text: '建议', model: 'test-model', redactedCount: 0 }),
aiTestProvider: vi.fn().mockResolvedValue({ success: true, status: 200, model: 'test-model', latencyMs: 88, answer: 'OK' }),
aiCatalogFetch: vi.fn().mockResolvedValue({ success: false, documents: [], errors: ['offline'] }),
// Dialog
showSaveDialog: vi.fn().mockResolvedValue({ success: true, canceled: false, filePath: '/tmp/test' }),
@@ -46,32 +76,66 @@ const mockElectronAPI = {
// File
localReadDir: vi.fn().mockResolvedValue({ success: true, files: [] }),
localReadFile: vi.fn().mockResolvedValue({ success: true, content: '' }),
localWriteFile: vi.fn().mockResolvedValue({ success: true }),
readFile: vi.fn().mockResolvedValue({ success: true, data: '' }),
writeFile: vi.fn().mockResolvedValue({ success: true }),
localGetStats: vi.fn().mockResolvedValue({ success: true }),
localRename: vi.fn().mockResolvedValue({ success: true }),
localDelete: vi.fn().mockResolvedValue({ success: true }),
localMkdir: vi.fn().mockResolvedValue({ success: true }),
getHomePath: vi.fn().mockResolvedValue({ success: true, path: '/Users/test' }),
getDownloadsPath: vi.fn().mockResolvedValue({ success: true, path: '/Users/test/Downloads' }),
// Zmodem
onZmodemSend: vi.fn(),
getDownloadsPath: vi.fn().mockResolvedValue('/Users/test/Downloads'),
// App
getVersion: vi.fn().mockResolvedValue('1.0.0'),
getPlatform: vi.fn().mockReturnValue('darwin'),
openExternal: vi.fn().mockResolvedValue(undefined),
clipboardReadText: vi.fn().mockResolvedValue(''),
clipboardWriteText: vi.fn().mockResolvedValue(undefined),
toggleMaximize: vi.fn().mockResolvedValue(undefined),
// Local terminal
localListShells: vi.fn().mockResolvedValue({
success: true,
shells: [
{ path: '/bin/zsh', name: 'Zsh', kind: 'zsh', isDefault: true },
{ path: '/bin/bash', name: 'Bash', kind: 'bash', isDefault: false },
],
}),
localCreateShell: vi.fn().mockResolvedValue({
success: true,
shellId: 'local-test-1',
shellPath: '/bin/zsh',
shellName: 'zsh',
}),
localWrite: vi.fn().mockResolvedValue({ success: true }),
localResize: vi.fn().mockResolvedValue({ success: true }),
localKill: vi.fn().mockResolvedValue({ success: true }),
onLocalData: vi.fn(),
// Event listeners
onShellData: vi.fn(),
onConnectionClosed: vi.fn(),
onHostKeyPrompt: vi.fn(),
onMenuNewSession: vi.fn(),
onMenuQuickConnect: vi.fn(),
onMenuLocalTerminal: vi.fn(),
onMenuImportSessions: vi.fn(),
onMenuExportSessions: vi.fn(),
onMenuOpenSettings: vi.fn(),
onSshLinkAvailable: vi.fn(),
respondHostKeyPrompt: vi.fn(),
knownHostsList: vi.fn().mockResolvedValue({ success: true, entries: [] }),
knownHostsRemove: vi.fn().mockResolvedValue({ success: true }),
knownHostsClear: vi.fn().mockResolvedValue({ success: true }),
removeListener: vi.fn(),
removeAllListeners: vi.fn(),
}
window.electronAPI = mockElectronAPI as unknown as typeof window.electronAPI
window.opsxApi = mockDesktopApi
// Mock navigator
Object.defineProperty(window, 'navigator', {
value: { platform: 'MacIntel' },
writable: true
Object.defineProperty(window.navigator, 'platform', {
configurable: true,
value: 'MacIntel',
})
// Mock ResizeObserver
@@ -82,10 +146,18 @@ class ResizeObserverMock {
}
window.ResizeObserver = ResizeObserverMock
// XTerm probes canvas support during component smoke tests. jsdom intentionally
// does not implement a rendering context, so provide the same null fallback
// browsers expose when a context type is unavailable.
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: vi.fn(() => null),
})
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
@@ -101,10 +173,7 @@ Object.defineProperty(window, 'matchMedia', {
const originalError = console.error
beforeAll(() => {
console.error = (...args: unknown[]) => {
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))
) {
if (typeof args[0] === 'string' && (args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))) {
return
}
originalError.call(console, ...args)
+26
View File
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDesktopApi } from '../setup'
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
describe('AI provider store secret boundary', () => {
beforeEach(() => {
vi.clearAllMocks()
useAiProviderStore.setState({
providers: [],
activeProviderId: '',
isLoaded: true,
catalogProviders: [],
catalogUpdatedAt: null,
})
})
it('saves API keys through Rust and never writes them to normal settings', async () => {
const providerId = await useAiProviderStore.getState().addPreset('deepseek')
const provider = useAiProviderStore.getState().providers.find((item) => item.id === providerId)!
await useAiProviderStore.getState().saveProvider(provider, 'sk-very-secret')
expect(mockDesktopApi.aiProviderSaveKey).toHaveBeenCalledWith(providerId, 'sk-very-secret')
const settingsPayloads = vi.mocked(mockDesktopApi.settingsSet).mock.calls.map((call) => call[1])
expect(JSON.stringify(settingsPayloads)).not.toContain('sk-very-secret')
})
})
+8 -8
View File
@@ -35,14 +35,14 @@ describe('KeybindingStore', () => {
expect(state.isLoaded).toBe(false)
})
it('loadFromSettings 应从 electronAPI 加载自定义快捷键', async () => {
it('loadFromSettings 应从 opsxApi 加载自定义快捷键', async () => {
const customData = {
[KeybindingAction.TAB_CLOSE]: 'Ctrl+Q',
[KeybindingAction.TAB_NEW]: 'Ctrl+T',
}
// Mock electronAPI 返回自定义快捷键
vi.mocked(window.electronAPI.settingsGet).mockResolvedValueOnce({
// Mock opsxApi 返回自定义快捷键
vi.mocked(window.opsxApi.settingsGet).mockResolvedValueOnce({
success: true,
value: customData,
})
@@ -55,7 +55,7 @@ describe('KeybindingStore', () => {
})
it('loadFromSettings 无保存数据时应使用空自定义', async () => {
vi.mocked(window.electronAPI.settingsGet).mockResolvedValueOnce({
vi.mocked(window.opsxApi.settingsGet).mockResolvedValueOnce({
success: false,
})
@@ -67,7 +67,7 @@ describe('KeybindingStore', () => {
})
it('loadFromSettings API 报错时应安全降级', async () => {
vi.mocked(window.electronAPI.settingsGet).mockRejectedValueOnce(new Error('API error'))
vi.mocked(window.opsxApi.settingsGet).mockRejectedValueOnce(new Error('API error'))
await useKeybindingStore.getState().loadFromSettings()
@@ -121,7 +121,7 @@ describe('KeybindingStore', () => {
expect(state.customKeybindings[KeybindingAction.TAB_CLOSE]).toBe('Ctrl+Q')
// 应调用 settingsSet 持久化
expect(window.electronAPI.settingsSet).toHaveBeenCalledWith(
expect(window.opsxApi.settingsSet).toHaveBeenCalledWith(
'keybindings',
{ [KeybindingAction.TAB_CLOSE]: 'Ctrl+Q' }
)
@@ -142,7 +142,7 @@ describe('KeybindingStore', () => {
expect(state.customKeybindings[KeybindingAction.TAB_NEW]).toBe('Ctrl+T')
// 第二次调用应包含两个
const lastCall = vi.mocked(window.electronAPI.settingsSet).mock.calls.at(-1)
const lastCall = vi.mocked(window.opsxApi.settingsSet).mock.calls.at(-1)
expect(lastCall?.[1]).toEqual({
[KeybindingAction.TAB_CLOSE]: 'Ctrl+Q',
[KeybindingAction.TAB_NEW]: 'Ctrl+T',
@@ -190,7 +190,7 @@ describe('KeybindingStore', () => {
const state = useKeybindingStore.getState()
expect(state.customKeybindings).toEqual({})
expect(window.electronAPI.settingsSet).toHaveBeenCalledWith('keybindings', {})
expect(window.opsxApi.settingsSet).toHaveBeenCalledWith('keybindings', {})
})
})
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { usePoolStore } from '../../src/stores/connection/poolStore'
import type { PooledConnection } from '../../src/stores/connection/types'
function connection(connectionId: string): PooledConnection {
return {
connectionId,
config: { host: 'opsbase.cn', port: 55555, username: 'root' },
state: 'connected',
refCount: 1,
tabIds: new Set(['tab-a']),
activeTabId: 'tab-a',
shellIds: new Set(['shell-a']),
sftpIds: new Set(),
stats: { connectedAt: Date.now(), lastActivityAt: Date.now(), bytesTransferred: 0, commandsExecuted: 0 },
keepaliveInterval: 1,
reconnectAttempts: 0,
maxReconnectAttempts: 3,
}
}
afterEach(() => usePoolStore.setState({ connections: new Map() }))
describe('native connection close', () => {
it('removes the stale renderer connection without another SSH disconnect', () => {
const clearIntervalSpy = vi.spyOn(window, 'clearInterval')
usePoolStore.setState({ connections: new Map([['native-gone', connection('native-gone')]]) })
usePoolStore.getState().forgetNativeConnection('native-gone')
expect(usePoolStore.getState().connections.has('native-gone')).toBe(false)
expect(clearIntervalSpy).toHaveBeenCalled()
clearIntervalSpy.mockRestore()
})
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { normalizeAppSettings } from '../../src/stores/settingsStore'
describe('settings migration', () => {
it('drops obsolete legacy settings while preserving supported values', () => {
const settings = normalizeAppSettings({
theme: 'light',
timeout: 45,
language: 'en-US',
showNotifications: true,
encryptPasswords: false,
lockOnSleep: true,
})
expect(settings.theme).toBe('light')
expect(settings.timeout).toBe(45)
expect(settings).not.toHaveProperty('language')
expect(settings).not.toHaveProperty('showNotifications')
expect(settings).not.toHaveProperty('encryptPasswords')
expect(settings).not.toHaveProperty('lockOnSleep')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../src/utils/aiResponse'
describe('terminal AI response guards', () => {
it('extracts shell code without treating prose as a command', () => {
expect(extractBashBlocks('检查:\n```bash\nuname -a\n```')).toEqual(['uname -a'])
expect(extractBashBlocks('请运行 uname -a')).toEqual([])
})
it('only permits a single line and never adds enter', () => {
expect(singleLineTerminalInsert('date\n')).toBe('date')
expect(singleLineTerminalInsert('date\nuname -a')).toBeNull()
expect(singleLineTerminalInsert('')).toBeNull()
})
it('counts exactly the context lines shown to the user', () => {
expect(terminalContextLineCount('a\nb\n')).toBe(2)
expect(terminalContextLineCount('')).toBe(0)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { ErrorCode, handleError } from '../../src/utils/error-handler'
vi.mock('../../src/utils/logger', () => ({
logger: { error: vi.fn() },
}))
describe('connection error classification', () => {
it.each([
'connection timed out',
'SSH handshake timeout',
'连接超时',
])('classifies timeout: %s', (message) => {
expect(handleError(new Error(message)).code).toBe(ErrorCode.CONNECTION_TIMEOUT)
})
it.each([
'getaddrinfo ENOTFOUND host.invalid',
'无法解析主机 host.invalid',
])('classifies DNS failures: %s', (message) => {
expect(handleError(new Error(message)).code).toBe(ErrorCode.HOST_UNREACHABLE)
})
it('classifies connection refusal separately from timeout', () => {
expect(handleError(new Error('Connection refused')).code).toBe(ErrorCode.CONNECTION_FAILED)
})
})
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { guessLanguageFromPath } from '../../src/utils/fileLanguage'
describe('guessLanguageFromPath', () => {
it('detects common file extensions for the SFTP editor', () => {
expect(guessLanguageFromPath('/a/b/c.ts')).toBe('typescript')
expect(guessLanguageFromPath('Dockerfile')).toBe('dockerfile')
expect(guessLanguageFromPath('app.py')).toBe('python')
expect(guessLanguageFromPath('readme.md')).toBe('markdown')
})
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import type { JumpHostConfig, SSHConnectionConfig } from '../../shared/types/session.types'
describe('JumpHost types', () => {
it('allows jumpHost on connection config', () => {
const jump: JumpHostConfig = {
host: 'bastion.example.com',
port: 22,
username: 'ops',
authType: 'key',
privateKey: '~/.ssh/id_ed25519',
}
const config: SSHConnectionConfig = {
host: '10.0.0.5',
port: 22,
username: 'root',
jumpHost: jump,
}
expect(config.jumpHost?.host).toBe('bastion.example.com')
expect(config.host).toBe('10.0.0.5')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { redactForLog } from '../../src/utils/logger'
describe('redactForLog', () => {
it('redacts nested credentials without mutating the source', () => {
const source = {
host: 'opsbase.cn',
password: 'hunter2',
auth: { passphrase: 'secret', username: 'root' },
}
expect(redactForLog(source)).toEqual({
host: 'opsbase.cn',
password: '[REDACTED]',
auth: { passphrase: '[REDACTED]', username: 'root' },
})
expect(source.password).toBe('hunter2')
})
it('redacts private keys, authorization values and URL passwords', () => {
expect(redactForLog('-----BEGIN OPENSSH PRIVATE KEY-----\nabc')).toBe('[REDACTED]')
expect(redactForLog('Bearer abc.def_123')).toBe('Bearer [REDACTED]')
expect(redactForLog('ssh://root:password@example.com')).toBe('ssh://root:[REDACTED]@example.com')
})
it('handles circular structures', () => {
const value: Record<string, unknown> = {}
value.self = value
expect(redactForLog(value)).toEqual({ self: '[Circular]' })
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { allowsNativeContextMenu, suppressWebViewContextMenu } from '../../src/utils/nativeContextMenu'
describe('native context menu policy', () => {
it('suppresses the WebView menu on ordinary application surfaces', () => {
const surface = document.createElement('div')
document.body.append(surface)
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
surface.addEventListener('contextmenu', suppressWebViewContextMenu)
surface.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('keeps native copy and paste actions for editable fields', () => {
const input = document.createElement('input')
const editor = document.createElement('div')
editor.setAttribute('contenteditable', 'true')
expect(allowsNativeContextMenu(input)).toBe(true)
expect(allowsNativeContextMenu(editor)).toBe(true)
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
input.addEventListener('contextmenu', suppressWebViewContextMenu)
input.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { parseSshTarget } from '../../src/components/sessions/QuickConnectDialog'
describe('parseSshTarget', () => {
it('解析 host', () => {
expect(parseSshTarget('192.168.1.1')).toEqual({
host: '192.168.1.1',
port: 22,
username: 'root',
})
})
it('解析 user@host', () => {
expect(parseSshTarget('ubuntu@opsbase.cn')).toEqual({
host: 'opsbase.cn',
port: 22,
username: 'ubuntu',
})
})
it('解析 user@host:port', () => {
expect(parseSshTarget('root@opsbase.cn:5555')).toEqual({
host: 'opsbase.cn',
port: 5555,
username: 'root',
})
})
it('解析 host:port', () => {
expect(parseSshTarget('example.com:2222')).toEqual({
host: 'example.com',
port: 2222,
username: 'root',
})
})
it('空串返回 null', () => {
expect(parseSshTarget(' ')).toBeNull()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { decodeShellPayload, shellPayloadByteLength } from '../../src/utils/shellData'
describe('decodeShellPayload', () => {
it('decodes number arrays as UTF-8 bytes (not Array.join)', () => {
// ESC [ ? 2 0 0 4 l → bracketed paste off
const bytes = [27, 91, 63, 50, 48, 48, 52, 108]
const text = decodeShellPayload(bytes)
expect(text).toBe('\x1b[?2004l')
expect(text).not.toContain(',')
})
it('decodes Buffer-like objects', () => {
const payload = { type: 'Buffer', data: [114, 111, 111, 116] } // "root"
expect(decodeShellPayload(payload)).toBe('root')
})
it('passes through strings', () => {
expect(decodeShellPayload('hello')).toBe('hello')
})
it('decodes Uint8Array', () => {
expect(decodeShellPayload(new Uint8Array([65, 66, 67]))).toBe('ABC')
})
})
describe('shellPayloadByteLength', () => {
it('counts array length', () => {
expect(shellPayloadByteLength([1, 2, 3])).toBe(3)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { expandSnippetVariables } from '../../src/utils/snippetVariables'
import { applyKeywordHighlights, DEFAULT_KEYWORD_HIGHLIGHT_RULES } from '../../src/utils/keywordHighlight'
describe('expandSnippetVariables', () => {
it('replaces known placeholders', () => {
const out = expandSnippetVariables('ssh {{username}}@{{host}}:{{port}}', {
username: 'root',
host: '10.0.0.1',
port: 22,
})
expect(out).toBe('ssh root@10.0.0.1:22')
})
it('uses default when missing', () => {
const out = expandSnippetVariables('cd {{path:/tmp}}', {})
expect(out).toBe('cd /tmp')
})
it('keeps unknown placeholders without default', () => {
const out = expandSnippetVariables('echo {{unknown}}', {})
expect(out).toBe('echo {{unknown}}')
})
})
describe('applyKeywordHighlights', () => {
it('highlights error keywords', () => {
const out = applyKeywordHighlights('FATAL error happened', DEFAULT_KEYWORD_HIGHLIGHT_RULES)
expect(out).toContain('\x1b[')
expect(out).toContain('FATAL')
})
it('returns original when disabled rules only', () => {
const rules = DEFAULT_KEYWORD_HIGHLIGHT_RULES.map((r) => ({ ...r, enabled: false }))
const text = 'no highlight'
expect(applyKeywordHighlights(text, rules)).toBe(text)
})
})
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { parseSshConfig } from '../../src/utils/sshConfig'
describe('parseSshConfig', () => {
it('imports concrete hosts and common connection fields', () => {
const sessions = parseSshConfig(`
Host bastion
HostName gateway.example.com
User ops
IdentityFile ~/.ssh/ops_ed25519
Host production prod-short
HostName 10.10.0.8
Port 2222
User root
ProxyJump bastion
`)
expect(sessions).toEqual([
{
name: 'bastion',
host: 'gateway.example.com',
port: 22,
username: 'ops',
authType: 'key',
privateKey: '~/.ssh/ops_ed25519',
proxyJumpAlias: undefined,
group: 'SSH Config',
},
expect.objectContaining({
name: 'production',
host: '10.10.0.8',
port: 2222,
username: 'root',
authType: 'agent',
proxyJumpAlias: 'bastion',
}),
expect.objectContaining({ name: 'prod-short', proxyJumpAlias: 'bastion' }),
])
})
it('skips wildcard and negated host patterns', () => {
const sessions = parseSshConfig(`
Host *
ServerAliveInterval 30
Host !blocked *.internal
User deploy
Host usable
HostName usable.example.com
`)
expect(sessions).toHaveLength(1)
expect(sessions[0].name).toBe('usable')
})
})
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { formatSshLinkTarget } from '../../src/utils/sshLink'
describe('formatSshLinkTarget', () => {
it('formats default and custom ports', () => {
expect(formatSshLinkTarget({ host: 'example.com', port: 22, username: 'root' })).toBe('root@example.com')
expect(formatSshLinkTarget({ host: 'example.com', port: 2222, username: 'admin' })).toBe('admin@example.com:2222')
})
it('keeps IPv6 unambiguous', () => {
expect(formatSshLinkTarget({ host: '2001:db8::1', port: 22, username: 'root' })).toBe('root@[2001:db8::1]')
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { TerminalOutputBuffer } from '../../src/utils/terminalOutputBuffer'
function harness(options: { maxChunkChars?: number; maxPendingChars?: number } = {}) {
const callbacks: Array<() => void> = []
const write = vi.fn()
const buffer = new TerminalOutputBuffer({
write,
schedule: (callback) => {
callbacks.push(callback)
return callbacks.length
},
cancel: vi.fn(),
...options,
})
return { buffer, callbacks, write }
}
describe('TerminalOutputBuffer', () => {
it('coalesces events into one write per frame', () => {
const { buffer, callbacks, write } = harness()
buffer.enqueue('one')
buffer.enqueue('two')
expect(callbacks).toHaveLength(1)
callbacks.shift()?.()
expect(write).toHaveBeenCalledWith('onetwo')
})
it('preserves UTF-8 characters split across native events', () => {
const { buffer, callbacks, write } = harness()
const bytes = new TextEncoder().encode('中文')
buffer.enqueue(bytes.slice(0, 2))
buffer.enqueue(bytes.slice(2, 4))
buffer.enqueue(bytes.slice(4))
callbacks.shift()?.()
expect(write).toHaveBeenCalledWith('中文')
})
it('limits work per frame and schedules remaining output', () => {
const { buffer, callbacks, write } = harness({ maxChunkChars: 4 })
buffer.enqueue('abcdefgh')
callbacks.shift()?.()
expect(write).toHaveBeenLastCalledWith('abcd')
expect(callbacks).toHaveLength(1)
callbacks.shift()?.()
expect(write).toHaveBeenLastCalledWith('efgh')
})
it('bounds queued output and reports truncation', () => {
const { buffer, callbacks, write } = harness({ maxPendingChars: 4 })
buffer.enqueue('abcdefgh')
callbacks.shift()?.()
const output = write.mock.calls[0][0] as string
expect(output).toContain('output truncated')
expect(output).toContain('efgh')
expect(output).not.toContain('abcd')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalInputTargets } from '../../src/utils/terminalPanes'
const panes = [
{ id: 'left', shellId: 'shell-1' },
{ id: 'starting', shellId: null },
{ id: 'right', shellId: 'shell-3' },
]
describe('terminal pane input routing', () => {
it('routes normal input only to the source pane', () => {
expect(resolveTerminalInputTargets(panes, 'right', false)).toEqual(['shell-3'])
})
it('routes broadcast input to every ready shell in pane order', () => {
expect(resolveTerminalInputTargets(panes, 'left', true)).toEqual([
'shell-1',
'shell-3',
])
})
it('does not write when the source pane has no ready shell', () => {
expect(resolveTerminalInputTargets(panes, 'starting', false)).toEqual([])
expect(resolveTerminalInputTargets(panes, 'missing', false)).toEqual([])
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import { TerminalPipeline } from '../../src/utils/terminalPipeline'
describe('TerminalPipeline', () => {
it('routes input and reports output bytes through one boundary', () => {
const input = vi.fn()
const write = vi.fn()
const onOutputBytes = vi.fn()
const scheduled: Array<() => void> = []
const pipeline = new TerminalPipeline({
input,
output: {
write,
schedule: (callback) => {
scheduled.push(callback)
return scheduled.length
},
cancel: vi.fn(),
},
onOutputBytes,
})
pipeline.input('date\r')
pipeline.output(Uint8Array.from([0x68, 0x69]))
scheduled.shift()?.()
expect(input).toHaveBeenCalledWith('date\r')
expect(onOutputBytes).toHaveBeenCalledWith(2)
expect(write).toHaveBeenCalledWith('hi')
})
it('keeps split UTF-8 output intact and catches async input failures', async () => {
const write = vi.fn()
const onError = vi.fn()
const scheduled: Array<() => void> = []
const pipeline = new TerminalPipeline({
input: () => Promise.reject(new Error('closed')),
output: {
write,
schedule: (callback) => {
scheduled.push(callback)
return scheduled.length
},
cancel: vi.fn(),
},
onError,
})
pipeline.input('x')
pipeline.output(Uint8Array.from([0xe4, 0xb8]))
pipeline.output(Uint8Array.from([0xad]))
scheduled.shift()?.()
await Promise.resolve()
expect(write).toHaveBeenCalledWith('中')
expect(onError).toHaveBeenCalledOnce()
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalCopyShortcut,
isTerminalPasteShortcut,
isTerminalSplitShortcut,
} from '../../src/utils/terminalShortcuts'
function keyEvent(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
return new KeyboardEvent('keydown', { key, ...init })
}
describe('terminal shortcuts', () => {
it('uses Cmd+C and Cmd+V on macOS', () => {
expect(isTerminalCopyShortcut(keyEvent('c', { metaKey: true }))).toBe(true)
expect(isTerminalPasteShortcut(keyEvent('v', { metaKey: true }))).toBe(true)
})
it('uses Ctrl+Shift+C and Ctrl+Shift+V outside macOS', () => {
expect(isTerminalCopyShortcut(keyEvent('C', { ctrlKey: true, shiftKey: true }))).toBe(true)
expect(isTerminalPasteShortcut(keyEvent('V', { ctrlKey: true, shiftKey: true }))).toBe(true)
})
it('uses Cmd+D for splitting', () => {
expect(isTerminalSplitShortcut(keyEvent('d', { metaKey: true }))).toBe(true)
})
it('does not consume Ctrl+D, which must reach the remote shell as EOF', () => {
expect(isTerminalSplitShortcut(keyEvent('d', { ctrlKey: true }))).toBe(false)
})
})