mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
feat(e2e): Playwright E2E 前端测试基建 — 后端合成数据 mock 模式 + 5 用例 + CI
- web/e2e_mock.py:EASY_TDX_E2E_MOCK=1 时 lifespan 替换 TDX/MAC 客户端为 确定性合成数据(CRC32 播种随机游走,分页语义对齐真实 /bars);回测/WF/ 评估/自选/策略库仍走真实后端,SSE 由 QuoteStreamer 真轮询(mock 下 2s 一拍) - web-ui:@playwright/test + playwright.config.ts(webServer 自动起 serve, EASY_TDX_CONFIG_DIR 指向每轮临时目录);e2e/ 5 用例覆盖看板五指数/SSE、 自选增删、回测全流程、附加分析(WF 柱状图+一条龙评估卡)、策略库保存 - package.json 加 test:e2e;CI frontend job 追加 E2E;verify_ci.sh 补前端段 - tests/unit/test_e2e_mock.py(11 例)守护 mock 契约;本地 npx playwright test 全绿
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# easy-tdx Web UI — Playwright E2E
|
||||
|
||||
无头浏览器端到端测试,覆盖:市场看板(五大指数 SSE)、自选增删、回测全流程
|
||||
(取行情→净值图→绩效表→成交记录)、「附加分析」开关(WF 逐窗柱状图 + 一条龙
|
||||
评估卡)、策略库保存。
|
||||
|
||||
## Mock 方案:后端合成数据(EASY_TDX_E2E_MOCK=1),不是 page.route 拦截
|
||||
|
||||
两种可行路径的取舍:
|
||||
|
||||
| | 后端 mock(已选) | Playwright `page.route` 拦截 |
|
||||
|---|---|---|
|
||||
| 回测/WF/评估/自选/策略库 | **走真实后端代码**,schema 变更会被 E2E 捕获 | 静态 JSON,会与后端 schema 漂移 |
|
||||
| SSE `/stream/quotes`(EventSource) | QuoteStreamer 真轮询合成数据,SSE 全链路被覆盖 | 无法稳定 mock(需流式 body) |
|
||||
| 任务型端点(提交→轮询→done) | 天然支持 | 要手写状态机 |
|
||||
| 代价 | 后端多一个 `src/easy_tdx/web/e2e_mock.py`(约 400 行,由单测守护契约) | 无后端改动 |
|
||||
|
||||
实现:`playwright.config.ts` 的 `webServer` 以 `EASY_TDX_E2E_MOCK=1` 启动
|
||||
`easy-tdx serve --port 8001`,lifespan 把 TDX/MAC 客户端替换为合成数据客户端
|
||||
(确定性随机游走、按 (market, code) 播种,逐轮结果完全一致)。
|
||||
`EASY_TDX_CONFIG_DIR` 指向每轮独立的临时目录——自选/策略库/任务从空开始,
|
||||
断言可以写死,也不污染真实 `~/.easy_tdx`。
|
||||
|
||||
## 本地跑法
|
||||
|
||||
```bash
|
||||
# 仓库根(Python 侧,一次性)
|
||||
pip install -e ".[web]" # fastapi + uvicorn
|
||||
|
||||
cd web-ui
|
||||
npm install
|
||||
npx playwright install chromium # 首次下载浏览器(约 115MB)
|
||||
npm run build # 必须:serve 从 ../web-ui/dist 托管前端
|
||||
npm run test:e2e # = npx playwright test(无头)
|
||||
```
|
||||
|
||||
- Python 解释器自动探测仓库 `.venv`;没有 `.venv` 时用 `EASY_TDX_PYTHON`
|
||||
环境变量指定(CI 里是系统 `python`)。
|
||||
- 调试:`npx playwright test --headed`,或 `--ui` 打开交互式运行器;
|
||||
失败时自动留 trace/截图在 `e2e/.results/`(已 gitignore)。
|
||||
- 想复用已启动的 serve:直接跑即可(`reuseExistingServer`,本地非 CI 默认开),
|
||||
但注意该 serve 必须带 `EASY_TDX_E2E_MOCK=1` 才有合成行情。
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/ci.yml` 的 `frontend` job 在 typecheck+build 后追加:
|
||||
安装 Python + `pip install -e ".[web]"` → `npx playwright install chromium` →
|
||||
`npm run test:e2e`(mock 模式,不需要任何真实行情连接)。
|
||||
|
||||
## 用例清单
|
||||
|
||||
| 文件 | 覆盖 |
|
||||
|---|---|
|
||||
| `dashboard.spec.ts` | 五大指数区块渲染 + SSE 首帧价格 + 市场统计卡 |
|
||||
| `watchlist.spec.ts` | 自选加入(含名称补全)/删除/空态 |
|
||||
| `backtest.spec.ts` | 策略下拉 + 回测全流程 + WF 柱状图 + 一条龙评估卡 |
|
||||
| `strategies.spec.ts` | 保存策略对话框 + 策略库页可见 |
|
||||
@@ -0,0 +1,54 @@
|
||||
// 回测页 E2E:选策略 → 开始回测(自动取行情)→ 净值图 + 绩效表 + 成交记录。
|
||||
//
|
||||
// 行情来自 mock /bars(确定性合成 OHLCV),回测跑真实引擎;
|
||||
// URL query 指定较短日期区间加快取数(页面 onMounted 回填 startDate/endDate)。
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('回测全流程出净值图与绩效表', async ({ page }) => {
|
||||
await page.goto('/backtest?startDate=2024-01-01&endDate=2025-12-31')
|
||||
|
||||
// 策略下拉加载出内置策略,默认 ma_cross
|
||||
const strategySelect = page.locator('.strategy-picker select')
|
||||
await expect(strategySelect).toHaveValue('ma_cross')
|
||||
const optionCount = await strategySelect.locator('option').count()
|
||||
expect(optionCount).toBeGreaterThanOrEqual(18)
|
||||
|
||||
// 取行情(mock /bars)+ 回测(真实引擎)
|
||||
await page.getByRole('button', { name: '开始回测' }).click()
|
||||
|
||||
// 报告区:净值曲线(echarts canvas)+ 绩效指标 + 成交记录
|
||||
await expect(page.getByRole('heading', { name: '净值曲线与回撤' })).toBeVisible({ timeout: 60_000 })
|
||||
await expect(page.locator('.report-section canvas').first()).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: '绩效指标' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: /成交记录(\d+ 笔)/ })).toBeVisible()
|
||||
|
||||
// 绩效表渲染出具体数值(总收益/夏普等指标行)
|
||||
const perfSection = page.locator('.report-section', { hasText: '绩效指标' })
|
||||
await expect(perfSection.locator('td, .metric-value, .mono').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('勾选附加分析后出现 WF 逐窗柱状图与一条龙评估卡', async ({ page }) => {
|
||||
await page.goto('/backtest?startDate=2023-01-01&endDate=2025-12-31')
|
||||
|
||||
// 勾选两个「附加分析」开关(v1.27 新增)
|
||||
await page.getByLabel('Walk-Forward 样本外验证').check()
|
||||
await expect(page.getByLabel('一条龙评估')).toBeVisible()
|
||||
await page.getByLabel('一条龙评估').check()
|
||||
|
||||
await page.getByRole('button', { name: '开始回测' }).click()
|
||||
|
||||
// WF:先出现「验证中…」区块,随后逐窗柱状图(echarts canvas)+ 稳定性汇总
|
||||
await expect(page.getByRole('heading', { name: 'Walk-Forward 样本外验证' })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
})
|
||||
await expect(page.locator('.wf-chart canvas')).toBeVisible({ timeout: 120_000 })
|
||||
await expect(page.getByText('盈利窗占比', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.wf-summary .stat')).toHaveCount(6)
|
||||
|
||||
// 一条龙评估:综合评分 + 高适配徽标 + 基准对比
|
||||
await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 })
|
||||
await expect(page.getByText('综合评分')).toBeVisible()
|
||||
await expect(page.getByText('对比买入持有')).toBeVisible()
|
||||
await expect(page.getByText(/适配性体检 \d+\/\d+/)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// 看板 E2E:五大指数区块加载 + SSE 合成行情到达后价格渲染。
|
||||
//
|
||||
// 数据链路:QuoteStreamer 轮询 mock 客户端 → SSE /stream/quotes →
|
||||
// quoteStore → .idx-card。首帧在打开页面后 1~3 秒内到达(轮询循环
|
||||
// 「无订阅 1s 待命 → 有订阅立即拉一轮」),盘外时段也不受影响。
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('市场看板加载五大指数区块并渲染实时价格', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const cards = page.locator('.idx-card')
|
||||
await expect(cards).toHaveCount(5)
|
||||
|
||||
const names = cards.locator('.idx-name')
|
||||
await expect(names).toHaveText(['上证指数', '深证成指', '创业板指', '科创50', '沪深300'])
|
||||
|
||||
// SSE 首帧到达前价格是占位符「—」;合成行情到达后变为数值
|
||||
await expect(cards.first().locator('.idx-price')).toHaveText(/\d/, { timeout: 30_000 })
|
||||
|
||||
// 市场统计卡也从 mock /market/stat 拿到数据(不再是「加载中…」)
|
||||
await expect(page.locator('.card', { hasText: '市场统计' }).locator('.stat-nums')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// 策略库 E2E:回测出结果 → 保存策略(含成绩快照)→ 策略库页可见。
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('回测结果保存到策略库并在策略库页可见', async ({ page }) => {
|
||||
await page.goto('/backtest?startDate=2024-01-01&endDate=2025-12-31')
|
||||
|
||||
// 跑一次回测拿到结果(保存按钮只在有结果时出现)
|
||||
await page.getByRole('button', { name: '开始回测' }).click()
|
||||
await expect(page.getByRole('button', { name: '💾 保存策略' })).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// 打开保存对话框(名称已预填「双均线交叉 · 000001」)
|
||||
await page.getByRole('button', { name: '💾 保存策略' }).click()
|
||||
await expect(page.locator('.modal')).toBeVisible()
|
||||
const nameInput = page.getByPlaceholder('给这个策略起个名')
|
||||
await expect(nameInput).toHaveValue('双均线交叉 · 000001')
|
||||
await nameInput.fill('E2E 冒烟策略')
|
||||
|
||||
await page.locator('.modal-actions .primary').click()
|
||||
await expect(page.getByText('✓ 已保存到策略库')).toBeVisible()
|
||||
|
||||
// 策略库页列出刚保存的条目(SQLite strategies.db 落在临时 EASY_TDX_CONFIG_DIR)
|
||||
await page.goto('/strategies')
|
||||
await expect(page.getByText('E2E 冒烟策略').first()).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
// 自选页 E2E:加入自选(行情校验 + 名称补全走 mock)→ 表格出现 → 删除 → 消失。
|
||||
//
|
||||
// 每轮 E2E 用独立的临时 EASY_TDX_CONFIG_DIR,自选从空开始,断言可写死。
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('自选页增删自选', async ({ page }) => {
|
||||
await page.goto('/watchlist')
|
||||
|
||||
// 初始为空(临时配置目录)
|
||||
await expect(page.locator('.empty-row')).toBeVisible()
|
||||
|
||||
// 加入 600519(市场自动识别 SH;名称走 mock /mac/symbol-info → 贵州茅台)
|
||||
await page.fill('.code-input', '600519')
|
||||
await page.getByRole('button', { name: '加入自选' }).click()
|
||||
await expect(page.locator('.data-row')).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(page.locator('.data-row .cell-name')).toHaveText('贵州茅台')
|
||||
await expect(page.locator('.data-row .cell-code')).toHaveText('SH600519')
|
||||
|
||||
// 删除后表格回到空态
|
||||
await page.locator('.data-row .del').first().click()
|
||||
await expect(page.locator('.data-row')).toHaveCount(0)
|
||||
await expect(page.locator('.empty-row')).toBeVisible()
|
||||
})
|
||||
Generated
+64
@@ -14,6 +14,7 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
@@ -137,6 +138,22 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
@@ -1122,6 +1139,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
|
||||
+3
-1
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^6.1.0",
|
||||
@@ -15,6 +16,7 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Playwright E2E 配置:mock 模式的 easy-tdx serve + 已构建的前端 dist。
|
||||
//
|
||||
// 运行前置:
|
||||
// 1. `npm run build`(serve 从仓库根 web-ui/dist 托管前端,SPA fallback);
|
||||
// 2. 后端可用(仓库根 `pip install -e ".[web]"`)——自动探测仓库 .venv,
|
||||
// 否则可用 EASY_TDX_PYTHON 指定解释器(CI 里是 `python`)。
|
||||
//
|
||||
// 行情全部来自合成数据(EASY_TDX_E2E_MOCK=1,见 src/easy_tdx/web/e2e_mock.py),
|
||||
// 不连真实通达信服务器、不受交易时段限制;回测/WF/评估/自选/策略库走真实后端。
|
||||
// 详见 e2e/README.md。
|
||||
|
||||
import { existsSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
const PORT = Number(process.env.E2E_PORT ?? 8001)
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`
|
||||
const WEB_UI_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** 启动 serve 的 Python 解释器:优先仓库 .venv(开发态 editable 安装),
|
||||
* 没有则回退 EASY_TDX_PYTHON / 系统 python(CI 态 pip install -e 之后)。 */
|
||||
function resolvePython(): string {
|
||||
const repoRoot = path.resolve(WEB_UI_DIR, '..')
|
||||
if (existsSync(path.join(repoRoot, '.venv/Scripts/python.exe'))) {
|
||||
return path.join(repoRoot, '.venv/Scripts/python.exe')
|
||||
}
|
||||
if (existsSync(path.join(repoRoot, '.venv/bin/python'))) {
|
||||
return path.join(repoRoot, '.venv/bin/python')
|
||||
}
|
||||
return process.env.EASY_TDX_PYTHON ?? 'python'
|
||||
}
|
||||
|
||||
// 每次运行独立的临时配置目录:watchlist.db / strategies.db / tasks.db 写在这里,
|
||||
// 不污染真实 ~/.easy_tdx,且每轮 E2E 从空自选、空策略库开始(断言可写死)。
|
||||
const RUNTIME_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'easy-tdx-e2e-'))
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 180_000,
|
||||
// 单 worker 串行:全部用例共享同一个 serve(SQLite 自选/策略库会互相干扰)
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
headless: true,
|
||||
locale: 'zh-CN',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
expect: {
|
||||
timeout: 20_000,
|
||||
},
|
||||
outputDir: './e2e/.results',
|
||||
webServer: {
|
||||
command: `${resolvePython()} -m easy_tdx serve --host 127.0.0.1 --port ${PORT} --no-open-browser`,
|
||||
url: `${BASE_URL}/api/v1/backtest/strategies`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
EASY_TDX_E2E_MOCK: '1',
|
||||
EASY_TDX_CONFIG_DIR: RUNTIME_CONFIG_DIR,
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user