feat: harden local shell, SFTP, and desktop session reliability
Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
188f3556ed
commit
08696896f9
223 files changed
+13493
-3423
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# test/patrol — 基础能力巡检
|
||||
|
||||
面向 SSH / SFTP / RDP / VNC 等基础回归,避免改 UI 或授权后无验收就引入回归。
|
||||
|
||||
与同级其他测试的关系:
|
||||
|
||||
| 路径 | 用途 |
|
||||
|------|------|
|
||||
| `test/**` | 单元、组件、原有 acceptance 细测 |
|
||||
| `test/patrol/` | 跨模块基础巡检;改功能后应优先跑 |
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 只跑巡检
|
||||
npm run test:patrol
|
||||
|
||||
# 快速验收(含 typecheck + 全量 vitest,已包含本目录)
|
||||
npm run acceptance:quick
|
||||
```
|
||||
|
||||
## 覆盖面
|
||||
|
||||
- `contracts` — 平台契约与协议默认值
|
||||
- `ssh` — SSH 会话创建 / 打开终端
|
||||
- `sftp` — 双栏、上级目录、远程 mkdir、拖拽传输
|
||||
- `desktop` — RDP / VNC 连接对话框与 open-tab 载荷
|
||||
|
||||
真实远程主机联调不在此目录(需本机 target);此处用 mock 原生 API 做渲染层 + 契约巡检。
|
||||
|
||||
异常路径也用 mock 锁住(不连真机):
|
||||
|
||||
- 列目录 Permission denied → 多语言错误条 + toast
|
||||
- 传输忙 / MaxSessions 通道失败 → 忙提示或重连条
|
||||
- SFTP 会话丢失 → 重连条
|
||||
- 上传失败 / 超 2MB 编辑 → toast 映射
|
||||
- 同名覆盖确认(覆盖/跳过)与失败任务重试
|
||||
- 断线后一键重连并恢复上次远程目录
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Platform / protocol contract patrol — catches missing commands or broken defaults.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import {
|
||||
buildOpenTabPayload,
|
||||
defaultPortForProtocol,
|
||||
isSshSession,
|
||||
sessionProtocol,
|
||||
sessionToRdpConfig,
|
||||
sessionToVncConfig,
|
||||
tabTypeForProtocol,
|
||||
} from '../../src/utils/sessionProtocol'
|
||||
import { RDP_SESSION, VNC_SESSION, SSH_SESSION } from '../helpers/patrol'
|
||||
import * as rdp from '../../src/platform/rdp'
|
||||
import * as vnc from '../../src/platform/vnc'
|
||||
import * as sftpPlatform from '../../src/platform/sftp'
|
||||
|
||||
const REQUIRED_SSH_KEYS = [
|
||||
'sshConnect',
|
||||
'sshDisconnect',
|
||||
'sshCreateShell',
|
||||
'sshShellWrite',
|
||||
'sshShellResize',
|
||||
'sshCloseShell',
|
||||
'sshForwardLocal',
|
||||
'sshForwardRemote',
|
||||
'sshForwardDynamic',
|
||||
'sshForwardStop',
|
||||
] as const
|
||||
|
||||
const REQUIRED_SFTP_KEYS = [
|
||||
'sftpConnect',
|
||||
'sftpDisconnect',
|
||||
'sftpListDirectory',
|
||||
'sftpUpload',
|
||||
'sftpUploadDir',
|
||||
'sftpDownload',
|
||||
'sftpDownloadDir',
|
||||
'sftpMkdir',
|
||||
'sftpRename',
|
||||
'sftpDelete',
|
||||
'sftpStat',
|
||||
'sftpChmod',
|
||||
] as const
|
||||
|
||||
describe('patrol / contracts', () => {
|
||||
it('leaves HTML5 drag-and-drop available to the SFTP panes', () => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), 'src-tauri/tauri.conf.json'), 'utf8')
|
||||
) as { app?: { windows?: Array<{ dragDropEnabled?: boolean }> } }
|
||||
|
||||
expect(config.app?.windows?.[0]?.dragDropEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes SSH + SFTP native commands on the desktop mock contract', () => {
|
||||
for (const key of REQUIRED_SSH_KEYS) {
|
||||
expect(typeof mockDesktopApi[key], key).toBe('function')
|
||||
}
|
||||
for (const key of REQUIRED_SFTP_KEYS) {
|
||||
expect(typeof mockDesktopApi[key], key).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps protocol defaults for SSH / RDP / VNC', () => {
|
||||
expect(defaultPortForProtocol('ssh')).toBe(22)
|
||||
expect(defaultPortForProtocol('rdp')).toBe(3389)
|
||||
expect(defaultPortForProtocol('vnc')).toBe(5900)
|
||||
expect(tabTypeForProtocol('ssh')).toBe('terminal')
|
||||
expect(tabTypeForProtocol('rdp')).toBe('rdp')
|
||||
expect(tabTypeForProtocol('vnc')).toBe('vnc')
|
||||
expect(isSshSession(SSH_SESSION)).toBe(true)
|
||||
expect(isSshSession(RDP_SESSION)).toBe(false)
|
||||
expect(sessionProtocol(VNC_SESSION)).toBe('vnc')
|
||||
})
|
||||
|
||||
it('builds open-tab payloads with desktop configs for RDP/VNC', () => {
|
||||
const rdpTab = buildOpenTabPayload(RDP_SESSION)
|
||||
expect(rdpTab.type).toBe('rdp')
|
||||
expect(rdpTab.rdpConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
host: 'win.example.com',
|
||||
port: 3389,
|
||||
username: 'Administrator',
|
||||
})
|
||||
)
|
||||
|
||||
const vncTab = buildOpenTabPayload(VNC_SESSION)
|
||||
expect(vncTab.type).toBe('vnc')
|
||||
expect(vncTab.vncConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
host: '192.168.1.50',
|
||||
port: 5900,
|
||||
})
|
||||
)
|
||||
|
||||
const sshTab = buildOpenTabPayload(SSH_SESSION)
|
||||
expect(sshTab.type).toBe('terminal')
|
||||
expect(sshTab.rdpConfig).toBeUndefined()
|
||||
expect(sshTab.vncConfig).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps session credentials into RDP/VNC engine configs', () => {
|
||||
expect(sessionToRdpConfig(RDP_SESSION).password).toBe('secret')
|
||||
expect(sessionToVncConfig(VNC_SESSION).password).toBe('vnc-pass')
|
||||
})
|
||||
|
||||
it('exports RDP / VNC / SFTP platform adapters', () => {
|
||||
expect(typeof rdp.rdpConnect).toBe('function')
|
||||
expect(typeof rdp.rdpDisconnect).toBe('function')
|
||||
expect(typeof rdp.rdpResize).toBe('function')
|
||||
expect(typeof vnc.vncConnect).toBe('function')
|
||||
expect(typeof vnc.vncDisconnect).toBe('function')
|
||||
expect(typeof sftpPlatform.uploadRemoteFile).toBe('function')
|
||||
expect(typeof sftpPlatform.downloadRemoteFile).toBe('function')
|
||||
})
|
||||
|
||||
it('refuses RDP/VNC connect outside Tauri runtime', async () => {
|
||||
await expect(rdp.rdpConnect(sessionToRdpConfig(RDP_SESSION))).rejects.toThrow(/原生|native|Tauri|RDP/i)
|
||||
await expect(vnc.vncConnect(sessionToVncConfig(VNC_SESSION))).rejects.toThrow(/原生|native|Tauri|VNC/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* RDP / VNC desktop connect + tab open patrol
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DesktopConnectDialog from '../../src/components/desktop/DesktopConnectDialog'
|
||||
import SessionDialog from '../../src/components/sessions/SessionDialog'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { buildOpenTabPayload } from '../../src/utils/sessionProtocol'
|
||||
import { RDP_SESSION, resetPatrolStores, VNC_SESSION } from '../helpers/patrol'
|
||||
|
||||
describe('patrol / desktop (rdp + vnc)', () => {
|
||||
beforeEach(() => {
|
||||
resetPatrolStores()
|
||||
})
|
||||
|
||||
it('RDP connect dialog submits host/port/username/password', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConnect = vi.fn()
|
||||
render(
|
||||
<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('连接 RDP')).toBeVisible()
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(3389)
|
||||
|
||||
await user.type(screen.getByLabelText('主机'), 'win.example.com')
|
||||
await user.clear(screen.getByLabelText('用户名'))
|
||||
await user.type(screen.getByLabelText('用户名'), 'Administrator')
|
||||
await user.type(screen.getByLabelText('密码'), 'rdp-secret')
|
||||
await user.click(screen.getByRole('button', { name: '连接' }))
|
||||
|
||||
expect(onConnect).toHaveBeenCalledWith(
|
||||
'rdp',
|
||||
expect.objectContaining({
|
||||
host: 'win.example.com',
|
||||
port: 3389,
|
||||
username: 'Administrator',
|
||||
password: 'rdp-secret',
|
||||
security: 'nla',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('VNC connect dialog defaults to port 5900 and allows password-only auth', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConnect = vi.fn()
|
||||
render(
|
||||
<DesktopConnectDialog protocol="vnc" isOpen onClose={vi.fn()} onConnect={onConnect} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('连接 VNC')).toBeVisible()
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(5900)
|
||||
expect(screen.queryByLabelText('用户名')).not.toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByLabelText('主机'), '192.168.1.50')
|
||||
await user.type(screen.getByLabelText(/密码/), 'vnc-pass')
|
||||
await user.click(screen.getByRole('button', { name: '连接' }))
|
||||
|
||||
expect(onConnect).toHaveBeenCalledWith(
|
||||
'vnc',
|
||||
expect.objectContaining({
|
||||
host: '192.168.1.50',
|
||||
port: 5900,
|
||||
password: 'vnc-pass',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('requires host before connecting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConnect = vi.fn()
|
||||
render(
|
||||
<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '连接' }))
|
||||
expect(await screen.findByText(/请输入主机/)).toBeVisible()
|
||||
expect(onConnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens RDP and VNC tabs from saved sessions with engine configs', () => {
|
||||
const rdpPayload = buildOpenTabPayload(RDP_SESSION)
|
||||
useTabStore.getState().addTab(rdpPayload)
|
||||
expect(useTabStore.getState().tabs.some((t) => t.type === 'rdp' && t.rdpConfig?.host === 'win.example.com')).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
const vncPayload = buildOpenTabPayload(VNC_SESSION)
|
||||
useTabStore.getState().addTab(vncPayload)
|
||||
expect(useTabStore.getState().tabs.some((t) => t.type === 'vnc' && t.vncConfig?.port === 5900)).toBe(true)
|
||||
})
|
||||
|
||||
it('session dialog can save an RDP session with port 3389', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
const rdpTab = screen.queryByRole('button', { name: /^RDP$/i }) || screen.queryByText(/^RDP$/i)
|
||||
if (rdpTab) {
|
||||
await user.click(rdpTab)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(3389)
|
||||
})
|
||||
await user.clear(screen.getByLabelText('主机'))
|
||||
await user.type(screen.getByLabelText('主机'), 'win.lab.local')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'win.lab.local',
|
||||
port: 3389,
|
||||
protocol: 'rdp',
|
||||
})
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Protocol switcher may use different labels — still assert helper defaults
|
||||
expect(buildOpenTabPayload(RDP_SESSION).type).toBe('rdp')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* SFTP dual-pane patrol — navigation, mkdir, drag transfer
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor, within } 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 { useTabStore } from '../../src/stores/tabStore'
|
||||
import { useToastStore } from '../../src/stores/toastStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { resetPatrolStores, SSH_SESSION } from '../helpers/patrol'
|
||||
|
||||
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 },
|
||||
}
|
||||
|
||||
describe('patrol / sftp', () => {
|
||||
beforeEach(() => {
|
||||
resetPatrolStores([SSH_SESSION])
|
||||
useToastStore.getState().clearAll()
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'sftp-tab', title: 'SFTP', type: 'sftp', sessionId: SSH_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: SSH_SESSION })
|
||||
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-native' })
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockImplementation(async (_id, path) => {
|
||||
if (path === '/root' || path === '/home/root') {
|
||||
return { success: true, files: [remoteFile] }
|
||||
}
|
||||
if (path === '/home') {
|
||||
return { success: false, error: 'Permission denied' }
|
||||
}
|
||||
if (path === '/') {
|
||||
return {
|
||||
success: true,
|
||||
files: [
|
||||
{
|
||||
filename: 'root',
|
||||
longname: 'drwxr-xr-x 1 root root 0 Jan 1 00:00 root',
|
||||
attrs: { size: 0, mtime: 1_700_000_000, mode: 0o040755 },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { success: true, files: [remoteFile] }
|
||||
})
|
||||
vi.mocked(mockDesktopApi.getHomePath).mockResolvedValue({ success: true, path: '/Users/test' })
|
||||
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 }],
|
||||
})
|
||||
vi.mocked(mockDesktopApi.sftpMkdir).mockResolvedValue({ success: true })
|
||||
vi.mocked(mockDesktopApi.sftpUpload).mockResolvedValue({ success: true })
|
||||
vi.mocked(mockDesktopApi.sftpDownload).mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
it('connects and shows dual panes with remote listing', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
expect(await screen.findByLabelText('本地文件面板')).toBeVisible()
|
||||
expect(await screen.findByLabelText('远程文件面板')).toBeVisible()
|
||||
expect(await screen.findByText('remote.txt')).toBeVisible()
|
||||
})
|
||||
|
||||
it('navigates to filesystem root via breadcrumb /', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
const rootButton = within(remotePanel).getByTitle('/')
|
||||
await user.click(rootButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpListDirectory).toHaveBeenCalledWith('sftp-native', '/')
|
||||
})
|
||||
expect(await within(remotePanel).findByText('root')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows parent .. row and navigates up from /root to /', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
const parentRows = within(remotePanel).getAllByText('..')
|
||||
await user.dblClick(parentRows[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpListDirectory).toHaveBeenCalledWith('sftp-native', '/')
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a remote folder via sftpMkdir when remote pane is focused', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
fireEvent.mouseDown(screen.getByLabelText('远程文件面板'))
|
||||
const newFolderButtons = screen.getAllByRole('button', { name: '在当前面板新建文件夹' })
|
||||
await user.click(newFolderButtons[newFolderButtons.length - 1])
|
||||
const input = await screen.findByPlaceholderText('文件夹名称')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'tests')
|
||||
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpMkdir).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
expect.stringMatching(/\/tests$/)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads by pointer-dragging local → remote and downloads remote → local', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('local.txt')
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
const localRow = screen.getByText('local.txt')
|
||||
const remoteRow = screen.getByText('remote.txt')
|
||||
const localPanel = screen.getByLabelText('本地文件面板')
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
const hitTest = vi.fn<() => Element | null>(() => remotePanel)
|
||||
const originalElementFromPoint = document.elementFromPoint
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: hitTest,
|
||||
})
|
||||
|
||||
fireEvent.pointerDown(localRow, { button: 0, pointerId: 21, clientX: 10, clientY: 10 })
|
||||
fireEvent.pointerMove(localRow, { pointerId: 21, clientX: 30, clientY: 30 })
|
||||
fireEvent.pointerUp(localRow, { button: 0, pointerId: 21, clientX: 30, clientY: 30 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/Users/test/local.txt',
|
||||
expect.stringMatching(/local\.txt$/)
|
||||
)
|
||||
})
|
||||
|
||||
hitTest.mockReturnValue(localPanel)
|
||||
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 22, clientX: 50, clientY: 50 })
|
||||
fireEvent.pointerMove(remoteRow, { pointerId: 22, clientX: 70, clientY: 70 })
|
||||
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 22, clientX: 70, clientY: 70 })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
expect.stringMatching(/remote\.txt$/),
|
||||
expect.stringMatching(/remote\.txt$/)
|
||||
)
|
||||
})
|
||||
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
value: originalElementFromPoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('transfers via dual-pane rail arrows without drag-drop', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('local.txt')
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
await user.click(screen.getByText('local.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '上传到远程' }))
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/Users/test/local.txt',
|
||||
expect.stringMatching(/local\.txt$/)
|
||||
)
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('remote.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '下载到本地' }))
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
expect.stringMatching(/remote\.txt$/),
|
||||
expect.stringMatching(/remote\.txt$/)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows translated permission error when remote listing fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Permission denied',
|
||||
})
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
|
||||
|
||||
expect(
|
||||
within(remotePanel).getAllByText('权限不足,无法访问该文件或目录').length
|
||||
).toBeGreaterThan(0)
|
||||
await waitFor(() => {
|
||||
expect(useToastStore.getState().toasts.some((item) => item.message.includes('权限不足'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('drops to reconnect UI when native reports SFTP session gone', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'SFTP 会话不存在: sftp-native',
|
||||
})
|
||||
fireEvent.mouseDown(screen.getByLabelText('远程文件面板'))
|
||||
const refreshButtons = screen.getAllByRole('button', { name: '刷新当前面板' })
|
||||
fireEvent.click(refreshButtons[refreshButtons.length - 1])
|
||||
|
||||
expect(await screen.findByText('SFTP 会话已结束,请重新连接')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '重新连接' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('toasts translated upload failure from native error', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(mockDesktopApi.sftpUpload).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Permission denied',
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('local.txt')
|
||||
|
||||
await user.click(screen.getByText('local.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '上传到远程' }))
|
||||
|
||||
await waitFor(() => {
|
||||
const messages = useToastStore.getState().toasts.map((item) => item.message)
|
||||
expect(messages.some((m) => m.includes('权限不足') || m.includes('失败'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('warns in current language when editing a file over the 2MB soft cap', async () => {
|
||||
const user = userEvent.setup()
|
||||
const bigFile = {
|
||||
filename: 'huge.log',
|
||||
longname: '-rw-r--r-- 1 root root 3000000 Jul 30 12:00 huge.log',
|
||||
attrs: { size: 3 * 1024 * 1024, mtime: 1_700_000_000, mode: 0o100644 },
|
||||
}
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: true,
|
||||
files: [bigFile],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
const row = await screen.findByText('huge.log')
|
||||
fireEvent.contextMenu(row)
|
||||
await user.click(await screen.findByText('编辑'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
useToastStore.getState().toasts.some((item) => item.message.includes('2MB'))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('maps transfer-busy list errors to a human toast', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'SFTP 正在传输文件,请稍后再试',
|
||||
})
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
|
||||
|
||||
expect(await within(remotePanel).findByText('正在传输文件,请稍后再试')).toBeVisible()
|
||||
await waitFor(() => {
|
||||
expect(useToastStore.getState().toasts.some((item) => item.message.includes('传输'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('maps MaxSessions / channel failure to reconnect guidance', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Unable to startup channel (MaxSessions)',
|
||||
})
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
|
||||
|
||||
// Channel failure also tears down the browse session → reconnect strip.
|
||||
expect(
|
||||
await screen.findByText('无法打开 SFTP 通道(服务器限制或会话繁忙),请重连后再试')
|
||||
).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '重新连接' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('asks before overwriting an existing remote file, skip leaves target untouched', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
|
||||
success: true,
|
||||
files: [
|
||||
remoteFile,
|
||||
{
|
||||
filename: 'local.txt',
|
||||
longname: '-rw-r--r-- 1 root root 8 Jul 30 12:00 local.txt',
|
||||
attrs: { size: 8, mtime: 1_700_000_000, mode: 0o100644 },
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
const localPanel = await screen.findByLabelText('本地文件面板')
|
||||
await within(localPanel).findByText('local.txt')
|
||||
await within(await screen.findByLabelText('远程文件面板')).findByText('local.txt')
|
||||
|
||||
await user.click(within(localPanel).getByText('local.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '上传到远程' }))
|
||||
|
||||
expect(await screen.findByText('覆盖已有文件?')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: '跳过' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useToastStore.getState().toasts.some((item) => item.message.includes('跳过'))).toBe(true)
|
||||
})
|
||||
expect(mockDesktopApi.sftpUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries a failed transfer from the queue', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(mockDesktopApi.sftpUpload)
|
||||
.mockResolvedValueOnce({ success: false, error: 'Permission denied' })
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('local.txt')
|
||||
await user.click(screen.getByText('local.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '上传到远程' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
const retry = await screen.findByRole('button', { name: '重试' })
|
||||
await user.click(retry)
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('reconnects and resumes the last remote directory after connection loss', async () => {
|
||||
const user = userEvent.setup()
|
||||
let onClosed: ((event: unknown, connectionId: string, reason: string) => void) | undefined
|
||||
vi.mocked(mockDesktopApi.onConnectionClosed).mockImplementation((callback) => {
|
||||
onClosed = callback as typeof onClosed
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
|
||||
await screen.findByText('remote.txt')
|
||||
|
||||
const remotePanel = screen.getByLabelText('远程文件面板')
|
||||
await user.click(within(remotePanel).getByTitle('/'))
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpListDirectory).toHaveBeenCalledWith('sftp-native', '/')
|
||||
})
|
||||
expect(await within(remotePanel).findByText('root')).toBeVisible()
|
||||
|
||||
expect(onClosed).toBeTypeOf('function')
|
||||
onClosed?.({}, 'conn-sftp', 'network reset')
|
||||
|
||||
expect(await screen.findByText(/连接已关闭|Connection closed/i)).toBeVisible()
|
||||
expect(screen.getByText(/重连后将回到 \//)).toBeVisible()
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockClear()
|
||||
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-resumed' })
|
||||
await user.click(screen.getByRole('button', { name: '重新连接' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpConnect).toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => {
|
||||
const paths = vi.mocked(mockDesktopApi.sftpListDirectory).mock.calls.map((call) => call[1])
|
||||
expect(paths[0]).toBe('/')
|
||||
})
|
||||
expect(await screen.findByLabelText('远程文件面板')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* SSH session / terminal open patrol
|
||||
*/
|
||||
|
||||
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, { parseSshTarget } from '../../src/components/sessions/QuickConnectDialog'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { resetPatrolStores, SSH_SESSION } from '../helpers/patrol'
|
||||
import { buildOpenTabPayload } from '../../src/utils/sessionProtocol'
|
||||
|
||||
describe('patrol / ssh', () => {
|
||||
beforeEach(() => {
|
||||
resetPatrolStores()
|
||||
})
|
||||
|
||||
it('parses ssh targets used by quick connect', () => {
|
||||
expect(parseSshTarget('admin@10.0.0.8:2222')).toEqual({
|
||||
host: '10.0.0.8',
|
||||
port: 2222,
|
||||
username: 'admin',
|
||||
})
|
||||
expect(parseSshTarget('opsbase.cn')).toEqual({
|
||||
host: 'opsbase.cn',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an SSH session with default port 22 and password auth', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(22)
|
||||
await user.clear(screen.getByLabelText('主机'))
|
||||
await user.type(screen.getByLabelText('主机'), 'root@opsbase.cn')
|
||||
await user.type(screen.getByLabelText('密码'), 'patrol-secret')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'opsbase.cn',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'password',
|
||||
password: 'patrol-secret',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a terminal tab from a saved SSH session payload', () => {
|
||||
const payload = buildOpenTabPayload(SSH_SESSION)
|
||||
useTabStore.getState().addTab(payload)
|
||||
const tab = useTabStore.getState().tabs.find((item) => item.sessionId === SSH_SESSION.id)
|
||||
expect(tab).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
sessionId: SSH_SESSION.id,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quick-connect opens a terminal tab for user@host:port', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<QuickConnectDialog isOpen onClose={vi.fn()} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'admin@10.0.0.8:2222')
|
||||
await user.type(screen.getByPlaceholderText('••••••••'), 'x')
|
||||
await user.click(screen.getByRole('button', { name: /连接/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
const tabs = useTabStore.getState().tabs
|
||||
expect(tabs.some((tab) => tab.type === 'terminal')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps SSH sessions in the session store', () => {
|
||||
useSessionStore.setState({ sessions: [SSH_SESSION] })
|
||||
expect(useSessionStore.getState().sessions).toHaveLength(1)
|
||||
expect(useSessionStore.getState().sessions[0].protocol ?? 'ssh').toBe('ssh')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user