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

59 KiB
Raw Permalink Blame History

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

// 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
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
// 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
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' 保持兼容。

// 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
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.tsapps/web/e2e/smoke/login.spec.ts

  • Move: apps/web/e2e/users.spec.tsapps/web/e2e/smoke/users.spec.ts

  • Move: apps/web/e2e/plugins.spec.tsapps/web/e2e/smoke/plugins.spec.ts

  • Move: apps/web/e2e/tenant-isolation.spec.tsapps/web/e2e/smoke/tenant-isolation.spec.ts

  • Step 1: 创建 check-readiness.ts

// 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
// 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/ 目录
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.tsplugins.spec.tstenant-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
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

// 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
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

// 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
// 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
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

// 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
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

// 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
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 统一导出

// 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
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

// 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
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

// 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
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

// 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
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

// 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
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
// 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
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.jsonscripts 中添加:

"test:e2e": "vitest run --config e2e/vitest.config.ts"
  • Step 3: 创建 AutomatorClient
// 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
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

// 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
// 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
// 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
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

// 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
// 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
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

// 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
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

// 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
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

// 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
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

// 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
git add apps/miniprogram/e2e/flows/mall-flow.spec.ts
git commit -m "test(mp): 添加积分商城浏览 E2E 测试"

验证清单

实施完成后,按以下顺序验证:

  • V1: Web 基础设施编译

    cd apps/web && npx tsc --noEmit --skipLibCheck e2e/fixtures/test-data.ts e2e/fixtures/api-client.ts
    
  • V2: Web Playwright 配置生效

    cd apps/web && pnpm test:e2e -- --list 2>&1 | head -20
    

    预期:列出 smoke/ + flows/ 下的所有测试

  • V3: Web smoke tests 通过(需后端运行)

    cd apps/web && pnpm test:e2e -- e2e/smoke/
    
  • V4: Web 单条 flow 通过(需后端运行)

    cd apps/web && pnpm test:e2e -- e2e/flows/patient-journey.spec.ts
    
  • V5: Web 全量 E2E 通过

    cd apps/web && pnpm test:e2e
    
  • V6: 小程序配置生效

    cd apps/miniprogram && pnpm test:e2e -- --dry-run 2>&1 | head -10
    
  • V7: 小程序 E2E 通过(需 WeChat DevTools + 构建产物)

    cd apps/miniprogram && pnpm test:e2e
    
  • V8: Git push

    git push