核心功能: - SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮 - SFTP管理: 双栏文件管理、上传下载、传输队列 - 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理 架构改进: - 拆分大型组件 (SFTPView/TabBar/Sidebar) - 添加统一日志和错误处理 - 修复React严格模式下的终端黑屏问题 UI更新: - 新增端口转发视图 - 更新UI预览文档 - 左侧栏右键菜单支持SFTP快捷打开
555 lines
14 KiB
Markdown
555 lines
14 KiB
Markdown
# TDD_PLAN.md - 测试计划
|
|
|
|
本文档规划 OpsXShell 的测试策略,采用 **先有后优** 的开发理念。
|
|
|
|
**核心理念**: 先确保功能完整可用,再逐步建立自动化测试覆盖
|
|
|
|
---
|
|
|
|
## 测试策略
|
|
|
|
### 当前阶段 (v1.0.0)
|
|
|
|
```
|
|
功能实现 → 手动验证 → 测试框架 → 单元测试 → 集成测试
|
|
↑__________________________________________________|
|
|
```
|
|
|
|
**原则**:
|
|
1. 功能优先:先实现并手动验证功能可用
|
|
2. 测试跟进:功能稳定后建立自动化测试
|
|
3. 逐步覆盖:从核心模块开始扩展测试范围
|
|
4. 集成验证:确保 IPC 通信和端到端流程正确
|
|
|
|
---
|
|
|
|
## 第一阶段:测试框架建立 (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'],
|
|
coverage: {
|
|
provider: 'v8',
|
|
reporter: ['text', 'html'],
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
```typescript
|
|
// tests/setup.ts
|
|
import '@testing-library/jest-dom'
|
|
|
|
// 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
|
|
```
|
|
|
|
---
|
|
|
|
## 第二阶段:单元测试 (P0)
|
|
|
|
### 2.1 Store 测试
|
|
|
|
#### tabStore.test.ts
|
|
|
|
```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('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('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')
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
#### connectionStore.test.ts
|
|
|
|
```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,
|
|
})),
|
|
}))
|
|
|
|
vi.mock('@xterm/addon-fit', () => ({
|
|
FitAddon: vi.fn().mockImplementation(() => ({
|
|
fit: vi.fn(),
|
|
loadAddon: vi.fn(),
|
|
})),
|
|
}))
|
|
|
|
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()
|
|
})
|
|
})
|
|
```
|
|
|
|
#### Sidebar.test.tsx
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
import Sidebar from '../../src/components/Sidebar'
|
|
|
|
describe('Sidebar', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
window.electronAPI.sessionGetAll.mockResolvedValue({
|
|
success: true,
|
|
sessions: [],
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 第三阶段:集成测试 (P1)
|
|
|
|
### 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 配置
|
|
|
|
```typescript
|
|
// playwright.config.ts
|
|
import { defineConfig } from '@playwright/test'
|
|
|
|
export default defineConfig({
|
|
testDir: './tests/e2e',
|
|
timeout: 30000,
|
|
use: {
|
|
baseURL: 'http://localhost:5173',
|
|
headless: true,
|
|
},
|
|
webServer: {
|
|
command: 'npm run dev',
|
|
port: 5173,
|
|
reuseExistingServer: true,
|
|
},
|
|
})
|
|
```
|
|
|
|
### 4.2 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()
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 测试执行
|
|
|
|
### 命令
|
|
|
|
```bash
|
|
# 运行所有测试
|
|
npm test
|
|
|
|
# 带覆盖率
|
|
npm test -- --coverage
|
|
|
|
# 监视模式
|
|
npm test -- --watch
|
|
|
|
# E2E 测试
|
|
npm run test:e2e
|
|
|
|
# 带 UI 的 Vitest
|
|
npm run test:ui
|
|
```
|
|
|
|
### 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
|
|
```
|
|
|
|
---
|
|
|
|
## 覆盖率目标
|
|
|
|
| 版本 | 单元测试 | 集成测试 | 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 |
|