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'],
},
});

View File

@@ -5,7 +5,8 @@
"description": "HMS 健康管理平台患者小程序",
"scripts": {
"build:weapp": "taro build --type weapp",
"dev:weapp": "taro build --type weapp --watch"
"dev:weapp": "taro build --type weapp --watch",
"test:e2e": "vitest run --config e2e/vitest.config.ts"
},
"browserslist": [
"last 3 versions",
@@ -41,6 +42,7 @@
"miniprogram-automator": "^0.12.1",
"sass": "^1.87.0",
"typescript": "^5.8.0",
"vitest": "^4.1.5",
"webpack": "~5.95.0"
}
}

View File

@@ -87,6 +87,9 @@ importers:
typescript:
specifier: ^5.8.0
version: 5.9.3
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(jsdom@24.1.3)
webpack:
specifier: ~5.95.0
version: 5.95.0(@swc/core@1.3.96)
@@ -1371,6 +1374,9 @@ packages:
resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stencil/core@2.22.3':
resolution: {integrity: sha512-kmVA0M/HojwsfkeHsifvHVIYe4l5tin7J5+DLgtl8h6WWfiMClND5K3ifCXXI2ETDNKiEk21p6jql3Fx9o2rng==}
engines: {node: '>=12.10.0', npm: '>=6.0.0'}
@@ -1729,6 +1735,9 @@ packages:
'@types/bonjour@3.5.13':
resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect-history-api-fallback@1.5.4':
resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==}
@@ -1741,6 +1750,9 @@ packages:
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1857,6 +1869,35 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
'@vitest/mocker@4.1.5':
resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.5':
resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==}
'@vitest/runner@4.1.5':
resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==}
'@vitest/snapshot@4.1.5':
resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==}
'@vitest/spy@4.1.5':
resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==}
'@vitest/utils@4.1.5':
resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -2019,6 +2060,10 @@ packages:
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-limiter@1.0.1:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
@@ -2236,6 +2281,10 @@ packages:
centra@2.7.0:
resolution: {integrity: sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@3.0.0:
resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
engines: {node: '>=8'}
@@ -2787,6 +2836,9 @@ packages:
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -2863,6 +2915,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -2885,6 +2940,10 @@ packages:
exif-parser@0.1.12:
resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
expr-parser@1.0.0:
resolution: {integrity: sha512-ncuWTCWH0M5KbaYikXxZ3FG3Q+FTYIEXeXAbxYscdZLFNnR5Le5gRU2r/a/JUZHnxwBDZcxWEWzCoPQlW9Engg==}
@@ -2926,6 +2985,15 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@@ -3785,6 +3853,9 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@1.3.0:
resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==}
engines: {node: '>=4'}
@@ -4028,6 +4099,9 @@ packages:
obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
omggif@1.0.10:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
@@ -4191,6 +4265,9 @@ packages:
resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==}
engines: {node: '>=18'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -4895,6 +4972,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -4947,6 +5027,9 @@ packages:
resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==}
engines: {node: '>=6.0.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
@@ -4958,6 +5041,9 @@ packages:
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
strict-uri-encode@1.1.0:
resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==}
engines: {node: '>=0.10.0'}
@@ -5116,9 +5202,24 @@ packages:
timm@1.7.1:
resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@1.1.1:
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-buffer@1.2.2:
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
engines: {node: '>= 0.4'}
@@ -5285,6 +5386,47 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vitest@4.1.5:
resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.5
'@vitest/browser-preview': 4.1.5
'@vitest/browser-webdriverio': 4.1.5
'@vitest/coverage-istanbul': 4.1.5
'@vitest/coverage-v8': 4.1.5
'@vitest/ui': 4.1.5
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vm2@3.10.5:
resolution: {integrity: sha512-3P/2QDccVFBcujfCOeP8vVNuGfuBJHEuvGR8eMmI10p/iwLL2UwF5PDaNaoOS2pRGQEDmJRyeEcc8kmm2Z59RA==}
engines: {node: '>=6.0'}
@@ -5393,6 +5535,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wildcard@2.0.1:
resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==}
@@ -6878,6 +7025,8 @@ snapshots:
'@sindresorhus/merge-streams@2.3.0': {}
'@standard-schema/spec@1.1.0': {}
'@stencil/core@2.22.3': {}
'@swc/core-darwin-arm64@1.3.96':
@@ -7276,6 +7425,11 @@ snapshots:
dependencies:
'@types/node': 25.6.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.1
@@ -7291,6 +7445,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
'@types/express-serve-static-core@4.19.8':
@@ -7432,6 +7588,45 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.5
'@vitest/utils': 4.1.5
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.5':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
'@vitest/pretty-format@4.1.5':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.5':
dependencies:
'@vitest/utils': 4.1.5
pathe: 2.0.3
'@vitest/snapshot@4.1.5':
dependencies:
'@vitest/pretty-format': 4.1.5
'@vitest/utils': 4.1.5
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.5': {}
'@vitest/utils@4.1.5':
dependencies:
'@vitest/pretty-format': 4.1.5
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@@ -7604,6 +7799,8 @@ snapshots:
array-flatten@1.1.1: {}
assertion-error@2.0.1: {}
async-limiter@1.0.1: {}
asynckit@0.4.0: {}
@@ -7892,6 +8089,8 @@ snapshots:
transitivePeerDependencies:
- debug
chai@6.2.2: {}
chalk@3.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -8462,6 +8661,8 @@ snapshots:
es-module-lexer@1.7.0: {}
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -8618,6 +8819,10 @@ snapshots:
estraverse@5.3.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
etag@1.8.1: {}
@@ -8640,6 +8845,8 @@ snapshots:
exif-parser@0.1.12: {}
expect-type@1.3.0: {}
expr-parser@1.0.0: {}
express@4.22.1:
@@ -8715,6 +8922,10 @@ snapshots:
dependencies:
pend: 1.2.0
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -9602,6 +9813,10 @@ snapshots:
dependencies:
yallist: 3.1.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@1.3.0:
dependencies:
pify: 3.0.0
@@ -9823,6 +10038,8 @@ snapshots:
obuf@1.1.2: {}
obug@2.1.1: {}
omggif@1.0.10: {}
on-finished@2.4.1:
@@ -9985,6 +10202,8 @@ snapshots:
path-type@6.0.0: {}
pathe@2.0.3: {}
pend@1.2.0: {}
phin@2.9.3: {}
@@ -10684,6 +10903,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -10742,12 +10963,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
stackback@0.0.2: {}
statuses@1.5.0: {}
statuses@2.0.2: {}
std-env@3.10.0: {}
std-env@4.1.0: {}
strict-uri-encode@1.1.0: {}
string-width@4.2.3:
@@ -10899,8 +11124,19 @@ snapshots:
timm@1.7.1: {}
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@1.1.1: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
to-buffer@1.2.2:
dependencies:
isarray: 2.0.5
@@ -11036,6 +11272,33 @@ snapshots:
vary@1.1.2: {}
vitest@4.1.5(@types/node@25.6.0)(jsdom@24.1.3):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
'@vitest/spy': 4.1.5
'@vitest/utils': 4.1.5
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
jsdom: 24.1.3
transitivePeerDependencies:
- msw
vm2@3.10.5:
dependencies:
acorn: 8.16.0
@@ -11199,6 +11462,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wildcard@2.0.1: {}
word-wrap@1.2.5: {}

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
// apps/web/e2e/fixtures/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) => {
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';

View File

@@ -0,0 +1,190 @@
// 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;
phone?: 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,
};
}

View File

@@ -0,0 +1,47 @@
// 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));
const rule = await api.createAlertRule(makeAlertRule({
indicator: 'heart_rate',
condition: 'greater_than',
threshold: 50,
severity: 'warning',
}));
cleanup.push(() => api.deleteAlertRule(rule.id, rule.version));
const vitalSigns = await api.createVitalSigns(patient.id, makeVitalSigns({
heart_rate: 110,
}));
cleanup.push(() => api.deleteVitalSigns(patient.id, vitalSigns.id, vitalSigns.version));
let alert: Record<string, unknown> | undefined;
await expect(async () => {
const alerts = await api.listAlerts();
alert = alerts.find((a) => (a as Record<string, unknown>).patient_id === patient.id);
expect(alert).toBeDefined();
}).toPass({ timeout: 15000 });
if (!alert!) throw new Error('告警未生成');
await page.goto('/#/health/alerts');
await page.waitForSelector('.ant-table', { timeout: 10000 });
const updated = await api.acknowledgeAlert(alert.id as string, alert.version as number);
await api.resolveAlert(updated.id, updated.version);
});
});

View File

@@ -0,0 +1,38 @@
// 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));
const schedule = await api.createSchedule(makeSchedule(doctor.id));
cleanup.push(() => api.deleteSchedule(schedule.id, schedule.version));
const appointmentPage = new AppointmentPage(page);
await appointmentPage.gotoSchedule();
const appointment = await api.createAppointment(
makeAppointment(patient.id, doctor.id, schedule.id),
);
cleanup.push(() => api.deleteAppointment(appointment.id, appointment.version));
await appointmentPage.gotoAppointments();
const tableText = await page.locator('.ant-table-tbody').textContent();
expect(tableText).toBeTruthy();
});
});

View File

@@ -0,0 +1,36 @@
// 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));
await page.goto('/#/health/follow-up-tasks');
await page.waitForSelector('.ant-table', { timeout: 10000 });
const task = await api.createFollowUpTask(
makeFollowUpTask(patient.id, template.id),
);
cleanup.push(() => api.deleteFollowUpTask(task.id, task.version));
await page.reload();
await page.waitForSelector('.ant-table');
const rowCount = await page.locator('.ant-table-tbody tr').count();
expect(rowCount).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,51 @@
// 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 }) => {
const doctorData = makeDoctor();
const doctor = await api.createDoctor(doctorData);
cleanup.push(() => api.deleteDoctor(doctor.id, doctor.version));
const listPage = new PatientListPage(page);
await listPage.goto();
const patientData = makePatient();
await listPage.clickCreate();
await listPage.fillCreateForm({
name: patientData.name,
phone: patientData.phone,
});
await listPage.submitForm();
await expect(async () => {
const found = await listPage.hasPatientInTable(patientData.name);
expect(found).toBeTruthy();
}).toPass({ timeout: 10000 });
const patient = await api.createPatient({ ...patientData, name: `${patientData.name}_detail` });
cleanup.push(() => api.deletePatient(patient.id, patient.version));
const detailPage = new PatientDetailPage(page);
await detailPage.goto(patient.id);
const name = await detailPage.getPatientName();
expect(name).toContain('E2E');
await detailPage.clickAssignDoctor();
await detailPage.selectDoctor(doctorData.name);
await detailPage.confirmAssign();
});
});

View File

@@ -0,0 +1,49 @@
// 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));
const detailPage = new PatientDetailPage(page);
await detailPage.goto(patient.id);
await detailPage.clickTab('体征');
const healthPage = new HealthDataPage(page);
await healthPage.clickAddVitalSigns();
await healthPage.fillVitalSignsForm({
systolic_bp: 125,
diastolic_bp: 82,
heart_rate: 75,
});
await healthPage.submitVitalSigns();
const list = await healthPage.getVitalSignsList();
expect(list.length).toBeGreaterThanOrEqual(1);
const vitalSigns = await api.createVitalSigns(patient.id, makeVitalSigns({
systolic_bp: 130,
heart_rate: 80,
}));
cleanup.push(() => api.deleteVitalSigns(patient.id, vitalSigns.id, vitalSigns.version));
await page.reload();
await page.waitForSelector('.ant-table');
const updatedList = await healthPage.getVitalSignsList();
expect(updatedList.length).toBeGreaterThanOrEqual(1);
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
// apps/web/e2e/pages/login.page.ts
import type { Page } 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;
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('显示登录页面', async ({ page }) => {
await page.goto('/#/login');
await expect(page.locator('.ant-card, .ant-form')).toBeVisible();
});
test('空表单提交显示验证错误', async ({ page }) => {
await page.goto('/#/login');
await page.click('button[type="submit"]');
// Ant Design 应显示验证错误
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2); // 用户名 + 密码
});
});

View File

@@ -0,0 +1,24 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('插件管理', () => {
test('插件管理页面加载', async ({ page }) => {
await page.goto('/#/');
// 侧边栏显示"扩展管理插件管理"
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
// 页面不崩溃
await expect(page.locator('main')).toBeVisible();
});
test('刷新按钮可点击', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=扩展管理').first().click();
await page.waitForLoadState('networkidle');
const refreshBtn = page.locator('button:has-text("刷新")');
if (await refreshBtn.isVisible().catch(() => false)) {
await expect(refreshBtn).toBeEnabled();
await refreshBtn.click();
await expect(page.locator('main')).toBeVisible();
}
});
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('多租户隔离', () => {
test('侧边栏按模块分组显示', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证侧边栏模块分组
await expect(page.locator('text=基础模块').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('text=业务模块').first()).toBeVisible();
await expect(page.locator('text=系统').first()).toBeVisible();
// 验证关键菜单项
await expect(page.locator('text=工作台').first()).toBeVisible();
await expect(page.locator('text=用户管理').first()).toBeVisible();
});
test('顶部导航栏显示用户信息', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 验证顶部导航栏显示管理员信息
await expect(page.locator('text=系统管理员').first()).toBeVisible({ timeout: 10000 });
});
test('页面间导航正常工作', async ({ page }) => {
await page.goto('/#/');
await page.waitForLoadState('networkidle');
// 点击侧边栏的用户管理(精确匹配侧边栏区域)
const sidebar = page.locator('complementary, [class*=sider], [class*=menu], nav').first();
await sidebar.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击工作台返回
await sidebar.locator('text=工作台').first().click();
await expect(page).toHaveURL(/#\/$/, { timeout: 10000 });
});
});

View File

@@ -0,0 +1,48 @@
import { test, expect } from '../fixtures/auth.fixture';
test.describe('用户管理', () => {
test('用户列表页面加载并显示表格', async ({ page }) => {
await page.goto('/#/');
// 通过侧边栏导航到用户管理
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 标题
await expect(page.locator('h4')).toContainText('用户管理');
// 新建用户按钮
await expect(page.locator('button:has-text("新建用户")')).toBeVisible();
// 搜索框
await expect(page.locator('input[placeholder*="搜索"]')).toBeVisible();
// 表格列头
await expect(page.locator('text=用户').first()).toBeVisible();
await expect(page.locator('text=状态').first()).toBeVisible();
});
test('新建用户弹窗表单验证', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
// 点击新建
await page.click('button:has-text("新建用户")');
// 弹窗出现
await expect(page.locator('.ant-modal')).toBeVisible();
// 直接提交应显示验证错误(点击 modal 内最后一个 button 即确认按钮)
const modalButtons = page.locator('.ant-modal .ant-modal-footer button');
await modalButtons.last().click();
// Ant Design 应显示验证错误(用户名 + 密码必填)
await expect(page.locator('.ant-form-item-explain-error')).toHaveCount(2);
// 关闭弹窗(点击第一个按钮即取消)
await modalButtons.first().click();
});
test('搜索框可输入', async ({ page }) => {
await page.goto('/#/');
await page.locator('text=用户管理').first().click();
await expect(page).toHaveURL(/#\/users/, { timeout: 10000 });
const searchInput = page.locator('input[placeholder*="搜索"]');
await searchInput.fill('admin');
await expect(searchInput).toHaveValue('admin');
});
});

View File

@@ -2,16 +2,18 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
testMatch: ['smoke/**/*.spec.ts', 'flows/**/*.spec.ts'],
timeout: 60_000,
retries: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
reporter: 'html',
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://localhost:5174',
baseURL: process.env.E2E_BASE_URL || 'http://localhost:5174',
headless: true,
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'retain-on-failure',
},
projects: [
{
@@ -19,10 +21,11 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
globalSetup: './e2e/check-readiness',
webServer: {
command: 'pnpm dev',
port: 5174,
reuseExistingServer: true,
timeout: 30000,
timeout: 30_000,
},
});