hookehuyr

chore: 添加完整的代码质量和测试工具链

## 新增工具

### 1. ESLint + Prettier
- ESLint 9.39.2 - 代码质量检查(Vue 3 规则)
- Prettier 3.8.1 - 代码格式化
- prettier-plugin-tailwindcss - TailwindCSS 类名排序
- 配置文件:eslint.config.js, .prettierrc
- VS Code 自动格式化配置

### 2. Husky + lint-staged
- Husky 9.1.7 - Git hooks 管理
- lint-staged 16.2.7 - 暂存文件检查
- pre-commit hook 自动运行 lint 和 format
- 提交前自动修复代码问题

### 3. Playwright E2E 测试
- @playwright/test 1.58.0 - E2E 测试框架
- 已安装 Chromium 和 Chrome Headless Shell
- 配置文件:playwright.config.js
- 示例测试:e2e/example.spec.js
- 支持 3 个测试项目(移动端、桌面端、Safari)

## 配置更新

- package.json:添加 lint、format、test:e2e 等脚本
- eslint.config.js:支持 Vue、Vitest、Playwright
- .gitignore:忽略 Playwright 测试结果

## 新增文档

- docs/ESLINT_PRETTIER.md - ESLint 和 Prettier 使用指南
- docs/HUSKY_LINT_STAGED.md - Git Hooks 配置说明
- docs/PLAYWRIGHT.md - E2E 测试完整指南

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -44,3 +44,8 @@ mlaj
# Plan directory video resources
docs/plan/*/video/
# Playwright E2E 测试
test-results/
playwright-report/
playwright/.cache/
......
# Playwright E2E 测试指南
## ✅ 配置完成
你的项目现在已经配置了 **Playwright** E2E 测试框架!
## 📦 已安装的包
```json
{
"devDependencies": {
"@playwright/test": "^1.58.0"
}
}
```
**已安装的浏览器**:
- Chromium (Chrome for Testing)
- Chrome Headless Shell
## 📁 配置文件
| 文件 | 说明 |
| ---------------------- | ------------------------------- |
| `playwright.config.js` | Playwright 主配置文件 |
| `e2e/example.spec.js` | 示例 E2E 测试 |
| `test-results/` | 测试结果输出目录(.gitignore) |
| `playwright-report/` | HTML 报告输出目录(.gitignore) |
## 🚀 快速开始
### 1. 运行所有测试
```bash
# 运行所有 E2E 测试
pnpm test:e2e
# 输出示例:
# Running 9 tests using 1 worker
#
# ✓ [chromium-mobile]: › example.spec.js:3:1 › 基础功能测试 › 首页加载成功 (2.5s)
# ✓ [chromium-mobile]: › example.spec.js:10:1 › 基础功能测试 › 导航功能正常 (1.8s)
# ...
#
# 9 passed (12.3s)
```
### 2. 查看测试报告
```bash
# 生成并打开 HTML 报告
pnpm test:e2e:report
# 报告会在浏览器中自动打开
```
### 3. 调试模式
```bash
# 调试模式(逐步执行)
pnpm test:e2e:debug
# 有头模式(可以看到浏览器操作)
pnpm test:e2e:headed
# UI 模式(可视化界面)
pnpm test:e2e:ui
```
## 📝 编写测试
### 基础测试结构
```javascript
import { test, expect } from '@playwright/test'
test.describe('测试套件', () => {
test.beforeEach(async ({ page }) => {
// 每个测试前执行
await page.goto('/')
})
test('测试名称', async ({ page }) => {
// 测试步骤
await page.click('button')
await expect(page).toHaveURL('/dashboard')
})
})
```
### 常用操作
#### 页面导航
```javascript
// 访问 URL
await page.goto('/login')
// 等待导航完成
await page.waitForLoadState('networkidle')
// 等待特定 URL
await expect(page).toHaveURL(/\/dashboard/)
```
#### 元素定位
```javascript
// CSS 选择器
const button = page.locator('button.submit')
const input = page.locator('#username')
const element = page.locator('.class-name')
// 文本选择器
const link = page.locator('text=点击这里')
const title = page.locator('h1:has-text("欢迎")')
// XPath
const element = page.locator('//button[@type="submit"]')
```
#### 交互操作
```javascript
// 点击
await page.click('button')
// 输入
await page.fill('input[name="email"]', 'test@example.com')
// 选择下拉
await page.selectOption('select#country', 'China')
// 上传文件
await page.setInputFiles('input[type="file"]', 'file.pdf')
// 悬停
await page.hover('.menu-item')
# 滚动
await page.evaluate(() => window.scrollTo(0, 1000))
```
#### 断言
```javascript
// 元素可见性
await expect(page.locator('.header')).toBeVisible()
// 元素存在
await expect(page.locator('.error')).not.toBeVisible()
// 文本内容
await expect(page.locator('h1')).toHaveText('欢迎')
// 属性值
await expect(page.locator('input')).toHaveValue('test')
# URL
await expect(page).toHaveURL('/dashboard')
# 标题
await expect(page).toHaveTitle('首页')
```
### 高级用法
#### 等待策略
```javascript
// 等待元素出现
await page.waitForSelector('.loading', { state: 'hidden' })
// 等待特定时间
await page.waitForTimeout(1000)
// 等待网络响应
await page.waitForResponse('**/api/user')
// 等待函数条件
await page.waitForFunction(() => {
return document.querySelector('.button').disabled === false
})
```
#### 处理弹窗
```javascript
// 处理 alert
page.on('dialog', dialog => {
console.log(dialog.message())
dialog.accept()
})
await page.click('button#alert')
// 处理 confirm
page.on('dialog', dialog => {
dialog.dismiss() // 取消
})
await page.click('button#confirm')
```
#### 截图和录制
```javascript
// 截图
await page.screenshot({ path: 'screenshot.png' })
// 元素截图
await page.locator('.header').screenshot({ path: 'header.png' })
// 全页截图
await page.screenshot({ path: 'full.png', fullPage: true })
```
#### API 请求
```javascript
// 拦截请求
page.route('**/api/user', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ name: 'Test' })
})
})
// 监听响应
page.on('response', response => {
if (response.url().includes('api/user')) {
console.log(await response.json())
}
})
```
## 📋 测试最佳实践
### 1. 测试命名
```javascript
// ✓ GOOD - 清晰的描述
test('用户登录后应跳转到首页', async ({ page }) => {})
// ✗ BAD - 不明确的名称
test('test1', async ({ page }) => {})
```
### 2. 选择器优先级
```javascript
// 优先级:data-testid > aria-label > 文本 > CSS 选择器
// ✓ BEST - 使用 data-testid
await page.click('[data-testid="submit-button"]')
// ✓ GOOD - 使用 aria-label
await page.click('button[aria-label="提交"]')
// ✓ OK - 使用文本
await page.click('text=提交')
// ✗ BAD - 脆弱的 CSS 选择器
await page.click('div.container > div:nth-child(3) > button')
```
### 3. Page Object Model
```javascript
// pages/LoginPage.js
class LoginPage {
constructor(page) {
this.page = page
this.usernameInput = page.locator('input[name="username"]')
this.passwordInput = page.locator('input[name="password"]')
this.submitButton = page.locator('button[type="submit"]')
}
async login(username, password) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
// 测试中使用
test('用户登录', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.login('test@example.com', 'password')
await expect(page).toHaveURL('/dashboard')
})
```
### 4. 测试数据管理
```javascript
// 使用 fixtures 管理测试数据
const testUsers = {
validUser: {
email: 'test@example.com',
password: 'password123',
},
invalidUser: {
email: 'invalid@example.com',
password: 'wrongpassword',
},
}
test('登录成功', async ({ page }) => {
await login(page, testUsers.validUser)
await expect(page).toHaveURL('/dashboard')
})
```
## 🎯 测试场景示例
### 1. 完整的用户流程
```javascript
test('新用户注册流程', async ({ page }) => {
// 1. 访问注册页面
await page.goto('/register')
// 2. 填写表单
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirm"]', 'password123')
// 3. 提交表单
await page.click('button[type="submit"]')
// 4. 验证跳转
await expect(page).toHaveURL('/welcome')
// 5. 验证提示信息
const toast = page.locator('.van-toast--success')
await expect(toast).toBeVisible()
})
```
### 2. 表单验证
```javascript
test('登录表单验证', async ({ page }) => {
await page.goto('/login')
// 不输入任何信息,直接提交
await page.click('button[type="submit"]')
// 验证错误提示
const errorToast = page.locator('.van-toast--fail')
await expect(errorToast).toBeVisible()
// 输入无效邮箱
await page.fill('input[name="email"]', 'invalid-email')
await page.click('button[type="submit"]')
// 验证错误提示
await expect(errorToast).toContainText('邮箱格式不正确')
})
```
### 3. 响应式测试
```javascript
test.describe('响应式布局', () => {
test('移动端显示底部导航', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
const bottomNav = page.locator('.van-tabbar')
await expect(bottomNav).toBeVisible()
})
test('桌面端显示侧边栏', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 720 })
await page.goto('/')
const sidebar = page.locator('.sidebar')
await expect(sidebar).toBeVisible()
})
})
```
## 🔧 调试技巧
### 1. 使用 Playwright Inspector
```bash
# 启动 Inspector 模式
pnpm test:e2e:debug
# 在代码中添加断点
await page.pause() // 暂停执行,打开 Inspector
```
### 2. 查看执行过程
```bash
# 有头模式(可以看到浏览器操作)
pnpm test:e2e --headed
# 慢动作模式
pnpm test:e2e --slow-mo=1000 # 每个操作延迟 1 秒
```
### 3. 详细日志
```javascript
// 在测试中启用调试
test('调试示例', async ({ page }) => {
// 启用详细日志
page.on('console', msg => console.log(msg.text()))
page.on('pageerror', err => console.log(err))
await page.goto('/')
})
```
## 📊 测试报告
### HTML 报告
```bash
# 生成 HTML 报告
pnpm test:e2e
# 查看报告
pnpm test:e2e:report
```
**报告功能**:
- 查看所有测试结果
- 查看截图和视频
- 查看时间线
- 追踪错误
### JSON 报告
```bash
# JSON 报告会自动生成
cat test-results/test-results.json
```
### JUnit 报告
```bash
# JUnit 报告用于 CI 集成
cat test-results/test-results.xml
```
## 🎭 配置选项
### 测试项目(projects)
`playwright.config.js` 中配置了 3 个测试项目:
```javascript
projects: [
{
name: 'chromium-mobile',
use: { ...devices['iPhone 12'] },
},
{
name: 'chromium-desktop',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit-mobile',
use: { ...devices['iPhone 12'], browserName: 'webkit' },
},
]
```
### 运行特定项目
```bash
# 只运行移动端测试
pnpm test:e2e --project=chromium-mobile
# 只运行桌面端测试
pnpm test:e2e --project=chromium-desktop
```
### 失败重试
```javascript
// CI 环境自动重试 2 次
retries: process.env.CI ? 2 : 0
```
### 超时配置
```javascript
// 测试超时 30 秒
timeout: 30 * 1000
// 断言超时 5 秒
expect: {
timeout: 5 * 1000
}
// 操作超时 10 秒
use: {
actionTimeout: 10 * 1000
}
```
## 🚀 CI/CD 集成
### GitHub Actions
```yaml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
```
## 📚 参考资源
- [Playwright 官方文档](https://playwright.dev)
- [Playwright 中文文档](https://playwright.dev/docs/intro)
- [最佳实践](https://playwright.dev/docs/best-practices)
- [API 参考](https://playwright.dev/docs/api/class-playwright)
## 🎉 总结
**Playwright 优势**:
- ✅ 跨浏览器支持(Chromium、Firefox、WebKit)
- ✅ 跨平台支持(Windows、macOS、Linux)
- ✅ 快速可靠(并行执行、自动等待)
- ✅ 强大的调试工具(Inspector、Trace、Video)
- ✅ 完整的测试报告
- ✅ 移动端支持
**下一步**:
- [ ] 编写核心功能的 E2E 测试
- [ ] 集成到 CI/CD 流程
- [ ] 定期更新测试用例
享受高质量的 E2E 测试!🚀
/*
* @Date: 2026-01-28 21:45:00
* @Description: Playwright 示例 E2E 测试
*/
import { test, expect } from '@playwright/test'
test.describe('基础功能测试', () => {
test('首页加载成功', async ({ page }) => {
// 访问首页
await page.goto('/')
// 等待页面加载
await page.waitForLoadState('networkidle')
// 检查标题
await expect(page).toHaveTitle(/美乐爱觉/)
// 检查关键元素
const header = page.locator('header').first()
await expect(header).toBeVisible()
})
test('导航功能正常', async ({ page }) => {
await page.goto('/')
// 点击课程列表
await page.click('text=课程')
// 验证导航
await expect(page).toHaveURL(/\/courses-list/)
})
})
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-list')
// 输入搜索关键字
const searchInput = page.locator('input[placeholder*="搜索"]').first()
await searchInput.fill('Vue')
// 触发搜索
await searchInput.press('Enter')
// 等待结果加载
await page.waitForTimeout(500)
// 验证搜索结果
const courseCards = page.locator('.course-card')
const count = await courseCards.count()
expect(count).toBeGreaterThan(0)
})
})
test.describe('用户认证测试', () => {
test('显示登录页面', async ({ page }) => {
await page.goto('/login')
// 检查登录表单
const loginForm = page.locator('form').first()
await expect(loginForm).toBeVisible()
// 检查手机号输入框
const phoneInput = page.locator('input[name="phone"]')
await expect(phoneInput).toBeVisible()
})
test('登录表单验证', async ({ page }) => {
await page.goto('/login')
// 点击登录按钮(不输入信息)
await page.click('button[type="submit"]')
// 检查错误提示
const errorToast = page.locator('.van-toast--fail')
await expect(errorToast).toBeVisible({ timeout: 3000 })
})
})
test.describe('响应式测试', () => {
test('移动端布局正确', async ({ page }) => {
// 设置移动端视口
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
// 检查移动端导航
const bottomNav = page.locator('.van-tabbar')
await expect(bottomNav).toBeVisible()
})
test('桌面端布局正确', async ({ page }) => {
// 设置桌面端视口
await page.setViewportSize({ width: 1280, height: 720 })
await page.goto('/')
// 检查页面内容
const mainContent = page.locator('main').first()
await expect(mainContent).toBeVisible()
})
})
......@@ -136,7 +136,7 @@ export default [
},
},
// 测试文件配置
// 测试文件配置(Vitest)
{
files: ['**/*.test.js', '**/*.spec.js', 'test/**'],
languageOptions: {
......@@ -154,6 +154,21 @@ export default [
},
},
// E2E 测试文件配置(Playwright)
{
files: ['e2e/**/*.{js,ts}'],
languageOptions: {
globals: {
test: 'readonly',
expect: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
},
},
},
// Prettier 配置(必须最后)
prettier,
]
......
......@@ -10,6 +10,11 @@
"test": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"format": "prettier --write \"src/**/*.{js,vue,css,less,md,json}\"",
......
/*
* @Date: 2026-01-28 21:45:00
* @Description: Playwright E2E 测试配置
*/
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// 测试文件位置
testDir: './e2e',
// 测试超时时间(毫秒)
timeout: 30 * 1000,
// 期望超时时间
expect: {
timeout: 5 * 1000,
},
// 失败时重试次数
retries: process.env.CI ? 2 : 0,
// 并行执行
workers: process.env.CI ? 1 : undefined,
// 报告器
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/test-results.json' }],
['junit', { outputFile: 'test-results/test-results.xml' }],
],
// 共享配置
use: {
// 基础 URL(开发服务器地址)
baseURL: 'http://localhost:5173',
// 追踪失败测试(用于调试)
trace: 'on-first-retry',
// 截图(仅失败时)
screenshot: 'only-on-failure',
// 视频录制(仅失败时)
video: 'retain-on-failure',
// 浏览器视口大小(移动端优先)
viewport: { width: 375, height: 667 },
// 忽略 HTTPS 错误
ignoreHTTPSErrors: true,
// 操作超时
actionTimeout: 10 * 1000,
navigationTimeout: 30 * 1000,
},
// 测试项目(不同浏览器和视口)
projects: [
{
name: 'chromium-mobile',
use: {
...devices['iPhone 12'],
browserName: 'chromium',
},
},
{
name: 'chromium-desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
{
name: 'webkit-mobile',
use: {
...devices['iPhone 12'],
browserName: 'webkit',
},
},
],
// 开发服务器(测试前启动)
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
})