diff --git a/docs/superpowers/plans/2026-04-28-e2e-testing-plan.md b/docs/superpowers/plans/2026-04-28-e2e-testing-plan.md new file mode 100644 index 0000000..20ed20c --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-e2e-testing-plan.md @@ -0,0 +1,2040 @@ +# 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 + ```