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() 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() 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() 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() 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() 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() 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() 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() 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() 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, }) }) })