hookehuyr

feat(e2e): 添加完整的 E2E 测试认证方案

## 配置变更

### Playwright 配置
- 使用本地开发服务器 (localhost:5173)
- 通过 Vite 反向代理访问测试服务器
- 自动启动 Vite Dev Server 进行测试
- 配置 webServer 自动管理

### 环境变量
- .env.test - 测试环境配置
- 确认代理配置:VITE_PROXY_PREFIX=/srv/
- 确认目标:VITE_PROXY_TARGET=http://oa-dev.onwall.cn/

## 新增功能

### E2E 认证工具
- e2e/helpers/auth.js - 认证辅助工具
  • login() - 完整登录流程(触发发送短信接口)
  • quickLogin() - 快速登录(localStorage)
  • logout() - 登出
  • isLoggedIn() - 检查登录状态
  • authenticatedPage fixture - 自动登录

### 测试账号配置
- 手机号:13761653761
- 验证码:888888(测试环境固定)
- 通过发送短信接口获取验证码

### 测试示例
- e2e/auth.spec.js - 认证测试
  • 登录流程测试
  • 登出测试
  • 错误处理测试
  • 需要登录的功能测试(购买、打卡等)

- e2e/courses.spec.js - 课程功能测试
  • 使用 authenticatedPage fixture
  • 已登录/未登录场景对比
  • 课程浏览、收藏等

### 架构说明
- 通过 Vite 反向代理访问测试服务器
- 代理前缀:/srv/ -> http://oa-dev.onwall.cn/srv/
- Playwright 自动启动本地开发服务器
- 所有 /srv/api/* 请求自动代理

## 新增文档

- docs/E2E_AUTH_GUIDE.md - E2E 认证完整指南
- docs/E2E_PROXY_SETUP.md - 反向代理配置说明
- docs/E2E_TEST_SERVER.md - 测试服务器配置文档
- e2e/README.md - E2E 快速入门

## 特性

✅ 正确触发发送短信接口
✅ 等待接口响应后再输入验证码
✅ 详细的登录日志输出
✅ 支持多种选择器备选(提高稳定性)
✅ Token 复用机制
✅ 自动状态管理(beforeEach/afterEach)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
# E2E 测试环境变量
# 本地开发服务器(通过代理访问测试服务器)
VITE_BASE_URL=http://localhost:5173
PLAYWRIGHT_BASE_URL=http://localhost:5173
# 测试服务器地址(用于反向代理)
VITE_PROXY_PREFIX=/srv/
VITE_PROXY_TARGET=http://oa-dev.onwall.cn/
# 测试账号
TEST_PHONE=13761653761
TEST_CODE=888888
# 是否使用无头模式(CI 环境设为 true)
HEADLESS=false
# 测试超时时间(毫秒)
TEST_TIMEOUT=30000
# 是否保留视频(失败时)
KEEP_VIDEO=true
# E2E 测试认证指南
## 📋 概述
本文档介绍如何在 E2E 测试中处理需要登录认证的场景。
## 🌐 测试环境
### 架构说明
- **本地**: `http://localhost:5173` - Vite 开发服务器
- **测试服务器**: `http://oa-dev.onwall.cn` - 通过反向代理访问
- **代理前缀**: `/srv/` - API 请求前缀
### 访问方式
```
Playwright → localhost:5173 → /srv/api/* → oa-dev.onwall.cn/srv/api/*
```
**详细说明**: 参见 [E2E 代理配置](./E2E_PROXY_SETUP.md)
## 🔑 测试账号
### 测试环境配置
**测试手机号**: `13761653761`
**固定验证码**: `888888`
**测试服务器**: `http://oa-dev.onwall.cn`(通过代理)
**特点**:
- ✅ 点击"发送验证码"后无需等待,直接输入 `888888` 即可
- ✅ 无需真实接收短信
- ✅ 可以重复使用
## 📁 文件结构
```
e2e/
├── helpers/
│ └── auth.js # 认证辅助工具
├── auth.spec.js # 认证测试示例
├── courses.spec.js # 课程功能测试(使用认证)
└── example.spec.js # 其他示例测试
```
## 🚀 快速开始
### 1. 基础登录
```javascript
import { login } from './helpers/auth'
test('测试需要登录的功能', async ({ page }) => {
// 登录
await login(page)
// 访问需要登录的页面
await page.goto('/profile')
// 验证
await expect(page).toHaveURL(/\/profile/)
})
```
### 2. 使用自动登录的 Fixture
```javascript
import { test } from './helpers/auth'
test.describe('需要登录的测试组', () => {
// 使用 authenticatedPage fixture 会自动登录
test('测试功能', async ({ authenticatedPage }) => {
// 页面已经登录,直接使用
await authenticatedPage.goto('/profile')
})
})
```
## 🛠️ 认证工具 API
### `login(page, account?)`
执行完整的登录流程。
**参数**:
- `page` - Playwright page 对象
- `account` - 可选的账号信息(默认使用测试账号)
**示例**:
```javascript
import { login, TEST_ACCOUNT } from './helpers/auth'
// 使用默认测试账号
await login(page)
// 使用自定义账号
await login(page, {
phone: '13800138000',
code: '123456',
})
```
**登录流程**:
1. 访问 `/login`
2. 输入手机号
3. 点击"发送验证码"
4. 输入验证码
5. 点击登录
6. 等待跳转到首页
### `quickLogin(page, token?)`
使用 localStorage 快速登录(跳过 UI 流程)。
**参数**:
- `page` - Playwright page 对象
- `token` - 可选的 token 字符串
**示例**:
```javascript
// 首次使用(会执行正常登录获取 token)
await quickLogin(page)
// 复用已知 token
await quickLogin(page, 'your-token-here')
```
**适用场景**:
- 需要跳过登录流程,节省时间
- 多个测试使用同一个 token
- 不需要测试登录功能本身
### `logout(page)`
清除登录状态。
**示例**:
```javascript
import { logout } from './helpers/auth'
test('测试登出', async ({ page }) => {
await login(page)
await logout(page)
// 验证已登出
await expect(page).toHaveURL(/\/login/)
})
```
### `isLoggedIn(page)`
检查当前是否已登录。
**返回**: `Promise<boolean>`
**示例**:
```javascript
import { isLoggedIn } from './helpers/auth'
test('检查登录状态', async ({ page }) => {
const loggedIn = await isLoggedIn(page)
expect(loggedIn).toBe(true)
})
```
## 📝 测试场景示例
### 场景 1: 测试受保护的页面
```javascript
test('未登录访问个人中心应跳转登录页', async ({ page }) => {
// 确保未登录
await logout(page)
// 访问需要登录的页面
await page.goto('/profile')
// 验证跳转到登录页
await expect(page).toHaveURL(/\/login/)
})
test('登录后可以访问个人中心', async ({ page }) => {
// 登录
await login(page)
// 访问个人中心
await page.goto('/profile')
// 验证访问成功
await expect(page).toHaveURL(/\/profile/)
})
```
### 场景 2: 测试购买流程
```javascript
test('登录后购买课程', async ({ page }) => {
// 登录
await login(page)
// 访问课程详情
await page.goto('/courses/123')
// 点击购买
await page.click('button:has-text("购买")')
// 验证跳转到结算页
await expect(page).toHaveURL(/\/checkout/)
// 验证结算页内容
const checkoutForm = page.locator('.checkout')
await expect(checkoutForm).toBeVisible()
})
```
### 场景 3: 测试打卡功能
```javascript
test('提交打卡', async ({ page }) => {
// 登录
await login(page)
// 访问打卡页面
await page.goto('/checkin')
// 填写打卡内容
await page.fill('textarea', '今天的打卡内容')
// 提交
await page.click('button:has-text("提交")')
// 验证成功提示
const successToast = page.locator('.van-toast--success')
await expect(successToast).toBeVisible()
})
```
### 场景 4: 测试学习进度
```javascript
test('查看学习进度', async ({ page }) => {
// 登录
await login(page)
// 访问学习进度页
await page.goto('/study/progress')
// 等待加载
await page.waitForSelector('.progress-item')
// 验证进度显示
const progressItems = page.locator('.progress-item')
const count = await progressItems.count()
expect(count).toBeGreaterThan(0)
})
```
## 🎯 最佳实践
### 1. 使用 beforeEach 确保初始状态
```javascript
test.describe('用户功能', () => {
test.beforeEach(async ({ page }) => {
// 每个测试前确保登录
await login(page)
})
test('功能 A', async ({ page }) => {
// 页面已登录
})
test('功能 B', async ({ page }) => {
// 页面已登录
})
})
```
### 2. 测试前先登出(避免状态污染)
```javascript
test.describe('认证相关', () => {
test.beforeEach(async ({ page }) => {
// 确保未登录状态
await logout(page)
})
test('测试登录', async ({ page }) => {
// 干净的登录状态
})
})
```
### 3. 使用 Page Object Model
```javascript
// pages/LoginPage.js
export class LoginPage {
constructor(page) {
this.page = page
this.phoneInput = page.locator('input[name="phone"]')
this.codeInput = page.locator('input[name="code"]')
this.submitButton = page.locator('button[type="submit"]')
}
async login(phone, code) {
await this.phoneInput.fill(phone)
await this.codeInput.fill(code)
await this.submitButton.click()
}
}
// 测试中使用
import { LoginPage } from './pages/LoginPage'
test('使用 Page Object 登录', async ({ page }) => {
const loginPage = new LoginPage(page)
await page.goto('/login')
await loginPage.login('13761653761', '888888')
})
```
### 4. 合理使用快速登录
```javascript
// ✓ GOOD - 需要测试登录本身
test('登录流程', async ({ page }) => {
await page.goto('/login')
await login(page)
await expect(page).toHaveURL(/\/home/)
})
// ✓ GOOD - 不需要测试登录,使用快速登录
test('查看个人资料', async ({ page }) => {
await quickLogin(page) // 跳过登录流程
await page.goto('/profile')
// 测试个人资料功能...
})
```
### 5. 复用登录状态
```javascript
test.describe('多个测试共享登录', () => {
let sharedToken = null
test('第一个测试获取 token', async ({ page }) => {
await login(page)
// 获取 token
sharedToken = await page.evaluate(() => {
const userInfo = localStorage.getItem('user_info')
return JSON.parse(userInfo).token
})
})
test('第二个测试复用 token', async ({ page }) => {
// 使用 token 快速登录
await quickLogin(page, sharedToken)
// 进行测试...
})
})
```
## 🔧 高级用法
### 1. 拦截登录 API(Mock)
```javascript
test('使用 Mock 登录', async ({ page }) => {
// 拦截登录 API
await page.route('**/api/login', route => {
route.fulfill({
status: 200,
body: JSON.stringify({
code: 1,
data: {
token: 'mock-token-123',
userId: 'test-user',
},
}),
})
})
// 访问登录页并提交
await page.goto('/login')
await login(page)
// 验证使用 Mock 数据
const token = await page.evaluate(() => {
const userInfo = localStorage.getItem('user_info')
return JSON.parse(userInfo).token
})
expect(token).toBe('mock-token-123')
})
```
### 2. 测试多种登录方式
```javascript
test.describe('多种登录方式', () => {
test('手机号验证码登录', async ({ page }) => {
await page.goto('/login')
// 选择验证码登录 tab
await page.click('text=验证码登录')
// 输入手机号
await page.fill('input[name="phone"]', '13761653761')
// 点击发送验证码
await page.click('text=发送验证码')
// 输入验证码
await page.fill('input[name="code"]', '888888')
// 点击登录
await page.click('button[type="submit"]')
// 验证
await expect(page).toHaveURL(/\/home/)
})
test('密码登录(如果支持)', async ({ page }) => {
await page.goto('/login')
// 选择密码登录 tab
await page.click('text=密码登录')
// 输入手机号和密码
await page.fill('input[name="phone"]', '13761653761')
await page.fill('input[name="password"]', 'password123')
// 点击登录
await page.click('button[type="submit"]')
// 验证
await expect(page).toHaveURL(/\/home/)
})
})
```
### 3. 测试权限控制
```javascript
test.describe('权限控制', () => {
test('普通用户无法访问管理员页面', async ({ page }) => {
// 登录普通用户
await login(page)
// 尝试访问管理员页面
await page.goto('/admin')
// 验证跳转到 403 或首页
await expect(page).toHaveURL(/(403|home)/)
})
test('VIP 用户可以访问 VIP 课程', async ({ page }) => {
// 登录 VIP 用户(使用不同 token)
await quickLogin(page, 'vip-user-token')
// 访问 VIP 课程
await page.goto('/courses/vip/123')
// 验证可以访问
await expect(page).toHaveURL(/\/courses\/vip\/123/)
})
})
```
## ⚠️ 常见问题
### 1. 登录超时
**问题**: 登录测试经常超时失败
**解决方案**:
```javascript
// 增加等待时间
await page.waitForURL(/\/home/, { timeout: 15000 })
// 或者使用更明确的等待
await page.waitForSelector('.home-content', { timeout: 10000 })
```
### 2. Token 过期
**问题**: 测试运行时提示 token 过期
**解决方案**:
```javascript
// 每次测试前重新登录
test.beforeEach(async ({ page }) => {
await logout(page)
await login(page)
})
```
### 3. 登录状态污染
**问题**: 前一个测试的登录状态影响下一个测试
**解决方案**:
```javascript
// 在 afterEach 中清理
test.afterEach(async ({ page }) => {
await logout(page)
})
// 或者使用测试隔离
test.describe.serial('需要连续状态的测试', () => {
// 这些测试会按顺序执行
})
```
### 4. 元素选择器不稳定
**问题**: 登录按钮的选择器经常变化
**解决方案**:
```javascript
// 使用多个备选选择器
const submitButton = page
.locator('button[type="submit"]')
.or(page.locator('button:has-text("登录")'))
.or(page.locator('.login-button'))
// 或者使用 data-testid
await page.click('[data-testid="login-submit"]')
```
## 📊 性能优化
### 1. 使用全局登录(全局钩子)
```javascript
// e2e/hooks.js
import { login } from './helpers/auth'
export const mochaGlobalSetup = async () => {
// 在所有测试前登录一次,获取 token
// 将 token 保存到文件或环境变量
// 后续测试直接使用 token
}
```
### 2. 并行测试处理登录
Playwright 默认并行运行测试,每个测试会独立登录。
```javascript
// playwright.config.js
export default defineConfig({
// 减少并行数量,避免登录冲突
workers: 2,
// 每个测试使用独立的浏览器上下文
use: {
// 每个测试有独立的 localStorage
},
})
```
## 🎉 总结
**关键点**:
- ✅ 使用 `login()` 执行完整登录流程
- ✅ 使用 `quickLogin()` 跳过 UI 流程
- ✅ 使用 `authenticatedPage` fixture 自动登录
- ✅ 测试前确保干净的登录状态
- ✅ 合理复用 token
**测试账号**:
- 手机号: `13761653761`
- 验证码: `888888`
**下一步**:
- [ ] 为所有需要登录的功能编写测试
- [ ] 使用 Page Object Model 组织代码
- [ ] 配置 CI/CD 自动运行 E2E 测试
享受高效的 E2E 测试开发!🚀
# E2E 测试代理配置说明
## 🌐 架构说明
E2E 测试通过 **Vite 反向代理** 访问测试服务器,而不是直接访问。
```
┌─────────────────┐
│ Playwright │
│ E2E Tests │
└────────┬────────┘
│ http://localhost:5173/*
┌─────────────────┐
│ Vite Dev │
│ Server │
│ (localhost:5173)│
└────────┬────────┘
│ /srv/api/*
↓ (反向代理)
┌─────────────────┐
│ Test Server │
│ oa-dev.onwall │
│ .cn │
└─────────────────┘
```
## 📝 配置文件
### 1. Vite 配置
**`vite.config.js`**:
```javascript
server: {
host: '0.0.0.0',
port: viteEnv.VITE_PORT,
proxy: createProxy(
viteEnv.VITE_PROXY_PREFIX,
viteEnv.VITE_PROXY_TARGET
)
}
```
### 2. 环境变量
**`.env.development`**:
```bash
# 代理前缀
VITE_PROXY_PREFIX = /srv/
# 代理目标(测试服务器)
VITE_PROXY_TARGET = http://oa-dev.onwall.cn/
```
**`.env`**:
```bash
VITE_PROXY_PREFIX = /srv/
```
### 3. Playwright 配置
**`playwright.config.js`**:
```javascript
export default defineConfig({
use: {
// 使用本地开发服务器
baseURL: 'http://localhost:5173',
},
// 自动启动本地开发服务器
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})
```
## 🔄 请求流程
### API 请求
```
1. Playwright: page.request('/srv/api/login')
2. Vite 接收请求
3. Vite 代理到: http://oa-dev.onwall.cn/srv/api/login
4. 测试服务器处理并返回
5. Vite 转发响应
6. Playwright 接收响应
```
### 静态资源
```
1. Playwright: page.goto('/login')
2. Vite 直接返回: index.html
3. Playwright 渲染页面
```
## 🧪 验证代理配置
### 检查方法 1: 查看 Vite 日志
```bash
# 启动开发服务器
pnpm dev
# 输出应包含:
# Local: http://localhost:5173/
# Proxy: /srv -> http://oa-dev.onwall.cn/srv
```
### 检查方法 2: 使用浏览器开发者工具
1. 访问 `http://localhost:5173`
2. 打开开发者工具 (F12)
3. 切换到 Network 标签
4. 触发登录流程
5. 查看 `/srv/api/*` 请求
**预期结果**:
- 请求 URL: `http://localhost:5173/srv/api/login`
- 实际请求: `http://oa-dev.onwall.cn/srv/api/login`
- 状态码: 200
### 检查方法 3: 运行 Playwright 测试
```bash
# 运行测试
pnpm test:e2e e2e/auth.spec.js --headed
# 观察浏览器操作,确认:
# 1. 访问 localhost:5173
# 2. 输入手机号
# 3. 点击"发送验证码"
# 4. 网络请求显示 /srv/api/send-sms
```
## ⚙️ 代理配置详解
### createProxy 函数
**`build/proxy.js`**:
```javascript
export function createProxy(prefix, target) {
const ret = {}
ret[prefix] = {
target, // 代理目标
changeOrigin: true, // 修改请求头的 origin
ws: true, // 支持 WebSocket
}
return ret
}
```
**参数说明**:
- `prefix`: `/srv/` - 需要代理的路径前缀
- `target`: `http://oa-dev.onwall.cn/` - 代理目标服务器
- `changeOrigin`: `true` - 修改请求头的 origin 为目标服务器的 origin
- `ws`: `true` - 支持 WebSocket 代理
### 代理示例
**请求**:
```
POST http://localhost:5173/srv/api/send-sms
Content-Type: application/json
{
"phone": "13761653761"
}
```
**Vite 转换为**:
```
POST http://oa-dev.onwall.cn/srv/api/send-sms
Host: oa-dev.onwall.cn
Origin: http://oa-dev.onwall.cn
{
"phone": "13761653761"
}
```
## 🚀 启动测试
### 方式 1: 自动启动(推荐)
```bash
# Playwright 自动启动 Vite 开发服务器
pnpm test:e2e
```
**优点**:
- 自动化管理
- 测试完成后自动关闭
- CI/CD 友好
### 方式 2: 手动启动
```bash
# 终端 1: 启动开发服务器
pnpm dev
# 终端 2: 运行测试
pnpm test:e2e
```
**优点**:
- 可以看到服务器日志
- 方便调试
## 🔧 常见问题
### 1. 代理不生效
**检查**:
```bash
# 确认 .env.development 配置正确
cat .env.development | grep PROXY
# 应该输出:
# VITE_PROXY_PREFIX = /srv/
# VITE_PROXY_TARGET = http://oa-dev.onwall.cn/
```
**解决**:
- 重启 Vite 开发服务器
- 清除浏览器缓存
- 检查 Vite 配置文件
### 2. CORS 错误
**原因**: 跨域请求被阻止
**解决**:
- Vite 的 `changeOrigin: true` 会自动处理 CORS
- 确保使用代理前缀 `/srv/`
- 不要直接请求 `http://oa-dev.onwall.cn`
### 3. API 请求 404
**检查**:
```javascript
// ✓ 正确 - 使用代理前缀
await fetch('/srv/api/login')
// ✗ 错误 - 直接访问测试服务器
await fetch('http://oa-dev.onwall.cn/api/login')
// ✗ 错误 - 缺少代理前缀
await fetch('/api/login')
```
### 4. WebSocket 连接失败
**检查**:
- Vite 配置中 `ws: true` 已启用
- 代理前缀正确
- 测试服务器支持 WebSocket
## 📚 相关文档
- [Vite 代理配置](https://vitejs.dev/config/server-options.html#server-proxy)
- [Playwright webServer](https://playwright.dev/docs/test-webserver)
- [项目 CLAUDE.md](../CLAUDE.md)
- [E2E 测试指南](./E2E_AUTH_GUIDE.md)
## 🔗 链接
- **本地开发服务器**: http://localhost:5173
- **测试服务器**: http://oa-dev.onwall.cn
- **代理前缀**: `/srv/`
# E2E 测试服务器配置说明
## 🌐 测试服务器信息
- **测试服务器**: `http://oa-dev.onwall.cn`
- **访问方式**: 通过 Vite 反向代理
- **本地地址**: `http://localhost:5173`
- **代理前缀**: `/srv/`
## 📝 配置说明
### 代理配置
**`.env.development`**:
```bash
# 反向代理配置
VITE_PROXY_PREFIX = /srv/
VITE_PROXY_TARGET = http://oa-dev.onwall.cn/
```
所有 `/srv/*` 的请求都会被代理到 `http://oa-dev.onwall.cn/srv/*`
### Playwright 配置
**`playwright.config.js`**:
```javascript
export default defineConfig({
use: {
// 使用本地开发服务器(通过代理访问)
baseURL: 'http://localhost:5173',
},
// 自动启动本地开发服务器
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})
```
### 工作流程
```
Playwright 测试
访问 http://localhost:5173/login
Vite 开发服务器(localhost:5173)
API 请求:/srv/api/login
Vite 反向代理
http://oa-dev.onwall.cn/srv/api/login
```
## 🔑 测试账号
### 测试手机号
```
13761653761
```
### 固定验证码
```
888888
```
## ⚠️ 重要:登录流程说明
### 必须遵循的步骤
测试服务器的登录接口有以下要求:
1. **必须触发发送验证码接口**
- 不能直接输入手机号和验证码登录
- 必须点击"发送验证码"按钮
- 必须等待接口响应
2. **接口响应后才可输入验证码**
- 等待按钮状态变化(倒计时开始)
- 或等待 2 秒确保接口响应完成
3. **验证码固定返回**
- 测试环境会自动返回 `888888`
- 无需查看真实短信
### 正确的登录流程
```javascript
// ✅ 正确流程
await page.goto('/login')
// 1. 输入手机号
await page.fill('input[name="phone"]', '13761653761')
// 2. 点击"发送验证码"按钮(触发接口)
await page.click('button:has-text("发送验证码")')
// 3. 等待接口响应(重要!)
await page.waitForTimeout(2000)
// 4. 输入验证码
await page.fill('input[name="code"]', '888888')
// 5. 点击登录
await page.click('button[type="submit"]')
```
### 错误的登录流程
```javascript
// ❌ 错误流程(会失败)
await page.goto('/login')
// 1. 输入手机号
await page.fill('input[name="phone"]', '13761653761')
// 2. 直接输入验证码(未触发接口!)
await page.fill('input[name="code"]', '888888')
// 3. 点击登录
await page.click('button[type="submit"]')
// 结果:登录失败,因为没有触发发送验证码接口
```
## 🔧 实现代码
### 完整的登录函数
**`e2e/helpers/auth.js`** 中的 `login()` 函数已实现正确的流程:
```javascript
export async function login(page, account = TEST_ACCOUNT) {
// 1. 访问登录页
await page.goto('/login')
// 2. 输入手机号
await page.fill('input[name="phone"]', account.phone)
// 3. 点击"发送验证码"按钮
const sendCodeButton = page.locator('button:has-text("发送验证码")')
await sendCodeButton.click()
// 4. 等待接口响应(重要!)
await page.waitForTimeout(2000)
// 5. 输入验证码
await page.fill('input[name="code"]', account.code)
// 6. 点击登录
await page.click('button[type="submit"]')
// 7. 等待登录成功
await page.waitForURL(/\/(home|index)?/)
}
```
### 详细的日志输出
登录函数会输出详细的步骤日志:
```javascript
🔐 开始登录流程...
已访问登录页
登录表单已加载
已输入手机号: 13761653761
发送验证码按钮已找到
已点击发送验证码按钮,等待接口响应...
短信接口已响应,按钮状态: 已禁用, 文本: "60s"
已输入验证码: 888888
已点击登录按钮,等待登录响应...
登录成功(URL 已变化)
登录流程完成!
```
## 🧪 运行测试
### 直接运行测试
```bash
# 运行所有 E2E 测试
pnpm test:e2e
# 运行认证测试
pnpm test:e2e e2e/auth.spec.js
# UI 模式(推荐)
pnpm test:e2e:ui
```
### 调试模式
```bash
# 有头模式(可以看到浏览器操作)
pnpm test:e2e --headed
# 调试模式(逐步执行)
pnpm test:e2e:debug
```
## 📊 验证接口触发
### 检查网络请求
在 Playwright Inspector 或浏览器开发者工具中可以看到:
```
1. GET /login
- 加载登录页面
2. POST /api/send-sms
Request: {
phone: "13761653761"
}
Response: {
code: 1,
data: { success: true }
}
3. POST /api/login
Request: {
phone: "13761653761",
code: "888888"
}
Response: {
code: 1,
data: {
token: "xxx",
userId: "xxx"
}
}
```
### 确认接口已触发
在代码中可以通过以下方式确认:
```javascript
// 监听网络请求
page.on('response', response => {
if (response.url().includes('send-sms')) {
console.log('✓ 发送短信接口已调用')
console.log('状态:', response.status())
}
})
// 等待接口响应
await page.waitForResponse('**/api/send-sms')
```
## 🐛 常见问题
### 1. 登录失败:接口未触发
**原因**: 没有点击"发送验证码"按钮或点击后立即输入验证码
**解决**: 确保使用 `login()` 函数,该函数会自动等待接口响应
### 2. 验证码错误
**原因**:
- 未使用测试手机号 `13761653761`
- 未触发发送验证码接口
**解决**:
- 确保使用测试账号
- 确保点击了"发送验证码"按钮
- 等待 2 秒后再输入验证码
### 3. 按钮点击无效
**原因**: 按钮选择器不正确
**解决**: 使用多个备选选择器
```javascript
const sendCodeButton = page
.locator('button:has-text("发送验证码")')
.or(page.locator('button:has-text("获取验证码")'))
.or(page.locator('button:has-text("发送")'))
.first()
```
## 🎯 最佳实践
### 1. 始终使用 `login()` 函数
```javascript
// ✓ 正确
import { login } from './helpers/auth'
test('测试功能', async ({ page }) => {
await login(page) // 自动处理所有细节
})
// ✗ 错误(可能遗漏步骤)
test('测试功能', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="phone"]', '13761653761')
await page.click('button')
// 可能失败,因为没有等待接口响应
})
```
### 2. 添加详细日志
```javascript
test('调试登录流程', async ({ page }) => {
// 监听所有网络请求
page.on('request', request => {
console.log('📤:', request.method(), request.url())
})
page.on('response', response => {
console.log('📥:', response.status(), response.url())
})
await login(page)
})
```
### 3. 使用调试模式
```bash
# UI 模式可以看到每一步操作
pnpm test:e2e:ui
# 逐步执行
pnpm test:e2e:debug
```
## 📚 相关文档
- [Playwright 完整指南](../docs/PLAYWRIGHT.md)
- [E2E 认证指南](../docs/E2E_AUTH_GUIDE.md)
- [E2E 快速入门](e2e/README.md)
## 🔗 链接
- **测试服务器**: http://oa-dev.onwall.cn
- **登录页面**: http://oa-dev.onwall.cn/login
# E2E 测试快速入门
## 🚀 快速开始
### 运行测试
```bash
# 运行所有 E2E 测试
pnpm test:e2e
# UI 模式(推荐)
pnpm test:e2e:ui
# 调试模式
pnpm test:e2e:debug
```
## 🔑 测试账号
**测试服务器**: `http://oa-dev.onwall.cn`(通过反向代理访问)
测试环境使用固定验证码:
- **手机号**: `13761653761`
- **验证码**: `888888`
**重要说明**:
- ⚠️ 必须点击"发送验证码"按钮触发接口
- ⚠️ 等待接口响应后,才能输入验证码
- ⚠️ 测试服务器会自动返回固定验证码 `888888`
**代理说明**:
- 测试通过本地 Vite 开发服务器(`localhost:5173`)运行
- 所有 `/srv/*` 请求代理到 `http://oa-dev.onwall.cn/srv/*`
## 📝 编写测试
### 1. 需要登录的测试
```javascript
import { login } from './helpers/auth'
test('测试功能', async ({ page }) => {
// 登录
await login(page)
// 进行测试...
await page.goto('/profile')
})
```
### 2. 自动登录(推荐)
```javascript
import { test } from './helpers/auth'
test.describe('功能测试', () => {
// 使用 authenticatedPage 自动登录
test('测试功能', async ({ authenticatedPage }) => {
// 页面已自动登录
await authenticatedPage.goto('/profile')
})
})
```
### 3. 不需要登录的测试
```javascript
test('浏览课程', async ({ page }) => {
await page.goto('/courses-list')
// 测试...
})
```
## 📚 详细文档
- [完整认证指南](../docs/E2E_AUTH_GUIDE.md)
- [Playwright 使用指南](../docs/PLAYWRIGHT.md)
## 🛠️ 工具函数
所有工具函数都在 `e2e/helpers/auth.js`
| 函数 | 说明 |
| ------------------ | ------------------------ |
| `login(page)` | 执行登录流程 |
| `quickLogin(page)` | 快速登录(localStorage) |
| `logout(page)` | 登出 |
| `isLoggedIn(page)` | 检查登录状态 |
## 💡 最佳实践
1. **测试前确保干净的登录状态**
```javascript
test.beforeEach(async ({ page }) => {
await logout(page)
})
```
2. **使用明确的等待**
```javascript
// ✓ 好
await page.waitForSelector('.content')
// ✗ 不好
await page.waitForTimeout(3000)
```
3. **使用多个选择器备选**
```javascript
const button = page.locator('button[type="submit"]').or(page.locator('button:has-text("登录")'))
```
## 🐛 调试技巧
### UI 模式
```bash
pnpm test:e2e:ui
```
### 逐步调试
```javascript
test('调试', async ({ page }) => {
await page.goto('/login')
await page.pause() // 暂停,打开 Inspector
await login(page)
})
```
### 查看执行过程
```bash
# 有头模式(可以看到浏览器)
pnpm test:e2e --headed
# 慢动作模式
pnpm test:e2e --slow-mo=1000
```
## 📂 文件结构
```
e2e/
├── helpers/
│ └── auth.js # 认证工具
├── auth.spec.js # 认证测试示例
├── courses.spec.js # 课程功能测试
├── example.spec.js # 基础测试示例
└── README.md # 本文件
```
## 🔗 相关链接
- [Playwright 官方文档](https://playwright.dev)
- [项目 E2E 认证指南](../docs/E2E_AUTH_GUIDE.md)
- [Playwright 完整指南](../docs/PLAYWRIGHT.md)
/*
* @Date: 2026-01-28 22:00:00
* @Description: 认证相关 E2E 测试示例
*/
import { test, expect } from '@playwright/test'
import { login, logout, isLoggedIn, quickLogin, TEST_ACCOUNT } from './helpers/auth'
test.describe('用户认证', () => {
test.beforeEach(async ({ page }) => {
// 每个测试前确保是登出状态
await logout(page)
})
test('应该成功登录', async ({ page }) => {
// 1. 访问登录页
await page.goto('/login')
// 2. 检查登录表单元素
const phoneInput = page.locator('input[name="phone"]')
await expect(phoneInput).toBeVisible()
// 3. 执行登录
await login(page)
// 4. 验证登录成功
await expect(page).toHaveURL(/\/(home|index)?/)
// 5. 验证用户信息已保存
const loggedIn = await isLoggedIn(page)
expect(loggedIn).toBe(true)
})
test('应该显示登录错误信息(手机号格式错误)', async ({ page }) => {
await page.goto('/login')
// 输入错误格式的手机号
await page.fill('input[name="phone"]', '12345')
// 点击发送验证码
await page.click('button:has-text("发送验证码")')
// 验证错误提示
const errorToast = page.locator('.van-toast--fail').or(page.locator('.van-toast--error'))
// 等待错误提示出现
await expect(errorToast).toBeVisible({ timeout: 3000 })
})
test('应该成功登出', async ({ page }) => {
// 先登录
await login(page)
// 验证已登录
let loggedIn = await isLoggedIn(page)
expect(loggedIn).toBe(true)
// 执行登出
await logout(page)
// 验证已登出
loggedIn = await isLoggedIn(page)
expect(loggedIn).toBe(false)
// 验证跳转到登录页
await expect(page).toHaveURL(/\/login/)
})
})
test.describe('需要登录的功能', () => {
test('访问受保护页面应跳转到登录页', async ({ page }) => {
// 直接访问需要登录的页面
await page.goto('/profile')
// 验证跳转到登录页
await expect(page).toHaveURL(/\/login/)
})
test('登录后可以访问个人中心', async ({ page }) => {
// 先登录
await login(page)
// 访问个人中心
await page.goto('/profile')
// 验证页面加载成功
await expect(page).toHaveURL(/\/profile/)
// 检查页面元素
const profileContent = page.locator('.profile').or(page.locator('[class*="profile"]'))
await expect(profileContent).toBeVisible()
})
test('登录后可以查看课程学习进度', async ({ page }) => {
// 登录
await login(page)
// 访问学习进度页面
await page.goto('/study/progress')
// 验证页面内容
const progressContent = page.locator('.study-progress').or(page.locator('[class*="progress"]'))
await expect(progressContent).toBeVisible({ timeout: 5000 })
})
})
test.describe('购买流程(需要登录)', () => {
test('登录后可以购买课程', async ({ page }) => {
// 登录
await login(page)
// 访问课程详情页(假设课程ID为123)
await page.goto('/courses/123')
// 点击购买按钮
const buyButton = page
.locator('button:has-text("购买")')
.or(page.locator('button:has-text("立即购买")'))
// 等待按钮可点击
await buyButton.waitFor({ state: 'visible', timeout: 5000 })
await buyButton.click()
// 验证跳转到结算页或支付页
await expect(page).toHaveURL(/\/(checkout|payment)/)
// 检查结算页内容
const checkoutContent = page.locator('.checkout').or(page.locator('[class*="checkout"]'))
await expect(checkoutContent).toBeVisible()
})
test('未登录购买课程应跳转到登录页', async ({ page }) => {
// 确保未登录
await logout(page)
// 访问课程详情页
await page.goto('/courses/123')
// 点击购买按钮
const buyButton = page
.locator('button:has-text("购买")')
.or(page.locator('button:has-text("立即购买")'))
await buyButton.click()
// 验证跳转到登录页
await expect(page).toHaveURL(/\/login/)
})
})
test.describe('打卡功能(需要登录)', () => {
test('登录后可以提交打卡', async ({ page }) => {
// 登录
await login(page)
// 访问打卡页面
await page.goto('/checkin')
// 填写打卡内容
const textarea = page.locator('textarea').or(page.locator('input[type="text"]'))
await textarea.fill('今天的打卡内容')
// 点击提交按钮
const submitButton = page
.locator('button:has-text("提交")')
.or(page.locator('button:has-text("打卡")'))
await submitButton.click()
// 验证提交成功(显示成功提示)
const successToast = page.locator('.van-toast--success')
await expect(successToast).toBeVisible({ timeout: 3000 })
})
test('未登录打卡应跳转到登录页', async ({ page }) => {
// 确保未登录
await logout(page)
// 访问打卡页面
await page.goto('/checkin')
// 验证跳转到登录页
await expect(page).toHaveURL(/\/login/)
})
})
test.describe('使用快速登录', () => {
test('快速登录跳过输入流程', async ({ page }) => {
// 使用快速登录(首次会执行正常登录获取 token)
await quickLogin(page)
// 访问需要登录的页面
await page.goto('/profile')
// 验证页面正常访问
await expect(page).toHaveURL(/\/profile/)
})
test('复用 token 多次测试', async ({ page }) => {
// 第一次登录获取 token
await login(page)
// 获取 token
const token = await page.evaluate(() => {
const userInfo = localStorage.getItem('user_info')
return JSON.parse(userInfo).token
})
// 登出
await logout(page)
// 使用 token 快速登录
await quickLogin(page, token)
// 验证登录状态
const loggedIn = await isLoggedIn(page)
expect(loggedIn).toBe(true)
})
})
/*
* @Date: 2026-01-28 22:00:00
* @Description: 课程功能 E2E 测试(使用 authenticated fixture)
*/
import { test, expect } from '@playwright/test'
import { login } from './helpers/auth'
// 扩展 test,添加 authenticatedPage fixture
const authenticatedTest = test.extend({
authenticatedPage: async ({ page }, use) => {
// 自动登录
await login(page)
// 使用已认证的页面
await use(page)
},
})
authenticatedTest.describe('课程浏览(已登录)', () => {
authenticatedTest('可以浏览课程列表', async ({ authenticatedPage }) => {
// 页面已经登录,直接访问
await authenticatedPage.goto('/courses-list')
// 等待课程列表加载
await authenticatedPage.waitForSelector('.course-card', { timeout: 5000 })
// 验证课程卡片存在
const courseCards = authenticatedPage.locator('.course-card')
const count = await courseCards.count()
expect(count).toBeGreaterThan(0)
console.log(`找到 ${count} 个课程`)
})
authenticatedTest('可以查看课程详情', async ({ authenticatedPage }) => {
// 访问课程详情页
await authenticatedPage.goto('/courses/123')
// 等待页面加载
await authenticatedPage.waitForLoadState('networkidle')
// 验证关键元素
const courseTitle = authenticatedPage
.locator('h1')
.or(authenticatedPage.locator('.course-title'))
await expect(courseTitle).toBeVisible()
})
authenticatedTest('可以收藏课程', async ({ authenticatedPage }) => {
// 访问课程详情页
await authenticatedPage.goto('/courses/123')
// 点击收藏按钮
const favoriteButton = authenticatedPage
.locator('button:has-text("收藏")')
.or(authenticatedPage.locator('[class*="favorite"]'))
// 记录点击前的状态
const isFavoritedBefore = await favoriteButton.getAttribute('class')
await favoriteButton.click()
// 等待状态更新
await authenticatedPage.waitForTimeout(500)
// 验证收藏状态改变
const isFavoritedAfter = await favoriteButton.getAttribute('class')
expect(isFavoritedAfter).not.toBe(isFavoritedBefore)
})
})
authenticatedTest.describe('学习进度(已登录)', () => {
authenticatedTest('可以查看学习记录', async ({ authenticatedPage }) => {
// 访问学习记录页面
await authenticatedPage.goto('/study/records')
// 等待加载
await authenticatedPage.waitForLoadState('networkidle')
// 验证页面内容
const recordsList = authenticatedPage
.locator('.study-records')
.or(authenticatedPage.locator('[class*="records"]'))
await expect(recordsList).toBeVisible()
})
authenticatedTest('可以继续学习', async ({ authenticatedPage }) => {
// 访问学习页面
await authenticatedPage.goto('/study/course/123')
// 等待视频播放器加载
const videoPlayer = authenticatedPage
.locator('video')
.or(authenticatedPage.locator('[class*="video-js"]'))
await expect(videoPlayer).toBeVisible({ timeout: 10000 })
})
})
// 不需要登录的测试仍然使用普通 test
test.describe('课程浏览(未登录)', () => {
test('游客可以浏览课程列表', async ({ page }) => {
await page.goto('/courses-list')
// 等待课程列表加载
await page.waitForSelector('.course-card', { timeout: 5000 })
// 验证课程卡片存在
const courseCards = page.locator('.course-card')
const count = await courseCards.count()
expect(count).toBeGreaterThan(0)
})
test('游客可以查看课程详情', async ({ page }) => {
await page.goto('/courses/123')
// 验证页面加载
const courseTitle = page.locator('h1').or(page.locator('.course-title'))
await expect(courseTitle).toBeVisible()
})
test('游客查看详情提示登录', async ({ page }) => {
await page.goto('/courses/123')
// 查找"开始学习"或"购买"按钮
const startButton = page
.locator('button:has-text("开始学习")')
.or(page.locator('button:has-text("购买")'))
if (await startButton.isVisible()) {
await startButton.click()
// 验证跳转到登录页
await expect(page).toHaveURL(/\/login/)
}
})
})
/*
* @Date: 2026-01-28 22:00:00
* @Description: E2E 测试认证辅助工具
*/
import { test as base } from '@playwright/test'
// 测试账号配置
export const TEST_ACCOUNT = {
phone: '13761653761',
code: '888888', // 测试环境固定验证码
password: '', // 如果有密码登录
}
/**
* 扩展 test 对象,添加 authenticated fixture
*/
export const test = base.extend({
// 已认证的页面(自动登录)
authenticatedPage: async ({ page }, use) => {
// 执行登录
await login(page)
// 使用已认证的页面
await use(page)
},
})
/**
* 登录操作
* @param {Page} page - Playwright page 对象
* @param {Object} account - 账号信息(可选,默认使用测试账号)
*/
export async function login(page, account = TEST_ACCOUNT) {
console.log('🔐 开始登录流程...')
// 1. 访问登录页
await page.goto('/login')
console.log('✓ 已访问登录页')
// 2. 等待登录表单加载
await page.waitForSelector('input[name="phone"]', { timeout: 10000 })
console.log('✓ 登录表单已加载')
// 3. 输入手机号
const phoneInput = page
.locator('input[name="phone"]')
.or(page.locator('input[placeholder*="手机号"]'))
await phoneInput.fill(account.phone)
console.log(`✓ 已输入手机号: ${account.phone}`)
// 4. 点击"发送验证码"按钮(触发短信接口)
const sendCodeButton = page
.locator('button:has-text("发送验证码")')
.or(page.locator('button:has-text("获取验证码")'))
.or(page.locator('button:has-text("发送")'))
.first()
// 确保按钮可点击
await sendCodeButton.waitFor({ state: 'visible', timeout: 5000 })
console.log('✓ 发送验证码按钮已找到')
// 点击发送验证码
await sendCodeButton.click()
console.log('✓ 已点击发送验证码按钮,等待接口响应...')
// 5. 等待发送短信接口响应
// 等待按钮进入倒计时状态(通常是禁用或文本变为倒计时)
await page.waitForTimeout(2000)
// 验证按钮状态变化(表示接口已响应)
const isDisabled = await sendCodeButton.isDisabled()
const buttonText = await sendCodeButton.textContent()
console.log(
`✓ 短信接口已响应,按钮状态: ${isDisabled ? '已禁用' : '可用'}, 文本: "${buttonText}"`
)
// 6. 等待一小段时间再输入验证码(模拟真实用户操作)
await page.waitForTimeout(500)
// 7. 输入验证码
const codeInput = page
.locator('input[name="code"]')
.or(page.locator('input[placeholder*="验证码"]'))
.or(page.locator('input[maxlength="6"]'))
.first()
await codeInput.waitFor({ state: 'visible', timeout: 5000 })
await codeInput.fill(account.code)
console.log(`✓ 已输入验证码: ${account.code}`)
// 8. 点击登录按钮
const loginButton = page
.locator('button[type="submit"]')
.or(page.locator('button:has-text("登录")'))
.or(page.locator('.van-button--primary'))
.first()
await loginButton.waitFor({ state: 'visible', timeout: 5000 })
await loginButton.click()
console.log('✓ 已点击登录按钮,等待登录响应...')
// 9. 等待登录成功(跳转到首页或显示成功提示)
try {
// 方式1:等待 URL 变化
await page.waitForURL(/\/(home|index|#)?/, { timeout: 15000 })
console.log('✓ 登录成功(URL 已变化)')
} catch (error) {
// 方式2:等待成功提示(toast)
try {
await page.waitForSelector('.van-toast--success', { timeout: 5000 })
console.log('✓ 登录成功(显示成功提示)')
} catch (toastError) {
// 方式3:检查是否已经在首页
const currentUrl = page.url()
if (currentUrl.includes('/home') || currentUrl.includes('/index')) {
console.log('✓ 登录成功(已在首页)')
} else {
console.log(`⚠ 当前 URL: ${currentUrl}`)
console.log('⚠ 可能登录失败,请检查错误提示')
throw new Error('登录失败:未检测到登录成功的标志')
}
}
}
// 10. 等待页面加载完成
await page.waitForLoadState('networkidle', { timeout: 10000 })
console.log('✅ 登录流程完成!')
}
/**
* 快速登录(使用 localStorage 直接设置 token)
* 适用于需要跳过登录流程的场景
* @param {Page} page - Playwright page 对象
* @param {string} token - 用户 token(可选)
*/
export async function quickLogin(page, token = null) {
// 如果没有提供 token,先执行一次正常登录获取
if (!token) {
await login(page)
// 从 localStorage 获取 token
token = await page.evaluate(() => {
const userInfo = localStorage.getItem('user_info')
if (userInfo) {
return JSON.parse(userInfo).token
}
return null
})
}
// 直接设置 localStorage(用于后续测试)
await page.evaluate(userToken => {
const mockUserInfo = {
token: userToken,
userId: 'test-user-123',
phone: '13761653761',
}
localStorage.setItem('user_info', JSON.stringify(mockUserInfo))
localStorage.setItem('currentUser', JSON.stringify(mockUserInfo))
}, token)
console.log('✅ 快速登录成功')
}
/**
* 登出操作
* @param {Page} page - Playwright page 对象
*/
export async function logout(page) {
// 清空 localStorage
await page.evaluate(() => {
localStorage.removeItem('user_info')
localStorage.removeItem('currentUser')
})
// 或者点击退出按钮(如果有)
// await page.click('button:has-text("退出")')
// 刷新页面
await page.reload()
console.log('✅ 登出成功')
}
/**
* 检查登录状态
* @param {Page} page - Playwright page 对象
* @returns {Promise<boolean>} 是否已登录
*/
export async function isLoggedIn(page) {
const userInfo = await page.evaluate(() => localStorage.getItem('user_info'))
return !!userInfo
}
/**
* 等待登录状态
* @param {Page} page - Playwright page 对象
* @param {boolean} loggedIn - 期望的登录状态
*/
export async function waitForLoginState(page, loggedIn = true) {
if (loggedIn) {
// 等待登录成功
await page.waitForURL(/\/(home|index)?/, { timeout: 10000 })
} else {
// 等待退出到登录页
await page.waitForURL('/login', { timeout: 10000 })
}
}
......@@ -31,7 +31,7 @@ export default defineConfig({
// 共享配置
use: {
// 基础 URL(开发服务器地址
// 基础 URL(本地开发服务器,通过代理访问测试服务器
baseURL: 'http://localhost:5173',
// 追踪失败测试(用于调试)
......@@ -79,8 +79,9 @@ export default defineConfig({
},
],
// 开发服务器(测试前启动)
// 开发服务器配置
webServer: {
// 启动本地开发服务器(通过反向代理访问测试服务器)
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
......