From c6e8048bc5cf57bffcf44b59b6deb1935f1273a1 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 29 Apr 2026 04:58:01 +0800 Subject: [PATCH] =?UTF-8?q?test(web+mp):=20E2E=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=AE=9E=E6=96=BD=20=E2=80=94=20Web=205=20fl?= =?UTF-8?q?ow=20+=20MP=204=20flow=20+=20=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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),全部就绪 --- apps/miniprogram/e2e/check-readiness.ts | 18 ++ apps/miniprogram/e2e/flows/mall-flow.spec.ts | 41 +++ .../e2e/flows/patient-health-view.spec.ts | 45 +++ .../miniprogram/e2e/flows/points-flow.spec.ts | 47 +++ .../e2e/flows/vital-signs-input.spec.ts | 47 +++ apps/miniprogram/e2e/helpers/api-client.ts | 70 +++++ apps/miniprogram/e2e/helpers/auth.helper.ts | 26 ++ .../e2e/helpers/automator-client.ts | 96 +++++++ .../e2e/helpers/navigation.helper.ts | 34 +++ apps/miniprogram/e2e/vitest.config.ts | 13 + apps/miniprogram/package.json | 4 +- apps/miniprogram/pnpm-lock.yaml | 268 ++++++++++++++++++ apps/web/e2e/check-readiness.ts | 22 ++ apps/web/e2e/fixtures/api-client.ts | 188 ++++++++++++ apps/web/e2e/fixtures/auth.fixture.ts | 66 +++++ apps/web/e2e/fixtures/test-data.ts | 190 +++++++++++++ apps/web/e2e/flows/alert-flow.spec.ts | 47 +++ apps/web/e2e/flows/appointment-flow.spec.ts | 38 +++ apps/web/e2e/flows/follow-up-flow.spec.ts | 36 +++ apps/web/e2e/flows/patient-journey.spec.ts | 51 ++++ apps/web/e2e/flows/vital-signs-flow.spec.ts | 49 ++++ apps/web/e2e/pages/appointment.page.ts | 56 ++++ apps/web/e2e/pages/health-data.page.ts | 49 ++++ apps/web/e2e/pages/index.ts | 6 + apps/web/e2e/pages/login.page.ts | 48 ++++ apps/web/e2e/pages/patient-detail.page.ts | 44 +++ apps/web/e2e/pages/patient-list.page.ts | 67 +++++ apps/web/e2e/smoke/login.spec.ts | 15 + apps/web/e2e/smoke/plugins.spec.ts | 24 ++ apps/web/e2e/smoke/tenant-isolation.spec.ts | 39 +++ apps/web/e2e/smoke/users.spec.ts | 48 ++++ apps/web/playwright.config.ts | 11 +- 32 files changed, 1798 insertions(+), 5 deletions(-) create mode 100644 apps/miniprogram/e2e/check-readiness.ts create mode 100644 apps/miniprogram/e2e/flows/mall-flow.spec.ts create mode 100644 apps/miniprogram/e2e/flows/patient-health-view.spec.ts create mode 100644 apps/miniprogram/e2e/flows/points-flow.spec.ts create mode 100644 apps/miniprogram/e2e/flows/vital-signs-input.spec.ts create mode 100644 apps/miniprogram/e2e/helpers/api-client.ts create mode 100644 apps/miniprogram/e2e/helpers/auth.helper.ts create mode 100644 apps/miniprogram/e2e/helpers/automator-client.ts create mode 100644 apps/miniprogram/e2e/helpers/navigation.helper.ts create mode 100644 apps/miniprogram/e2e/vitest.config.ts create mode 100644 apps/web/e2e/check-readiness.ts create mode 100644 apps/web/e2e/fixtures/api-client.ts create mode 100644 apps/web/e2e/fixtures/auth.fixture.ts create mode 100644 apps/web/e2e/fixtures/test-data.ts create mode 100644 apps/web/e2e/flows/alert-flow.spec.ts create mode 100644 apps/web/e2e/flows/appointment-flow.spec.ts create mode 100644 apps/web/e2e/flows/follow-up-flow.spec.ts create mode 100644 apps/web/e2e/flows/patient-journey.spec.ts create mode 100644 apps/web/e2e/flows/vital-signs-flow.spec.ts create mode 100644 apps/web/e2e/pages/appointment.page.ts create mode 100644 apps/web/e2e/pages/health-data.page.ts create mode 100644 apps/web/e2e/pages/index.ts create mode 100644 apps/web/e2e/pages/login.page.ts create mode 100644 apps/web/e2e/pages/patient-detail.page.ts create mode 100644 apps/web/e2e/pages/patient-list.page.ts create mode 100644 apps/web/e2e/smoke/login.spec.ts create mode 100644 apps/web/e2e/smoke/plugins.spec.ts create mode 100644 apps/web/e2e/smoke/tenant-isolation.spec.ts create mode 100644 apps/web/e2e/smoke/users.spec.ts diff --git a/apps/miniprogram/e2e/check-readiness.ts b/apps/miniprogram/e2e/check-readiness.ts new file mode 100644 index 0000000..4d5b7ce --- /dev/null +++ b/apps/miniprogram/e2e/check-readiness.ts @@ -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 环境就绪'); +} diff --git a/apps/miniprogram/e2e/flows/mall-flow.spec.ts b/apps/miniprogram/e2e/flows/mall-flow.spec.ts new file mode 100644 index 0000000..1f5f926 --- /dev/null +++ b/apps/miniprogram/e2e/flows/mall-flow.spec.ts @@ -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(); + }); +}); diff --git a/apps/miniprogram/e2e/flows/patient-health-view.spec.ts b/apps/miniprogram/e2e/flows/patient-health-view.spec.ts new file mode 100644 index 0000000..9e0f5e8 --- /dev/null +++ b/apps/miniprogram/e2e/flows/patient-health-view.spec.ts @@ -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(); + }); +}); diff --git a/apps/miniprogram/e2e/flows/points-flow.spec.ts b/apps/miniprogram/e2e/flows/points-flow.spec.ts new file mode 100644 index 0000000..53fd817 --- /dev/null +++ b/apps/miniprogram/e2e/flows/points-flow.spec.ts @@ -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(); + }); +}); diff --git a/apps/miniprogram/e2e/flows/vital-signs-input.spec.ts b/apps/miniprogram/e2e/flows/vital-signs-input.spec.ts new file mode 100644 index 0000000..5c4dc4a --- /dev/null +++ b/apps/miniprogram/e2e/flows/vital-signs-input.spec.ts @@ -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> = []; + + 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(); + }); +}); diff --git a/apps/miniprogram/e2e/helpers/api-client.ts b/apps/miniprogram/e2e/helpers/api-client.ts new file mode 100644 index 0000000..2ae1f23 --- /dev/null +++ b/apps/miniprogram/e2e/helpers/api-client.ts @@ -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) { + 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) { + 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, + }); + } +} diff --git a/apps/miniprogram/e2e/helpers/auth.helper.ts b/apps/miniprogram/e2e/helpers/auth.helper.ts new file mode 100644 index 0000000..f9d7d0a --- /dev/null +++ b/apps/miniprogram/e2e/helpers/auth.helper.ts @@ -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'); + } +} diff --git a/apps/miniprogram/e2e/helpers/automator-client.ts b/apps/miniprogram/e2e/helpers/automator-client.ts new file mode 100644 index 0000000..16c64ea --- /dev/null +++ b/apps/miniprogram/e2e/helpers/automator-client.ts @@ -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 { + return this.getMini().currentPage(); + } + + async navigateTo(path: string, _query?: Record) { + 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 { + 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 { + 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); + } +} diff --git a/apps/miniprogram/e2e/helpers/navigation.helper.ts b/apps/miniprogram/e2e/helpers/navigation.helper.ts new file mode 100644 index 0000000..848406d --- /dev/null +++ b/apps/miniprogram/e2e/helpers/navigation.helper.ts @@ -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'); + } +} diff --git a/apps/miniprogram/e2e/vitest.config.ts b/apps/miniprogram/e2e/vitest.config.ts new file mode 100644 index 0000000..e100332 --- /dev/null +++ b/apps/miniprogram/e2e/vitest.config.ts @@ -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'], + }, +}); diff --git a/apps/miniprogram/package.json b/apps/miniprogram/package.json index 3c3fc3a..96f5df7 100644 --- a/apps/miniprogram/package.json +++ b/apps/miniprogram/package.json @@ -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" } } diff --git a/apps/miniprogram/pnpm-lock.yaml b/apps/miniprogram/pnpm-lock.yaml index 07ac8d5..68b3034 100644 --- a/apps/miniprogram/pnpm-lock.yaml +++ b/apps/miniprogram/pnpm-lock.yaml @@ -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: {} diff --git a/apps/web/e2e/check-readiness.ts b/apps/web/e2e/check-readiness.ts new file mode 100644 index 0000000..df031c6 --- /dev/null +++ b/apps/web/e2e/check-readiness.ts @@ -0,0 +1,22 @@ +// apps/web/e2e/check-readiness.ts +import type { FullConfig } from '@playwright/test'; + +async function check(url: string, label: string): Promise { + 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 环境就绪'); +} diff --git a/apps/web/e2e/fixtures/api-client.ts b/apps/web/e2e/fixtures/api-client.ts new file mode 100644 index 0000000..7d12538 --- /dev/null +++ b/apps/web/e2e/fixtures/api-client.ts @@ -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 { success: boolean; data: T } +interface Versioned { id: string; version: number } +type VEntity = 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 { + 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 { + return this.login(); + } + + getToken(): string { return this.token; } + + async createPatient(overrides?: Partial): Promise>> { + return this.post('/health/patients', overrides ?? {}); + } + + async updatePatient(id: string, version: number, data: Partial): Promise>> { + return this.put(`/health/patients/${id}`, { ...data, version }); + } + + async deletePatient(id: string, version: number): Promise { + await this.del(`/health/patients/${id}`, { version }); + } + + async createDoctor(overrides?: Partial): Promise>> { + return this.post('/health/doctors', overrides ?? {}); + } + + async deleteDoctor(id: string, version: number): Promise { + await this.del(`/health/doctors/${id}`, { version }); + } + + async createVitalSigns(patientId: string, overrides?: Partial): Promise>> { + return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {}); + } + + async deleteVitalSigns(patientId: string, id: string, version: number): Promise { + await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version }); + } + + async createSchedule(overrides: ScheduleData): Promise>> { + return this.post('/health/doctor-schedules', overrides); + } + + async deleteSchedule(id: string, version: number): Promise { + await this.del(`/health/doctor-schedules/${id}`, { version }); + } + + async createAppointment(overrides: AppointmentData): Promise>> { + return this.post('/health/appointments', overrides); + } + + async updateAppointmentStatus(id: string, version: number, status: string): Promise>> { + return this.put(`/health/appointments/${id}/status`, { status, version }); + } + + async deleteAppointment(id: string, version: number): Promise { + await this.del(`/health/appointments/${id}`, { version }); + } + + async createFollowUpTemplate(overrides?: Partial): Promise>> { + return this.post('/health/follow-up-templates', overrides ?? {}); + } + + async deleteFollowUpTemplate(id: string, version: number): Promise { + await this.del(`/health/follow-up-templates/${id}`, { version }); + } + + async createFollowUpTask(overrides: FollowUpTaskData): Promise>> { + return this.post('/health/follow-up-tasks', overrides); + } + + async deleteFollowUpTask(id: string, version: number): Promise { + await this.del(`/health/follow-up-tasks/${id}`, { version }); + } + + async createAlertRule(overrides?: Partial): Promise>> { + return this.post('/health/alert-rules', overrides ?? {}); + } + + async deleteAlertRule(id: string, version: number): Promise { + await this.del(`/health/alert-rules/${id}`, { version }); + } + + async listAlerts(): Promise>[]> { + const res = await this.get<{ items: VEntity>[] }>('/health/alerts'); + return res.items ?? []; + } + + async acknowledgeAlert(id: string, version: number): Promise>> { + return this.put(`/health/alerts/${id}/acknowledge`, { version }); + } + + async resolveAlert(id: string, version: number): Promise>> { + return this.put(`/health/alerts/${id}/resolve`, { version }); + } + + async dismissAlert(id: string, version: number): Promise>> { + return this.put(`/health/alerts/${id}/dismiss`, { version }); + } + + private async headers(): Promise> { + return { + 'Content-Type': 'application/json', + ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), + }; + } + + private async get(path: string): Promise { + 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(path: string, body: unknown): Promise { + 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(path: string, body: unknown): Promise { + 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 { + 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(path: string, body: unknown): Promise { + 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; + } +} diff --git a/apps/web/e2e/fixtures/auth.fixture.ts b/apps/web/e2e/fixtures/auth.fixture.ts new file mode 100644 index 0000000..7efc785 --- /dev/null +++ b/apps/web/e2e/fixtures/auth.fixture.ts @@ -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({ + 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'; diff --git a/apps/web/e2e/fixtures/test-data.ts b/apps/web/e2e/fixtures/test-data.ts new file mode 100644 index 0000000..912a1b0 --- /dev/null +++ b/apps/web/e2e/fixtures/test-data.ts @@ -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 { + 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 { + const id = uid(); + return { + name: `E2E医生_${id}`, + department: '内科', + title: '主治医师', + specialty: '全科', + license_number: `DOC${id}`, + ...overrides, + }; +} + +export function makeVitalSigns(overrides?: Partial): 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 { + 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 { + 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 { + 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 { + 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 { + return { + name: `E2E告警规则_${uid()}`, + indicator: 'heart_rate', + condition: 'greater_than', + threshold: 50, + severity: 'warning', + description: 'E2E测试低阈值规则,用于触发告警', + ...overrides, + }; +} diff --git a/apps/web/e2e/flows/alert-flow.spec.ts b/apps/web/e2e/flows/alert-flow.spec.ts new file mode 100644 index 0000000..c66b5a8 --- /dev/null +++ b/apps/web/e2e/flows/alert-flow.spec.ts @@ -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> = []; + + 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 | undefined; + await expect(async () => { + const alerts = await api.listAlerts(); + alert = alerts.find((a) => (a as Record).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); + }); +}); diff --git a/apps/web/e2e/flows/appointment-flow.spec.ts b/apps/web/e2e/flows/appointment-flow.spec.ts new file mode 100644 index 0000000..8c7f9b4 --- /dev/null +++ b/apps/web/e2e/flows/appointment-flow.spec.ts @@ -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> = []; + + 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(); + }); +}); diff --git a/apps/web/e2e/flows/follow-up-flow.spec.ts b/apps/web/e2e/flows/follow-up-flow.spec.ts new file mode 100644 index 0000000..79518ac --- /dev/null +++ b/apps/web/e2e/flows/follow-up-flow.spec.ts @@ -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> = []; + + 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); + }); +}); diff --git a/apps/web/e2e/flows/patient-journey.spec.ts b/apps/web/e2e/flows/patient-journey.spec.ts new file mode 100644 index 0000000..e716b05 --- /dev/null +++ b/apps/web/e2e/flows/patient-journey.spec.ts @@ -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> = []; + + 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(); + }); +}); diff --git a/apps/web/e2e/flows/vital-signs-flow.spec.ts b/apps/web/e2e/flows/vital-signs-flow.spec.ts new file mode 100644 index 0000000..076b97b --- /dev/null +++ b/apps/web/e2e/flows/vital-signs-flow.spec.ts @@ -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> = []; + + 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); + }); +}); diff --git a/apps/web/e2e/pages/appointment.page.ts b/apps/web/e2e/pages/appointment.page.ts new file mode 100644 index 0000000..c65ce0d --- /dev/null +++ b/apps/web/e2e/pages/appointment.page.ts @@ -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 }); + } +} diff --git a/apps/web/e2e/pages/health-data.page.ts b/apps/web/e2e/pages/health-data.page.ts new file mode 100644 index 0000000..379e8d8 --- /dev/null +++ b/apps/web/e2e/pages/health-data.page.ts @@ -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 { + 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 { + const chart = this.page.locator('canvas, .recharts-wrapper, [class*="chart"]'); + return chart.isVisible(); + } +} diff --git a/apps/web/e2e/pages/index.ts b/apps/web/e2e/pages/index.ts new file mode 100644 index 0000000..9f3abe0 --- /dev/null +++ b/apps/web/e2e/pages/index.ts @@ -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'; diff --git a/apps/web/e2e/pages/login.page.ts b/apps/web/e2e/pages/login.page.ts new file mode 100644 index 0000000..7138ac8 --- /dev/null +++ b/apps/web/e2e/pages/login.page.ts @@ -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 { + const el = this.page.locator('.ant-form-item-explain-error, .ant-message-error, .ant-alert-error'); + return el.first().textContent() ?? ''; + } + + async isLoggedIn(): Promise { + try { + await this.page.waitForURL('**/#/', { timeout: 5000 }); + return true; + } catch { + return false; + } + } +} diff --git a/apps/web/e2e/pages/patient-detail.page.ts b/apps/web/e2e/pages/patient-detail.page.ts new file mode 100644 index 0000000..9b33b93 --- /dev/null +++ b/apps/web/e2e/pages/patient-detail.page.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/apps/web/e2e/pages/patient-list.page.ts b/apps/web/e2e/pages/patient-list.page.ts new file mode 100644 index 0000000..d78d1d7 --- /dev/null +++ b/apps/web/e2e/pages/patient-list.page.ts @@ -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 { + return this.page.locator('.ant-table-tbody tr').count(); + } + + async hasPatientInTable(name: string): Promise { + await this.searchPatient(name); + const count = await this.page.locator(`.ant-table-tbody tr:has-text("${name}")`).count(); + return count > 0; + } +} diff --git a/apps/web/e2e/smoke/login.spec.ts b/apps/web/e2e/smoke/login.spec.ts new file mode 100644 index 0000000..88e24f8 --- /dev/null +++ b/apps/web/e2e/smoke/login.spec.ts @@ -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); // 用户名 + 密码 + }); +}); diff --git a/apps/web/e2e/smoke/plugins.spec.ts b/apps/web/e2e/smoke/plugins.spec.ts new file mode 100644 index 0000000..48cea8b --- /dev/null +++ b/apps/web/e2e/smoke/plugins.spec.ts @@ -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(); + } + }); +}); diff --git a/apps/web/e2e/smoke/tenant-isolation.spec.ts b/apps/web/e2e/smoke/tenant-isolation.spec.ts new file mode 100644 index 0000000..7a87fb4 --- /dev/null +++ b/apps/web/e2e/smoke/tenant-isolation.spec.ts @@ -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 }); + }); +}); diff --git a/apps/web/e2e/smoke/users.spec.ts b/apps/web/e2e/smoke/users.spec.ts new file mode 100644 index 0000000..eb2543f --- /dev/null +++ b/apps/web/e2e/smoke/users.spec.ts @@ -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'); + }); +}); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 6e24534..cca4b42 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -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, }, });