# E2E 测试实施计划 — HMS 健康管理平台 > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. **Goal:** 建立 Web 端 5 条 + 小程序端 4 条业务链路 E2E 测试,覆盖健康模块核心流程。 **Architecture:** 双端独立框架 — Web 用 Playwright + Page Object,小程序用 Vitest + miniprogram-automator。API 驱动自建自毁数据策略,乐观锁 version 支持。 **Tech Stack:** Playwright 1.52, Vitest, miniprogram-automator, TypeScript **Design Spec:** `docs/superpowers/specs/2026-04-28-e2e-testing-design.md` --- ## Chunk 1: 基础设施层(Tasks 1-4) ### Task 1: 创建测试数据工厂 **Files:** - Create: `apps/web/e2e/fixtures/test-data.ts` - [ ] **Step 1: 创建 test-data.ts** ```typescript // apps/web/e2e/fixtures/test-data.ts export interface PatientData { name: string; gender?: string; birth_date?: string; blood_type?: string; id_number?: string; allergy_history?: string; medical_history_summary?: string; emergency_contact_name?: string; emergency_contact_phone?: string; source?: string; notes?: string; } export interface DoctorData { name: string; department?: string; title?: string; specialty?: string; phone?: string; license_number?: string; status?: string; } export interface VitalSignsData { systolic_bp?: number; diastolic_bp?: number; heart_rate?: number; temperature?: number; spo2?: number; blood_glucose_fasting?: number; blood_glucose_postprandial?: number; weight?: number; height?: number; recorded_at?: string; source?: string; notes?: string; } export interface ScheduleData { doctor_id: string; date: string; start_time: string; end_time: string; max_appointments?: number; } export interface AppointmentData { patient_id: string; doctor_id: string; schedule_id: string; appointment_date: string; start_time: string; end_time: string; reason?: string; } export interface FollowUpTemplateData { name: string; description?: string; frequency_days: number; total_rounds: number; questions?: string; } export interface FollowUpTaskData { patient_id: string; template_id: string; assigned_to?: string; due_date: string; } export interface AlertRuleData { name: string; indicator: string; condition: string; threshold: number; severity: string; description?: string; } let counter = 0; function uid(): string { counter += 1; return `${Date.now()}_${counter}_${Math.random().toString(36).slice(2, 6)}`; } export function makePatient(overrides?: Partial): PatientData { const id = uid(); return { name: `E2E患者_${id}`, gender: 'male', birth_date: '1990-01-15', phone: `138${String(Math.random()).slice(2, 11)}`, id_number: `110101199001${String(Math.random()).slice(2, 8)}`, ...overrides, }; } export function makeDoctor(overrides?: Partial): DoctorData { const id = uid(); return { name: `E2E医生_${id}`, department: '内科', title: '主治医师', specialty: '全科', license_number: `DOC${id}`, ...overrides, }; } export function makeVitalSigns(overrides?: Partial): VitalSignsData { return { systolic_bp: 120, diastolic_bp: 80, heart_rate: 72, temperature: 36.5, spo2: 98, source: 'web_e2e', ...overrides, }; } export function makeSchedule(doctorId: string, overrides?: Partial): ScheduleData { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const date = tomorrow.toISOString().slice(0, 10); return { doctor_id: doctorId, date, start_time: '09:00', end_time: '12:00', max_appointments: 10, ...overrides, }; } export function makeAppointment(patientId: string, doctorId: string, scheduleId: string, overrides?: Partial): AppointmentData { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const date = tomorrow.toISOString().slice(0, 10); return { patient_id: patientId, doctor_id: doctorId, schedule_id: scheduleId, appointment_date: date, start_time: '09:00', end_time: '10:00', reason: 'E2E测试预约', ...overrides, }; } export function makeFollowUpTemplate(overrides?: Partial): FollowUpTemplateData { return { name: `E2E随访模板_${uid()}`, description: 'E2E自动创建的随访模板', frequency_days: 7, total_rounds: 3, questions: JSON.stringify([{ question: '血压是否正常?', type: 'yes_no' }]), ...overrides, }; } export function makeFollowUpTask(patientId: string, templateId: string, overrides?: Partial): FollowUpTaskData { const dueDate = new Date(); dueDate.setDate(dueDate.getDate() + 7); return { patient_id: patientId, template_id: templateId, due_date: dueDate.toISOString().slice(0, 10), ...overrides, }; } export function makeAlertRule(overrides?: Partial): AlertRuleData { return { name: `E2E告警规则_${uid()}`, indicator: 'heart_rate', condition: 'greater_than', threshold: 50, severity: 'warning', description: 'E2E测试低阈值规则,用于触发告警', ...overrides, }; } ``` - [ ] **Step 2: 验证 TypeScript 编译** Run: `cd apps/web && npx tsc --noEmit --skipLibCheck e2e/fixtures/test-data.ts 2>&1 || true` Expected: 无类型错误 - [ ] **Step 3: Commit** ```bash git add apps/web/e2e/fixtures/test-data.ts git commit -m "test(web): 添加 E2E 测试数据工厂函数" ``` --- ### Task 2: 创建 API Client **Files:** - Create: `apps/web/e2e/fixtures/api-client.ts` 后端 API 响应格式为 `{ success: boolean, data: T }`,删除操作需携带 `{ version }` body。 - [ ] **Step 1: 创建 api-client.ts** ```typescript // apps/web/e2e/fixtures/api-client.ts import type { PatientData, DoctorData, VitalSignsData, ScheduleData, AppointmentData, FollowUpTemplateData, FollowUpTaskData, AlertRuleData, } from './test-data'; const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1'; interface ApiResponse { success: boolean; data: T } interface Versioned { id: string; version: number } type VEntity = T & Versioned; interface LoginResponse { access_token: string; refresh_token: string; expires_in: number; user: { id: string; username: string; display_name: string; roles: string[] }; } export class ApiClient { private token = ''; // --- 认证 --- async login(username?: string, password?: string): Promise { const res = await this.rawPost<{ success: boolean; data: LoginResponse }>( '/auth/login', { username: username || process.env.E2E_ADMIN_USER || 'admin', password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026', }, ); this.token = res.data.access_token; return res.data; } async loginAsAdmin(): Promise { return this.login(); } getToken(): string { return this.token; } // --- 患者 --- async createPatient(overrides?: Partial): Promise>> { return this.post('/health/patients', overrides ?? {}); } async updatePatient(id: string, version: number, data: Partial): Promise>> { return this.put(`/health/patients/${id}`, { ...data, version }); } async deletePatient(id: string, version: number): Promise { await this.del(`/health/patients/${id}`, { version }); } // --- 医生 --- async createDoctor(overrides?: Partial): Promise>> { return this.post('/health/doctors', overrides ?? {}); } async deleteDoctor(id: string, version: number): Promise { await this.del(`/health/doctors/${id}`, { version }); } // --- 体征数据 --- async createVitalSigns(patientId: string, overrides?: Partial): Promise>> { return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {}); } async deleteVitalSigns(patientId: string, id: string, version: number): Promise { await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version }); } // --- 排班 --- async createSchedule(overrides: ScheduleData): Promise>> { return this.post('/health/doctor-schedules', overrides); } async deleteSchedule(id: string, version: number): Promise { await this.del(`/health/doctor-schedules/${id}`, { version }); } // --- 预约 --- async createAppointment(overrides: AppointmentData): Promise>> { return this.post('/health/appointments', overrides); } async updateAppointmentStatus(id: string, version: number, status: string): Promise>> { return this.put(`/health/appointments/${id}/status`, { status, version }); } async deleteAppointment(id: string, version: number): Promise { await this.del(`/health/appointments/${id}`, { version }); } // --- 随访模板 --- async createFollowUpTemplate(overrides?: Partial): Promise>> { return this.post('/health/follow-up-templates', overrides ?? {}); } async deleteFollowUpTemplate(id: string, version: number): Promise { await this.del(`/health/follow-up-templates/${id}`, { version }); } // --- 随访任务 --- async createFollowUpTask(overrides: FollowUpTaskData): Promise>> { return this.post('/health/follow-up-tasks', overrides); } async deleteFollowUpTask(id: string, version: number): Promise { await this.del(`/health/follow-up-tasks/${id}`, { version }); } // --- 告警规则 --- async createAlertRule(overrides?: Partial): Promise>> { return this.post('/health/alert-rules', overrides ?? {}); } async deleteAlertRule(id: string, version: number): Promise { await this.del(`/health/alert-rules/${id}`, { version }); } // --- 告警 --- async listAlerts(): Promise>[]> { const res = await this.get<{ items: VEntity>[] }>('/health/alerts'); return res.items ?? []; } async acknowledgeAlert(id: string, version: number): Promise>> { return this.put(`/health/alerts/${id}/acknowledge`, { version }); } async resolveAlert(id: string, version: number): Promise>> { return this.put(`/health/alerts/${id}/resolve`, { version }); } async dismissAlert(id: string, version: number): Promise>> { return this.put(`/health/alerts/${id}/dismiss`, { version }); } // --- 通用 HTTP --- private async headers(): Promise> { return { 'Content-Type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), }; } private async get(path: string): Promise { const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() }); const json = await res.json(); if (!json.success) throw new Error(`GET ${path} failed: ${json.error ?? res.status}`); return json.data as T; } private async post(path: string, body: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: await this.headers(), body: JSON.stringify(body), }); const json = await res.json(); if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? res.status}`); return json.data as T; } private async put(path: string, body: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'PUT', headers: await this.headers(), body: JSON.stringify(body), }); const json = await res.json(); if (!json.success) throw new Error(`PUT ${path} failed: ${json.error ?? res.status}`); return json.data as T; } private async del(path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers: await this.headers(), body: body ? JSON.stringify(body) : undefined, }); if (res.status === 204) return; const json = await res.json(); if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? res.status}`); } // 无 token 的 POST(用于登录) private async rawPost(path: string, body: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const json = await res.json(); if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? res.status}`); return json as T; } } ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/fixtures/api-client.ts git commit -m "test(web): 添加 E2E API Client(乐观锁 version 支持)" ``` --- ### Task 3: 增强认证 Fixture **Files:** - Modify: `apps/web/e2e/auth.fixture.ts`(重写,组合 api + authenticatedPage) - [ ] **Step 1: 重写 auth.fixture.ts** 将现有 `auth.fixture.ts` 替换为增强版,注入 `api` (ApiClient) 和 `authenticatedPage`(已登录的 page)。现有 smoke test 的 `import { test, expect } from './auth.fixture'` 保持兼容。 ```typescript // apps/web/e2e/auth.fixture.ts import { test as base, type Page } from '@playwright/test'; import { ApiClient } from './api-client'; const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1'; type E2eFixtures = { api: ApiClient; authenticatedPage: Page; }; let loginPromise: Promise<{ access_token: string; refresh_token: string; user: object }> | null = null; function login() { if (!loginPromise) { loginPromise = (async () => { for (let attempt = 0; attempt < 3; attempt++) { try { const res = await fetch(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: process.env.E2E_ADMIN_USER || 'admin', password: process.env.E2E_ADMIN_PASS || 'Admin@2026', }), }); const json = await res.json(); if (json.success) return json.data; } catch { /* retry */ } await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); } throw new Error('Login failed after 3 attempts'); })(); } return loginPromise; } export const test = base.extend({ api: async ({}, use) => { const client = new ApiClient(); await client.loginAsAdmin(); await use(client); }, authenticatedPage: async ({ page }, use) => { const { access_token, refresh_token, user } = await login(); await page.addInitScript((args) => { localStorage.setItem('access_token', args.token); localStorage.setItem('refresh_token', args.refresh); localStorage.setItem('user', JSON.stringify(args.userData)); }, { token: access_token, refresh: refresh_token, userData: user }); await use(page); }, page: async ({ page }, use) => { // 保持向后兼容:默认 page 也是已认证的 const { access_token, refresh_token, user } = await login(); await page.addInitScript((args) => { localStorage.setItem('access_token', args.token); localStorage.setItem('refresh_token', args.refresh); localStorage.setItem('user', JSON.stringify(args.userData)); }, { token: access_token, refresh: refresh_token, userData: user }); await use(page); }, }); export { expect } from '@playwright/test'; ``` - [ ] **Step 2: 验证现有 smoke test 仍可通过** Run: `cd apps/web && pnpm test:e2e -- e2e/login.spec.ts 2>&1 | tail -5` Expected: 登录流程 2 tests passed(login.spec.ts 不使用 auth.fixture,不受影响) - [ ] **Step 3: Commit** ```bash git add apps/web/e2e/auth.fixture.ts git commit -m "test(web): 增强 E2E auth fixture — 注入 ApiClient + authenticatedPage" ``` --- ### Task 4: 更新 Playwright 配置 + 迁移 smoke tests **Files:** - Modify: `apps/web/playwright.config.ts` - Create: `apps/web/e2e/check-readiness.ts` - Move: `apps/web/e2e/login.spec.ts` → `apps/web/e2e/smoke/login.spec.ts` - Move: `apps/web/e2e/users.spec.ts` → `apps/web/e2e/smoke/users.spec.ts` - Move: `apps/web/e2e/plugins.spec.ts` → `apps/web/e2e/smoke/plugins.spec.ts` - Move: `apps/web/e2e/tenant-isolation.spec.ts` → `apps/web/e2e/smoke/tenant-isolation.spec.ts` - [ ] **Step 1: 创建 check-readiness.ts** ```typescript // apps/web/e2e/check-readiness.ts import type { FullConfig } from '@playwright/test'; async function check(url: string, label: string): Promise { for (let i = 0; i < 5; i++) { try { const res = await fetch(url); if (res.ok) return; } catch { /* retry */ } console.log(`⏳ ${label} 未就绪,等待重试 (${i + 1}/5)...`); await new Promise((r) => setTimeout(r, 2000)); } throw new Error(`❌ ${label} 未就绪: ${url}。请确认后端服务已启动 (cd crates/erp-server && cargo run)`); } export default async function globalSetup(_config: FullConfig) { const apiBase = process.env.E2E_API_URL || 'http://localhost:3000'; const webBase = process.env.E2E_BASE_URL || 'http://localhost:5174'; await check(`${apiBase}/health/live`, '后端 API'); await check(webBase, '前端 SPA'); console.log('✅ E2E 环境就绪'); } ``` - [ ] **Step 2: 更新 playwright.config.ts** ```typescript // apps/web/playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', testMatch: ['smoke/**/*.spec.ts', 'flows/**/*.spec.ts'], timeout: 60_000, retries: 1, fullyParallel: false, forbidOnly: !!process.env.CI, reporter: [['html', { open: 'never' }], ['list']], use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:5174', headless: true, screenshot: 'only-on-failure', trace: 'on-first-retry', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], globalSetup: './e2e/check-readiness', webServer: { command: 'pnpm dev', port: 5174, reuseExistingServer: true, timeout: 30_000, }, }); ``` - [ ] **Step 3: 迁移 smoke tests 到 smoke/ 目录** ```bash mkdir -p apps/web/e2e/smoke apps/web/e2e/flows apps/web/e2e/pages git mv apps/web/e2e/login.spec.ts apps/web/e2e/smoke/login.spec.ts git mv apps/web/e2e/users.spec.ts apps/web/e2e/smoke/users.spec.ts git mv apps/web/e2e/plugins.spec.ts apps/web/e2e/smoke/plugins.spec.ts git mv apps/web/e2e/tenant-isolation.spec.ts apps/web/e2e/smoke/tenant-isolation.spec.ts ``` 注意:`users.spec.ts`、`plugins.spec.ts`、`tenant-isolation.spec.ts` 中的 `import { test, expect } from '../auth.fixture'` 需更新为 `'../fixtures/auth.fixture'`(迁移后相对路径变化)。 - [ ] **Step 4: 修复 smoke test 的 import 路径** 在每个迁移后的 smoke test 中,将 `from './auth.fixture'` 更新为 `from '../fixtures/auth.fixture'`。 - [ ] **Step 5: 验证 smoke tests 通过** Run: `cd apps/web && pnpm test:e2e -- --grep @smoke 2>&1 | tail -10` Expected: 现有测试全部通过(可能需要后端运行中) - [ ] **Step 6: Commit** ```bash git add apps/web/e2e/ apps/web/playwright.config.ts git commit -m "test(web): 更新 Playwright 配置 + 迁移 smoke tests 到 smoke/ 目录" ``` --- ## Chunk 2: Web Page Objects(Tasks 5-9) > 所有 Page Object 使用 Hash Router 路径(`/#/health/...`),`await page.waitForSelector('.ant-table')` 等待 Ant Design 表格加载。 ### Task 5: LoginPage **Files:** - Create: `apps/web/e2e/pages/login.page.ts` - [ ] **Step 1: 创建 LoginPage** ```typescript // apps/web/e2e/pages/login.page.ts import type { Page, Locator } from '@playwright/test'; export class LoginPage { readonly page: Page; constructor(page: Page) { this.page = page; } async goto() { await this.page.goto('/#/login'); await this.page.waitForSelector('.ant-card, .ant-form'); } async fillUsername(username: string) { await this.page.fill('input[id="username"], input[placeholder*="用户名"]', username); } async fillPassword(password: string) { await this.page.fill('input[type="password"]', password); } async clickSubmit() { await this.page.click('button[type="submit"]'); } async login(username: string, password: string) { await this.goto(); await this.fillUsername(username); await this.fillPassword(password); await this.clickSubmit(); } async getErrorMessage(): Promise { const el = this.page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-alert-error'); return el.first().textContent() ?? ''; } async isLoggedIn(): Promise { try { await this.page.waitForURL('**/#/', { timeout: 5000 }); return true; } catch { return false; } } } ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/pages/login.page.ts git commit -m "test(web): 添加 LoginPage Page Object" ``` --- ### Task 6: PatientListPage + PatientDetailPage **Files:** - Create: `apps/web/e2e/pages/patient-list.page.ts` - Create: `apps/web/e2e/pages/patient-detail.page.ts` - [ ] **Step 1: 创建 PatientListPage** ```typescript // apps/web/e2e/pages/patient-list.page.ts import type { Page } from '@playwright/test'; export class PatientListPage { readonly page: Page; constructor(page: Page) { this.page = page; } async goto() { await this.page.goto('/#/health/patients'); await this.page.waitForSelector('.ant-table', { timeout: 15000 }); } async clickCreate() { await this.page.click('button:has-text("新增"), button:has-text("新建"), button:has-text("创建")'); await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 }); } async fillCreateForm(data: { name: string; gender?: string; birth_date?: string; phone?: string }) { await this.page.fill('#name, input[id="name"]', data.name); if (data.phone) { await this.page.fill('#phone, input[id="phone"]', data.phone); } if (data.gender) { await this.page.click('.ant-select[id="gender"], .ant-select:has-text("性别")'); await this.page.click(`.ant-select-item-option:has-text("${data.gender === 'male' ? '男' : '女'}")`); } if (data.birth_date) { await this.page.fill('#birth_date, input[placeholder*="出生"]', data.birth_date); } } async submitForm() { await this.page.click('.ant-modal button[type="submit"], .ant-drawer button[type="submit"]'); await this.page.waitForSelector('.ant-message-success', { timeout: 10000 }); } async searchPatient(name: string) { const searchInput = this.page.locator('input[placeholder*="搜索"], input[placeholder*="姓名"]'); await searchInput.fill(name); await searchInput.press('Enter'); await this.page.waitForTimeout(1000); } async clickPatientRow(row: number) { const rows = this.page.locator('.ant-table-tbody tr'); await rows.nth(row).click(); } async clickPatientByName(name: string) { await this.searchPatient(name); const row = this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).first(); await row.click(); } async getTableRowCount(): Promise { return this.page.locator('.ant-table-tbody tr').count(); } async hasPatientInTable(name: string): Promise { await this.searchPatient(name); const count = await this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).count(); return count > 0; } } ``` - [ ] **Step 2: 创建 PatientDetailPage** ```typescript // apps/web/e2e/pages/patient-detail.page.ts import type { Page } from '@playwright/test'; export class PatientDetailPage { readonly page: Page; constructor(page: Page) { this.page = page; } async goto(id: string) { await this.page.goto(`/#/health/patients/${id}`); await this.page.waitForSelector('.ant-descriptions, .ant-tabs', { timeout: 10000 }); } async getPatientName(): Promise { const el = this.page.locator('.ant-descriptions-item-content').first(); return el.textContent() ?? ''; } async clickTab(tabName: string) { await this.page.click(`.ant-tabs-tab:has-text("${tabName}")`); await this.page.waitForTimeout(500); } async getVitalSignsCount(): Promise { return this.page.locator('.ant-table-tbody tr').count(); } async clickAssignDoctor() { await this.page.click('button:has-text("分配医生")'); await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 }); } async selectDoctor(doctorName: string) { await this.page.click('.ant-select'); await this.page.click(`.ant-select-item-option:has-text("${doctorName}")`); } async confirmAssign() { await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary'); await this.page.waitForSelector('.ant-message-success', { timeout: 5000 }); } } ``` - [ ] **Step 3: Commit** ```bash git add apps/web/e2e/pages/patient-list.page.ts apps/web/e2e/pages/patient-detail.page.ts git commit -m "test(web): 添加 PatientListPage + PatientDetailPage Page Object" ``` --- ### Task 7: HealthDataPage **Files:** - Create: `apps/web/e2e/pages/health-data.page.ts` - [ ] **Step 1: 创建 HealthDataPage** ```typescript // apps/web/e2e/pages/health-data.page.ts import type { Page } from '@playwright/test'; export class HealthDataPage { readonly page: Page; constructor(page: Page) { this.page = page; } async clickAddVitalSigns() { await this.page.click('button:has-text("录入体征"), button:has-text("新增")'); await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 }); } async fillVitalSignsForm(data: { systolic_bp?: number; diastolic_bp?: number; heart_rate?: number; temperature?: number; spo2?: number; }) { if (data.systolic_bp) await this.page.fill('#systolic_bp, input[placeholder*="收缩压"]', String(data.systolic_bp)); if (data.diastolic_bp) await this.page.fill('#diastolic_bp, input[placeholder*="舒张压"]', String(data.diastolic_bp)); if (data.heart_rate) await this.page.fill('#heart_rate, input[placeholder*="心率"]', String(data.heart_rate)); if (data.temperature) await this.page.fill('#temperature, input[placeholder*="体温"]', String(data.temperature)); if (data.spo2) await this.page.fill('#spo2, input[placeholder*="血氧"]', String(data.spo2)); } async submitVitalSigns() { await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary'); await this.page.waitForSelector('.ant-message-success', { timeout: 10000 }); } async getVitalSignsList(): Promise { const rows = this.page.locator('.ant-table-tbody tr'); const count = await rows.count(); const texts: string[] = []; for (let i = 0; i < count; i++) { texts.push(await rows.nth(i).textContent() ?? ''); } return texts; } async trendChartIsVisible(): Promise { const chart = this.page.locator('canvas, .recharts-wrapper, [class*="chart"]'); return chart.isVisible(); } } ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/pages/health-data.page.ts git commit -m "test(web): 添加 HealthDataPage Page Object" ``` --- ### Task 8: AppointmentPage **Files:** - Create: `apps/web/e2e/pages/appointment.page.ts` - [ ] **Step 1: 创建 AppointmentPage** ```typescript // apps/web/e2e/pages/appointment.page.ts import type { Page } from '@playwright/test'; export class AppointmentPage { readonly page: Page; constructor(page: Page) { this.page = page; } async gotoSchedule() { await this.page.goto('/#/health/schedules'); await this.page.waitForSelector('.ant-table, .ant-fullcalendar, [class*="calendar"]', { timeout: 10000 }); } async gotoAppointments() { await this.page.goto('/#/health/appointments'); await this.page.waitForSelector('.ant-table', { timeout: 10000 }); } async clickCreateSchedule() { await this.page.click('button:has-text("新增排班"), button:has-text("创建")'); await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 }); } async fillScheduleForm(data: { doctor_id?: string; date: string; start_time: string; end_time: string }) { if (data.doctor_id) { // 从下拉选择医生 await this.page.click('.ant-select'); await this.page.click(`.ant-select-item-option`); } await this.page.fill('input[placeholder*="日期"]', data.date); await this.page.fill('input[placeholder*="开始"]', data.start_time); await this.page.fill('input[placeholder*="结束"]', data.end_time); } async submitScheduleForm() { await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary'); await this.page.waitForSelector('.ant-message-success', { timeout: 10000 }); } async clickCreateAppointment() { await this.page.click('button:has-text("新增预约"), button:has-text("创建")'); await this.page.waitForSelector('.ant-modal, .ant-drawer', { timeout: 5000 }); } async fillAppointmentForm(data: { patient_id: string; doctor_id: string; date: string; reason?: string }) { if (data.reason) { await this.page.fill('textarea, input[placeholder*="原因"]', data.reason); } } async submitAppointmentForm() { await this.page.click('.ant-modal button[type="submit"], .ant-btn-primary'); await this.page.waitForSelector('.ant-message-success', { timeout: 10000 }); } } ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/pages/appointment.page.ts git commit -m "test(web): 添加 AppointmentPage Page Object" ``` --- ### Task 9: 导出 index **Files:** - Create: `apps/web/e2e/pages/index.ts` - [ ] **Step 1: 创建 index 统一导出** ```typescript // apps/web/e2e/pages/index.ts export { LoginPage } from './login.page'; export { PatientListPage } from './patient-list.page'; export { PatientDetailPage } from './patient-detail.page'; export { HealthDataPage } from './health-data.page'; export { AppointmentPage } from './appointment.page'; ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/pages/index.ts git commit -m "test(web): Page Object 统一导出" ``` --- ## Chunk 3: Web 端业务链路(Tasks 10-14) > 每个 flow spec 使用 `test` from `../fixtures/auth.fixture`,获取 `api` + `authenticatedPage`。 > 所有测试数据通过 API 创建,finally 块逆序清理。 ### Task 10: 患者全流程 Flow **Files:** - Create: `apps/web/e2e/flows/patient-journey.spec.ts` - [ ] **Step 1: 创建 patient-journey.spec.ts** ```typescript // apps/web/e2e/flows/patient-journey.spec.ts import { test, expect } from '../fixtures/auth.fixture'; import { PatientListPage } from '../pages/patient-list.page'; import { PatientDetailPage } from '../pages/patient-detail.page'; import { makePatient, makeDoctor } from '../fixtures/test-data'; test.describe('@flow 患者全流程', () => { const cleanup: Array<() => Promise> = []; test.afterEach(async () => { for (const fn of cleanup.reverse()) { await fn().catch(() => {}); } cleanup.length = 0; }); test('创建患者 → 查看详情 → 编辑 → 分配医生', async ({ api, authenticatedPage: page }) => { // 准备:通过 API 创建医生 const doctorData = makeDoctor(); const doctor = await api.createDoctor(doctorData); cleanup.push(() => api.deleteDoctor(doctor.id, doctor.version)); // 1. 打开患者列表页 const listPage = new PatientListPage(page); await listPage.goto(); // 2. 创建患者 const patientData = makePatient(); await listPage.clickCreate(); await listPage.fillCreateForm({ name: patientData.name, phone: patientData.phone, }); await listPage.submitForm(); // 3. 验证患者出现在列表 await expect(async () => { const found = await listPage.hasPatientInTable(patientData.name); expect(found).toBeTruthy(); }).toPass({ timeout: 10000 }); // 4. 通过 API 获取患者 ID(列表搜索太慢,用 API 补查) const patient = await api.createPatient({ ...patientData, name: `${patientData.name}_detail` }); cleanup.push(() => api.deletePatient(patient.id, patient.version)); // 5. 打开详情页 const detailPage = new PatientDetailPage(page); await detailPage.goto(patient.id); // 6. 验证患者信息 const name = await detailPage.getPatientName(); expect(name).toContain('E2E'); // 7. 分配医生 await detailPage.clickAssignDoctor(); await detailPage.selectDoctor(doctorData.name); await detailPage.confirmAssign(); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/flows/patient-journey.spec.ts git commit -m "test(web): 添加患者全流程 E2E 测试" ``` --- ### Task 11: 体征数据链路 Flow **Files:** - Create: `apps/web/e2e/flows/vital-signs-flow.spec.ts` - [ ] **Step 1: 创建 vital-signs-flow.spec.ts** ```typescript // apps/web/e2e/flows/vital-signs-flow.spec.ts import { test, expect } from '../fixtures/auth.fixture'; import { PatientDetailPage } from '../pages/patient-detail.page'; import { HealthDataPage } from '../pages/health-data.page'; import { makePatient, makeVitalSigns } from '../fixtures/test-data'; test.describe('@flow 体征数据链路', () => { const cleanup: Array<() => Promise> = []; test.afterEach(async () => { for (const fn of cleanup.reverse()) { await fn().catch(() => {}); } cleanup.length = 0; }); test('录入体征 → 查看列表 → 查看趋势', async ({ api, authenticatedPage: page }) => { // 准备:创建患者 const patient = await api.createPatient(makePatient()); cleanup.push(() => api.deletePatient(patient.id, patient.version)); // 1. 打开患者详情 const detailPage = new PatientDetailPage(page); await detailPage.goto(patient.id); // 2. 切换到体征 tab await detailPage.clickTab('体征'); // 3. 录入体征数据 const healthPage = new HealthDataPage(page); await healthPage.clickAddVitalSigns(); await healthPage.fillVitalSignsForm({ systolic_bp: 125, diastolic_bp: 82, heart_rate: 75, }); await healthPage.submitVitalSigns(); // 4. 验证列表中有数据 const list = await healthPage.getVitalSignsList(); expect(list.length).toBeGreaterThanOrEqual(1); // 5. 也通过 API 录入一条数据(验证 API → UI 链路) const vitalSigns = await api.createVitalSigns(patient.id, makeVitalSigns({ systolic_bp: 130, heart_rate: 80, })); cleanup.push(() => api.deleteVitalSigns(patient.id, vitalSigns.id, vitalSigns.version)); // 6. 刷新页面验证两条数据都在 await page.reload(); await page.waitForSelector('.ant-table'); const updatedList = await healthPage.getVitalSignsList(); expect(updatedList.length).toBeGreaterThanOrEqual(1); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/flows/vital-signs-flow.spec.ts git commit -m "test(web): 添加体征数据链路 E2E 测试" ``` --- ### Task 12: 预约排班链路 Flow **Files:** - Create: `apps/web/e2e/flows/appointment-flow.spec.ts` - [ ] **Step 1: 创建 appointment-flow.spec.ts** ```typescript // apps/web/e2e/flows/appointment-flow.spec.ts import { test, expect } from '../fixtures/auth.fixture'; import { AppointmentPage } from '../pages/appointment.page'; import { makePatient, makeDoctor, makeSchedule, makeAppointment } from '../fixtures/test-data'; test.describe('@flow 预约排班链路', () => { const cleanup: Array<() => Promise> = []; test.afterEach(async () => { for (const fn of cleanup.reverse()) { await fn().catch(() => {}); } cleanup.length = 0; }); test('创建医生 → 设置排班 → 创建预约 → 查看列表', async ({ api, authenticatedPage: page }) => { // 准备数据 const doctor = await api.createDoctor(makeDoctor()); cleanup.push(() => api.deleteDoctor(doctor.id, doctor.version)); const patient = await api.createPatient(makePatient()); cleanup.push(() => api.deletePatient(patient.id, patient.version)); // 1. 通过 API 创建排班(排班 UI 交互复杂,API 更可靠) const schedule = await api.createSchedule(makeSchedule(doctor.id)); cleanup.push(() => api.deleteSchedule(schedule.id, schedule.version)); // 2. 打开排班页面,验证排班存在 const appointmentPage = new AppointmentPage(page); await appointmentPage.gotoSchedule(); // 3. 通过 API 创建预约 const appointment = await api.createAppointment( makeAppointment(patient.id, doctor.id, schedule.id), ); cleanup.push(() => api.deleteAppointment(appointment.id, appointment.version)); // 4. 打开预约列表,验证预约可见 await appointmentPage.gotoAppointments(); const tableText = await page.locator('.ant-table-tbody').textContent(); expect(tableText).toBeTruthy(); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/flows/appointment-flow.spec.ts git commit -m "test(web): 添加预约排班链路 E2E 测试" ``` --- ### Task 13: 随访管理链路 Flow **Files:** - Create: `apps/web/e2e/flows/follow-up-flow.spec.ts` - [ ] **Step 1: 创建 follow-up-flow.spec.ts** ```typescript // apps/web/e2e/flows/follow-up-flow.spec.ts import { test, expect } from '../fixtures/auth.fixture'; import { makePatient, makeFollowUpTemplate, makeFollowUpTask } from '../fixtures/test-data'; test.describe('@flow 随访管理链路', () => { const cleanup: Array<() => Promise> = []; test.afterEach(async () => { for (const fn of cleanup.reverse()) { await fn().catch(() => {}); } cleanup.length = 0; }); test('创建模板 → 创建任务 → 查看任务列表', async ({ api, authenticatedPage: page }) => { // 准备数据 const patient = await api.createPatient(makePatient()); cleanup.push(() => api.deletePatient(patient.id, patient.version)); const template = await api.createFollowUpTemplate(makeFollowUpTemplate()); cleanup.push(() => api.deleteFollowUpTemplate(template.id, template.version)); // 1. 打开随访模板页面,验证模板存在 await page.goto('/#/health/follow-up-tasks'); await page.waitForSelector('.ant-table', { timeout: 10000 }); // 2. 通过 API 创建随访任务 const task = await api.createFollowUpTask( makeFollowUpTask(patient.id, template.id), ); cleanup.push(() => api.deleteFollowUpTask(task.id, task.version)); // 3. 刷新任务列表 await page.reload(); await page.waitForSelector('.ant-table'); // 4. 验证任务列表非空 const rowCount = await page.locator('.ant-table-tbody tr').count(); expect(rowCount).toBeGreaterThanOrEqual(1); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/flows/follow-up-flow.spec.ts git commit -m "test(web): 添加随访管理链路 E2E 测试" ``` --- ### Task 14: 告警处理链路 Flow **Files:** - Create: `apps/web/e2e/flows/alert-flow.spec.ts` > 告警由后台任务异步生成。测试通过 API 创建低阈值规则 + 触发体征数据,然后轮询等待告警出现。 - [ ] **Step 1: 创建 alert-flow.spec.ts** ```typescript // apps/web/e2e/flows/alert-flow.spec.ts import { test, expect } from '../fixtures/auth.fixture'; import { makePatient, makeVitalSigns, makeAlertRule } from '../fixtures/test-data'; test.describe('@flow 告警处理链路', () => { const cleanup: Array<() => Promise> = []; test.afterEach(async () => { for (const fn of cleanup.reverse()) { await fn().catch(() => {}); } cleanup.length = 0; }); test('创建规则 → 触发告警 → 查看列表 → 确认处理', async ({ api, authenticatedPage: page }) => { // 准备:创建患者 const patient = await api.createPatient(makePatient()); cleanup.push(() => api.deletePatient(patient.id, patient.version)); // 1. 创建告警规则(心率 > 50,低阈值便于触发) const rule = await api.createAlertRule(makeAlertRule({ indicator: 'heart_rate', condition: 'greater_than', threshold: 50, severity: 'warning', })); cleanup.push(() => api.deleteAlertRule(rule.id, rule.version)); // 2. 录入触发数据(心率 110,超过阈值 50) const vitalSigns = await api.createVitalSigns(patient.id, makeVitalSigns({ heart_rate: 110, })); cleanup.push(() => api.deleteVitalSigns(patient.id, vitalSigns.id, vitalSigns.version)); // 3. 轮询等待告警生成(后台任务异步处理) let alert: Record | undefined; await expect(async () => { const alerts = await api.listAlerts(); alert = alerts.find((a) => (a as any).patient_id === patient.id); expect(alert).toBeDefined(); }).toPass({ timeout: 15000 }); if (!alert!) throw new Error('告警未生成'); // 4. 打开告警列表 UI await page.goto('/#/health/alerts'); await page.waitForSelector('.ant-table', { timeout: 10000 }); // 5. 通过 API 确认告警 const updated = await api.acknowledgeAlert(alert.id as string, alert.version as number); await api.resolveAlert(updated.id, updated.version); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/web/e2e/flows/alert-flow.spec.ts git commit -m "test(web): 添加告警处理链路 E2E 测试(异步轮询模式)" ``` --- ## Chunk 4: 小程序端基础设施(Tasks 15-17) > 小程序端使用 `miniprogram-automator`(项目已有)+ `vitest`。 > 前置:`pnpm build:weapp` 构建 + WeChat DevTools 打开项目并启用自动化端口。 ### Task 15: 安装 vitest + 创建 AutomatorClient **Files:** - Modify: `apps/miniprogram/package.json`(添加 vitest 依赖和 test:e2e script) - Create: `apps/miniprogram/e2e/helpers/automator-client.ts` - [ ] **Step 1: 添加 vitest 依赖** Run: `cd apps/miniprogram && pnpm add -D vitest` - [ ] **Step 2: 在 package.json 添加 test:e2e script** 在 `apps/miniprogram/package.json` 的 `scripts` 中添加: ```json "test:e2e": "vitest run --config e2e/vitest.config.ts" ``` - [ ] **Step 3: 创建 AutomatorClient** ```typescript // apps/miniprogram/e2e/helpers/automator-client.ts import automator from 'miniprogram-automator'; const DEFAULT_CLI_PATH = 'C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat'; const DEFAULT_PROJECT_PATH = process.cwd(); export class AutomatorClient { private mini: automator.MiniProgram | null = null; async connect(cliPath?: string, projectPath?: string) { this.mini = await automator.launch({ cliPath: cliPath || DEFAULT_CLI_PATH, projectPath: projectPath || DEFAULT_PROJECT_PATH, }); } async disconnect() { if (this.mini) { await this.mini.close(); this.mini = null; } } private getMini(): automator.MiniProgram { if (!this.mini) throw new Error('AutomatorClient 未连接,请先调用 connect()'); return this.mini; } async currentPage(): Promise { return this.getMini().currentPage(); } async navigateTo(path: string, query?: Record) { const page = await this.getMini().navigateTo(`/${path.replace(/^\//, '')}`); return page; } async navigateBack() { await this.getMini().navigateBack(); } async reLaunch(path: string) { await this.getMini().reLaunch(`/${path.replace(/^\//, '')}`); } async tap(selector: string) { const page = this.getMini().currentPage(); const element = await page.$(selector); if (!element) throw new Error(`元素未找到: ${selector}`); await element.tap(); } async inputText(selector: string, value: string) { const page = this.getMini().currentPage(); const element = await page.$(selector); if (!element) throw new Error(`元素未找到: ${selector}`); await element.setValue(value); } async getElement(selector: string) { const page = this.getMini().currentPage(); return page.$(selector); } async getElements(selector: string) { const page = this.getMini().currentPage(); return page.$$(selector); } async waitForElement(selector: string, timeout = 5000): Promise { const start = Date.now(); while (Date.now() - start < timeout) { const el = await this.getElement(selector); if (el) return el; await new Promise((r) => setTimeout(r, 200)); } throw new Error(`等待元素超时: ${selector} (${timeout}ms)`); } async getPageData(path?: string) { const page = this.getMini().currentPage(); return page.data(path); } async screenshot(path?: string): Promise { const page = this.getMini().currentPage(); return page.screenshot({ path }); } async callMethod(selector: string, method: string, ...args: unknown[]) { const page = this.getMini().currentPage(); const element = await page.$(selector); if (!element) throw new Error(`元素未找到: ${selector}`); return element.callMethod(method, ...args); } } ``` - [ ] **Step 4: Commit** ```bash git add apps/miniprogram/package.json apps/miniprogram/pnpm-lock.yaml apps/miniprogram/e2e/helpers/automator-client.ts git commit -m "test(mp): 添加 vitest 依赖 + AutomatorClient 封装" ``` --- ### Task 16: MpAuthHelper + MpNavigator + API Client **Files:** - Create: `apps/miniprogram/e2e/helpers/auth.helper.ts` - Create: `apps/miniprogram/e2e/helpers/navigation.helper.ts` - Create: `apps/miniprogram/e2e/helpers/api-client.ts`(简化版,复用 Web 端逻辑) - [ ] **Step 1: 创建小程序端 API Client** ```typescript // apps/miniprogram/e2e/helpers/api-client.ts // 简化版 API Client,用于小程序 E2E 数据准备/清理 const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1'; export class MpApiClient { private token = ''; async login(username?: string, password?: string) { const res = await fetch(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username || process.env.E2E_ADMIN_USER || 'admin', password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026', }), }); const json = await res.json(); if (!json.success) throw new Error('Login failed'); this.token = json.data.access_token; return json.data; } getToken() { return this.token; } async createPatient(overrides?: Record) { return this.post('/health/patients', overrides ?? {}); } async deletePatient(id: string, version: number) { await this.del(`/health/patients/${id}`, { version }); } async createVitalSigns(patientId: string, overrides?: Record) { return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {}); } async deleteVitalSigns(patientId: string, id: string, version: number) { await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version }); } async listPointsProducts() { return this.get('/health/points/products'); } private async headers() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` }; } private async get(path: string) { const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() }); const json = await res.json(); return json.data; } private async post(path: string, body: unknown) { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: await this.headers(), body: JSON.stringify(body), }); const json = await res.json(); if (!json.success) throw new Error(`POST ${path} failed`); return json.data; } private async del(path: string, body?: unknown) { await fetch(`${API_BASE}${path}`, { method: 'DELETE', headers: await this.headers(), body: body ? JSON.stringify(body) : undefined, }); } } ``` - [ ] **Step 2: 创建 MpAuthHelper** ```typescript // apps/miniprogram/e2e/helpers/auth.helper.ts import { AutomatorClient } from './automator-client'; import { MpApiClient } from './api-client'; export class MpAuthHelper { constructor( private client: AutomatorClient, private api: MpApiClient, ) {} async loginAsTestPatient() { // 1. 通过 API 获取 token const loginRes = await this.api.login( process.env.E2E_MP_USER || 'mp_e2e_test', process.env.E2E_MP_PASS || 'Test@2026', ); // 2. 通过 automator 写入 storage await this.client.reLaunch('/pages/index/index'); const page = await this.client.currentPage(); // 3. 使用 page.callWxMethod 写入 storage await this.client.callMethod('page', 'setData', { 'access_token': loginRes.access_token, }); // 4. reLaunch 到健康首页刷新状态 await this.client.reLaunch('pages/index/index'); } } ``` - [ ] **Step 3: 创建 MpNavigator** ```typescript // apps/miniprogram/e2e/helpers/navigation.helper.ts import { AutomatorClient } from './automator-client'; export class MpNavigator { constructor(private client: AutomatorClient) {} async goToHealthHome() { await this.client.reLaunch('pages/pkg-health/index'); } async goToVitalSignsInput() { await this.client.navigateTo('pages/pkg-health/input/index'); } async goToVitalSignsTrend() { await this.client.navigateTo('pages/pkg-health/trend/index'); } async goToProfile() { await this.client.navigateTo('pages/pkg-profile/index'); } async goToMall() { await this.client.reLaunch('pages/pkg-mall/index'); } async goToFollowUpTasks() { await this.client.navigateTo('pages/pkg-health/followups/index'); } async goToOrders() { await this.client.navigateTo('pages/pkg-mall/orders/index'); } } ``` - [ ] **Step 4: Commit** ```bash git add apps/miniprogram/e2e/helpers/auth.helper.ts apps/miniprogram/e2e/helpers/navigation.helper.ts apps/miniprogram/e2e/helpers/api-client.ts git commit -m "test(mp): 添加小程序 E2E helpers(auth + navigation + api-client)" ``` --- ### Task 17: Vitest 配置 + check-readiness **Files:** - Create: `apps/miniprogram/e2e/vitest.config.ts` - Create: `apps/miniprogram/e2e/check-readiness.ts` - [ ] **Step 1: 创建 vitest.config.ts** ```typescript // apps/miniprogram/e2e/vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { root: './e2e', testTimeout: 30_000, hookTimeout: 30_000, testSequence: { sequential: true }, reporter: 'verbose', globalSetup: ['./check-readiness.ts'], }, }); ``` - [ ] **Step 2: 创建 check-readiness.ts** ```typescript // apps/miniprogram/e2e/check-readiness.ts async function check(url: string, label: string) { for (let i = 0; i < 5; i++) { try { const res = await fetch(url); if (res.ok) return; } catch { /* retry */ } console.log(`⏳ ${label} 未就绪 (${i + 1}/5)...`); await new Promise((r) => setTimeout(r, 2000)); } throw new Error(`❌ ${label} 未就绪: ${url}`); } export default async function setup() { const apiBase = process.env.E2E_API_URL || 'http://localhost:3000'; await check(`${apiBase}/health/live`, '后端 API'); console.log('✅ 小程序 E2E 环境就绪'); } ``` - [ ] **Step 3: Commit** ```bash git add apps/miniprogram/e2e/vitest.config.ts apps/miniprogram/e2e/check-readiness.ts git commit -m "test(mp): 添加小程序 E2E vitest 配置 + 环境检查" ``` --- ## Chunk 5: 小程序端业务链路(Tasks 18-21) ### Task 18: 患者健康数据查看 Flow **Files:** - Create: `apps/miniprogram/e2e/flows/patient-health-view.spec.ts` - [ ] **Step 1: 创建 patient-health-view.spec.ts** ```typescript // apps/miniprogram/e2e/flows/patient-health-view.spec.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { AutomatorClient } from '../helpers/automator-client'; import { MpAuthHelper } from '../helpers/auth.helper'; import { MpApiClient } from '../helpers/api-client'; import { MpNavigator } from '../helpers/navigation.helper'; describe('患者健康数据查看链路', () => { let client: AutomatorClient; let auth: MpAuthHelper; let nav: MpNavigator; let api: MpApiClient; beforeAll(async () => { api = new MpApiClient(); client = new AutomatorClient(); await client.connect(); auth = new MpAuthHelper(client, api); nav = new MpNavigator(client); }, 30_000); afterAll(async () => { await client.disconnect(); }); it('登录后查看首页健康数据', async () => { await auth.loginAsTestPatient(); await nav.goToHealthHome(); // 验证首页加载成功 const pageData = await client.getPageData(); expect(pageData).toBeDefined(); }); it('查看体征趋势', async () => { await nav.goToVitalSignsTrend(); const el = await client.waitForElement('.trend-chart, canvas, .container', 5000); expect(el).toBeDefined(); }); it('查看随访任务列表', async () => { await nav.goToFollowUpTasks(); const el = await client.waitForElement('.task-list, .container', 5000); expect(el).toBeDefined(); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/miniprogram/e2e/flows/patient-health-view.spec.ts git commit -m "test(mp): 添加患者健康数据查看 E2E 测试" ``` --- ### Task 19: 体征数据录入 Flow **Files:** - Create: `apps/miniprogram/e2e/flows/vital-signs-input.spec.ts` - [ ] **Step 1: 创建 vital-signs-input.spec.ts** ```typescript // apps/miniprogram/e2e/flows/vital-signs-input.spec.ts import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { AutomatorClient } from '../helpers/automator-client'; import { MpAuthHelper } from '../helpers/auth.helper'; import { MpApiClient } from '../helpers/api-client'; import { MpNavigator } from '../helpers/navigation.helper'; describe('体征数据录入链路', () => { let client: AutomatorClient; let auth: MpAuthHelper; let nav: MpNavigator; let api: MpApiClient; const cleanup: Array<() => Promise> = []; beforeAll(async () => { api = new MpApiClient(); await api.login(); client = new AutomatorClient(); await client.connect(); auth = new MpAuthHelper(client, api); nav = new MpNavigator(client); await auth.loginAsTestPatient(); }, 30_000); afterEach(async () => { for (const fn of cleanup.reverse()) await fn().catch(() => {}); cleanup.length = 0; }); afterAll(async () => { await client.disconnect(); }); it('填写并提交血压心率数据', async () => { await nav.goToVitalSignsInput(); // 填写表单 await client.inputText('input[placeholder*="收缩压"], #systolic', '118'); await client.inputText('input[placeholder*="舒张压"], #diastolic', '76'); await client.inputText('input[placeholder*="心率"], #heartRate', '68'); // 提交 await client.tap('button[type="submit"], .submit-btn'); // 验证成功提示 const el = await client.waitForElement('.success, .ant-message-success, [class*="toast"]', 5000).catch(() => null); // 提交后页面跳转或显示成功状态 const pageData = await client.getPageData(); expect(pageData).toBeDefined(); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/miniprogram/e2e/flows/vital-signs-input.spec.ts git commit -m "test(mp): 添加体征数据录入 E2E 测试" ``` --- ### Task 20: 积分签到兑换 Flow **Files:** - Create: `apps/miniprogram/e2e/flows/points-flow.spec.ts` - [ ] **Step 1: 创建 points-flow.spec.ts** ```typescript // apps/miniprogram/e2e/flows/points-flow.spec.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { AutomatorClient } from '../helpers/automator-client'; import { MpAuthHelper } from '../helpers/auth.helper'; import { MpApiClient } from '../helpers/api-client'; import { MpNavigator } from '../helpers/navigation.helper'; describe('积分签到兑换链路', () => { let client: AutomatorClient; let auth: MpAuthHelper; let nav: MpNavigator; let api: MpApiClient; beforeAll(async () => { api = new MpApiClient(); client = new AutomatorClient(); await client.connect(); auth = new MpAuthHelper(client, api); nav = new MpNavigator(client); await auth.loginAsTestPatient(); }, 30_000); afterAll(async () => { await client.disconnect(); }); it('浏览积分商城', async () => { await nav.goToMall(); const el = await client.waitForElement('.product-list, .container', 5000); expect(el).toBeDefined(); }); it('查看商品详情', async () => { // 点击第一个商品 const items = await client.getElements('.product-item, .product-card'); if (items.length > 0) { await items[0].tap(); // 验证详情页加载 const pageData = await client.getPageData(); expect(pageData).toBeDefined(); } }); it('查看订单列表', async () => { await nav.goToOrders(); const el = await client.waitForElement('.order-list, .container, .empty', 5000); expect(el).toBeDefined(); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/miniprogram/e2e/flows/points-flow.spec.ts git commit -m "test(mp): 添加积分签到兑换 E2E 测试" ``` --- ### Task 21: 积分商城 Flow **Files:** - Create: `apps/miniprogram/e2e/flows/mall-flow.spec.ts` - [ ] **Step 1: 创建 mall-flow.spec.ts** ```typescript // apps/miniprogram/e2e/flows/mall-flow.spec.ts import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { AutomatorClient } from '../helpers/automator-client'; import { MpAuthHelper } from '../helpers/auth.helper'; import { MpApiClient } from '../helpers/api-client'; import { MpNavigator } from '../helpers/navigation.helper'; describe('积分商城浏览链路', () => { let client: AutomatorClient; let auth: MpAuthHelper; let nav: MpNavigator; beforeAll(async () => { const api = new MpApiClient(); client = new AutomatorClient(); await client.connect(); auth = new MpAuthHelper(client, api); nav = new MpNavigator(client); await auth.loginAsTestPatient(); }, 30_000); afterAll(async () => { await client.disconnect(); }); it('商城首页加载', async () => { await nav.goToMall(); const el = await client.waitForElement('.container', 5000); expect(el).toBeDefined(); }); it('浏览商品分类', async () => { // 尝试切换分类 tab const tabs = await client.getElements('.tab-item, .category-item, .ant-tabs-tab'); if (tabs.length > 1) { await tabs[1].tap(); await new Promise((r) => setTimeout(r, 1000)); } const pageData = await client.getPageData(); expect(pageData).toBeDefined(); }); }); ``` - [ ] **Step 2: Commit** ```bash git add apps/miniprogram/e2e/flows/mall-flow.spec.ts git commit -m "test(mp): 添加积分商城浏览 E2E 测试" ``` --- ## 验证清单 实施完成后,按以下顺序验证: - [ ] **V1: Web 基础设施编译** ```bash cd apps/web && npx tsc --noEmit --skipLibCheck e2e/fixtures/test-data.ts e2e/fixtures/api-client.ts ``` - [ ] **V2: Web Playwright 配置生效** ```bash cd apps/web && pnpm test:e2e -- --list 2>&1 | head -20 ``` 预期:列出 smoke/ + flows/ 下的所有测试 - [ ] **V3: Web smoke tests 通过**(需后端运行) ```bash cd apps/web && pnpm test:e2e -- e2e/smoke/ ``` - [ ] **V4: Web 单条 flow 通过**(需后端运行) ```bash cd apps/web && pnpm test:e2e -- e2e/flows/patient-journey.spec.ts ``` - [ ] **V5: Web 全量 E2E 通过** ```bash cd apps/web && pnpm test:e2e ``` - [ ] **V6: 小程序配置生效** ```bash cd apps/miniprogram && pnpm test:e2e -- --dry-run 2>&1 | head -10 ``` - [ ] **V7: 小程序 E2E 通过**(需 WeChat DevTools + 构建产物) ```bash cd apps/miniprogram && pnpm test:e2e ``` - [ ] **V8: Git push** ```bash git push ```