Files
opsxshell/TDD_PLAN.md
T
Logan.Li 484d850abd feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现
核心功能:
- SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮
- SFTP管理: 双栏文件管理、上传下载、传输队列
- 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理

架构改进:
- 拆分大型组件 (SFTPView/TabBar/Sidebar)
- 添加统一日志和错误处理
- 修复React严格模式下的终端黑屏问题

UI更新:
- 新增端口转发视图
- 更新UI预览文档
- 左侧栏右键菜单支持SFTP快捷打开
2026-04-06 01:20:47 +08:00

14 KiB

TDD_PLAN.md - 测试计划

本文档规划 OpsXShell 的测试策略,采用 先有后优 的开发理念。

核心理念: 先确保功能完整可用,再逐步建立自动化测试覆盖


测试策略

当前阶段 (v1.0.0)

功能实现 → 手动验证 → 测试框架 → 单元测试 → 集成测试
    ↑__________________________________________________|

原则:

  1. 功能优先:先实现并手动验证功能可用
  2. 测试跟进:功能稳定后建立自动化测试
  3. 逐步覆盖:从核心模块开始扩展测试范围
  4. 集成验证:确保 IPC 通信和端到端流程正确

第一阶段:测试框架建立 (P0)

1.1 安装测试依赖

# 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 配置文件

// 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'],
    },
  },
})
// 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

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

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

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

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 通信测试

// 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 集成测试

// 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.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 测试用例

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

测试执行

命令

# 运行所有测试
npm test

# 带覆盖率
npm test -- --coverage

# 监视模式
npm test -- --watch

# E2E 测试
npm run test:e2e

# 带 UI 的 Vitest
npm run test:ui

CI 配置

# .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