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>
1 +# E2E 测试环境变量
2 +
3 +# 本地开发服务器(通过代理访问测试服务器)
4 +VITE_BASE_URL=http://localhost:5173
5 +PLAYWRIGHT_BASE_URL=http://localhost:5173
6 +
7 +# 测试服务器地址(用于反向代理)
8 +VITE_PROXY_PREFIX=/srv/
9 +VITE_PROXY_TARGET=http://oa-dev.onwall.cn/
10 +
11 +# 测试账号
12 +TEST_PHONE=13761653761
13 +TEST_CODE=888888
14 +
15 +# 是否使用无头模式(CI 环境设为 true)
16 +HEADLESS=false
17 +
18 +# 测试超时时间(毫秒)
19 +TEST_TIMEOUT=30000
20 +
21 +# 是否保留视频(失败时)
22 +KEEP_VIDEO=true
This diff is collapsed. Click to expand it.
1 +# E2E 测试代理配置说明
2 +
3 +## 🌐 架构说明
4 +
5 +E2E 测试通过 **Vite 反向代理** 访问测试服务器,而不是直接访问。
6 +
7 +```
8 +┌─────────────────┐
9 +│ Playwright │
10 +│ E2E Tests │
11 +└────────┬────────┘
12 +
13 + │ http://localhost:5173/*
14 +
15 +┌─────────────────┐
16 +│ Vite Dev │
17 +│ Server │
18 +│ (localhost:5173)│
19 +└────────┬────────┘
20 +
21 + │ /srv/api/*
22 + ↓ (反向代理)
23 +┌─────────────────┐
24 +│ Test Server │
25 +│ oa-dev.onwall │
26 +│ .cn │
27 +└─────────────────┘
28 +```
29 +
30 +## 📝 配置文件
31 +
32 +### 1. Vite 配置
33 +
34 +**`vite.config.js`**:
35 +
36 +```javascript
37 +server: {
38 + host: '0.0.0.0',
39 + port: viteEnv.VITE_PORT,
40 + proxy: createProxy(
41 + viteEnv.VITE_PROXY_PREFIX,
42 + viteEnv.VITE_PROXY_TARGET
43 + )
44 +}
45 +```
46 +
47 +### 2. 环境变量
48 +
49 +**`.env.development`**:
50 +
51 +```bash
52 +# 代理前缀
53 +VITE_PROXY_PREFIX = /srv/
54 +
55 +# 代理目标(测试服务器)
56 +VITE_PROXY_TARGET = http://oa-dev.onwall.cn/
57 +```
58 +
59 +**`.env`**:
60 +
61 +```bash
62 +VITE_PROXY_PREFIX = /srv/
63 +```
64 +
65 +### 3. Playwright 配置
66 +
67 +**`playwright.config.js`**:
68 +
69 +```javascript
70 +export default defineConfig({
71 + use: {
72 + // 使用本地开发服务器
73 + baseURL: 'http://localhost:5173',
74 + },
75 +
76 + // 自动启动本地开发服务器
77 + webServer: {
78 + command: 'pnpm dev',
79 + url: 'http://localhost:5173',
80 + reuseExistingServer: !process.env.CI,
81 + timeout: 120 * 1000,
82 + },
83 +})
84 +```
85 +
86 +## 🔄 请求流程
87 +
88 +### API 请求
89 +
90 +```
91 +1. Playwright: page.request('/srv/api/login')
92 +
93 +2. Vite 接收请求
94 +
95 +3. Vite 代理到: http://oa-dev.onwall.cn/srv/api/login
96 +
97 +4. 测试服务器处理并返回
98 +
99 +5. Vite 转发响应
100 +
101 +6. Playwright 接收响应
102 +```
103 +
104 +### 静态资源
105 +
106 +```
107 +1. Playwright: page.goto('/login')
108 +
109 +2. Vite 直接返回: index.html
110 +
111 +3. Playwright 渲染页面
112 +```
113 +
114 +## 🧪 验证代理配置
115 +
116 +### 检查方法 1: 查看 Vite 日志
117 +
118 +```bash
119 +# 启动开发服务器
120 +pnpm dev
121 +
122 +# 输出应包含:
123 +# Local: http://localhost:5173/
124 +# Proxy: /srv -> http://oa-dev.onwall.cn/srv
125 +```
126 +
127 +### 检查方法 2: 使用浏览器开发者工具
128 +
129 +1. 访问 `http://localhost:5173`
130 +2. 打开开发者工具 (F12)
131 +3. 切换到 Network 标签
132 +4. 触发登录流程
133 +5. 查看 `/srv/api/*` 请求
134 +
135 +**预期结果**:
136 +
137 +- 请求 URL: `http://localhost:5173/srv/api/login`
138 +- 实际请求: `http://oa-dev.onwall.cn/srv/api/login`
139 +- 状态码: 200
140 +
141 +### 检查方法 3: 运行 Playwright 测试
142 +
143 +```bash
144 +# 运行测试
145 +pnpm test:e2e e2e/auth.spec.js --headed
146 +
147 +# 观察浏览器操作,确认:
148 +# 1. 访问 localhost:5173
149 +# 2. 输入手机号
150 +# 3. 点击"发送验证码"
151 +# 4. 网络请求显示 /srv/api/send-sms
152 +```
153 +
154 +## ⚙️ 代理配置详解
155 +
156 +### createProxy 函数
157 +
158 +**`build/proxy.js`**:
159 +
160 +```javascript
161 +export function createProxy(prefix, target) {
162 + const ret = {}
163 + ret[prefix] = {
164 + target, // 代理目标
165 + changeOrigin: true, // 修改请求头的 origin
166 + ws: true, // 支持 WebSocket
167 + }
168 + return ret
169 +}
170 +```
171 +
172 +**参数说明**:
173 +
174 +- `prefix`: `/srv/` - 需要代理的路径前缀
175 +- `target`: `http://oa-dev.onwall.cn/` - 代理目标服务器
176 +- `changeOrigin`: `true` - 修改请求头的 origin 为目标服务器的 origin
177 +- `ws`: `true` - 支持 WebSocket 代理
178 +
179 +### 代理示例
180 +
181 +**请求**:
182 +
183 +```
184 +POST http://localhost:5173/srv/api/send-sms
185 +Content-Type: application/json
186 +
187 +{
188 + "phone": "13761653761"
189 +}
190 +```
191 +
192 +**Vite 转换为**:
193 +
194 +```
195 +POST http://oa-dev.onwall.cn/srv/api/send-sms
196 +Host: oa-dev.onwall.cn
197 +Origin: http://oa-dev.onwall.cn
198 +
199 +{
200 + "phone": "13761653761"
201 +}
202 +```
203 +
204 +## 🚀 启动测试
205 +
206 +### 方式 1: 自动启动(推荐)
207 +
208 +```bash
209 +# Playwright 自动启动 Vite 开发服务器
210 +pnpm test:e2e
211 +```
212 +
213 +**优点**:
214 +
215 +- 自动化管理
216 +- 测试完成后自动关闭
217 +- CI/CD 友好
218 +
219 +### 方式 2: 手动启动
220 +
221 +```bash
222 +# 终端 1: 启动开发服务器
223 +pnpm dev
224 +
225 +# 终端 2: 运行测试
226 +pnpm test:e2e
227 +```
228 +
229 +**优点**:
230 +
231 +- 可以看到服务器日志
232 +- 方便调试
233 +
234 +## 🔧 常见问题
235 +
236 +### 1. 代理不生效
237 +
238 +**检查**:
239 +
240 +```bash
241 +# 确认 .env.development 配置正确
242 +cat .env.development | grep PROXY
243 +
244 +# 应该输出:
245 +# VITE_PROXY_PREFIX = /srv/
246 +# VITE_PROXY_TARGET = http://oa-dev.onwall.cn/
247 +```
248 +
249 +**解决**:
250 +
251 +- 重启 Vite 开发服务器
252 +- 清除浏览器缓存
253 +- 检查 Vite 配置文件
254 +
255 +### 2. CORS 错误
256 +
257 +**原因**: 跨域请求被阻止
258 +
259 +**解决**:
260 +
261 +- Vite 的 `changeOrigin: true` 会自动处理 CORS
262 +- 确保使用代理前缀 `/srv/`
263 +- 不要直接请求 `http://oa-dev.onwall.cn`
264 +
265 +### 3. API 请求 404
266 +
267 +**检查**:
268 +
269 +```javascript
270 +// ✓ 正确 - 使用代理前缀
271 +await fetch('/srv/api/login')
272 +
273 +// ✗ 错误 - 直接访问测试服务器
274 +await fetch('http://oa-dev.onwall.cn/api/login')
275 +
276 +// ✗ 错误 - 缺少代理前缀
277 +await fetch('/api/login')
278 +```
279 +
280 +### 4. WebSocket 连接失败
281 +
282 +**检查**:
283 +
284 +- Vite 配置中 `ws: true` 已启用
285 +- 代理前缀正确
286 +- 测试服务器支持 WebSocket
287 +
288 +## 📚 相关文档
289 +
290 +- [Vite 代理配置](https://vitejs.dev/config/server-options.html#server-proxy)
291 +- [Playwright webServer](https://playwright.dev/docs/test-webserver)
292 +- [项目 CLAUDE.md](../CLAUDE.md)
293 +- [E2E 测试指南](./E2E_AUTH_GUIDE.md)
294 +
295 +## 🔗 链接
296 +
297 +- **本地开发服务器**: http://localhost:5173
298 +- **测试服务器**: http://oa-dev.onwall.cn
299 +- **代理前缀**: `/srv/`
1 +# E2E 测试服务器配置说明
2 +
3 +## 🌐 测试服务器信息
4 +
5 +- **测试服务器**: `http://oa-dev.onwall.cn`
6 +- **访问方式**: 通过 Vite 反向代理
7 +- **本地地址**: `http://localhost:5173`
8 +- **代理前缀**: `/srv/`
9 +
10 +## 📝 配置说明
11 +
12 +### 代理配置
13 +
14 +**`.env.development`**:
15 +
16 +```bash
17 +# 反向代理配置
18 +VITE_PROXY_PREFIX = /srv/
19 +VITE_PROXY_TARGET = http://oa-dev.onwall.cn/
20 +```
21 +
22 +所有 `/srv/*` 的请求都会被代理到 `http://oa-dev.onwall.cn/srv/*`
23 +
24 +### Playwright 配置
25 +
26 +**`playwright.config.js`**:
27 +
28 +```javascript
29 +export default defineConfig({
30 + use: {
31 + // 使用本地开发服务器(通过代理访问)
32 + baseURL: 'http://localhost:5173',
33 + },
34 +
35 + // 自动启动本地开发服务器
36 + webServer: {
37 + command: 'pnpm dev',
38 + url: 'http://localhost:5173',
39 + reuseExistingServer: !process.env.CI,
40 + timeout: 120 * 1000,
41 + },
42 +})
43 +```
44 +
45 +### 工作流程
46 +
47 +```
48 +Playwright 测试
49 +
50 +访问 http://localhost:5173/login
51 +
52 +Vite 开发服务器(localhost:5173)
53 +
54 +API 请求:/srv/api/login
55 +
56 +Vite 反向代理
57 +
58 +http://oa-dev.onwall.cn/srv/api/login
59 +```
60 +
61 +## 🔑 测试账号
62 +
63 +### 测试手机号
64 +
65 +```
66 +13761653761
67 +```
68 +
69 +### 固定验证码
70 +
71 +```
72 +888888
73 +```
74 +
75 +## ⚠️ 重要:登录流程说明
76 +
77 +### 必须遵循的步骤
78 +
79 +测试服务器的登录接口有以下要求:
80 +
81 +1. **必须触发发送验证码接口**
82 + - 不能直接输入手机号和验证码登录
83 + - 必须点击"发送验证码"按钮
84 + - 必须等待接口响应
85 +
86 +2. **接口响应后才可输入验证码**
87 + - 等待按钮状态变化(倒计时开始)
88 + - 或等待 2 秒确保接口响应完成
89 +
90 +3. **验证码固定返回**
91 + - 测试环境会自动返回 `888888`
92 + - 无需查看真实短信
93 +
94 +### 正确的登录流程
95 +
96 +```javascript
97 +// ✅ 正确流程
98 +await page.goto('/login')
99 +
100 +// 1. 输入手机号
101 +await page.fill('input[name="phone"]', '13761653761')
102 +
103 +// 2. 点击"发送验证码"按钮(触发接口)
104 +await page.click('button:has-text("发送验证码")')
105 +
106 +// 3. 等待接口响应(重要!)
107 +await page.waitForTimeout(2000)
108 +
109 +// 4. 输入验证码
110 +await page.fill('input[name="code"]', '888888')
111 +
112 +// 5. 点击登录
113 +await page.click('button[type="submit"]')
114 +```
115 +
116 +### 错误的登录流程
117 +
118 +```javascript
119 +// ❌ 错误流程(会失败)
120 +await page.goto('/login')
121 +
122 +// 1. 输入手机号
123 +await page.fill('input[name="phone"]', '13761653761')
124 +
125 +// 2. 直接输入验证码(未触发接口!)
126 +await page.fill('input[name="code"]', '888888')
127 +
128 +// 3. 点击登录
129 +await page.click('button[type="submit"]')
130 +// 结果:登录失败,因为没有触发发送验证码接口
131 +```
132 +
133 +## 🔧 实现代码
134 +
135 +### 完整的登录函数
136 +
137 +**`e2e/helpers/auth.js`** 中的 `login()` 函数已实现正确的流程:
138 +
139 +```javascript
140 +export async function login(page, account = TEST_ACCOUNT) {
141 + // 1. 访问登录页
142 + await page.goto('/login')
143 +
144 + // 2. 输入手机号
145 + await page.fill('input[name="phone"]', account.phone)
146 +
147 + // 3. 点击"发送验证码"按钮
148 + const sendCodeButton = page.locator('button:has-text("发送验证码")')
149 + await sendCodeButton.click()
150 +
151 + // 4. 等待接口响应(重要!)
152 + await page.waitForTimeout(2000)
153 +
154 + // 5. 输入验证码
155 + await page.fill('input[name="code"]', account.code)
156 +
157 + // 6. 点击登录
158 + await page.click('button[type="submit"]')
159 +
160 + // 7. 等待登录成功
161 + await page.waitForURL(/\/(home|index)?/)
162 +}
163 +```
164 +
165 +### 详细的日志输出
166 +
167 +登录函数会输出详细的步骤日志:
168 +
169 +```javascript
170 +🔐 开始登录流程...
171 + 已访问登录页
172 + 登录表单已加载
173 + 已输入手机号: 13761653761
174 + 发送验证码按钮已找到
175 + 已点击发送验证码按钮,等待接口响应...
176 + 短信接口已响应,按钮状态: 已禁用, 文本: "60s"
177 + 已输入验证码: 888888
178 + 已点击登录按钮,等待登录响应...
179 + 登录成功(URL 已变化)
180 + 登录流程完成!
181 +```
182 +
183 +## 🧪 运行测试
184 +
185 +### 直接运行测试
186 +
187 +```bash
188 +# 运行所有 E2E 测试
189 +pnpm test:e2e
190 +
191 +# 运行认证测试
192 +pnpm test:e2e e2e/auth.spec.js
193 +
194 +# UI 模式(推荐)
195 +pnpm test:e2e:ui
196 +```
197 +
198 +### 调试模式
199 +
200 +```bash
201 +# 有头模式(可以看到浏览器操作)
202 +pnpm test:e2e --headed
203 +
204 +# 调试模式(逐步执行)
205 +pnpm test:e2e:debug
206 +```
207 +
208 +## 📊 验证接口触发
209 +
210 +### 检查网络请求
211 +
212 +在 Playwright Inspector 或浏览器开发者工具中可以看到:
213 +
214 +```
215 +1. GET /login
216 + - 加载登录页面
217 +
218 +2. POST /api/send-sms
219 + Request: {
220 + phone: "13761653761"
221 + }
222 + Response: {
223 + code: 1,
224 + data: { success: true }
225 + }
226 +
227 +3. POST /api/login
228 + Request: {
229 + phone: "13761653761",
230 + code: "888888"
231 + }
232 + Response: {
233 + code: 1,
234 + data: {
235 + token: "xxx",
236 + userId: "xxx"
237 + }
238 + }
239 +```
240 +
241 +### 确认接口已触发
242 +
243 +在代码中可以通过以下方式确认:
244 +
245 +```javascript
246 +// 监听网络请求
247 +page.on('response', response => {
248 + if (response.url().includes('send-sms')) {
249 + console.log('✓ 发送短信接口已调用')
250 + console.log('状态:', response.status())
251 + }
252 +})
253 +
254 +// 等待接口响应
255 +await page.waitForResponse('**/api/send-sms')
256 +```
257 +
258 +## 🐛 常见问题
259 +
260 +### 1. 登录失败:接口未触发
261 +
262 +**原因**: 没有点击"发送验证码"按钮或点击后立即输入验证码
263 +
264 +**解决**: 确保使用 `login()` 函数,该函数会自动等待接口响应
265 +
266 +### 2. 验证码错误
267 +
268 +**原因**:
269 +
270 +- 未使用测试手机号 `13761653761`
271 +- 未触发发送验证码接口
272 +
273 +**解决**:
274 +
275 +- 确保使用测试账号
276 +- 确保点击了"发送验证码"按钮
277 +- 等待 2 秒后再输入验证码
278 +
279 +### 3. 按钮点击无效
280 +
281 +**原因**: 按钮选择器不正确
282 +
283 +**解决**: 使用多个备选选择器
284 +
285 +```javascript
286 +const sendCodeButton = page
287 + .locator('button:has-text("发送验证码")')
288 + .or(page.locator('button:has-text("获取验证码")'))
289 + .or(page.locator('button:has-text("发送")'))
290 + .first()
291 +```
292 +
293 +## 🎯 最佳实践
294 +
295 +### 1. 始终使用 `login()` 函数
296 +
297 +```javascript
298 +// ✓ 正确
299 +import { login } from './helpers/auth'
300 +
301 +test('测试功能', async ({ page }) => {
302 + await login(page) // 自动处理所有细节
303 +})
304 +
305 +// ✗ 错误(可能遗漏步骤)
306 +test('测试功能', async ({ page }) => {
307 + await page.goto('/login')
308 + await page.fill('input[name="phone"]', '13761653761')
309 + await page.click('button')
310 + // 可能失败,因为没有等待接口响应
311 +})
312 +```
313 +
314 +### 2. 添加详细日志
315 +
316 +```javascript
317 +test('调试登录流程', async ({ page }) => {
318 + // 监听所有网络请求
319 + page.on('request', request => {
320 + console.log('📤:', request.method(), request.url())
321 + })
322 +
323 + page.on('response', response => {
324 + console.log('📥:', response.status(), response.url())
325 + })
326 +
327 + await login(page)
328 +})
329 +```
330 +
331 +### 3. 使用调试模式
332 +
333 +```bash
334 +# UI 模式可以看到每一步操作
335 +pnpm test:e2e:ui
336 +
337 +# 逐步执行
338 +pnpm test:e2e:debug
339 +```
340 +
341 +## 📚 相关文档
342 +
343 +- [Playwright 完整指南](../docs/PLAYWRIGHT.md)
344 +- [E2E 认证指南](../docs/E2E_AUTH_GUIDE.md)
345 +- [E2E 快速入门](e2e/README.md)
346 +
347 +## 🔗 链接
348 +
349 +- **测试服务器**: http://oa-dev.onwall.cn
350 +- **登录页面**: http://oa-dev.onwall.cn/login
1 +# E2E 测试快速入门
2 +
3 +## 🚀 快速开始
4 +
5 +### 运行测试
6 +
7 +```bash
8 +# 运行所有 E2E 测试
9 +pnpm test:e2e
10 +
11 +# UI 模式(推荐)
12 +pnpm test:e2e:ui
13 +
14 +# 调试模式
15 +pnpm test:e2e:debug
16 +```
17 +
18 +## 🔑 测试账号
19 +
20 +**测试服务器**: `http://oa-dev.onwall.cn`(通过反向代理访问)
21 +
22 +测试环境使用固定验证码:
23 +
24 +- **手机号**: `13761653761`
25 +- **验证码**: `888888`
26 +
27 +**重要说明**:
28 +
29 +- ⚠️ 必须点击"发送验证码"按钮触发接口
30 +- ⚠️ 等待接口响应后,才能输入验证码
31 +- ⚠️ 测试服务器会自动返回固定验证码 `888888`
32 +
33 +**代理说明**:
34 +
35 +- 测试通过本地 Vite 开发服务器(`localhost:5173`)运行
36 +- 所有 `/srv/*` 请求代理到 `http://oa-dev.onwall.cn/srv/*`
37 +
38 +## 📝 编写测试
39 +
40 +### 1. 需要登录的测试
41 +
42 +```javascript
43 +import { login } from './helpers/auth'
44 +
45 +test('测试功能', async ({ page }) => {
46 + // 登录
47 + await login(page)
48 +
49 + // 进行测试...
50 + await page.goto('/profile')
51 +})
52 +```
53 +
54 +### 2. 自动登录(推荐)
55 +
56 +```javascript
57 +import { test } from './helpers/auth'
58 +
59 +test.describe('功能测试', () => {
60 + // 使用 authenticatedPage 自动登录
61 + test('测试功能', async ({ authenticatedPage }) => {
62 + // 页面已自动登录
63 + await authenticatedPage.goto('/profile')
64 + })
65 +})
66 +```
67 +
68 +### 3. 不需要登录的测试
69 +
70 +```javascript
71 +test('浏览课程', async ({ page }) => {
72 + await page.goto('/courses-list')
73 + // 测试...
74 +})
75 +```
76 +
77 +## 📚 详细文档
78 +
79 +- [完整认证指南](../docs/E2E_AUTH_GUIDE.md)
80 +- [Playwright 使用指南](../docs/PLAYWRIGHT.md)
81 +
82 +## 🛠️ 工具函数
83 +
84 +所有工具函数都在 `e2e/helpers/auth.js`
85 +
86 +| 函数 | 说明 |
87 +| ------------------ | ------------------------ |
88 +| `login(page)` | 执行登录流程 |
89 +| `quickLogin(page)` | 快速登录(localStorage) |
90 +| `logout(page)` | 登出 |
91 +| `isLoggedIn(page)` | 检查登录状态 |
92 +
93 +## 💡 最佳实践
94 +
95 +1. **测试前确保干净的登录状态**
96 +
97 + ```javascript
98 + test.beforeEach(async ({ page }) => {
99 + await logout(page)
100 + })
101 + ```
102 +
103 +2. **使用明确的等待**
104 +
105 + ```javascript
106 + // ✓ 好
107 + await page.waitForSelector('.content')
108 +
109 + // ✗ 不好
110 + await page.waitForTimeout(3000)
111 + ```
112 +
113 +3. **使用多个选择器备选**
114 + ```javascript
115 + const button = page.locator('button[type="submit"]').or(page.locator('button:has-text("登录")'))
116 + ```
117 +
118 +## 🐛 调试技巧
119 +
120 +### UI 模式
121 +
122 +```bash
123 +pnpm test:e2e:ui
124 +```
125 +
126 +### 逐步调试
127 +
128 +```javascript
129 +test('调试', async ({ page }) => {
130 + await page.goto('/login')
131 + await page.pause() // 暂停,打开 Inspector
132 + await login(page)
133 +})
134 +```
135 +
136 +### 查看执行过程
137 +
138 +```bash
139 +# 有头模式(可以看到浏览器)
140 +pnpm test:e2e --headed
141 +
142 +# 慢动作模式
143 +pnpm test:e2e --slow-mo=1000
144 +```
145 +
146 +## 📂 文件结构
147 +
148 +```
149 +e2e/
150 +├── helpers/
151 +│ └── auth.js # 认证工具
152 +├── auth.spec.js # 认证测试示例
153 +├── courses.spec.js # 课程功能测试
154 +├── example.spec.js # 基础测试示例
155 +└── README.md # 本文件
156 +```
157 +
158 +## 🔗 相关链接
159 +
160 +- [Playwright 官方文档](https://playwright.dev)
161 +- [项目 E2E 认证指南](../docs/E2E_AUTH_GUIDE.md)
162 +- [Playwright 完整指南](../docs/PLAYWRIGHT.md)
1 +/*
2 + * @Date: 2026-01-28 22:00:00
3 + * @Description: 认证相关 E2E 测试示例
4 + */
5 +import { test, expect } from '@playwright/test'
6 +import { login, logout, isLoggedIn, quickLogin, TEST_ACCOUNT } from './helpers/auth'
7 +
8 +test.describe('用户认证', () => {
9 + test.beforeEach(async ({ page }) => {
10 + // 每个测试前确保是登出状态
11 + await logout(page)
12 + })
13 +
14 + test('应该成功登录', async ({ page }) => {
15 + // 1. 访问登录页
16 + await page.goto('/login')
17 +
18 + // 2. 检查登录表单元素
19 + const phoneInput = page.locator('input[name="phone"]')
20 + await expect(phoneInput).toBeVisible()
21 +
22 + // 3. 执行登录
23 + await login(page)
24 +
25 + // 4. 验证登录成功
26 + await expect(page).toHaveURL(/\/(home|index)?/)
27 +
28 + // 5. 验证用户信息已保存
29 + const loggedIn = await isLoggedIn(page)
30 + expect(loggedIn).toBe(true)
31 + })
32 +
33 + test('应该显示登录错误信息(手机号格式错误)', async ({ page }) => {
34 + await page.goto('/login')
35 +
36 + // 输入错误格式的手机号
37 + await page.fill('input[name="phone"]', '12345')
38 +
39 + // 点击发送验证码
40 + await page.click('button:has-text("发送验证码")')
41 +
42 + // 验证错误提示
43 + const errorToast = page.locator('.van-toast--fail').or(page.locator('.van-toast--error'))
44 +
45 + // 等待错误提示出现
46 + await expect(errorToast).toBeVisible({ timeout: 3000 })
47 + })
48 +
49 + test('应该成功登出', async ({ page }) => {
50 + // 先登录
51 + await login(page)
52 +
53 + // 验证已登录
54 + let loggedIn = await isLoggedIn(page)
55 + expect(loggedIn).toBe(true)
56 +
57 + // 执行登出
58 + await logout(page)
59 +
60 + // 验证已登出
61 + loggedIn = await isLoggedIn(page)
62 + expect(loggedIn).toBe(false)
63 +
64 + // 验证跳转到登录页
65 + await expect(page).toHaveURL(/\/login/)
66 + })
67 +})
68 +
69 +test.describe('需要登录的功能', () => {
70 + test('访问受保护页面应跳转到登录页', async ({ page }) => {
71 + // 直接访问需要登录的页面
72 + await page.goto('/profile')
73 +
74 + // 验证跳转到登录页
75 + await expect(page).toHaveURL(/\/login/)
76 + })
77 +
78 + test('登录后可以访问个人中心', async ({ page }) => {
79 + // 先登录
80 + await login(page)
81 +
82 + // 访问个人中心
83 + await page.goto('/profile')
84 +
85 + // 验证页面加载成功
86 + await expect(page).toHaveURL(/\/profile/)
87 +
88 + // 检查页面元素
89 + const profileContent = page.locator('.profile').or(page.locator('[class*="profile"]'))
90 + await expect(profileContent).toBeVisible()
91 + })
92 +
93 + test('登录后可以查看课程学习进度', async ({ page }) => {
94 + // 登录
95 + await login(page)
96 +
97 + // 访问学习进度页面
98 + await page.goto('/study/progress')
99 +
100 + // 验证页面内容
101 + const progressContent = page.locator('.study-progress').or(page.locator('[class*="progress"]'))
102 + await expect(progressContent).toBeVisible({ timeout: 5000 })
103 + })
104 +})
105 +
106 +test.describe('购买流程(需要登录)', () => {
107 + test('登录后可以购买课程', async ({ page }) => {
108 + // 登录
109 + await login(page)
110 +
111 + // 访问课程详情页(假设课程ID为123)
112 + await page.goto('/courses/123')
113 +
114 + // 点击购买按钮
115 + const buyButton = page
116 + .locator('button:has-text("购买")')
117 + .or(page.locator('button:has-text("立即购买")'))
118 +
119 + // 等待按钮可点击
120 + await buyButton.waitFor({ state: 'visible', timeout: 5000 })
121 + await buyButton.click()
122 +
123 + // 验证跳转到结算页或支付页
124 + await expect(page).toHaveURL(/\/(checkout|payment)/)
125 +
126 + // 检查结算页内容
127 + const checkoutContent = page.locator('.checkout').or(page.locator('[class*="checkout"]'))
128 + await expect(checkoutContent).toBeVisible()
129 + })
130 +
131 + test('未登录购买课程应跳转到登录页', async ({ page }) => {
132 + // 确保未登录
133 + await logout(page)
134 +
135 + // 访问课程详情页
136 + await page.goto('/courses/123')
137 +
138 + // 点击购买按钮
139 + const buyButton = page
140 + .locator('button:has-text("购买")')
141 + .or(page.locator('button:has-text("立即购买")'))
142 +
143 + await buyButton.click()
144 +
145 + // 验证跳转到登录页
146 + await expect(page).toHaveURL(/\/login/)
147 + })
148 +})
149 +
150 +test.describe('打卡功能(需要登录)', () => {
151 + test('登录后可以提交打卡', async ({ page }) => {
152 + // 登录
153 + await login(page)
154 +
155 + // 访问打卡页面
156 + await page.goto('/checkin')
157 +
158 + // 填写打卡内容
159 + const textarea = page.locator('textarea').or(page.locator('input[type="text"]'))
160 + await textarea.fill('今天的打卡内容')
161 +
162 + // 点击提交按钮
163 + const submitButton = page
164 + .locator('button:has-text("提交")')
165 + .or(page.locator('button:has-text("打卡")'))
166 + await submitButton.click()
167 +
168 + // 验证提交成功(显示成功提示)
169 + const successToast = page.locator('.van-toast--success')
170 + await expect(successToast).toBeVisible({ timeout: 3000 })
171 + })
172 +
173 + test('未登录打卡应跳转到登录页', async ({ page }) => {
174 + // 确保未登录
175 + await logout(page)
176 +
177 + // 访问打卡页面
178 + await page.goto('/checkin')
179 +
180 + // 验证跳转到登录页
181 + await expect(page).toHaveURL(/\/login/)
182 + })
183 +})
184 +
185 +test.describe('使用快速登录', () => {
186 + test('快速登录跳过输入流程', async ({ page }) => {
187 + // 使用快速登录(首次会执行正常登录获取 token)
188 + await quickLogin(page)
189 +
190 + // 访问需要登录的页面
191 + await page.goto('/profile')
192 +
193 + // 验证页面正常访问
194 + await expect(page).toHaveURL(/\/profile/)
195 + })
196 +
197 + test('复用 token 多次测试', async ({ page }) => {
198 + // 第一次登录获取 token
199 + await login(page)
200 +
201 + // 获取 token
202 + const token = await page.evaluate(() => {
203 + const userInfo = localStorage.getItem('user_info')
204 + return JSON.parse(userInfo).token
205 + })
206 +
207 + // 登出
208 + await logout(page)
209 +
210 + // 使用 token 快速登录
211 + await quickLogin(page, token)
212 +
213 + // 验证登录状态
214 + const loggedIn = await isLoggedIn(page)
215 + expect(loggedIn).toBe(true)
216 + })
217 +})
1 +/*
2 + * @Date: 2026-01-28 22:00:00
3 + * @Description: 课程功能 E2E 测试(使用 authenticated fixture)
4 + */
5 +import { test, expect } from '@playwright/test'
6 +import { login } from './helpers/auth'
7 +
8 +// 扩展 test,添加 authenticatedPage fixture
9 +const authenticatedTest = test.extend({
10 + authenticatedPage: async ({ page }, use) => {
11 + // 自动登录
12 + await login(page)
13 + // 使用已认证的页面
14 + await use(page)
15 + },
16 +})
17 +
18 +authenticatedTest.describe('课程浏览(已登录)', () => {
19 + authenticatedTest('可以浏览课程列表', async ({ authenticatedPage }) => {
20 + // 页面已经登录,直接访问
21 + await authenticatedPage.goto('/courses-list')
22 +
23 + // 等待课程列表加载
24 + await authenticatedPage.waitForSelector('.course-card', { timeout: 5000 })
25 +
26 + // 验证课程卡片存在
27 + const courseCards = authenticatedPage.locator('.course-card')
28 + const count = await courseCards.count()
29 +
30 + expect(count).toBeGreaterThan(0)
31 +
32 + console.log(`找到 ${count} 个课程`)
33 + })
34 +
35 + authenticatedTest('可以查看课程详情', async ({ authenticatedPage }) => {
36 + // 访问课程详情页
37 + await authenticatedPage.goto('/courses/123')
38 +
39 + // 等待页面加载
40 + await authenticatedPage.waitForLoadState('networkidle')
41 +
42 + // 验证关键元素
43 + const courseTitle = authenticatedPage
44 + .locator('h1')
45 + .or(authenticatedPage.locator('.course-title'))
46 + await expect(courseTitle).toBeVisible()
47 + })
48 +
49 + authenticatedTest('可以收藏课程', async ({ authenticatedPage }) => {
50 + // 访问课程详情页
51 + await authenticatedPage.goto('/courses/123')
52 +
53 + // 点击收藏按钮
54 + const favoriteButton = authenticatedPage
55 + .locator('button:has-text("收藏")')
56 + .or(authenticatedPage.locator('[class*="favorite"]'))
57 +
58 + // 记录点击前的状态
59 + const isFavoritedBefore = await favoriteButton.getAttribute('class')
60 +
61 + await favoriteButton.click()
62 +
63 + // 等待状态更新
64 + await authenticatedPage.waitForTimeout(500)
65 +
66 + // 验证收藏状态改变
67 + const isFavoritedAfter = await favoriteButton.getAttribute('class')
68 + expect(isFavoritedAfter).not.toBe(isFavoritedBefore)
69 + })
70 +})
71 +
72 +authenticatedTest.describe('学习进度(已登录)', () => {
73 + authenticatedTest('可以查看学习记录', async ({ authenticatedPage }) => {
74 + // 访问学习记录页面
75 + await authenticatedPage.goto('/study/records')
76 +
77 + // 等待加载
78 + await authenticatedPage.waitForLoadState('networkidle')
79 +
80 + // 验证页面内容
81 + const recordsList = authenticatedPage
82 + .locator('.study-records')
83 + .or(authenticatedPage.locator('[class*="records"]'))
84 + await expect(recordsList).toBeVisible()
85 + })
86 +
87 + authenticatedTest('可以继续学习', async ({ authenticatedPage }) => {
88 + // 访问学习页面
89 + await authenticatedPage.goto('/study/course/123')
90 +
91 + // 等待视频播放器加载
92 + const videoPlayer = authenticatedPage
93 + .locator('video')
94 + .or(authenticatedPage.locator('[class*="video-js"]'))
95 +
96 + await expect(videoPlayer).toBeVisible({ timeout: 10000 })
97 + })
98 +})
99 +
100 +// 不需要登录的测试仍然使用普通 test
101 +test.describe('课程浏览(未登录)', () => {
102 + test('游客可以浏览课程列表', async ({ page }) => {
103 + await page.goto('/courses-list')
104 +
105 + // 等待课程列表加载
106 + await page.waitForSelector('.course-card', { timeout: 5000 })
107 +
108 + // 验证课程卡片存在
109 + const courseCards = page.locator('.course-card')
110 + const count = await courseCards.count()
111 +
112 + expect(count).toBeGreaterThan(0)
113 + })
114 +
115 + test('游客可以查看课程详情', async ({ page }) => {
116 + await page.goto('/courses/123')
117 +
118 + // 验证页面加载
119 + const courseTitle = page.locator('h1').or(page.locator('.course-title'))
120 + await expect(courseTitle).toBeVisible()
121 + })
122 +
123 + test('游客查看详情提示登录', async ({ page }) => {
124 + await page.goto('/courses/123')
125 +
126 + // 查找"开始学习"或"购买"按钮
127 + const startButton = page
128 + .locator('button:has-text("开始学习")')
129 + .or(page.locator('button:has-text("购买")'))
130 +
131 + if (await startButton.isVisible()) {
132 + await startButton.click()
133 +
134 + // 验证跳转到登录页
135 + await expect(page).toHaveURL(/\/login/)
136 + }
137 + })
138 +})
1 +/*
2 + * @Date: 2026-01-28 22:00:00
3 + * @Description: E2E 测试认证辅助工具
4 + */
5 +import { test as base } from '@playwright/test'
6 +
7 +// 测试账号配置
8 +export const TEST_ACCOUNT = {
9 + phone: '13761653761',
10 + code: '888888', // 测试环境固定验证码
11 + password: '', // 如果有密码登录
12 +}
13 +
14 +/**
15 + * 扩展 test 对象,添加 authenticated fixture
16 + */
17 +export const test = base.extend({
18 + // 已认证的页面(自动登录)
19 + authenticatedPage: async ({ page }, use) => {
20 + // 执行登录
21 + await login(page)
22 +
23 + // 使用已认证的页面
24 + await use(page)
25 + },
26 +})
27 +
28 +/**
29 + * 登录操作
30 + * @param {Page} page - Playwright page 对象
31 + * @param {Object} account - 账号信息(可选,默认使用测试账号)
32 + */
33 +export async function login(page, account = TEST_ACCOUNT) {
34 + console.log('🔐 开始登录流程...')
35 +
36 + // 1. 访问登录页
37 + await page.goto('/login')
38 + console.log('✓ 已访问登录页')
39 +
40 + // 2. 等待登录表单加载
41 + await page.waitForSelector('input[name="phone"]', { timeout: 10000 })
42 + console.log('✓ 登录表单已加载')
43 +
44 + // 3. 输入手机号
45 + const phoneInput = page
46 + .locator('input[name="phone"]')
47 + .or(page.locator('input[placeholder*="手机号"]'))
48 + await phoneInput.fill(account.phone)
49 + console.log(`✓ 已输入手机号: ${account.phone}`)
50 +
51 + // 4. 点击"发送验证码"按钮(触发短信接口)
52 + const sendCodeButton = page
53 + .locator('button:has-text("发送验证码")')
54 + .or(page.locator('button:has-text("获取验证码")'))
55 + .or(page.locator('button:has-text("发送")'))
56 + .first()
57 +
58 + // 确保按钮可点击
59 + await sendCodeButton.waitFor({ state: 'visible', timeout: 5000 })
60 + console.log('✓ 发送验证码按钮已找到')
61 +
62 + // 点击发送验证码
63 + await sendCodeButton.click()
64 + console.log('✓ 已点击发送验证码按钮,等待接口响应...')
65 +
66 + // 5. 等待发送短信接口响应
67 + // 等待按钮进入倒计时状态(通常是禁用或文本变为倒计时)
68 + await page.waitForTimeout(2000)
69 +
70 + // 验证按钮状态变化(表示接口已响应)
71 + const isDisabled = await sendCodeButton.isDisabled()
72 + const buttonText = await sendCodeButton.textContent()
73 + console.log(
74 + `✓ 短信接口已响应,按钮状态: ${isDisabled ? '已禁用' : '可用'}, 文本: "${buttonText}"`
75 + )
76 +
77 + // 6. 等待一小段时间再输入验证码(模拟真实用户操作)
78 + await page.waitForTimeout(500)
79 +
80 + // 7. 输入验证码
81 + const codeInput = page
82 + .locator('input[name="code"]')
83 + .or(page.locator('input[placeholder*="验证码"]'))
84 + .or(page.locator('input[maxlength="6"]'))
85 + .first()
86 +
87 + await codeInput.waitFor({ state: 'visible', timeout: 5000 })
88 + await codeInput.fill(account.code)
89 + console.log(`✓ 已输入验证码: ${account.code}`)
90 +
91 + // 8. 点击登录按钮
92 + const loginButton = page
93 + .locator('button[type="submit"]')
94 + .or(page.locator('button:has-text("登录")'))
95 + .or(page.locator('.van-button--primary'))
96 + .first()
97 +
98 + await loginButton.waitFor({ state: 'visible', timeout: 5000 })
99 + await loginButton.click()
100 + console.log('✓ 已点击登录按钮,等待登录响应...')
101 +
102 + // 9. 等待登录成功(跳转到首页或显示成功提示)
103 + try {
104 + // 方式1:等待 URL 变化
105 + await page.waitForURL(/\/(home|index|#)?/, { timeout: 15000 })
106 + console.log('✓ 登录成功(URL 已变化)')
107 + } catch (error) {
108 + // 方式2:等待成功提示(toast)
109 + try {
110 + await page.waitForSelector('.van-toast--success', { timeout: 5000 })
111 + console.log('✓ 登录成功(显示成功提示)')
112 + } catch (toastError) {
113 + // 方式3:检查是否已经在首页
114 + const currentUrl = page.url()
115 + if (currentUrl.includes('/home') || currentUrl.includes('/index')) {
116 + console.log('✓ 登录成功(已在首页)')
117 + } else {
118 + console.log(`⚠ 当前 URL: ${currentUrl}`)
119 + console.log('⚠ 可能登录失败,请检查错误提示')
120 + throw new Error('登录失败:未检测到登录成功的标志')
121 + }
122 + }
123 + }
124 +
125 + // 10. 等待页面加载完成
126 + await page.waitForLoadState('networkidle', { timeout: 10000 })
127 + console.log('✅ 登录流程完成!')
128 +}
129 +
130 +/**
131 + * 快速登录(使用 localStorage 直接设置 token)
132 + * 适用于需要跳过登录流程的场景
133 + * @param {Page} page - Playwright page 对象
134 + * @param {string} token - 用户 token(可选)
135 + */
136 +export async function quickLogin(page, token = null) {
137 + // 如果没有提供 token,先执行一次正常登录获取
138 + if (!token) {
139 + await login(page)
140 + // 从 localStorage 获取 token
141 + token = await page.evaluate(() => {
142 + const userInfo = localStorage.getItem('user_info')
143 + if (userInfo) {
144 + return JSON.parse(userInfo).token
145 + }
146 + return null
147 + })
148 + }
149 +
150 + // 直接设置 localStorage(用于后续测试)
151 + await page.evaluate(userToken => {
152 + const mockUserInfo = {
153 + token: userToken,
154 + userId: 'test-user-123',
155 + phone: '13761653761',
156 + }
157 + localStorage.setItem('user_info', JSON.stringify(mockUserInfo))
158 + localStorage.setItem('currentUser', JSON.stringify(mockUserInfo))
159 + }, token)
160 +
161 + console.log('✅ 快速登录成功')
162 +}
163 +
164 +/**
165 + * 登出操作
166 + * @param {Page} page - Playwright page 对象
167 + */
168 +export async function logout(page) {
169 + // 清空 localStorage
170 + await page.evaluate(() => {
171 + localStorage.removeItem('user_info')
172 + localStorage.removeItem('currentUser')
173 + })
174 +
175 + // 或者点击退出按钮(如果有)
176 + // await page.click('button:has-text("退出")')
177 +
178 + // 刷新页面
179 + await page.reload()
180 +
181 + console.log('✅ 登出成功')
182 +}
183 +
184 +/**
185 + * 检查登录状态
186 + * @param {Page} page - Playwright page 对象
187 + * @returns {Promise<boolean>} 是否已登录
188 + */
189 +export async function isLoggedIn(page) {
190 + const userInfo = await page.evaluate(() => localStorage.getItem('user_info'))
191 +
192 + return !!userInfo
193 +}
194 +
195 +/**
196 + * 等待登录状态
197 + * @param {Page} page - Playwright page 对象
198 + * @param {boolean} loggedIn - 期望的登录状态
199 + */
200 +export async function waitForLoginState(page, loggedIn = true) {
201 + if (loggedIn) {
202 + // 等待登录成功
203 + await page.waitForURL(/\/(home|index)?/, { timeout: 10000 })
204 + } else {
205 + // 等待退出到登录页
206 + await page.waitForURL('/login', { timeout: 10000 })
207 + }
208 +}
...@@ -31,7 +31,7 @@ export default defineConfig({ ...@@ -31,7 +31,7 @@ export default defineConfig({
31 31
32 // 共享配置 32 // 共享配置
33 use: { 33 use: {
34 - // 基础 URL(开发服务器地址 34 + // 基础 URL(本地开发服务器,通过代理访问测试服务器
35 baseURL: 'http://localhost:5173', 35 baseURL: 'http://localhost:5173',
36 36
37 // 追踪失败测试(用于调试) 37 // 追踪失败测试(用于调试)
...@@ -79,8 +79,9 @@ export default defineConfig({ ...@@ -79,8 +79,9 @@ export default defineConfig({
79 }, 79 },
80 ], 80 ],
81 81
82 - // 开发服务器(测试前启动) 82 + // 开发服务器配置
83 webServer: { 83 webServer: {
84 + // 启动本地开发服务器(通过反向代理访问测试服务器)
84 command: 'pnpm dev', 85 command: 'pnpm dev',
85 url: 'http://localhost:5173', 86 url: 'http://localhost:5173',
86 reuseExistingServer: !process.env.CI, 87 reuseExistingServer: !process.env.CI,
......