docs: v1.0.0 独立代码审计 - 重新评估(58/100) + 制定v1.1~v1.5优化路线图 + TDD计划
This commit is contained in:
1 parent
c4bf43f58f
commit
60c3d868c5
3 files changed
+777
-875
No files matched your search
+309
-461
@@ -1,489 +1,363 @@
|
||||
# TDD_PLAN.md - 测试计划
|
||||
# TDD_PLAN.md - 测试驱动开发计划
|
||||
|
||||
本文档规划 OpsXShell 的测试策略,采用 **先有后优** 的开发理念。
|
||||
本文档规划 OpsXShell 的测试策略,覆盖从单元测试到 E2E 测试的完整体系。
|
||||
|
||||
**核心理念**: 先确保功能完整可用,再逐步建立自动化测试覆盖
|
||||
---
|
||||
|
||||
## 当前状态
|
||||
|
||||
| 指标 | 当前值 | 目标值 |
|
||||
|------|--------|--------|
|
||||
| 测试文件数 | 3 | 15+ |
|
||||
| 测试用例数 | 34 | 150+ |
|
||||
| 估算覆盖率 | ~10% | ≥50% |
|
||||
| 核心模块覆盖 | 0% | ≥80% |
|
||||
|
||||
### 已有测试
|
||||
|
||||
| 测试文件 | 用例数 | 覆盖模块 | 质量 |
|
||||
|----------|--------|----------|------|
|
||||
| `test/stores/tabStore.test.ts` | 15 | tabStore | ✅ 完整 |
|
||||
| `test/stores/connectionPool.test.ts` | 7 | poolStore | 🟡 仅接口验证 |
|
||||
| `test/components/sftp/utils.test.ts` | 12 | sftp/utils | ✅ 完整 |
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 当前阶段 (v1.0.0)
|
||||
|
||||
```
|
||||
功能实现 → 手动验证 → 测试框架 → 单元测试 → 集成测试
|
||||
↑__________________________________________________|
|
||||
v1.3.0 测试计划
|
||||
├── P0: 核心安全模块(crypto, ssh-service)
|
||||
├── P1: 数据层(database, error-handler, stores)
|
||||
├── P1: IPC 通信层(8 个 IPC Handler)
|
||||
├── P2: UI 层(TerminalView, useSFTP hooks)
|
||||
└── P3: E2E 集成(Playwright)
|
||||
```
|
||||
|
||||
**原则**:
|
||||
1. 功能优先:先实现并手动验证功能可用
|
||||
2. 测试跟进:功能稳定后建立自动化测试
|
||||
3. 逐步覆盖:从核心模块开始扩展测试范围
|
||||
4. 集成验证:确保 IPC 通信和端到端流程正确
|
||||
### 原则
|
||||
|
||||
1. **安全先行**: 加密/解密模块必须 100% 测试覆盖
|
||||
2. **核心优先**: SSH 连接逻辑是应用核心,优先覆盖
|
||||
3. **可 mock 性**: 所有外部依赖(ssh2, better-sqlite3, electron)必须可 mock
|
||||
4. **独立性**: 每个测试独立运行,不依赖外部状态
|
||||
5. **快速反馈**: 单元测试 < 5s,集成测试 < 30s
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段:测试框架建立 (P0)
|
||||
## 测试环境配置
|
||||
|
||||
### 1.1 安装测试依赖
|
||||
|
||||
```bash
|
||||
# Vitest + React Testing Library
|
||||
npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom
|
||||
|
||||
# Playwright E2E
|
||||
npm install -D @playwright/test
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### 1.2 配置文件
|
||||
### 已有配置
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
thresholds: {
|
||||
statements: 50, // 当前远未达到
|
||||
branches: 40,
|
||||
functions: 50,
|
||||
lines: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Mock 策略
|
||||
|
||||
```typescript
|
||||
// tests/setup.ts
|
||||
import '@testing-library/jest-dom'
|
||||
// test/setup.ts 已有:
|
||||
// - window.electronAPI 完整 mock
|
||||
// - localStorage mock
|
||||
|
||||
// Mock electron API
|
||||
const mockElectronAPI = {
|
||||
sshConnect: vi.fn(),
|
||||
sshDisconnect: vi.fn(),
|
||||
sshCreateShell: vi.fn(),
|
||||
sshShellWrite: vi.fn(),
|
||||
sshShellResize: vi.fn(),
|
||||
sessionGetAll: vi.fn(),
|
||||
sessionGetById: vi.fn(),
|
||||
sessionCreate: vi.fn(),
|
||||
sessionUpdate: vi.fn(),
|
||||
sessionDelete: vi.fn(),
|
||||
sftpConnect: vi.fn(),
|
||||
sftpListDirectory: vi.fn(),
|
||||
sftpUpload: vi.fn(),
|
||||
sftpDownload: vi.fn(),
|
||||
onShellData: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
}
|
||||
|
||||
window.electronAPI = mockElectronAPI as any
|
||||
// 需要新增:
|
||||
// - ssh2 Client mock
|
||||
// - better-sqlite3 mock
|
||||
// - zmodem-ts mock
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:单元测试 (P0)
|
||||
## P0: 核心模块测试(第一批)
|
||||
|
||||
### 2.1 Store 测试
|
||||
### 1. crypto.ts 测试 — 加密/解密正确性
|
||||
|
||||
#### tabStore.test.ts
|
||||
**文件**: `test/services/crypto.test.ts`
|
||||
**用例目标**: 15+
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
|
||||
describe('useTabStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
describe('Crypto Service', () => {
|
||||
describe('encrypt / decrypt', () => {
|
||||
it('应正确加密并解密明文字符串')
|
||||
it('应正确加密并解密空字符串')
|
||||
it('应正确加密并解密特殊字符和 Unicode')
|
||||
it('每次加密应生成不同的密文(随机 IV)')
|
||||
it('密文篡改后解密应失败(Auth Tag 验证)')
|
||||
it('错误密钥解密应失败')
|
||||
})
|
||||
|
||||
describe('addTab', () => {
|
||||
it('should add a new terminal tab', () => {
|
||||
const { addTab } = useTabStore.getState()
|
||||
addTab({ title: 'Test', type: 'terminal' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[1].title).toBe('Test')
|
||||
})
|
||||
|
||||
it('should activate new tab', () => {
|
||||
const { addTab } = useTabStore.getState()
|
||||
addTab({ title: 'Test', type: 'terminal' })
|
||||
|
||||
const { activeTabId } = useTabStore.getState()
|
||||
expect(activeTabId).toBe('tab-1')
|
||||
})
|
||||
|
||||
it('should not duplicate settings tab', () => {
|
||||
const { addTab } = useTabStore.getState()
|
||||
addTab({ title: '设置', type: 'settings' })
|
||||
addTab({ title: '设置2', type: 'settings' })
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs.filter(t => t.type === 'settings')).toHaveLength(1)
|
||||
})
|
||||
describe('deriveKey', () => {
|
||||
it('相同密码和 salt 应派生相同密钥')
|
||||
it('不同 salt 应派生不同密钥')
|
||||
it('应使用 100,000 次 PBKDF2 迭代')
|
||||
})
|
||||
|
||||
describe('closeTab', () => {
|
||||
it('should close tab and activate next', () => {
|
||||
const { addTab, closeTab, tabs } = useTabStore.getState()
|
||||
addTab({ title: 'Tab1', type: 'terminal' })
|
||||
const tabToClose = tabs[1].id
|
||||
closeTab(tabToClose)
|
||||
|
||||
const { tabs: newTabs } = useTabStore.getState()
|
||||
expect(newTabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should not close last tab', () => {
|
||||
const { closeTab, tabs } = useTabStore.getState()
|
||||
closeTab(tabs[0].id)
|
||||
|
||||
const { tabs: newTabs } = useTabStore.getState()
|
||||
expect(newTabs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connectionStatus', () => {
|
||||
it('should update tab connection status', () => {
|
||||
const { addTab, updateTabStatus } = useTabStore.getState()
|
||||
addTab({ title: 'SSH', type: 'terminal' })
|
||||
const tabId = useTabStore.getState().tabs[1].id
|
||||
|
||||
updateTabStatus(tabId, 'connected')
|
||||
|
||||
const { tabs } = useTabStore.getState()
|
||||
expect(tabs.find(t => t.id === tabId)?.connectionStatus).toBe('connected')
|
||||
})
|
||||
describe('generateSalt / generateIV', () => {
|
||||
it('salt 应为 32 字节随机值')
|
||||
it('IV 应为 12 字节随机值(GCM 推荐)')
|
||||
it('每次生成的值应不同')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### connectionStore.test.ts
|
||||
### 2. ssh-service.ts 测试 — SSH 连接逻辑
|
||||
|
||||
**文件**: `test/services/ssh-service.test.ts`
|
||||
**用例目标**: 20+
|
||||
**Mock**: ssh2 Client
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useConnectionStore } from '../../src/stores/connectionStore'
|
||||
|
||||
describe('useConnectionStore', () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({ sessions: [] })
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('addSession', () => {
|
||||
it('should call sessionCreate API', async () => {
|
||||
window.electronAPI.sessionCreate.mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: 'test-1', name: 'Test', host: 'localhost' },
|
||||
})
|
||||
|
||||
const { addSession } = useConnectionStore.getState()
|
||||
await addSession({
|
||||
name: 'Test',
|
||||
host: 'localhost',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
authType: 'password',
|
||||
})
|
||||
|
||||
expect(window.electronAPI.sessionCreate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSession', () => {
|
||||
it('should remove session from state', async () => {
|
||||
useConnectionStore.setState({
|
||||
sessions: [{ id: 'test-1', name: 'Test', host: 'localhost' }],
|
||||
})
|
||||
|
||||
const { removeSession } = useConnectionStore.getState()
|
||||
await removeSession('test-1')
|
||||
|
||||
const { sessions } = useConnectionStore.getState()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 2.2 组件测试
|
||||
|
||||
#### TerminalView.test.tsx
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import TerminalView from '../../src/components/TerminalView'
|
||||
|
||||
// Mock xterm
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
onData: vi.fn(),
|
||||
onResize: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
// Mock ssh2
|
||||
vi.mock('ssh2', () => ({
|
||||
Client: vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn(),
|
||||
on: vi.fn(),
|
||||
end: vi.fn(),
|
||||
shell: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
forwardIn: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({
|
||||
fit: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
describe('SSH Service', () => {
|
||||
describe('connect', () => {
|
||||
it('应使用密码认证连接')
|
||||
it('应使用私钥认证连接')
|
||||
it('应使用 SSH Agent 认证连接')
|
||||
it('连接失败应返回错误')
|
||||
it('连接超时应返回超时错误')
|
||||
it('重复连接同一主机应复用')
|
||||
})
|
||||
|
||||
describe('createShell', () => {
|
||||
it('应创建交互式 Shell')
|
||||
it('应传入终端尺寸 cols/rows')
|
||||
it('Shell 创建失败应返回错误')
|
||||
})
|
||||
|
||||
describe('disconnect', () => {
|
||||
it('应正确关闭连接')
|
||||
it('应清理所有 Shell 会话')
|
||||
it('应清理端口转发')
|
||||
})
|
||||
|
||||
describe('portForward', () => {
|
||||
it('应创建本地端口转发')
|
||||
it('应创建远程端口转发')
|
||||
it('应创建 SOCKS5 动态代理')
|
||||
it('端口被占用应返回错误')
|
||||
})
|
||||
|
||||
describe('connectionPool', () => {
|
||||
it('不应超过最大连接数限制')
|
||||
it('空闲连接应被自动回收')
|
||||
it('LRU 淘汰策略应正确工作')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P1: 数据层 + Store 测试(第二批)
|
||||
|
||||
### 3. database-service.ts 测试
|
||||
|
||||
**文件**: `test/services/database.test.ts`
|
||||
**用例目标**: 15+
|
||||
|
||||
```typescript
|
||||
describe('Database Service', () => {
|
||||
describe('Session CRUD', () => {
|
||||
it('应创建新会话并返回 ID')
|
||||
it('应获取所有会话列表')
|
||||
it('应根据 ID 获取单个会话')
|
||||
it('应更新会话字段')
|
||||
it('应删除会话')
|
||||
it('密码字段应加密存储')
|
||||
it('读取时密码字段应解密')
|
||||
})
|
||||
|
||||
describe('Settings', () => {
|
||||
it('应保存和读取设置')
|
||||
it('未设置的键应返回默认值')
|
||||
it('应支持复杂对象类型设置')
|
||||
})
|
||||
|
||||
describe('数据完整性', () => {
|
||||
it('数据库文件不存在应自动创建')
|
||||
it('Schema 版本升级应自动迁移')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 4. error-handler.ts 测试
|
||||
|
||||
**文件**: `test/utils/error-handler.test.ts`
|
||||
**用例目标**: 10+
|
||||
|
||||
```typescript
|
||||
describe('Error Handler', () => {
|
||||
describe('inferErrorCode', () => {
|
||||
it('应从 ECONNREFUSED 推断连接错误')
|
||||
it('应从 ETIMEDOUT 推断超时错误')
|
||||
it('应从 auth 推断认证错误')
|
||||
it('应从 ENOENT 推断文件不存在错误')
|
||||
it('未知错误应返回通用错误码')
|
||||
})
|
||||
|
||||
describe('withErrorHandling', () => {
|
||||
it('应包装异步函数并捕获错误')
|
||||
it('应返回统一错误格式')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 5. poolStore 测试增强
|
||||
|
||||
**文件**: `test/stores/connectionPool.test.ts`
|
||||
**用例目标**: 15+ (新增 8)
|
||||
|
||||
```typescript
|
||||
describe('Connection Pool Store', () => {
|
||||
// 已有: 基本接口验证
|
||||
// 新增:
|
||||
it('应拒绝超过最大连接数的新连接')
|
||||
it('LRU 淘汰应移除最久未使用的连接')
|
||||
it('后台连接超过最大数应断开最旧')
|
||||
it('活跃连接不应被 LRU 淘汰')
|
||||
it('连接状态机转换应完整覆盖')
|
||||
it('挂起超时 30 分钟应彻底断开')
|
||||
it('保活心跳应重置空闲计时器')
|
||||
})
|
||||
```
|
||||
|
||||
### 6. lifecycleStore 测试
|
||||
|
||||
**文件**: `test/stores/lifecycleStore.test.ts`
|
||||
**用例目标**: 10+
|
||||
|
||||
---
|
||||
|
||||
## P1: IPC Handler 测试(第三批)
|
||||
|
||||
### 7. SSH IPC 测试
|
||||
|
||||
**文件**: `test/ipc/ssh.ipc.test.ts`
|
||||
**用例目标**: 8+
|
||||
|
||||
```typescript
|
||||
describe('SSH IPC Handlers', () => {
|
||||
it('ssh:connect 应调用 sshService.connect')
|
||||
it('ssh:disconnect 应调用 sshService.disconnect')
|
||||
it('ssh:create-shell 应调用 sshService.createShell')
|
||||
it('ssh:shell-write 应调用 sshService.shellWrite')
|
||||
it('ssh:shell-resize 应调用 sshService.shellResize')
|
||||
it('ssh:forward-local 应调用 sshService.forwardLocal')
|
||||
it('ssh:forward-remote 应调用 sshService.forwardRemote')
|
||||
it('无效参数应返回错误')
|
||||
})
|
||||
```
|
||||
|
||||
### 8. SFTP IPC 测试
|
||||
|
||||
**文件**: `test/ipc/sftp.ipc.test.ts`
|
||||
**用例目标**: 8+
|
||||
|
||||
### 9. Session IPC 测试
|
||||
|
||||
**文件**: `test/ipc/session.ipc.test.ts`
|
||||
**用例目标**: 6+
|
||||
|
||||
### 10. Settings IPC 测试
|
||||
|
||||
**文件**: `test/ipc/settings.ipc.test.ts`
|
||||
**用例目标**: 4+
|
||||
|
||||
### 11. 其他 IPC 测试
|
||||
|
||||
**文件**: `test/ipc/{dialog,file,zmodem,app}.ipc.test.ts`
|
||||
**用例目标**: 每个文件 3-5+
|
||||
|
||||
---
|
||||
|
||||
## P2: UI 层测试(第四批)
|
||||
|
||||
### 12. TerminalView 组件测试
|
||||
|
||||
**文件**: `test/components/TerminalView.test.tsx`
|
||||
**用例目标**: 10+
|
||||
|
||||
```typescript
|
||||
// Mock xterm
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
onData: vi.fn(), onResize: vi.fn(), open: vi.fn(),
|
||||
write: vi.fn(), dispose: vi.fn(), cols: 80, rows: 24,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('TerminalView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render terminal container', () => {
|
||||
render(<TerminalView />)
|
||||
expect(screen.getByText('连接成功!')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should show connecting status', () => {
|
||||
render(<TerminalView connectionConfig={{ host: 'test', port: 22, username: 'user' }} />)
|
||||
expect(screen.getByText('正在连接...')).toBeDefined()
|
||||
})
|
||||
it('应渲染终端容器')
|
||||
it('应显示连接中状态')
|
||||
it('连接成功应初始化 XTerm')
|
||||
it('连接失败应显示错误信息')
|
||||
it('Shell 数据应写入终端')
|
||||
it('终端输入应发送到 Shell')
|
||||
it('窗口调整应 resize 终端')
|
||||
it('组件卸载应释放资源')
|
||||
})
|
||||
```
|
||||
|
||||
#### Sidebar.test.tsx
|
||||
### 13. useSFTP Hooks 测试
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import Sidebar from '../../src/components/Sidebar'
|
||||
**文件**: `test/hooks/useSFTP.test.ts`
|
||||
**用例目标**: 15+
|
||||
|
||||
describe('Sidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.electronAPI.sessionGetAll.mockResolvedValue({
|
||||
success: true,
|
||||
sessions: [],
|
||||
})
|
||||
})
|
||||
### 14. 其他组件测试
|
||||
|
||||
it('should render quick connect button', () => {
|
||||
render(<Sidebar onNewSession={vi.fn()} onQuickConnect={vi.fn()} onEditSession={vi.fn()} />)
|
||||
expect(screen.getByText('快速连接')).toBeDefined()
|
||||
})
|
||||
|
||||
it('should call onQuickConnect when button clicked', () => {
|
||||
const onQuickConnect = vi.fn()
|
||||
render(<Sidebar onNewSession={vi.fn()} onQuickConnect={onQuickConnect} onEditSession={vi.fn()} />)
|
||||
|
||||
fireEvent.click(screen.getByText('快速连接'))
|
||||
expect(onQuickConnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display sessions from API', async () => {
|
||||
window.electronAPI.sessionGetAll.mockResolvedValue({
|
||||
success: true,
|
||||
sessions: [
|
||||
{ id: '1', name: 'Server 1', host: '192.168.1.1', port: 22 },
|
||||
],
|
||||
})
|
||||
|
||||
render(<Sidebar onNewSession={vi.fn()} onQuickConnect={vi.fn()} onEditSession={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('Server 1')).toBeDefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
- `test/components/Sidebar.test.tsx` — 5+
|
||||
- `test/components/SessionDialog.test.tsx` — 5+
|
||||
- `test/components/SFTPView.test.tsx` — 5+
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:集成测试 (P1)
|
||||
## P3: E2E 集成测试(远期)
|
||||
|
||||
### 3.1 IPC 通信测试
|
||||
|
||||
```typescript
|
||||
// tests/integration/ipc.test.ts
|
||||
describe('IPC Communication', () => {
|
||||
describe('SSH Operations', () => {
|
||||
it('should connect and create shell', async () => {
|
||||
// Mock successful connection
|
||||
window.electronAPI.sshConnect.mockResolvedValue({
|
||||
success: true,
|
||||
connectionId: 'conn-1',
|
||||
})
|
||||
window.electronAPI.sshCreateShell.mockResolvedValue({
|
||||
success: true,
|
||||
shellId: 'shell-1',
|
||||
})
|
||||
|
||||
// Simulate connection flow
|
||||
const config = { host: 'test', port: 22, username: 'user', password: 'pass' }
|
||||
const connResult = await window.electronAPI.sshConnect(config)
|
||||
|
||||
expect(connResult.success).toBe(true)
|
||||
expect(connResult.connectionId).toBe('conn-1')
|
||||
|
||||
const shellResult = await window.electronAPI.sshCreateShell('conn-1', { cols: 80, rows: 24 })
|
||||
expect(shellResult.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session CRUD', () => {
|
||||
it('should create, read, update, delete session', async () => {
|
||||
// Create
|
||||
window.electronAPI.sessionCreate.mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: 'sess-1', name: 'Test', host: 'localhost' },
|
||||
})
|
||||
|
||||
const createResult = await window.electronAPI.sessionCreate({
|
||||
name: 'Test',
|
||||
host: 'localhost',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
})
|
||||
expect(createResult.success).toBe(true)
|
||||
|
||||
// Update
|
||||
window.electronAPI.sessionUpdate.mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: 'sess-1', name: 'Updated', host: 'localhost' },
|
||||
})
|
||||
|
||||
const updateResult = await window.electronAPI.sessionUpdate('sess-1', { name: 'Updated' })
|
||||
expect(updateResult.success).toBe(true)
|
||||
expect(updateResult.session.name).toBe('Updated')
|
||||
|
||||
// Delete
|
||||
window.electronAPI.sessionDelete.mockResolvedValue({ success: true })
|
||||
const deleteResult = await window.electronAPI.sessionDelete('sess-1')
|
||||
expect(deleteResult.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3.2 SFTP 集成测试
|
||||
|
||||
```typescript
|
||||
// tests/integration/sftp.test.ts
|
||||
describe('SFTP Operations', () => {
|
||||
beforeEach(() => {
|
||||
window.electronAPI.sshConnect.mockResolvedValue({
|
||||
success: true,
|
||||
connectionId: 'conn-1',
|
||||
})
|
||||
window.electronAPI.sftpConnect.mockResolvedValue({
|
||||
success: true,
|
||||
sftpId: 'sftp-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should connect to SFTP and list directory', async () => {
|
||||
window.electronAPI.sftpListDirectory.mockResolvedValue({
|
||||
success: true,
|
||||
files: [
|
||||
{ filename: 'file1.txt', attrs: { isDirectory: () => false, size: 1024 } },
|
||||
{ filename: 'dir1', attrs: { isDirectory: () => true, size: 0 } },
|
||||
],
|
||||
})
|
||||
|
||||
const sftpResult = await window.electronAPI.sftpConnect('conn-1')
|
||||
expect(sftpResult.success).toBe(true)
|
||||
|
||||
const listResult = await window.electronAPI.sftpListDirectory('sftp-1', '/home/user')
|
||||
expect(listResult.success).toBe(true)
|
||||
expect(listResult.files).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:E2E 测试 (P2)
|
||||
|
||||
### 4.1 Playwright 配置
|
||||
### Playwright 配置
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testDir: './test/e2e',
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
headless: true,
|
||||
},
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
use: { baseURL: 'http://localhost:5173' },
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 E2E 测试用例
|
||||
### E2E 用例
|
||||
|
||||
```typescript
|
||||
// tests/e2e/connection.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('SSH Connection Flow', () => {
|
||||
test('should connect via quick connect dialog', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Click quick connect
|
||||
await page.getByText('快速连接').click()
|
||||
|
||||
// Fill form
|
||||
await page.getByPlaceholder('主机地址').fill('localhost')
|
||||
await page.getByPlaceholder('端 口').fill('22')
|
||||
await page.getByPlaceholder('用户名').fill('test')
|
||||
|
||||
// Submit
|
||||
await page.getByText('连接').click()
|
||||
|
||||
// Should show terminal
|
||||
await expect(page.locator('[data-testid="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should connect via sidebar session', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Add a session first (via API mock in real test)
|
||||
// Click session in sidebar
|
||||
await page.getByText('测试服务器').click()
|
||||
|
||||
// Should open terminal tab
|
||||
await expect(page.locator('[data-tab-title="测试服务器"]')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('SFTP Flow', () => {
|
||||
test('should show session selection when no session provided', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Open SFTP
|
||||
await page.getByText('SFTP 传输').click()
|
||||
|
||||
// Click connect
|
||||
await page.getByText('选择服务器').click()
|
||||
|
||||
// Should show session list
|
||||
await expect(page.getByText('选择要连接的服务器')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
- 快速连接 → SSH 终端出现
|
||||
- 侧边栏会话 → 点击 → 终端连接
|
||||
- SFTP → 选择服务器 → 浏览目录
|
||||
- 标签页 → 创建 → 切换 → 关闭
|
||||
|
||||
---
|
||||
|
||||
@@ -492,63 +366,37 @@ test.describe('SFTP Flow', () => {
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm test
|
||||
|
||||
# 带覆盖率
|
||||
npm test -- --coverage
|
||||
|
||||
# 监视模式
|
||||
npm test -- --watch
|
||||
|
||||
# E2E 测试
|
||||
npm run test:e2e
|
||||
|
||||
# 带 UI 的 Vitest
|
||||
npm run test:ui
|
||||
npm test # 运行所有测试
|
||||
npm test -- --coverage # 带覆盖率报告
|
||||
npm test -- test/services/ # 只跑 services 测试
|
||||
npm test -- --reporter=verbose # 详细输出
|
||||
npm test -- --watch # 监听模式
|
||||
```
|
||||
|
||||
### CI 配置
|
||||
### CI 集成(规划)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm test -- --coverage
|
||||
- uses: codecov/codecov-action@v3
|
||||
- name: Test
|
||||
run: npm test -- --coverage
|
||||
- name: Coverage Check
|
||||
run: |
|
||||
if [ $(npm test -- --coverage --reporter=json 2>/dev/null | jq '.total.lines.pct') -lt 50 ]; then
|
||||
echo "Coverage below 50%"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 覆盖率目标
|
||||
## 覆盖率追踪
|
||||
|
||||
| 版本 | 单元测试 | 集成测试 | E2E |
|
||||
|------|----------|----------|-----|
|
||||
| v1.0.0 | 20% | 10% | 5% |
|
||||
| v1.1.0 | 40% | 20% | 10% |
|
||||
| v1.2.0 | 60% | 30% | 15% |
|
||||
|
||||
---
|
||||
|
||||
## 测试优先级矩阵
|
||||
|
||||
| 功能模块 | 单元测试 | 集成测试 | E2E | 优先级 |
|
||||
|----------|----------|----------|-----|--------|
|
||||
| tabStore | ✅ | - | - | P0 |
|
||||
| connectionStore | ✅ | ✅ | - | P0 |
|
||||
| TerminalView | ✅ | ✅ | ✅ | P0 |
|
||||
| SFTPView | ✅ | ✅ | ✅ | P1 |
|
||||
| Sidebar | ✅ | ✅ | ✅ | P1 |
|
||||
| SessionDialog | ✅ | ✅ | - | P1 |
|
||||
| ssh-service | - | ✅ | - | P0 |
|
||||
| database-service | - | ✅ | - | P1 |
|
||||
| 版本 | 总覆盖率 | 核心模块 | 用例数 | 日期 |
|
||||
|------|----------|----------|--------|------|
|
||||
| v1.0.0 | ~10% | 0% | 34 | 2026-04-06 |
|
||||
| v1.1.0 | ~12% | 0% | 34 | — |
|
||||
| v1.2.0 | ~15% | 0% | 40 | — |
|
||||
| v1.3.0-batch1 | ~25% | 60% | 69 | — |
|
||||
| v1.3.0-batch2 | ~35% | 60% | 104 | — |
|
||||
| v1.3.0-batch3 | ~45% | 70% | 143 | — |
|
||||
| v1.3.0-final | **≥50%** | **≥80%** | **150+** | — |
|
||||
Reference in new issue
Block a user