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)
2041 lines
59 KiB
Markdown
2041 lines
59 KiB
Markdown
# 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 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<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 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<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 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<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
|
||
```
|