test(web+mp): E2E 测试全量实施 — Web 5 flow + MP 4 flow + 基础设施
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

Web 端 (Playwright):
- fixtures: test-data 工厂 + API Client (乐观锁 version) + 增强 auth fixture
- pages: LoginPage, PatientListPage, PatientDetailPage, HealthDataPage, AppointmentPage
- flows: 患者全流程, 体征数据链路, 预约排班链路, 随访管理链路, 告警处理链路
- smoke tests 迁移到 smoke/ 目录,import 路径更新
- playwright.config.ts 更新: globalSetup 环境检查, 60s timeout, video retain

小程序端 (Vitest + miniprogram-automator):
- helpers: AutomatorClient, MpApiClient, MpAuthHelper, MpNavigator
- flows: 患者健康数据查看, 体征数据录入, 积分签到兑换, 积分商城浏览
- vitest.config.ts + check-readiness.ts
- vitest 4.1.5 依赖安装

Playwright 发现 15 个测试 (5 flow + 10 smoke),全部就绪
This commit is contained in:
iven
2026-04-29 04:58:01 +08:00
parent 2f4be6dcd0
commit c6e8048bc5
32 changed files with 1798 additions and 5 deletions

View File

@@ -0,0 +1,18 @@
// 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 环境就绪');
}

View File

@@ -0,0 +1,41 @@
// 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 () => {
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();
});
});

View File

@@ -0,0 +1,45 @@
// 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();
});
});

View File

@@ -0,0 +1,47 @@
// 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();
});
});

View File

@@ -0,0 +1,47 @@
// 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();
});
});

View File

@@ -0,0 +1,70 @@
// 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,
});
}
}

View File

@@ -0,0 +1,26 @@
// 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() {
const loginRes = await this.api.login(
process.env.E2E_MP_USER || 'mp_e2e_test',
process.env.E2E_MP_PASS || 'Test@2026',
);
await this.client.reLaunch('/pages/index/index');
const page = await this.client.currentPage();
await this.client.callMethod('page', 'setData', {
'access_token': loginRes.access_token,
});
await this.client.reLaunch('pages/index/index');
}
}

View File

@@ -0,0 +1,96 @@
// 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);
}
}

View File

@@ -0,0 +1,34 @@
// 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');
}
}

View File

@@ -0,0 +1,13 @@
// 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'],
},
});