Browse Source

📝 docs(architecture): 更新测试框架文档,将Jest替换为Vitest

- 在技术栈表格中用Vitest 2.x替换Jest 29.x,更新用途说明为"填补测试空白,确保代码质量,更好的TypeORM支持"
- 修改技术决策依据,将"选择Jest而不是Vitest"改为"选择Vitest而不是Jest",理由更新为"基于对TypeORM装饰器的更好支持、更快的执行速度和现代化的开发体验"
- 在架构文档、技术栈文档、测试基础设施Epic文档和UI架构文档中统一将Jest替换为Vitest
- 更新用户故事中的测试框架描述,反映当前使用Vitest + Testing Library的状态
yourname 2 months ago
parent
commit
5389c4e95b

+ 31 - 0
.env.test.example

@@ -0,0 +1,31 @@
+# Test Environment Configuration
+NODE_ENV=test
+
+# Database
+DATABASE_URL=mysql://root:test_password@localhost:3306/test_d8dai
+
+# Server
+PORT=8080
+HOST=localhost
+
+# JWT Secret (for testing)
+JWT_SECRET=test_jwt_secret_1234567890
+
+# Session
+SESSION_SECRET=test_session_secret_1234567890
+
+# CORS
+CORS_ORIGIN=http://localhost:8080
+
+# Logging
+LOG_LEVEL=info
+
+# Feature Flags
+ENABLE_SWAGGER=true
+ENABLE_GRAPHQL=false
+
+# Test Data
+TEST_ADMIN_USERNAME=admin
+TEST_ADMIN_PASSWORD=admin123
+TEST_USER_USERNAME=testuser
+TEST_USER_PASSWORD=test123

+ 104 - 0
.github/workflows/e2e-tests.yml

@@ -0,0 +1,104 @@
+name: E2E Tests
+
+on:
+  push:
+    branches: [ main, develop ]
+  pull_request:
+    branches: [ main ]
+  workflow_dispatch:
+
+jobs:
+  e2e-tests:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+
+    services:
+      mysql:
+        image: mysql:8.0.36
+        env:
+          MYSQL_DATABASE: test_d8dai
+          MYSQL_ROOT_PASSWORD: test_password
+          MYSQL_USER: test_user
+          MYSQL_PASSWORD: test_password
+        options: >-
+          --health-cmd="mysqladmin ping"
+          --health-interval=10s
+          --health-timeout=5s
+          --health-retries=3
+        ports:
+          - 3306:3306
+
+    steps:
+    - name: Checkout code
+      uses: actions/checkout@v4
+
+    - name: Setup Node.js
+      uses: actions/setup-node@v4
+      with:
+        node-version: '20'
+        cache: 'pnpm'
+
+    - name: Install pnpm
+      uses: pnpm/action-setup@v2
+      with:
+        version: 8
+
+    - name: Install dependencies
+      run: pnpm install --frozen-lockfile
+
+    - name: Install Playwright browsers
+      run: npx playwright install --with-deps chromium
+
+    - name: Setup test environment
+      run: |
+        cp .env.example .env.test
+        echo "DATABASE_URL=mysql://root:test_password@localhost:3306/test_d8dai" >> .env.test
+        echo "NODE_ENV=test" >> .env.test
+
+    - name: Run database migrations
+      run: |
+        export NODE_ENV=test
+        pnpm db:migrate
+
+    - name: Run E2E tests
+      run: |
+        export NODE_ENV=test
+        pnpm test:e2e --project=chromium
+      env:
+        E2E_BASE_URL: http://localhost:8080
+
+    - name: Upload test results
+      if: always()
+      uses: actions/upload-artifact@v4
+      with:
+        name: playwright-report
+        path: tests/e2e/playwright-report/
+        retention-days: 7
+
+    - name: Upload test results (JUnit)
+      if: always()
+      uses: actions/upload-artifact@v4
+      with:
+        name: test-results
+        path: test-results/
+        retention-days: 7
+
+    - name: Generate test summary
+      if: always()
+      uses: test-summary/action@v2
+      with:
+        paths: test-results/junit.xml
+
+    - name: Analyze test results
+      if: always()
+      run: pnpm test:analyze
+
+    - name: Notify on failure
+      if: failure()
+      uses: 8398a7/action-slack@v3
+      with:
+        status: ${{ job.status }}
+        channel: '#ci-notifications'
+        webhook_url: ${{ secrets.SLACK_WEBHOOK }}
+      env:
+        SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

+ 7 - 0
package.json

@@ -14,9 +14,15 @@
     "test:watch": "vitest",
     "test:ui": "vitest src/client/__tests__",
     "test:api": "vitest src/server/__tests__",
+    "test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
+    "test:e2e:ui": "playwright test --config=tests/e2e/playwright.config.ts --ui",
+    "test:e2e:debug": "playwright test --config=tests/e2e/playwright.config.ts --debug",
+    "test:e2e:chromium": "playwright test --config=tests/e2e/playwright.config.ts --project=chromium",
     "db:migrate": "tsx scripts/migrate.ts",
     "db:seed": "tsx scripts/seed.ts",
     "db:reset": "tsx scripts/reset-db.ts",
+    "test:setup": "tsx scripts/setup-test-db.ts",
+    "test:analyze": "node scripts/analyze-test-results.js",
     "lint": "eslint . --ext .ts,.tsx",
     "lint:fix": "eslint . --ext .ts,.tsx --fix",
     "typecheck": "tsc --noEmit"
@@ -91,6 +97,7 @@
     "zod": "^4.0.15"
   },
   "devDependencies": {
+    "@playwright/test": "^1.55.0",
     "@tailwindcss/vite": "^4.1.11",
     "@types/bcrypt": "^6.0.0",
     "@types/debug": "^4.1.12",

File diff suppressed because it is too large
+ 24 - 759
pnpm-lock.yaml


+ 56 - 0
scripts/analyze-test-results.js

@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+
+const fs = require('fs');
+const path = require('path');
+
+function analyzeTestResults() {
+  const resultsDir = path.join(__dirname, '..', 'test-results');
+  const reportDir = path.join(__dirname, '..', 'tests', 'e2e', 'playwright-report');
+
+  console.log('🔍 Analyzing test results...\n');
+
+  // 检查测试结果目录
+  if (!fs.existsSync(resultsDir)) {
+    console.log('❌ No test results found');
+    return;
+  }
+
+  // 读取 JUnit 报告
+  const junitFile = path.join(resultsDir, 'junit.xml');
+  if (fs.existsSync(junitFile)) {
+    const junitContent = fs.readFileSync(junitFile, 'utf8');
+
+    // 简单的 XML 解析来获取测试统计
+    const testsMatch = junitContent.match(/tests="(\d+)"/);
+    const failuresMatch = junitContent.match(/failures="(\d+)"/);
+    const errorsMatch = junitContent.match(/errors="(\d+)"/);
+
+    const totalTests = testsMatch ? parseInt(testsMatch[1]) : 0;
+    const failures = failuresMatch ? parseInt(failuresMatch[1]) : 0;
+    const errors = errorsMatch ? parseInt(errorsMatch[1]) : 0;
+    const passed = totalTests - failures - errors;
+
+    console.log('📊 Test Results Summary:');
+    console.log(`✅ Passed: ${passed}`);
+    console.log(`❌ Failed: ${failures}`);
+    console.log(`⚠️  Errors: ${errors}`);
+    console.log(`📋 Total: ${totalTests}`);
+    console.log(`🎯 Success Rate: ${((passed / totalTests) * 100).toFixed(1)}%\n`);
+
+    if (failures > 0 || errors > 0) {
+      console.log('🔴 Test failures detected!');
+      process.exit(1);
+    } else {
+      console.log('🟢 All tests passed!');
+    }
+  }
+
+  // 检查 HTML 报告
+  if (fs.existsSync(reportDir)) {
+    console.log('📈 HTML report available at:');
+    console.log(`   file://${path.resolve(reportDir, 'index.html')}\n`);
+  }
+}
+
+// 运行分析
+analyzeTestResults();

+ 93 - 0
scripts/setup-test-db.ts

@@ -0,0 +1,93 @@
+import { DataSource } from 'typeorm';
+import { dataSource } from '../src/server/data-source';
+import { User } from '../src/server/modules/users/user.entity';
+import { Role } from '../src/server/modules/users/role.entity';
+import * as bcrypt from 'bcrypt';
+
+async function setupTestDatabase() {
+  console.log('Setting up test database...');
+
+  // 使用测试数据库配置
+  const testDataSource = new DataSource({
+    ...dataSource.options,
+    database: process.env.MYSQL_DATABASE || 'test_d8dai',
+  });
+
+  try {
+    await testDataSource.initialize();
+    console.log('Test database connected');
+
+    // 清空测试数据
+    await testDataSource.transaction(async (transactionalEntityManager) => {
+      await transactionalEntityManager.query('SET FOREIGN_KEY_CHECKS = 0');
+
+      const entities = testDataSource.entityMetadatas;
+      for (const entity of entities) {
+        const repository = transactionalEntityManager.getRepository(entity.name);
+        await repository.clear();
+      }
+
+      await transactionalEntityManager.query('SET FOREIGN_KEY_CHECKS = 1');
+    });
+
+    console.log('Test database cleared');
+
+    // 创建测试角色
+    const adminRole = testDataSource.getRepository(Role).create({
+      name: 'admin',
+      description: 'Administrator role',
+    });
+
+    const userRole = testDataSource.getRepository(Role).create({
+      name: 'user',
+      description: 'Regular user role',
+    });
+
+    await testDataSource.getRepository(Role).save([adminRole, userRole]);
+
+    // 创建测试用户
+    const hashedPassword = await bcrypt.hash('admin123', 10);
+
+    const adminUser = testDataSource.getRepository(User).create({
+      username: 'admin',
+      password: hashedPassword,
+      nickname: 'Administrator',
+      email: 'admin@example.com',
+      roles: [adminRole],
+      isDisabled: 0,
+    });
+
+    const testUser = testDataSource.getRepository(User).create({
+      username: 'testuser',
+      password: await bcrypt.hash('test123', 10),
+      nickname: 'Test User',
+      email: 'testuser@example.com',
+      roles: [userRole],
+      isDisabled: 0,
+    });
+
+    await testDataSource.getRepository(User).save([adminUser, testUser]);
+
+    console.log('Test data created successfully');
+    console.log('Admin user: admin / admin123');
+    console.log('Test user: testuser / test123');
+
+  } catch (error) {
+    console.error('Error setting up test database:', error);
+    throw error;
+  } finally {
+    await testDataSource.destroy();
+  }
+}
+
+// 如果是直接运行此脚本
+if (require.main === module) {
+  setupTestDatabase()
+    .then(() => process.exit(0))
+    .catch((error) => {
+      console.error(error);
+      process.exit(1);
+    });
+}
+
+export { setupTestDatabase };

+ 159 - 0
tests/e2e/README.md

@@ -0,0 +1,159 @@
+# E2E 测试框架
+
+基于 Playwright 的端到端测试框架,用于验证完整的用户流程和系统功能。
+
+## 目录结构
+
+```
+tests/e2e/
+├── specs/                 # 测试用例
+│   ├── auth/             # 认证相关测试
+│   │   ├── login.spec.ts
+│   │   ├── register.spec.ts
+│   │   └── logout.spec.ts
+│   ├── users/            # 用户管理测试
+│   │   ├── user-crud.spec.ts
+│   │   └── profile.spec.ts
+│   └── admin/            # 管理后台测试
+├── fixtures/             # 测试数据
+│   └── test-users.json
+├── pages/                # 页面对象模型
+│   ├── login.page.ts
+│   ├── register.page.ts
+│   ├── dashboard.page.ts
+│   └── user-management.page.ts
+├── utils/                # 测试工具
+│   ├── test-setup.ts
+│   ├── test-teardown.ts
+│   └── helpers.ts
+├── playwright.config.ts  # Playwright 配置
+├── global-setup.ts       # 全局设置
+└── global-teardown.ts    # 全局清理
+```
+
+## 安装和设置
+
+### 安装依赖
+```bash
+pnpm install
+```
+
+### 安装 Playwright 浏览器
+```bash
+npx playwright install --with-deps
+```
+
+### 设置测试数据库
+```bash
+pnpm test:setup
+```
+
+## 运行测试
+
+### 运行所有 E2E 测试
+```bash
+pnpm test:e2e
+```
+
+### 运行特定测试文件
+```bash
+pnpm test:e2e tests/e2e/specs/auth/login.spec.ts
+```
+
+### 使用 UI 模式运行测试
+```bash
+pnpm test:e2e:ui
+```
+
+### 调试模式
+```bash
+pnpm test:e2e:debug
+```
+
+### 仅运行 Chromium 测试
+```bash
+pnpm test:e2e:chromium
+```
+
+## 测试数据
+
+测试用户账号:
+- **管理员**: admin / admin123
+- **普通用户**: testuser / test123
+
+## CI/CD 集成
+
+### GitHub Actions
+测试在每次 push 到 main/develop 分支和 pull request 时自动运行。
+
+### 本地开发
+1. 启动开发服务器:`pnpm dev`
+2. 运行测试:`pnpm test:e2e`
+
+## 测试报告
+
+测试完成后会生成以下报告:
+- **HTML 报告**: `tests/e2e/playwright-report/`
+- **JUnit 报告**: `test-results/junit.xml`
+- **控制台输出**: 实时测试进度和结果
+
+## 编写测试
+
+### 页面对象模型 (Page Objects)
+使用页面对象模式封装页面交互逻辑:
+
+```typescript
+class LoginPage {
+  async login(username: string, password: string) {
+    await this.usernameInput.fill(username);
+    await this.passwordInput.fill(password);
+    await this.loginButton.click();
+  }
+}
+```
+
+### 测试用例结构
+```typescript
+test.describe('用户认证', () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto('/login');
+  });
+
+  test('成功登录', async ({ loginPage, dashboardPage }) => {
+    await loginPage.login('admin', 'admin123');
+    await dashboardPage.expectToBeVisible();
+  });
+});
+```
+
+## 最佳实践
+
+1. **使用页面对象**: 封装页面交互逻辑
+2. **使用夹具数据**: 在 fixtures/ 中管理测试数据
+3. **使用等待策略**: 使用 `page.waitForLoadState('networkidle')`
+4. **使用断言**: 验证页面状态和业务逻辑
+5. **处理异步操作**: 使用适当的等待和重试机制
+
+## 故障排除
+
+### 常见问题
+1. **浏览器启动失败**: 运行 `npx playwright install`
+2. **测试超时**: 检查服务器是否正常运行
+3. **元素找不到**: 使用正确的选择器和等待策略
+
+### 调试技巧
+- 使用 `test.e2e:debug` 启动调试模式
+- 使用 `page.pause()` 暂停测试执行
+- 查看 Playwright 追踪文件
+
+## 性能优化
+
+- 使用 `fullyParallel: true` 并行执行测试
+- 使用 `workers` 配置控制并发数
+- 使用 `retries` 处理偶发性失败
+
+## 监控和警报
+
+- GitHub Actions 集成 Slack 通知
+- 测试失败时自动发送警报
+- 历史测试结果追踪和分析

+ 20 - 0
tests/e2e/fixtures/test-users.json

@@ -0,0 +1,20 @@
+{
+  "admin": {
+    "username": "admin",
+    "password": "admin123",
+    "email": "admin@example.com",
+    "role": "admin"
+  },
+  "regularUser": {
+    "username": "testuser",
+    "password": "test123",
+    "email": "testuser@example.com",
+    "role": "user"
+  },
+  "invalidUser": {
+    "username": "invalid",
+    "password": "wrongpassword",
+    "email": "invalid@example.com",
+    "role": "user"
+  }
+}

+ 10 - 0
tests/e2e/global-setup.ts

@@ -0,0 +1,10 @@
+import { FullConfig } from '@playwright/test';
+
+async function globalSetup(config: FullConfig) {
+  console.log('Global setup: Preparing test environment');
+
+  // 这里可以添加测试环境准备逻辑
+  // 例如:创建测试数据库、设置测试数据等
+}
+
+export default globalSetup;

+ 10 - 0
tests/e2e/global-teardown.ts

@@ -0,0 +1,10 @@
+import { FullConfig } from '@playwright/test';
+
+async function globalTeardown(config: FullConfig) {
+  console.log('Global teardown: Cleaning up test environment');
+
+  // 这里可以添加测试环境清理逻辑
+  // 例如:删除测试数据库、清理测试文件等
+}
+
+export default globalTeardown;

+ 46 - 0
tests/e2e/pages/dashboard.page.ts

@@ -0,0 +1,46 @@
+import { Page, Locator, expect } from '@playwright/test';
+
+export class DashboardPage {
+  readonly page: Page;
+  readonly pageTitle: Locator;
+  readonly activeUsersCard: Locator;
+  readonly systemMessagesCard: Locator;
+  readonly onlineUsersCard: Locator;
+  readonly userManagementCard: Locator;
+  readonly systemSettingsCard: Locator;
+
+  constructor(page: Page) {
+    this.page = page;
+    this.pageTitle = page.getByRole('heading', { name: '仪表盘' });
+    this.activeUsersCard = page.getByText('活跃用户');
+    this.systemMessagesCard = page.getByText('系统消息');
+    this.onlineUsersCard = page.getByText('在线用户');
+    this.userManagementCard = page.getByText('用户管理');
+    this.systemSettingsCard = page.getByText('系统设置');
+  }
+
+  async expectToBeVisible() {
+    await expect(this.pageTitle).toBeVisible();
+    await expect(this.activeUsersCard).toBeVisible();
+    await expect(this.systemMessagesCard).toBeVisible();
+    await expect(this.onlineUsersCard).toBeVisible();
+  }
+
+  async navigateToUserManagement() {
+    await this.userManagementCard.click();
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async navigateToSystemSettings() {
+    await this.systemSettingsCard.click();
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async getActiveUsersCount(): Promise<string> {
+    return await this.activeUsersCard.locator('xpath=following-sibling::div//div[contains(@class, "text-2xl")]').textContent() || '';
+  }
+
+  async getSystemMessagesCount(): Promise<string> {
+    return await this.systemMessagesCard.locator('xpath=following-sibling::div//div[contains(@class, "text-2xl")]').textContent() || '';
+  }
+}

+ 43 - 0
tests/e2e/pages/login.page.ts

@@ -0,0 +1,43 @@
+import { Page, Locator, expect } from '@playwright/test';
+
+export class LoginPage {
+  readonly page: Page;
+  readonly usernameInput: Locator;
+  readonly passwordInput: Locator;
+  readonly loginButton: Locator;
+  readonly registerLink: Locator;
+  readonly errorMessage: Locator;
+
+  constructor(page: Page) {
+    this.page = page;
+    this.usernameInput = page.getByPlaceholder('请输入用户名');
+    this.passwordInput = page.getByPlaceholder('请输入密码');
+    this.loginButton = page.getByRole('button', { name: '登录' });
+    this.registerLink = page.getByRole('link', { name: '立即注册' });
+    this.errorMessage = page.locator('[data-sonner-toast]');
+  }
+
+  async goto() {
+    await this.page.goto('/login');
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async login(username: string, password: string) {
+    await this.usernameInput.fill(username);
+    await this.passwordInput.fill(password);
+    await this.loginButton.click();
+  }
+
+  async expectLoginSuccess() {
+    await expect(this.page).toHaveURL('/');
+    await expect(this.page.locator('text=登录成功')).toBeVisible();
+  }
+
+  async expectLoginError() {
+    await expect(this.errorMessage).toBeVisible();
+  }
+
+  async navigateToRegister() {
+    await this.registerLink.click();
+  }
+}

+ 46 - 0
tests/e2e/pages/register.page.ts

@@ -0,0 +1,46 @@
+import { Page, Locator, expect } from '@playwright/test';
+
+export class RegisterPage {
+  readonly page: Page;
+  readonly usernameInput: Locator;
+  readonly passwordInput: Locator;
+  readonly confirmPasswordInput: Locator;
+  readonly registerButton: Locator;
+  readonly loginLink: Locator;
+  readonly errorMessage: Locator;
+
+  constructor(page: Page) {
+    this.page = page;
+    this.usernameInput = page.getByPlaceholder('请输入用户名');
+    this.passwordInput = page.getByPlaceholder('请输入密码');
+    this.confirmPasswordInput = page.getByPlaceholder('请再次输入密码');
+    this.registerButton = page.getByRole('button', { name: '注册账号' });
+    this.loginLink = page.getByRole('link', { name: '立即登录' });
+    this.errorMessage = page.locator('[data-sonner-toast]');
+  }
+
+  async goto() {
+    await this.page.goto('/register');
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async register(username: string, password: string, confirmPassword?: string) {
+    await this.usernameInput.fill(username);
+    await this.passwordInput.fill(password);
+    await this.confirmPasswordInput.fill(confirmPassword || password);
+    await this.registerButton.click();
+  }
+
+  async expectRegistrationSuccess() {
+    await expect(this.page).toHaveURL('/');
+    await expect(this.page.locator('text=注册成功')).toBeVisible();
+  }
+
+  async expectRegistrationError() {
+    await expect(this.errorMessage).toBeVisible();
+  }
+
+  async navigateToLogin() {
+    await this.loginLink.click();
+  }
+}

+ 134 - 0
tests/e2e/pages/user-management.page.ts

@@ -0,0 +1,134 @@
+import { Page, Locator, expect } from '@playwright/test';
+
+export class UserManagementPage {
+  readonly page: Page;
+  readonly pageTitle: Locator;
+  readonly createUserButton: Locator;
+  readonly searchInput: Locator;
+  readonly searchButton: Locator;
+  readonly userTable: Locator;
+  readonly editButtons: Locator;
+  readonly deleteButtons: Locator;
+  readonly pagination: Locator;
+
+  constructor(page: Page) {
+    this.page = page;
+    this.pageTitle = page.getByRole('heading', { name: '用户管理' });
+    this.createUserButton = page.getByRole('button', { name: '创建用户' });
+    this.searchInput = page.getByPlaceholder('搜索用户名、昵称或邮箱...');
+    this.searchButton = page.getByRole('button', { name: '搜索' });
+    this.userTable = page.locator('table');
+    this.editButtons = page.locator('button').filter({ hasText: '编辑' });
+    this.deleteButtons = page.locator('button').filter({ hasText: '删除' });
+    this.pagination = page.locator('[data-testid="pagination"]');
+  }
+
+  async goto() {
+    await this.page.goto('/admin/users');
+    await this.page.waitForLoadState('networkidle');
+    await this.expectToBeVisible();
+  }
+
+  async expectToBeVisible() {
+    await expect(this.pageTitle).toBeVisible();
+    await expect(this.createUserButton).toBeVisible();
+    await expect(this.userTable).toBeVisible();
+  }
+
+  async searchUsers(keyword: string) {
+    await this.searchInput.fill(keyword);
+    await this.searchButton.click();
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async createUser(userData: {
+    username: string;
+    password: string;
+    nickname?: string;
+    email?: string;
+    phone?: string;
+    name?: string;
+  }) {
+    await this.createUserButton.click();
+
+    // 填写用户表单
+    await this.page.getByLabel('用户名').fill(userData.username);
+    await this.page.getByLabel('密码').fill(userData.password);
+
+    if (userData.nickname) {
+      await this.page.getByLabel('昵称').fill(userData.nickname);
+    }
+    if (userData.email) {
+      await this.page.getByLabel('邮箱').fill(userData.email);
+    }
+    if (userData.phone) {
+      await this.page.getByLabel('手机号').fill(userData.phone);
+    }
+    if (userData.name) {
+      await this.page.getByLabel('真实姓名').fill(userData.name);
+    }
+
+    // 提交表单
+    await this.page.getByRole('button', { name: '创建用户' }).click();
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async getUserCount(): Promise<number> {
+    const rows = await this.userTable.locator('tbody tr').count();
+    return rows;
+  }
+
+  async getUserByUsername(username: string): Promise<Locator | null> {
+    const userRow = this.userTable.locator('tbody tr').filter({ hasText: username }).first();
+    return (await userRow.count()) > 0 ? userRow : null;
+  }
+
+  async editUser(username: string, updates: {
+    nickname?: string;
+    email?: string;
+    phone?: string;
+    name?: string;
+  }) {
+    const userRow = await this.getUserByUsername(username);
+    if (!userRow) throw new Error(`User ${username} not found`);
+
+    await userRow.locator('button').filter({ hasText: '编辑' }).click();
+
+    // 更新字段
+    if (updates.nickname) {
+      await this.page.getByLabel('昵称').fill(updates.nickname);
+    }
+    if (updates.email) {
+      await this.page.getByLabel('邮箱').fill(updates.email);
+    }
+    if (updates.phone) {
+      await this.page.getByLabel('手机号').fill(updates.phone);
+    }
+    if (updates.name) {
+      await this.page.getByLabel('真实姓名').fill(updates.name);
+    }
+
+    // 提交更新
+    await this.page.getByRole('button', { name: '更新用户' }).click();
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async deleteUser(username: string) {
+    const userRow = await this.getUserByUsername(username);
+    if (!userRow) throw new Error(`User ${username} not found`);
+
+    await userRow.locator('button').filter({ hasText: '删除' }).click();
+    await this.page.getByRole('button', { name: '删除' }).click();
+    await this.page.waitForLoadState('networkidle');
+  }
+
+  async expectUserExists(username: string) {
+    const userRow = await this.getUserByUsername(username);
+    await expect(userRow).not.toBeNull();
+  }
+
+  async expectUserNotExists(username: string) {
+    const userRow = await this.getUserByUsername(username);
+    await expect(userRow).toBeNull();
+  }
+}

+ 48 - 0
tests/e2e/playwright.config.ts

@@ -0,0 +1,48 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+  testDir: './specs',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: [
+    ['html'],
+    ['list'],
+    ['junit', { outputFile: 'test-results/junit.xml' }]
+  ],
+  use: {
+    baseURL: process.env.E2E_BASE_URL || 'http://localhost:8080',
+    trace: 'on-first-retry',
+    screenshot: 'only-on-failure',
+    video: 'retain-on-failure',
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+    {
+      name: 'firefox',
+      use: { ...devices['Desktop Firefox'] },
+    },
+    {
+      name: 'webkit',
+      use: { ...devices['Desktop Safari'] },
+    },
+    {
+      name: 'Mobile Chrome',
+      use: { ...devices['Pixel 5'] },
+    },
+    {
+      name: 'Mobile Safari',
+      use: { ...devices['iPhone 12'] },
+    },
+  ],
+  webServer: {
+    command: 'npm run dev',
+    url: 'http://localhost:8080',
+    reuseExistingServer: !process.env.CI,
+    timeout: 120000,
+  },
+});

+ 49 - 0
tests/e2e/specs/auth/login.spec.ts

@@ -0,0 +1,49 @@
+import { test, expect } from '../utils/test-setup';
+import testUsers from '../../fixtures/test-users.json';
+
+test.describe('用户认证流程', () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto('/login');
+    await page.waitForLoadState('networkidle');
+  });
+
+  test('成功登录', async ({ loginPage, dashboardPage }) => {
+    await loginPage.login(testUsers.admin.username, testUsers.admin.password);
+    await dashboardPage.expectToBeVisible();
+  });
+
+  test('登录失败 - 错误密码', async ({ loginPage }) => {
+    await loginPage.login(testUsers.admin.username, 'wrongpassword');
+    await loginPage.expectLoginError();
+  });
+
+  test('登录失败 - 不存在的用户', async ({ loginPage }) => {
+    await loginPage.login('nonexistent', testUsers.admin.password);
+    await loginPage.expectLoginError();
+  });
+
+  test('导航到注册页面', async ({ loginPage, page }) => {
+    await loginPage.navigateToRegister();
+    await expect(page).toHaveURL('/register');
+  });
+
+  test('表单验证 - 空用户名', async ({ loginPage }) => {
+    await loginPage.login('', testUsers.admin.password);
+    await expect(loginPage.usernameInput).toHaveClass(/border-destructive/);
+  });
+
+  test('表单验证 - 空密码', async ({ loginPage }) => {
+    await loginPage.login(testUsers.admin.username, '');
+    await expect(loginPage.passwordInput).toHaveClass(/border-destructive/);
+  });
+
+  test('记住登录状态', async ({ loginPage, dashboardPage, context }) => {
+    await loginPage.login(testUsers.admin.username, testUsers.admin.password);
+    await dashboardPage.expectToBeVisible();
+
+    // 重新打开浏览器验证登录状态保持
+    const newPage = await context.newPage();
+    await newPage.goto('/');
+    await expect(newPage).toHaveURL('/');
+  });
+});

+ 37 - 0
tests/e2e/specs/auth/logout.spec.ts

@@ -0,0 +1,37 @@
+import { test, expect } from '../utils/test-setup';
+import testUsers from '../../fixtures/test-users.json';
+
+test.describe('用户登出流程', () => {
+  test.beforeEach(async ({ loginPage }) => {
+    await loginPage.goto();
+    await loginPage.login(testUsers.admin.username, testUsers.admin.password);
+  });
+
+  test('成功登出', async ({ page }) => {
+    // 点击用户菜单(需要根据实际UI调整选择器)
+    const userMenu = page.locator('[data-testid="user-menu"]');
+    await userMenu.click();
+
+    // 点击登出按钮
+    const logoutButton = page.getByRole('button', { name: '登出' });
+    await logoutButton.click();
+
+    // 验证重定向到登录页面
+    await expect(page).toHaveURL('/login');
+    await expect(page.getByText('欢迎回来')).toBeVisible();
+  });
+
+  test('登出后无法访问受保护页面', async ({ page }) => {
+    // 先登出
+    const userMenu = page.locator('[data-testid="user-menu"]');
+    await userMenu.click();
+    const logoutButton = page.getByRole('button', { name: '登出' });
+    await logoutButton.click();
+
+    // 尝试访问受保护页面
+    await page.goto('/dashboard');
+
+    // 应该被重定向到登录页面
+    await expect(page).toHaveURL(/\/login/);
+  });
+});

+ 45 - 0
tests/e2e/specs/auth/register.spec.ts

@@ -0,0 +1,45 @@
+import { test, expect } from '../utils/test-setup';
+
+test.describe('用户注册流程', () => {
+  test.beforeEach(async ({ registerPage }) => {
+    await registerPage.goto();
+  });
+
+  test('成功注册新用户', async ({ registerPage, dashboardPage }) => {
+    const testUsername = `testuser_${Date.now()}`;
+    const testPassword = 'Test123!@#';
+
+    await registerPage.register(testUsername, testPassword);
+    await dashboardPage.expectToBeVisible();
+  });
+
+  test('注册失败 - 用户名已存在', async ({ registerPage }) => {
+    await registerPage.register('admin', 'Test123!@#');
+    await registerPage.expectRegistrationError();
+  });
+
+  test('注册失败 - 密码不一致', async ({ registerPage }) => {
+    await registerPage.register('newuser', 'password123', 'differentpassword');
+    await expect(registerPage.confirmPasswordInput).toHaveClass(/border-destructive/);
+  });
+
+  test('表单验证 - 用户名太短', async ({ registerPage }) => {
+    await registerPage.register('ab', 'Test123!@#');
+    await expect(registerPage.usernameInput).toHaveClass(/border-destructive/);
+  });
+
+  test('表单验证 - 密码太短', async ({ registerPage }) => {
+    await registerPage.register('newuser', 'short');
+    await expect(registerPage.passwordInput).toHaveClass(/border-destructive/);
+  });
+
+  test('表单验证 - 无效用户名格式', async ({ registerPage }) => {
+    await registerPage.register('invalid user!', 'Test123!@#');
+    await expect(registerPage.usernameInput).toHaveClass(/border-destructive/);
+  });
+
+  test('导航到登录页面', async ({ registerPage, page }) => {
+    await registerPage.navigateToLogin();
+    await expect(page).toHaveURL('/login');
+  });
+});

+ 135 - 0
tests/e2e/specs/users/profile.spec.ts

@@ -0,0 +1,135 @@
+import { test, expect } from '../utils/test-setup';
+import testUsers from '../../fixtures/test-users.json';
+
+test.describe('用户个人资料管理', () => {
+  test.beforeEach(async ({ loginPage }) => {
+    await loginPage.goto();
+    await loginPage.login(testUsers.admin.username, testUsers.admin.password);
+  });
+
+  test('查看个人资料页面', async ({ page }) => {
+    // 导航到个人资料页面(需要根据实际路由调整)
+    await page.goto('/profile');
+    await page.waitForLoadState('networkidle');
+
+    // 验证页面标题和基本信息
+    await expect(page.getByRole('heading', { name: '个人资料' })).toBeVisible();
+    await expect(page.getByText(testUsers.admin.username)).toBeVisible();
+  });
+
+  test('更新个人资料信息', async ({ page }) => {
+    await page.goto('/profile');
+    await page.waitForLoadState('networkidle');
+
+    // 点击编辑按钮
+    const editButton = page.getByRole('button', { name: '编辑资料' });
+    await editButton.click();
+
+    // 更新昵称
+    const nicknameInput = page.getByLabel('昵称');
+    const newNickname = `测试昵称_${Date.now()}`;
+    await nicknameInput.fill(newNickname);
+
+    // 保存更改
+    const saveButton = page.getByRole('button', { name: '保存' });
+    await saveButton.click();
+
+    // 验证更新成功
+    await expect(page.locator('text=资料更新成功')).toBeVisible();
+    await expect(page.getByText(newNickname)).toBeVisible();
+  });
+
+  test('修改密码', async ({ page }) => {
+    await page.goto('/profile');
+    await page.waitForLoadState('networkidle');
+
+    // 导航到修改密码页面
+    const changePasswordTab = page.getByRole('tab', { name: '修改密码' });
+    await changePasswordTab.click();
+
+    // 填写密码表单
+    const currentPasswordInput = page.getByLabel('当前密码');
+    const newPasswordInput = page.getByLabel('新密码');
+    const confirmPasswordInput = page.getByLabel('确认新密码');
+
+    await currentPasswordInput.fill(testUsers.admin.password);
+    await newPasswordInput.fill('NewPassword123!');
+    await confirmPasswordInput.fill('NewPassword123!');
+
+    // 提交修改
+    const submitButton = page.getByRole('button', { name: '修改密码' });
+    await submitButton.click();
+
+    // 验证密码修改成功
+    await expect(page.locator('text=密码修改成功')).toBeVisible();
+
+    // 使用新密码重新登录验证
+    const logoutButton = page.getByRole('button', { name: '登出' });
+    await logoutButton.click();
+
+    await page.goto('/login');
+    await page.getByPlaceholder('请输入用户名').fill(testUsers.admin.username);
+    await page.getByPlaceholder('请输入密码').fill('NewPassword123!');
+    await page.getByRole('button', { name: '登录' }).click();
+
+    // 验证登录成功
+    await expect(page).toHaveURL('/');
+  });
+
+  test('密码修改验证 - 当前密码错误', async ({ page }) => {
+    await page.goto('/profile');
+    await page.waitForLoadState('networkidle');
+
+    const changePasswordTab = page.getByRole('tab', { name: '修改密码' });
+    await changePasswordTab.click();
+
+    const currentPasswordInput = page.getByLabel('当前密码');
+    const newPasswordInput = page.getByLabel('新密码');
+    const confirmPasswordInput = page.getByLabel('确认新密码');
+
+    await currentPasswordInput.fill('wrongpassword');
+    await newPasswordInput.fill('NewPassword123!');
+    await confirmPasswordInput.fill('NewPassword123!');
+
+    const submitButton = page.getByRole('button', { name: '修改密码' });
+    await submitButton.click();
+
+    // 验证错误提示
+    await expect(page.locator('text=当前密码错误')).toBeVisible();
+  });
+
+  test('密码修改验证 - 新密码不一致', async ({ page }) => {
+    await page.goto('/profile');
+    await page.waitForLoadState('networkidle');
+
+    const changePasswordTab = page.getByRole('tab', { name: '修改密码' });
+    await changePasswordTab.click();
+
+    const currentPasswordInput = page.getByLabel('当前密码');
+    const newPasswordInput = page.getByLabel('新密码');
+    const confirmPasswordInput = page.getByLabel('确认新密码');
+
+    await currentPasswordInput.fill(testUsers.admin.password);
+    await newPasswordInput.fill('NewPassword123!');
+    await confirmPasswordInput.fill('DifferentPassword123!');
+
+    const submitButton = page.getByRole('button', { name: '修改密码' });
+    await submitButton.click();
+
+    // 验证错误提示
+    await expect(page.locator('text=两次密码输入不一致')).toBeVisible();
+  });
+
+  test('查看登录历史', async ({ page }) => {
+    await page.goto('/profile');
+    await page.waitForLoadState('networkidle');
+
+    // 导航到安全设置页面
+    const securityTab = page.getByRole('tab', { name: '安全设置' });
+    await securityTab.click();
+
+    // 验证登录历史记录可见
+    await expect(page.getByText('登录历史')).toBeVisible();
+    await expect(page.getByText('最近登录')).toBeVisible();
+  });
+});

+ 124 - 0
tests/e2e/specs/users/user-crud.spec.ts

@@ -0,0 +1,124 @@
+import { test, expect } from '../utils/test-setup';
+import testUsers from '../../fixtures/test-users.json';
+
+test.describe('用户管理CRUD操作', () => {
+  test.beforeEach(async ({ loginPage, userManagementPage }) => {
+    // 以管理员身份登录
+    await loginPage.goto();
+    await loginPage.login(testUsers.admin.username, testUsers.admin.password);
+    await userManagementPage.goto();
+  });
+
+  test('查看用户列表', async ({ userManagementPage }) => {
+    await userManagementPage.expectToBeVisible();
+    const userCount = await userManagementPage.getUserCount();
+    expect(userCount).toBeGreaterThan(0);
+  });
+
+  test('搜索用户', async ({ userManagementPage }) => {
+    await userManagementPage.searchUsers('admin');
+    const userCount = await userManagementPage.getUserCount();
+    expect(userCount).toBeGreaterThan(0);
+
+    // 验证搜索结果包含admin用户
+    const adminUser = await userManagementPage.getUserByUsername('admin');
+    await expect(adminUser).not.toBeNull();
+  });
+
+  test('创建新用户', async ({ userManagementPage }) => {
+    const testUsername = `testuser_${Date.now()}`;
+    const testPassword = 'Test123!@#';
+
+    await userManagementPage.createUser({
+      username: testUsername,
+      password: testPassword,
+      nickname: '测试用户',
+      email: `${testUsername}@example.com`,
+      phone: '13800138000',
+      name: '测试用户'
+    });
+
+    // 验证用户创建成功
+    await userManagementPage.expectUserExists(testUsername);
+  });
+
+  test('编辑用户信息', async ({ userManagementPage }) => {
+    const testUsername = `edituser_${Date.now()}`;
+    const testPassword = 'Test123!@#';
+
+    // 先创建测试用户
+    await userManagementPage.createUser({
+      username: testUsername,
+      password: testPassword,
+      nickname: '原始昵称',
+      email: `${testUsername}@example.com`
+    });
+
+    // 编辑用户信息
+    await userManagementPage.editUser(testUsername, {
+      nickname: '更新后的昵称',
+      email: `updated_${testUsername}@example.com`,
+      phone: '13900139000',
+      name: '更新姓名'
+    });
+
+    // 验证用户信息已更新
+    const userRow = await userManagementPage.getUserByUsername(testUsername);
+    await expect(userRow).toContainText('更新后的昵称');
+    await expect(userRow).toContainText(`updated_${testUsername}@example.com`);
+  });
+
+  test('删除用户', async ({ userManagementPage }) => {
+    const testUsername = `deleteuser_${Date.now()}`;
+    const testPassword = 'Test123!@#';
+
+    // 先创建测试用户
+    await userManagementPage.createUser({
+      username: testUsername,
+      password: testPassword,
+      nickname: '待删除用户',
+      email: `${testUsername}@example.com`
+    });
+
+    // 验证用户存在
+    await userManagementPage.expectUserExists(testUsername);
+
+    // 删除用户
+    await userManagementPage.deleteUser(testUsername);
+
+    // 验证用户已被删除
+    await userManagementPage.expectUserNotExists(testUsername);
+  });
+
+  test('用户分页功能', async ({ userManagementPage }) => {
+    // 确保有足够多的用户来测试分页
+    const initialCount = await userManagementPage.getUserCount();
+
+    if (initialCount < 10) {
+      // 创建一些测试用户
+      for (let i = 0; i < 5; i++) {
+        await userManagementPage.createUser({
+          username: `pagetest_${Date.now()}_${i}`,
+          password: 'Test123!@#',
+          nickname: `分页测试用户 ${i}`
+        });
+      }
+    }
+
+    // 搜索并验证分页控件可见
+    await userManagementPage.searchUsers('');
+    await expect(userManagementPage.pagination).toBeVisible();
+  });
+
+  test('创建用户验证 - 用户名已存在', async ({ userManagementPage }) => {
+    // 尝试创建已存在的用户
+    await userManagementPage.createUser({
+      username: 'admin',
+      password: 'Test123!@#',
+      nickname: '重复用户'
+    });
+
+    // 应该显示错误消息
+    await expect(userManagementPage.page.locator('text=创建失败')).toBeVisible();
+  });
+});

+ 29 - 0
tests/e2e/utils/test-setup.ts

@@ -0,0 +1,29 @@
+import { test as base } from '@playwright/test';
+import { LoginPage } from '../pages/login.page';
+import { RegisterPage } from '../pages/register.page';
+import { DashboardPage } from '../pages/dashboard.page';
+import { UserManagementPage } from '../pages/user-management.page';
+
+type Fixtures = {
+  loginPage: LoginPage;
+  registerPage: RegisterPage;
+  dashboardPage: DashboardPage;
+  userManagementPage: UserManagementPage;
+};
+
+export const test = base.extend<Fixtures>({
+  loginPage: async ({ page }, use) => {
+    await use(new LoginPage(page));
+  },
+  registerPage: async ({ page }, use) => {
+    await use(new RegisterPage(page));
+  },
+  dashboardPage: async ({ page }, use) => {
+    await use(new DashboardPage(page));
+  },
+  userManagementPage: async ({ page }, use) => {
+    await use(new UserManagementPage(page));
+  },
+});
+
+export { expect } from '@playwright/test';

Some files were not shown because too many files changed in this diff