Files
hms/docs/superpowers/plans/2026-04-28-e2e-testing-plan.md
iven 2f4be6dcd0 docs(e2e): 添加 E2E 测试实施计划
5 个 Chunk, 21 个 Task:
- Chunk 1: 基础设施(test-data + api-client + auth fixture + config)
- Chunk 2: Web Page Objects(5 个关键页面)
- Chunk 3: Web 业务链路(5 条 flow spec)
- Chunk 4: 小程序基础设施(automator + helpers + vitest config)
- Chunk 5: 小程序业务链路(4 条 flow spec)
2026-04-28 22:39:24 +08:00

2041 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>): 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>): DoctorData {
const id = uid();
return {
name: `E2E医生_${id}`,
department: '内科',
title: '主治医师',
specialty: '全科',
license_number: `DOC${id}`,
...overrides,
};
}
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): 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>): 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>): 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>): 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>): 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>): 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<T> { success: boolean; data: T }
interface Versioned { id: string; version: number }
type VEntity<T> = 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<LoginResponse> {
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<LoginResponse> {
return this.login();
}
getToken(): string { return this.token; }
// --- 患者 ---
async createPatient(overrides?: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/patients', overrides ?? {});
}
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/patients/${id}`, { ...data, version });
}
async deletePatient(id: string, version: number): Promise<void> {
await this.del(`/health/patients/${id}`, { version });
}
// --- 医生 ---
async createDoctor(overrides?: Partial<DoctorData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctors', overrides ?? {});
}
async deleteDoctor(id: string, version: number): Promise<void> {
await this.del(`/health/doctors/${id}`, { version });
}
// --- 体征数据 ---
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VEntity<Record<string, unknown>>> {
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
}
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void> {
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
}
// --- 排班 ---
async createSchedule(overrides: ScheduleData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctor-schedules', overrides);
}
async deleteSchedule(id: string, version: number): Promise<void> {
await this.del(`/health/doctor-schedules/${id}`, { version });
}
// --- 预约 ---
async createAppointment(overrides: AppointmentData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/appointments', overrides);
}
async updateAppointmentStatus(id: string, version: number, status: string): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/appointments/${id}/status`, { status, version });
}
async deleteAppointment(id: string, version: number): Promise<void> {
await this.del(`/health/appointments/${id}`, { version });
}
// --- 随访模板 ---
async createFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-templates', overrides ?? {});
}
async deleteFollowUpTemplate(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-templates/${id}`, { version });
}
// --- 随访任务 ---
async createFollowUpTask(overrides: FollowUpTaskData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-tasks', overrides);
}
async deleteFollowUpTask(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-tasks/${id}`, { version });
}
// --- 告警规则 ---
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/alert-rules', overrides ?? {});
}
async deleteAlertRule(id: string, version: number): Promise<void> {
await this.del(`/health/alert-rules/${id}`, { version });
}
// --- 告警 ---
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
const res = await this.get<{ items: VEntity<Record<string, unknown>>[] }>('/health/alerts');
return res.items ?? [];
}
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/acknowledge`, { version });
}
async resolveAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/resolve`, { version });
}
async dismissAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/dismiss`, { version });
}
// --- 通用 HTTP ---
private async headers(): Promise<Record<string, string>> {
return {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
};
}
private async get<T>(path: string): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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<void> {
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<T>(path: string, body: unknown): Promise<T> {
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<E2eFixtures>({
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 passedlogin.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<void> {
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 ObjectsTasks 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<string> {
const el = this.page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-alert-error');
return el.first().textContent() ?? '';
}
async isLoggedIn(): Promise<boolean> {
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<number> {
return this.page.locator('.ant-table-tbody tr').count();
}
async hasPatientInTable(name: string): Promise<boolean> {
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<string> {
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<number> {
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<string[]> {
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<boolean> {
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<void>> = [];
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<void>> = [];
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<void>> = [];
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<void>> = [];
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<void>> = [];
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<string, unknown> | 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<automator.Page> {
return this.getMini().currentPage();
}
async navigateTo(path: string, query?: Record<string, string>) {
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<automator.Element> {
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<Buffer> {
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<string, unknown>) {
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<string, unknown>) {
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 helpersauth + 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<void>> = [];
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
```