396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
/**
|
|
* 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 { useSettingsStore } from '../../src/stores/settingsStore'
|
|
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')
|
|
useSettingsStore.setState((state) => ({
|
|
settings: { ...state.settings, reconnectBaseDelayMs: 10 },
|
|
}))
|
|
|
|
let releaseConnect: ((value: { success: true; sftpId: string }) => void) | undefined
|
|
vi.mocked(mockDesktopApi.sftpConnect).mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
releaseConnect = resolve
|
|
})
|
|
)
|
|
|
|
onClosed?.({}, 'conn-sftp', 'network reset')
|
|
|
|
expect(await screen.findByText(/正在重连并恢复 \//)).toBeVisible()
|
|
await waitFor(() => {
|
|
expect(releaseConnect).toBeTypeOf('function')
|
|
})
|
|
vi.mocked(mockDesktopApi.sftpListDirectory).mockClear()
|
|
releaseConnect?.({ success: true, sftpId: 'sftp-resumed' })
|
|
|
|
await waitFor(() => {
|
|
const paths = vi.mocked(mockDesktopApi.sftpListDirectory).mock.calls.map((call) => call[1])
|
|
expect(paths[0]).toBe('/')
|
|
})
|
|
expect(await screen.findByLabelText('远程文件面板')).toBeVisible()
|
|
})
|
|
})
|