diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 992e289..6033e05 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -35,6 +35,18 @@ ], "strict": false }, + { + "name": "agent-browser-plugin", + "source": "./skills-dev/agent-browser-plugin", + "description": "浏览器自动化技能。用于网页交互、E2E冒烟测试、需求验收验证、前端开发验证、截图对比。基于 Vercel agent-browser CLI。", + "version": "1.0.0", + "category": "utility", + "keywords": [ + "utility", + "tools" + ], + "strict": false + }, { "name": "dev-arch-plugin", "source": "./skills-dev/dev-arch-plugin", diff --git a/skills-dev/dev-test-plugin/skills/dev-test/SKILL.md b/skills-dev/dev-test-plugin/skills/dev-test/SKILL.md index 8762321..3b581ce 100644 --- a/skills-dev/dev-test-plugin/skills/dev-test/SKILL.md +++ b/skills-dev/dev-test-plugin/skills/dev-test/SKILL.md @@ -13,7 +13,7 @@ description: 软件测试技能。用于单元测试、集成测试、E2E测试 | `frontend-testing.md` | Vue (Vitest) + React (Jest) 前端测试 | | `ios-testing.md` | iOS 测试 (XCTest + Swift Concurrency) | | `android-testing.md` | Android 测试 (JUnit + Espresso + Compose) | -| `e2e-testing.md` | E2E Playwright + Coolbuy PaaS 集成测试 | +| `e2e-testing.md` | E2E Playwright:API Mock 冒烟测试(无后端)+ 全链路集成测试 | --- @@ -46,8 +46,9 @@ description: 软件测试技能。用于单元测试、集成测试、E2E测试 | React | `npm test` | `frontend-testing.md` | | iOS | `xcodebuild test` | `ios-testing.md` | | Android | `./gradlew test` | `android-testing.md` | -| E2E (通用) | `npm run test:e2e` | `e2e-testing.md` | -| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` | +| E2E (Mock 冒烟) | `npm run test:e2e:smoke-mock` | `e2e-testing.md` §API Mock | +| E2E (全链路) | `npm run test:e2e` | `e2e-testing.md` §全链路 | +| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` §Coolbuy | --- @@ -138,4 +139,5 @@ ai-proj task append-doc --id --content "# 测试报告 5. **持续集成** - 每次提交运行 6. **Biz 层禁止 Mock** - biz/service 层必须使用真实 PostgreSQL test DB + 真实 store,mock 等于没测 7. **Mock 仅限 Handler 层** - handler 层可以 mock biz 接口 + httptest -7. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md` +8. **Gate 4 必须用 API Mock E2E** - E2E 门禁不能依赖后端,否则形同虚设。用 `page.route()` 拦截 API,见 `e2e-testing.md` +9. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md` diff --git a/skills-dev/dev-test-plugin/skills/dev-test/e2e-testing.md b/skills-dev/dev-test-plugin/skills/dev-test/e2e-testing.md index af0a4c6..da1eb2a 100644 --- a/skills-dev/dev-test-plugin/skills/dev-test/e2e-testing.md +++ b/skills-dev/dev-test-plugin/skills/dev-test/e2e-testing.md @@ -1,6 +1,259 @@ # E2E 测试 (Playwright) -## 通用 Playwright 配置 +## 两种 E2E 测试模式 + +| 模式 | 后端依赖 | 速度 | 适用场景 | 门禁阶段 | +|------|---------|------|---------|---------| +| **API Mock 冒烟测试** | ❌ 无需后端 | 快(<30s) | UI 布局、路由、菜单、权限隔离 | Gate 4 自动化门禁 | +| **全链路集成测试** | ✅ 需完整后端+DB | 慢(分钟级) | CRUD 业务流程、数据持久化 | 手动/CI | + +**⚠️ 关键原则:Gate 4 E2E 门禁必须使用 API Mock 模式,不依赖后端。** 依赖后端的 E2E 在开发机上经常跑不通(后端没启动、DB 未初始化),导致门禁形同虚设。 + +--- + +## API Mock 冒烟测试(无后端) + +> 使用 Playwright `page.route()` 拦截所有 API 请求,返回 fixture 数据。只启动前端 dev server,零后端依赖。 + +### 核心思路 + +``` +前端 dev server (localhost:3000) + ↕ HTTP 请求 +Playwright page.route() 拦截层 ← 替代真实后端 + ↕ fixture 响应 +浏览器渲染 + ↕ +Playwright 断言(UI 可见性、路由跳转、CSS 样式) +``` + +### 1. Mock 工具函数模板(`e2e/utils/api-mock.ts`) + +```typescript +import { Page } from '@playwright/test'; + +export interface MockUserOptions { + role: string; // 角色代码:tenant_admin, company_admin, admin 等 + userType: string; // 用户类型:system, enterprise 等 + username?: string; + userId?: number; + tenantId?: number; +} + +// 构造 mock 用户对象(匹配前端 User interface) +function buildMockUser(opts: MockUserOptions) { + return { + id: opts.userId ?? 1, + username: opts.username ?? `test_${opts.role}`, + email: `${opts.role}@test.com`, + user_type: opts.userType, + role: opts.role, + tenant_id: opts.tenantId ?? 1, + status: 'active', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }; +} + +// ⚠️ 关键:生成能通过前端 TokenManager.isTokenValid() 校验的 mock JWT +// TokenManager 检查:3 段 split、base64 解码 payload、exp > 当前时间 +function buildMockJWT(user: ReturnType): string { + const header = { alg: 'HS256', typ: 'JWT' }; + const payload = { + user_id: user.id, + username: user.username, + role: user.role, + exp: Math.floor(Date.now() / 1000) + 3600, // 1 小时后过期 + iat: Math.floor(Date.now() / 1000), + }; + const encode = (obj: unknown) => + Buffer.from(JSON.stringify(obj)).toString('base64url'); + return `${encode(header)}.${encode(payload)}.mock-signature`; +} + +// 按角色构建菜单 fixture(根据项目实际菜单结构定制) +function buildMockMenus(role: string) { + const common = [ + { key: '/', name: '首页', path: '/', children: [] }, + ]; + // 根据 role 添加不同的菜单项... + return common; +} + +/** + * 设置完整 API Mock,调用后 page.goto('/') 即可渲染应用 + */ +export async function setupApiMocks(page: Page, opts: MockUserOptions) { + const user = buildMockUser(opts); + const menus = buildMockMenus(opts.role); + const token = buildMockJWT(user); + + // ⚠️ 必须在导航前注入 localStorage(addInitScript 在每次导航前执行) + await page.addInitScript(({ userData, authToken }) => { + localStorage.setItem('token', authToken); + localStorage.setItem('refreshToken', authToken); + localStorage.setItem('currentUser', JSON.stringify(userData)); + }, { userData: user, authToken: token }); + + // Mock 认证相关 + await page.route('**/api/v1/auth/me', route => + route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ data: user }) }) + ); + await page.route('**/api/v1/auth/refresh', route => + route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ token, refresh_token: token }) }) + ); + + // Mock 菜单 + await page.route('**/api/v1/menus/user-menus', route => + route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ data: menus }) }) + ); + + // Mock 权限(全部放行) + await page.route('**/api/v1/users/*/permissions', route => + route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ data: { permissions: ['*'] } }) }) + ); + + // Mock 列表接口(空数据) + await page.route('**/api/v1/projects**', route => + route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ data: [], total: 0 }) }) + ); + + // Catch-all:未覆盖的 API 返回空成功(并打日志) + await page.route('**/api/**', route => { + console.log(`[E2E Mock] Unhandled: ${route.request().method()} ${route.request().url()}`); + route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ data: {} }) }); + }); +} +``` + +### 2. 冒烟测试 Playwright Config + +```typescript +// playwright.smoke.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + reporter: [['list'], ['json', { outputFile: 'e2e-smoke-results.json' }]], + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + actionTimeout: 15000, + navigationTimeout: 30000, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'npm run start', + url: 'http://127.0.0.1:3000', + reuseExistingServer: true, // ⚠️ 开发时复用已有 server + timeout: 120 * 1000, + }, + // ⚠️ 不设置 globalSetup — 测试自己通过 setupApiMocks() 处理认证 +}); +``` + +**与全链路 config 的关键差异**: +- 无 `globalSetup` / `globalTeardown`(不需要真实登录流程) +- 只用 chromium(速度优先) +- `reuseExistingServer: true`(复用开发时已启动的 server) + +### 3. 测试用例编写模式 + +```typescript +import { test, expect } from '@playwright/test'; +import { setupApiMocks } from './utils/api-mock'; + +test.describe('角色菜单隔离', () => { + test('tenant_admin 看到租户菜单', async ({ page }) => { + await setupApiMocks(page, { role: 'tenant_admin', userType: 'enterprise' }); + await page.goto('/'); + + await expect(page.getByText('租户用户')).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('组织管理')).not.toBeVisible(); + }); + + test('company_admin 不看到租户菜单', async ({ page }) => { + await setupApiMocks(page, { role: 'company_admin', userType: 'enterprise' }); + await page.goto('/'); + + await expect(page.getByText('租户用户')).not.toBeVisible(); + }); +}); + +test.describe('路由重定向', () => { + test('旧路径自动重定向', async ({ page }) => { + await setupApiMocks(page, { role: 'tenant_admin', userType: 'enterprise' }); + await page.goto('/old-path/users'); + await expect(page).toHaveURL(/\/new-path\/users/, { timeout: 10000 }); + }); +}); +``` + +### 4. 常见陷阱与解决方案 + +| 陷阱 | 现象 | 解决 | +|------|------|------| +| **token 格式不对** | 所有页面重定向到 `/login` | 必须生成 3 段式 JWT,payload 含 `exp > now`(见 `buildMockJWT`) | +| **用 `page.evaluate` 设 localStorage** | 刷新后 token 丢失 | 改用 `page.addInitScript()`,每次导航前自动执行 | +| **漏 mock 关键接口** | 页面卡在 loading 或白屏 | 跑一次看控制台 `[E2E Mock] Unhandled:` 日志,补上对应 route | +| **route 注册顺序** | catch-all 吞掉了具体 route | Playwright route 按注册顺序匹配,**具体 route 先注册,catch-all 最后** | +| **chromium 未安装** | `Executable doesn't exist` | `npx playwright install chromium` | +| **前端 proxy** | API 请求未被 route 拦截 | `page.route()` 在浏览器层拦截,不受 webpack proxy 影响 | + +### 5. Gate 4 集成(dev-test 5-Gate 流程) + +```bash +# package.json 新增命令 +"test:e2e:smoke-mock": "playwright test --config=playwright.smoke.config.ts" + +# Gate 4 执行流程: +# 1. 确认 chromium 已安装 +npx playwright install chromium + +# 2. 运行冒烟测试(自动启动 dev server) +npm run test:e2e:smoke-mock + +# 3. 解析 e2e-smoke-results.json 判定通过/失败 +``` + +**Gate 4 判定标准**: +- ✅ 全部通过 → Gate 4 Pass +- ⚠️ 部分失败但非核心路径 → 记录失败项,人工判定 +- ❌ 核心路径失败(登录、主导航、角色隔离)→ Gate 4 Fail,阻断发布 + +### 6. 适合 Mock E2E 验证的场景 + +| 场景 | 验证点 | 示例断言 | +|------|--------|---------| +| **布局选择** | 不同角色渲染不同布局 | sidebar 颜色、logo、菜单结构 | +| **菜单权限隔离** | A 角色看不到 B 的菜单 | `expect(text).not.toBeVisible()` | +| **路由重定向** | 旧 URL 自动跳转 | `expect(page).toHaveURL(/new-path/)` | +| **守卫跳转** | 未登录 → /login | 不设 token → 检查跳转 | +| **条件渲染** | 根据 user_type 显示/隐藏组件 | 检查特定 `data-testid` | +| **响应式布局** | 移动端 Drawer vs 桌面端 Sider | 设置 viewport 后检查 | + +**不适合**(应走全链路测试): +- 表单提交 + 数据持久化 +- 复杂的多步骤业务流程 +- 数据一致性验证 + +--- + +## 全链路集成测试(需后端) + +### 通用 Playwright 配置 ```typescript // playwright.config.ts @@ -20,49 +273,18 @@ export default defineConfig({ }) ``` -## 通用 E2E 测试示例 +### 测试示例 ```typescript -// login.spec.ts import { test, expect } from '@playwright/test' test.describe('Login', () => { test('successful login', async ({ page }) => { await page.goto('/login') - await page.fill('[data-testid="username"]', 'testuser') await page.fill('[data-testid="password"]', 'password') await page.click('[data-testid="submit"]') - await expect(page).toHaveURL('/dashboard') - await expect(page.locator('.welcome')).toContainText('testuser') - }) - - test('invalid credentials', async ({ page }) => { - await page.goto('/login') - - await page.fill('[data-testid="username"]', 'wrong') - await page.fill('[data-testid="password"]', 'wrong') - await page.click('[data-testid="submit"]') - - await expect(page.locator('.error')).toBeVisible() - }) -}) - -test.describe('Task Management', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/login') - await page.fill('[data-testid="username"]', 'testuser') - await page.fill('[data-testid="password"]', 'password') - await page.click('[data-testid="submit"]') - }) - - test('create task', async ({ page }) => { - await page.click('[data-testid="new-task"]') - await page.fill('[data-testid="task-title"]', 'E2E Test Task') - await page.click('[data-testid="save"]') - - await expect(page.locator('text=E2E Test Task')).toBeVisible() }) }) ```