feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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', {})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user