Files
opsxshell/test/components/SFTPTransfer.acceptance.test.tsx
T
Logan.LiandCursor 08696896f9 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>
2026-08-16 16:08:11 +08:00

310 lines
11 KiB
TypeScript

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.findByRole('button', { name: '上传到远程' })
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.findByRole('button', { name: '上传到远程' })
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.findByRole('button', { name: '上传到远程' })
await user.click(screen.getByRole('button', { name: '上传到远程' }))
expect(await screen.findByText(/权限|Permission denied|失败/)).toBeVisible()
})
it('uploads by pointer-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.findByRole('button', { name: '上传到远程' })
const localRow = await screen.findByText('local.txt')
const remotePanel = screen.getByLabelText('远程文件面板')
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => remotePanel),
})
fireEvent.pointerDown(localRow, { button: 0, pointerId: 11, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 11, clientX: 30, clientY: 30 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 11, clientX: 30, clientY: 30 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
'/root/local.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('downloads by pointer-dragging a remote row onto the local panel', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传到远程' })
const remoteRow = await screen.findByText('remote.txt')
const localPanel = screen.getByLabelText('本地文件面板')
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => localPanel),
})
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 12, clientX: 50, clientY: 50 })
fireEvent.pointerMove(remoteRow, { pointerId: 12, clientX: 70, clientY: 70 })
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 12, clientX: 70, clientY: 70 })
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
'/root/remote.txt',
'/Users/test/remote.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('still accepts OS file HTML5 drops onto the remote panel', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传到远程' })
const file = new File(['hello'], 'from-os.txt', { type: 'text/plain' })
Object.defineProperty(file, 'path', { value: '/Users/test/from-os.txt' })
const transfer = createDataTransfer()
Object.defineProperty(transfer, 'types', { value: ['Files'] })
Object.defineProperty(transfer, 'files', {
value: {
length: 1,
0: file,
item: (index: number) => (index === 0 ? file : null),
[Symbol.iterator]: function* () {
yield file
},
},
})
fireEvent.dragEnter(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/from-os.txt',
'/root/from-os.txt'
)
})
})
it('transfers both ways through pointer dragging when the WebView does not deliver HTML5 drop', 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} />)
const localRow = await screen.findByText('local.txt')
const remoteRow = await screen.findByText('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: 1, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 1, clientX: 30, clientY: 30 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 1, clientX: 30, clientY: 30 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
'/root/local.txt'
)
})
hitTest.mockReturnValue(localPanel)
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 2, clientX: 50, clientY: 50 })
fireEvent.pointerMove(remoteRow, { pointerId: 2, clientX: 70, clientY: 70 })
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 2, clientX: 70, clientY: 70 })
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
'/root/remote.txt',
'/Users/test/remote.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('uses the directory under the pointer as the transfer destination', 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 }],
})
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: true,
files: [
remoteFile,
{
filename: 'archive',
longname: 'drwxr-xr-x 1 root root 0 Jul 30 12:00 archive',
attrs: { size: 0, mtime: 1_700_000_000, mode: 0o040755 },
},
],
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
const localRow = await screen.findByText('local.txt')
const archiveRow = await screen.findByText('archive')
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => archiveRow),
})
fireEvent.pointerDown(localRow, { button: 0, pointerId: 3, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 3, clientX: 40, clientY: 40 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 3, clientX: 40, clientY: 40 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
'/root/archive/local.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
})