feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现
核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
1 parent
cf1c054381
commit
484d850abd
105 files changed
+25330
-64
No files matched your search
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* SFTP Utils Tests - SFTP 工具函数测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatSize, formatSpeed, formatDate, getFileExtension, sortFiles } from '../../../src/components/sftp/utils'
|
||||
import type { FileItem } from '../../../src/components/sftp/types'
|
||||
|
||||
describe('SFTP Utils', () => {
|
||||
describe('formatSize', () => {
|
||||
it('应该正确格式化字节', () => {
|
||||
expect(formatSize(0)).toBe('--')
|
||||
expect(formatSize(512)).toBe('512 B')
|
||||
expect(formatSize(1024)).toBe('1 KB')
|
||||
expect(formatSize(1536)).toBe('1.5 KB')
|
||||
expect(formatSize(1048576)).toBe('1 MB')
|
||||
expect(formatSize(1073741824)).toBe('1 GB')
|
||||
})
|
||||
|
||||
it('应该保留一位小数', () => {
|
||||
expect(formatSize(1500)).toBe('1.5 KB')
|
||||
expect(formatSize(1572864)).toBe('1.5 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSpeed', () => {
|
||||
it('应该正确格式化传输速度', () => {
|
||||
expect(formatSpeed(0)).toBe('0 B/s')
|
||||
expect(formatSpeed(1024)).toBe('1 KB/s')
|
||||
expect(formatSpeed(1048576)).toBe('1 MB/s')
|
||||
})
|
||||
|
||||
it('应该保留一位小数', () => {
|
||||
expect(formatSpeed(1536)).toBe('1.5 KB/s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('应该显示今天', () => {
|
||||
const today = Date.now() / 1000
|
||||
expect(formatDate(today)).toBe('今天')
|
||||
})
|
||||
|
||||
it('应该显示昨天', () => {
|
||||
const yesterday = Date.now() / 1000 - 86400
|
||||
expect(formatDate(yesterday)).toBe('昨天')
|
||||
})
|
||||
|
||||
it('应该显示天数', () => {
|
||||
const threeDaysAgo = Date.now() / 1000 - 3 * 86400
|
||||
expect(formatDate(threeDaysAgo)).toBe('3 天前')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('应该返回正确的文件类型描述', () => {
|
||||
expect(getFileExtension('test.pdf')).toBe('PDF')
|
||||
expect(getFileExtension('test.json')).toBe('JSON')
|
||||
expect(getFileExtension('test.ts')).toBe('TypeScript')
|
||||
expect(getFileExtension('test.MD')).toBe('Markdown')
|
||||
})
|
||||
|
||||
it('应该处理无扩展名的文件', () => {
|
||||
expect(getFileExtension('Makefile')).toBe('文件')
|
||||
})
|
||||
|
||||
it('应该返回大写扩展名', () => {
|
||||
expect(getFileExtension('test.PNG')).toBe('PNG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortFiles', () => {
|
||||
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--' },
|
||||
]
|
||||
|
||||
it('应该按名称排序且目录优先', () => {
|
||||
const sorted = sortFiles(mockFiles, 'name', 'asc')
|
||||
expect(sorted[0].name).toBe('docs') // 目录优先
|
||||
expect(sorted[1].name).toBe('backup.zip')
|
||||
})
|
||||
|
||||
it('应该按大小排序且目录优先', () => {
|
||||
const sorted = sortFiles(mockFiles, 'size', 'asc')
|
||||
expect(sorted[0].name).toBe('docs') // 目录优先
|
||||
expect(sorted[sorted.length - 1].name).toBe('backup.zip') // 最大
|
||||
})
|
||||
|
||||
it('应该按修改时间排序且目录优先', () => {
|
||||
const sorted = sortFiles(mockFiles, 'modified', 'asc')
|
||||
expect(sorted[0].name).toBe('docs') // 目录优先
|
||||
expect(sorted[1].name).toBe('image.png') // 第二新
|
||||
})
|
||||
|
||||
it('应该反转排序顺序', () => {
|
||||
const sorted = sortFiles(mockFiles, 'name', 'desc')
|
||||
expect(sorted[0].name).toBe('docs') // 目录优先,即使降序
|
||||
})
|
||||
|
||||
it('应该总是目录优先', () => {
|
||||
const sorted = sortFiles(mockFiles, 'size', 'desc')
|
||||
expect(sorted[0].type).toBe('directory')
|
||||
})
|
||||
|
||||
it('不应该修改原始数组', () => {
|
||||
const original = [...mockFiles]
|
||||
sortFiles(mockFiles, 'name', 'asc')
|
||||
expect(mockFiles).toEqual(original)
|
||||
})
|
||||
})
|
||||
})
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Vitest Setup - 测试环境配置
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock Electron API
|
||||
const mockElectronAPI = {
|
||||
// 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: '' }),
|
||||
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 }),
|
||||
|
||||
// SFTP
|
||||
sftpConnect: vi.fn().mockResolvedValue({ success: true, sftpId: 'test-sftp-1' }),
|
||||
sftpListDirectory: vi.fn().mockResolvedValue({ success: true, files: [] }),
|
||||
sftpUpload: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpDownload: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpRename: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpDelete: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpMkdir: 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 }),
|
||||
|
||||
// Settings
|
||||
getSettings: vi.fn().mockResolvedValue({ success: true, settings: {} }),
|
||||
setSettings: vi.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
// Dialog
|
||||
showSaveDialog: vi.fn().mockResolvedValue({ success: true, canceled: false, filePath: '/tmp/test' }),
|
||||
showOpenDialog: vi.fn().mockResolvedValue({ success: true, canceled: false, filePaths: ['/tmp/test'] }),
|
||||
|
||||
// File
|
||||
localReadDir: vi.fn().mockResolvedValue({ success: true, files: [] }),
|
||||
localReadFile: vi.fn().mockResolvedValue({ success: true, content: '' }),
|
||||
localWriteFile: 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(),
|
||||
|
||||
// App
|
||||
getVersion: vi.fn().mockResolvedValue('1.0.0'),
|
||||
getPlatform: vi.fn().mockReturnValue('darwin'),
|
||||
|
||||
// Event listeners
|
||||
onShellData: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}
|
||||
|
||||
window.electronAPI = mockElectronAPI as any
|
||||
|
||||
// Mock navigator
|
||||
Object.defineProperty(window, 'navigator', {
|
||||
value: { platform: 'MacIntel' },
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock ResizeObserver
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
window.ResizeObserver = ResizeObserverMock
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Suppress console errors in tests
|
||||
const originalError = console.error
|
||||
beforeAll(() => {
|
||||
console.error = (...args: any[]) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
(args[0].includes('Warning:') || args[0].includes('ReactDOM.render'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
originalError.call(console, ...args)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ConnectionPool Store Tests - 连接池状态管理测试
|
||||
*
|
||||
* 注意: 这些测试验证 store 接口存在且可用
|
||||
* 实际的连接功能需要集成测试环境
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useConnectionPool, useConnectionStore, getConnectionStore } from '../../src/stores/connectionPool'
|
||||
|
||||
describe('ConnectionPool Store', () => {
|
||||
describe('store 初始化', () => {
|
||||
it('useConnectionStore hook 应该存在', () => {
|
||||
expect(typeof useConnectionStore).toBe('function')
|
||||
})
|
||||
|
||||
it('useConnectionPool hook 应该存在 (向后兼容别名)', () => {
|
||||
expect(typeof useConnectionPool).toBe('function')
|
||||
})
|
||||
|
||||
it('getConnectionStore 函数应该存在', () => {
|
||||
expect(typeof getConnectionStore).toBe('function')
|
||||
})
|
||||
|
||||
it('getConnectionStore 应该返回正确的接口', () => {
|
||||
const store = getConnectionStore()
|
||||
expect(store).toHaveProperty('createConnection')
|
||||
expect(store).toHaveProperty('getConnection')
|
||||
expect(store).toHaveProperty('releaseConnection')
|
||||
expect(store).toHaveProperty('disconnectConnection')
|
||||
expect(store).toHaveProperty('getStats')
|
||||
})
|
||||
})
|
||||
|
||||
describe('store 方法签名', () => {
|
||||
it('createConnection 应该是异步函数', () => {
|
||||
const store = getConnectionStore()
|
||||
expect(store.createConnection).toBeInstanceOf(Function)
|
||||
// 验证是 async function (通过返回 Promise)
|
||||
const result = store.createConnection('test-tab', {
|
||||
host: 'localhost',
|
||||
port: 22,
|
||||
username: 'test',
|
||||
})
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
})
|
||||
|
||||
it('getStats 应该返回连接统计', () => {
|
||||
const store = getConnectionStore()
|
||||
const stats = store.getStats()
|
||||
expect(stats).toHaveProperty('total')
|
||||
expect(stats).toHaveProperty('connected')
|
||||
expect(stats).toHaveProperty('background')
|
||||
expect(stats).toHaveProperty('suspended')
|
||||
})
|
||||
})
|
||||
|
||||
describe('配置', () => {
|
||||
it('应该有默认最大连接数配置', () => {
|
||||
const store = getConnectionStore()
|
||||
expect(store.config).toBeDefined()
|
||||
expect(store.config.maxConnections).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('应该有默认后台连接配置', () => {
|
||||
const store = getConnectionStore()
|
||||
expect(store.config.maxBackgroundConnections).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* TabStore Tests - 标签页状态管理测试
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
|
||||
describe('TabStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store to initial state
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
})
|
||||
|
||||
describe('初始状态', () => {
|
||||
it('应该包含默认的欢迎标签', () => {
|
||||
const { tabs, activeTabId } = useTabStore.getState()
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0].id).toBe('welcome')
|
||||
expect(tabs[0].type).toBe('welcome')
|
||||
expect(activeTabId).toBe('welcome')
|
||||
})
|
||||
})
|
||||
|
||||
describe('addTab', () => {
|
||||
it('应该添加新的终端标签', () => {
|
||||
const { addTab, tabs } = useTabStore.getState()
|
||||
addTab({ title: 'Terminal', type: 'terminal' })
|
||||
|
||||
const newTabs = useTabStore.getState().tabs
|
||||
expect(newTabs).toHaveLength(tabs.length + 1)
|
||||
expect(newTabs.find(t => t.type === 'terminal')).toBeDefined()
|
||||
})
|
||||
|
||||
it('添加新标签后应该激活它', () => {
|
||||
const { addTab } = useTabStore.getState()
|
||||
addTab({ title: 'Terminal', type: 'terminal' })
|
||||
|
||||
const { activeTabId, tabs } = useTabStore.getState()
|
||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||
expect(activeTab?.type).toBe('terminal')
|
||||
})
|
||||
|
||||
it('添加 settings 类型时如果已存在则激活而不是创建新的', () => {
|
||||
const { addTab, tabs } = useTabStore.getState()
|
||||
addTab({ title: 'Settings', type: 'settings' })
|
||||
addTab({ title: 'Settings 2', type: 'settings' })
|
||||
|
||||
const newTabs = useTabStore.getState().tabs
|
||||
expect(newTabs).toHaveLength(2) // welcome + settings
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTab', () => {
|
||||
it('应该更新指定标签的标题', () => {
|
||||
const { updateTab } = useTabStore.getState()
|
||||
updateTab('welcome', { title: '新的标题' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs[0].title).toBe('新的标题')
|
||||
})
|
||||
|
||||
it('应该更新标签的连接状态', () => {
|
||||
const { updateTab } = useTabStore.getState()
|
||||
updateTab('welcome', { connectionStatus: 'connected' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs[0].connectionStatus).toBe('connected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeTab', () => {
|
||||
it('不应该关闭最后一个标签', () => {
|
||||
const { closeTab } = useTabStore.getState()
|
||||
const closed = closeTab('welcome')
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(closed).toBeNull()
|
||||
})
|
||||
|
||||
it('关闭标签后应该激活另一个标签', () => {
|
||||
const { addTab, closeTab } = useTabStore.getState()
|
||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||
addTab({ title: 'Tab 2', type: 'sftp' })
|
||||
|
||||
const { tabs, activeTabId } = useTabStore.getState()
|
||||
const tabToClose = tabs.find(t => t.title === 'Tab 1')
|
||||
|
||||
if (tabToClose) {
|
||||
closeTab(tabToClose.id)
|
||||
const { tabs: newTabs, activeTabId: newActiveId } = useTabStore.getState()
|
||||
expect(newTabs.find(t => t.isActive)?.title).toBe('Tab 2')
|
||||
}
|
||||
})
|
||||
|
||||
it('应该返回被关闭的标签', () => {
|
||||
const { addTab, closeTab } = useTabStore.getState()
|
||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
const tabToClose = tabs.find(t => t.title === 'Tab 1')
|
||||
|
||||
if (tabToClose) {
|
||||
const closed = closeTab(tabToClose.id)
|
||||
expect(closed?.title).toBe('Tab 1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeOtherTabs', () => {
|
||||
it('应该关闭除指定标签外的所有标签', () => {
|
||||
const { addTab, closeOtherTabs, tabs } = useTabStore.getState()
|
||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||
addTab({ title: 'Tab 2', type: 'sftp' })
|
||||
|
||||
const tabToKeep = useTabStore.getState().tabs.find(t => t.title === 'Tab 1')
|
||||
if (tabToKeep) {
|
||||
closeOtherTabs(tabToKeep.id)
|
||||
const { tabs: newTabs } = useTabStore.getState()
|
||||
expect(newTabs).toHaveLength(1)
|
||||
expect(newTabs[0].title).toBe('Tab 1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('activateTab', () => {
|
||||
it('应该激活指定的标签', () => {
|
||||
const { addTab, activateTab } = useTabStore.getState()
|
||||
addTab({ title: 'Tab 1', type: 'terminal' })
|
||||
addTab({ title: 'Tab 2', type: 'sftp' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
const tabToActivate = tabs.find(t => t.title === 'Tab 1')
|
||||
|
||||
if (tabToActivate) {
|
||||
activateTab(tabToActivate.id)
|
||||
const { tabs: newTabs, activeTabId } = useTabStore.getState()
|
||||
const activeTab = newTabs.find(t => t.id === activeTabId)
|
||||
expect(activeTab?.title).toBe('Tab 1')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTabById', () => {
|
||||
it('应该返回指定 ID 的标签', () => {
|
||||
const { addTab, getTabById } = useTabStore.getState()
|
||||
addTab({ title: 'Test Tab', type: 'terminal' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
const testTab = tabs.find(t => t.title === 'Test Tab')
|
||||
|
||||
if (testTab) {
|
||||
const found = getTabById(testTab.id)
|
||||
expect(found?.title).toBe('Test Tab')
|
||||
}
|
||||
})
|
||||
|
||||
it('找不到时应该返回 undefined', () => {
|
||||
const { getTabById } = useTabStore.getState()
|
||||
const found = getTabById('non-existent-id')
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabTitle', () => {
|
||||
it('应该更新标签标题', () => {
|
||||
const { updateTabTitle } = useTabStore.getState()
|
||||
updateTabTitle('welcome', 'Updated Welcome')
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs[0].title).toBe('Updated Welcome')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user