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:
GitHub
2026-09-01 22:56:38 +08:00
parent 6b67885588
commit 211052f0e3
15 changed files with 1042 additions and 18 deletions
+13
View File
@@ -76,3 +76,16 @@ jobs:
working-directory: web-ui
- run: npm run build
working-directory: web-ui
# Playwright E2Emock 模式):serve 托管刚构建的 dist,行情全部来自
# EASY_TDX_E2E_MOCK=1 合成数据,不连真实通达信服务器、不受交易时段限制。
# 需要 Python 侧 easy-tdx[web]webServer 用系统 python 启动 serve)。
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -e ".[web]"
- run: npx playwright install --with-deps chromium
working-directory: web-ui
- run: npm run test:e2e
working-directory: web-ui
env:
EASY_TDX_PYTHON: python
+5
View File
@@ -32,3 +32,8 @@ CLAUDE.md
# 代码审计报告(本地产物,不入库)
audit-report-*.html
# Playwright E2E 运行产物(trace/截图/视频)与临时配置目录
web-ui/e2e/.results/
web-ui/test-results/
web-ui/playwright-report/
+6
View File
@@ -2,6 +2,12 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [未发布]
### 新增
- **Playwright E2E 前端测试基建**(升级计划 P4-1)——web-ui 引入 `@playwright/test``e2e/` + `playwright.config.ts``npm run test:e2e`)。**mock 方案选后端合成数据而非 page.route 拦截**`EASY_TDX_E2E_MOCK=1` 时 serve 的 lifespan 把 TDX/MAC 客户端替换为合成数据客户端(`web/e2e_mock.py`,按 (market, code) CRC32 播种的确定性随机游走,分页语义与真实 /bars 一致),回测/WF/一条龙评估/自选/策略库继续走**真实后端代码**(它们本就不依赖行情连接),SSE 由 QuoteStreamer 真轮询合成数据全链路覆盖(mock 模式下轮询降到 2s 一拍,不受交易时段限制)。用例覆盖:看板五大指数区块+SSE 价格渲染、自选增删、回测全流程(净值图/绩效表/成交记录)、「附加分析」开关(WF 逐窗柱状图+一条龙评估卡)、策略库保存;`EASY_TDX_CONFIG_DIR` 指向每轮独立临时目录(断言可写死、不污染真实 `~/.easy_tdx`)。CI frontend job 追加 E2E 步骤;`verify_ci.sh``--no-frontend` 与前端 typecheck+build+E2E 段。新增 `tests/unit/test_e2e_mock.py`(11 例)守护 mock 与真实客户端的契约。
## [1.27.0] — 2026-09-01
**公式与轮动版本**——升级计划 P3 + P4(部分)落地:通达信公式解析器让写惯公式的用户零 Python 进入筛选/回测,轮动组合引擎补齐「排名换仓」组合形态,附 Docker 部署与一键门禁脚本。
+22 -5
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# easy-tdx 一键本地门禁(等价 CI 的质量检查,v1.27 新增)。
# easy-tdx 一键本地门禁(等价 CI 的质量检查,v1.27 新增v1.28 补前端 E2E)。
#
# 用法:bash scripts/verify_ci.sh [--fast]
# --fast 跳过全量测试(只跑 ruff + mypy + 格式检查)
# 用法:bash scripts/verify_ci.sh [--fast] [--no-frontend]
# --fast 跳过全量测试与前端(只跑 ruff + mypy + 格式检查)
# --no-frontend 跳过前端 typecheck+build+E2E(只跑 Python 侧)
#
# 可选安装为 git hookpre-push):
# ln -s ../../scripts/verify_ci.sh .git/hooks/pre-push
@@ -19,7 +20,13 @@ if [ ! -f "$PY" ]; then
fi
FAST=0
[ "${1:-}" = "--fast" ] && FAST=1
NO_FRONTEND=0
for arg in "$@"; do
case "$arg" in
--fast) FAST=1 ;;
--no-frontend) NO_FRONTEND=1 ;;
esac
done
echo "── ruff check ──────────────────────────────────────────"
"$PY" -m ruff check src/ tests/
@@ -31,7 +38,7 @@ echo "── mypy --strict ─────────────────
"$PY" -m mypy src/easy_tdx/
if [ "$FAST" = "1" ]; then
echo "── 跳过测试(--fast)───────────────────────────────────"
echo "── 跳过测试与前端(--fast)─────────────────────────────"
echo "✓ verify_ci (fast) 全部通过"
exit 0
fi
@@ -39,5 +46,15 @@ fi
echo "── pytest(全量单元测试)────────────────────────────────"
"$PY" -m pytest tests/ -q --ignore=tests/integration
if [ "$NO_FRONTEND" = "0" ] && [ -d web-ui/node_modules ]; then
echo "── 前端 typecheck + build ──────────────────────────────"
(cd web-ui && npm run build)
echo "── Playwright E2Emock 模式,无需真实行情)────────────"
(cd web-ui && npm run test:e2e)
else
echo "── 跳过前端(--no-frontend 或 web-ui/node_modules 缺失)─"
fi
echo ""
echo "✓ verify_ci 全部通过"
+39 -12
View File
@@ -63,24 +63,40 @@ def _resolve_web_dist_dir() -> Path | None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。"""
from easy_tdx.client import AsyncTdxClient
"""管理 TDX 连接生命周期:启动时连接,关闭时断开。
E2E mock 模式(``EASY_TDX_E2E_MOCK=1``,见 :mod:`easy_tdx.web.e2e_mock`):
全部行情连接替换为合成数据客户端(不连真实服务器、不受交易时段限制),
回测 / 自选 / 策略库等纯计算路径保持真实,供 Playwright E2E 使用。
"""
from easy_tdx.web.e2e_mock import is_e2e_mock_enabled, log_mock_banner
mock_mode = is_e2e_mock_enabled()
# --- 标准 TDX 客户端 ---
host = app.state.tdx_host
port = app.state.tdx_port
timeout = app.state.tdx_timeout
client = AsyncTdxClient(host=host, port=port, timeout=timeout)
try:
await client.connect()
logger.info("TDX client connected to %s:%s", host, port)
except Exception:
logger.warning("TDX client connection failed — endpoints will return 503")
client: Any
if mock_mode:
from easy_tdx.web.e2e_mock import MockTdxClient
log_mock_banner()
client = MockTdxClient()
else:
from easy_tdx.client import AsyncTdxClient
client = AsyncTdxClient(host=host, port=port, timeout=timeout)
try:
await client.connect()
logger.info("TDX client connected to %s:%s", host, port)
except Exception:
logger.warning("TDX client connection failed — endpoints will return 503")
app.state.tdx_client = client
# --- 实时行情 SSE 推送器(共享轮询 + fan-out ---
# --- 实时行情 SSE 推送器(共享轮询 + fan-outmock 模式轮询合成数据 ---
try:
from easy_tdx.models.enums import Market
from easy_tdx.web.quote_streamer import QuoteStreamer
@@ -92,7 +108,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# SQLite 存 "SH"/"SZ"/"BJ" 字符串,轮询器需要 Market 枚举
return [(Market[mkt], code) for mkt, code in store.symbols()]
streamer = QuoteStreamer(client.get_security_quotes, _watch_symbols)
# mock 模式不受真实限流/交易时段约束,缩短轮询间隔让 E2E 的
# SSE 断言(首帧价格渲染)秒级到达,而不是等盘外 60s 慢拍
streamer = QuoteStreamer(
client.get_security_quotes,
_watch_symbols,
trading_interval=2.0 if mock_mode else 8.0,
idle_interval=2.0 if mock_mode else 60.0,
)
streamer.start()
app.state.quote_streamer = streamer
except Exception:
@@ -100,9 +123,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.quote_streamer = None
# --- MAC 协议客户端 ---
mac_client = None
mac_client: Any = None
enable_mac = getattr(app.state, "enable_mac", True)
if enable_mac:
if mock_mode:
from easy_tdx.web.e2e_mock import MockMacClient
mac_client = MockMacClient()
elif enable_mac:
try:
from easy_tdx.mac.client import AsyncMacClient
+467
View File
@@ -0,0 +1,467 @@
"""E2E 合成行情数据源(``EASY_TDX_E2E_MOCK=1`` 时由 serve 激活)。
**为什么在 FastAPI 层 mock 而不是用 Playwright ``page.route`` 拦截**
1. 回测 / WF / 一条龙评估 / 自选 / 策略库全部走**真实后端代码路径**(纯计算 +
SQLite CRUD,本来就不依赖行情连接),E2E 能捕获后端 schema 变更;route 拦截
的静态 JSON 会与后端 schema 漂移,测试通过不代表系统可用。
2. SSE``/stream/quotes``EventSource)无法用 ``page.route`` 稳定 mock(需要
流式 body);mock 客户端让 QuoteStreamer 真正轮询合成数据,前端 SSE 链路
(连接→首帧→快照渲染)也被覆盖。
3. 任务型端点(提交→task_id→轮询→done)在 route 拦截里要手写状态机,mock
客户端天然支持。
代价:本模块必须与真实客户端的方法签名/返回列保持一致——由
``tests/unit/test_e2e_mock.py`` 与 E2E 套件本身共同守护。
数据特征:
- **确定性**:每个 (market, code) 用 CRC32 做随机种子,同一进程内多次调用、
不同机器上跑 E2E,行情完全一致(回测结果可复现、断言可写死)。
- **锚定今天**:K 线序列以「今天」为最新一根(bdate_range),与前端默认日期
范围(开始 2020-01-06 ~ 结束今天)自然咬合。
- **分页语义**与真实 /bars 一致:``start=0`` 返回最新 ``count`` 根(页内升序),
``start`` 递增向更早翻页(前端 fetchBars 依赖此语义拼接)。
"""
from __future__ import annotations
import logging
import zlib
from datetime import datetime, timedelta
from typing import Any
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
__all__ = ["MockTdxClient", "MockMacClient", "E2E_MOCK_ENV"]
#: 激活环境变量名(serve 启动时读取,见 web/app.py lifespan)。
E2E_MOCK_ENV = "EASY_TDX_E2E_MOCK"
# 合成 K 线总根数(约 10.4 年日线)。前端默认区间 2020-01-06 ~ 今天约 1660 根,
# 翻页上限 10×800;2600 根既覆盖默认区间,又让第 3 页就翻到数据起点。
_HISTORY_BARS = 2600
# 已知代码 → 中文名(提升 E2E 可读性;未命中用「股票XXXXXX」兜底)。
_KNOWN_NAMES: dict[str, str] = {
"600519": "贵州茅台",
"000001": "平安银行",
"603986": "兆易创新",
"600000": "浦发银行",
"300750": "宁德时代",
"000001|SH": "上证指数",
"399001": "深证成指",
"399006": "创业板指",
"000688": "科创50",
"000300": "沪深300",
}
def _market_str(market: Any) -> str:
"""任意市场表示(枚举/int/字符串)→ 规范字符串 SZ/SH/BJ。"""
mapping = {
"0": "SZ",
"1": "SH",
"2": "BJ",
"MARKET.SZ": "SZ",
"MARKET.SH": "SH",
"MARKET.BJ": "BJ",
}
s = str(market).upper()
return mapping.get(s, s)
def _seed(market: Any, code: str) -> int:
"""(market, code) → 确定性随机种子(CRC32)。"""
return zlib.crc32(f"{_market_str(market)}{code}".encode())
def _display_name(market: Any, code: str) -> str:
"""代码 → 中文名。仅 SH 的 000001 有歧义(平安银行 vs 上证指数),用
``code|SH`` 键消歧;其余市场直接按代码查。"""
if _market_str(market) == "SH" and f"{code}|SH" in _KNOWN_NAMES:
return _KNOWN_NAMES[f"{code}|SH"]
return _KNOWN_NAMES.get(code, f"股票{code}")
def _synth_closes(market: Any, code: str, n: int) -> np.ndarray:
"""生成 n 根确定性随机游走收盘价(指数基准价位 5~50 元)。"""
rng = np.random.default_rng(_seed(market, code))
base = 5.0 + rng.uniform(0.0, 45.0)
rets = rng.normal(0.0004, 0.018, n)
return base * np.cumprod(1.0 + rets)
def _synth_ohlcv(market: Any, code: str, n: int) -> pd.DataFrame:
"""生成 n 根升序 OHLCV 日线(datetime 为 pd.TimestampMAC 契约)。"""
rng = np.random.default_rng(_seed(market, code))
close = _synth_closes(market, code, n)
open_ = np.empty(n)
open_[0] = close[0]
open_[1:] = close[:-1] * (1.0 + rng.normal(0.0, 0.004, n - 1))
high = np.maximum(open_, close) * (1.0 + np.abs(rng.normal(0.0, 0.006, n)))
low = np.minimum(open_, close) * (1.0 - np.abs(rng.normal(0.0, 0.006, n)))
vol = rng.integers(50_000, 5_000_000, n).astype(np.float64)
amount = vol * close * 100.0
dates = pd.bdate_range(end=pd.Timestamp.now().normalize(), periods=n)
return pd.DataFrame(
{
"datetime": dates,
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": amount,
}
)
def _page_bars(full: pd.DataFrame, start: int, count: int) -> pd.DataFrame:
"""按真实 /bars 分页语义切片:start=0 → 最新 count 根(页内升序)。"""
n = len(full)
lo = max(0, n - start - count)
hi = n - start
if hi <= 0 or lo >= n:
return full.iloc[0:0]
return full.iloc[lo:hi]
def _quote_dict(mkt_enum: Any, code: str) -> dict[str, Any]:
"""单标的五档快照(列集合对齐 quote_streamer._QUOTE_FIELDS 白名单)。"""
closes = _synth_closes(market_enum_key(mkt_enum), code, 30)
price = float(closes[-1])
pre_close = float(closes[-2])
rng = np.random.default_rng(_seed(market_enum_key(mkt_enum), code) ^ 0xBEEF)
bid1 = round(price - 0.02, 2)
ask1 = round(price + 0.02, 2)
return {
"market": mkt_enum,
"code": code,
"price": price,
"pre_close": pre_close,
"open": float(closes[-1]),
"high": round(price * 1.01, 2),
"low": round(price * 0.99, 2),
"vol": float(rng.integers(10_000, 900_000)),
"cur_vol": float(rng.integers(10, 900)),
"amount": price * float(rng.integers(10_000, 900_000)) * 100.0,
"s_vol": float(rng.integers(1000, 9000)),
"b_vol": float(rng.integers(1000, 9000)),
"rise_speed": round(float(rng.uniform(-1, 1)), 2),
"limit_up": round(pre_close * 1.1, 2),
"limit_down": round(pre_close * 0.9, 2),
"decimal_point": 2,
"server_time": "10:30:00",
"trading_status": 0,
**{f"bid{i}": round(bid1 - 0.01 * (i - 1), 2) for i in range(1, 6)},
**{f"ask{i}": round(ask1 + 0.01 * (i - 1), 2) for i in range(1, 6)},
**{f"bid_vol{i}": float(rng.integers(10, 500)) for i in range(1, 6)},
**{f"ask_vol{i}": float(rng.integers(10, 500)) for i in range(1, 6)},
}
def market_enum_key(mkt_enum: Any) -> str:
"""Market 枚举 → 种子用的字符串键(SZ/SH/BJ)。"""
from easy_tdx.models.enums import Market
return {Market.SZ: "SZ", Market.SH: "SH", Market.BJ: "BJ"}.get(mkt_enum, str(mkt_enum))
def _int_market_to_enum(value: int) -> Any:
"""int 市场 → Market 枚举(MAC 客户端约定 int)。"""
from easy_tdx.models.enums import Market
return {0: Market.SZ, 1: Market.SH, 2: Market.BJ}.get(int(value), Market.SZ)
class MockTdxClient:
"""AsyncTdxClient 的合成数据替身(覆盖 web 路由用到的方法)。"""
async def close(self) -> None:
"""lifespan 关闭时调用(无真实连接,空操作)。"""
async def get_security_bars(
self,
market: Any,
code: str,
category: Any,
start: int = 0,
count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame:
"""个股 K 线(/bars 回退路径,MAC 可用时不会走到)。"""
df = _page_bars(_synth_ohlcv(market_enum_key(market), code, _HISTORY_BARS), start, count)
daily = _is_daily_category(category)
return _bars_to_legacy_cols(df, daily)
async def get_index_bars(
self,
market: Any,
code: str,
category: Any,
start: int = 0,
count: int = 800,
*,
bar_time: str = "start",
) -> pd.DataFrame:
"""指数 K 线(/bars/index,看板迷你 K 线与情绪雷达数据源)。"""
df = _page_bars(_synth_ohlcv(market_enum_key(market), code, _HISTORY_BARS), start, count)
return _bars_to_legacy_cols(df, _is_daily_category(category))
async def get_minute_time_data(self, market: Any, code: str) -> pd.DataFrame:
"""今日分时(240 点:价格围绕昨收随机游走 + 每分钟量)。"""
rng = np.random.default_rng(_seed(market_enum_key(market), code) ^ 0xCAFE)
pre_close = float(_synth_closes(market_enum_key(market), code, 30)[-2])
prices = pre_close * np.cumprod(1.0 + rng.normal(0.0, 0.0015, 240))
t0 = datetime.now().replace(hour=9, minute=30, second=0, microsecond=0)
times = [t0 + timedelta(minutes=i) for i in range(240)]
return pd.DataFrame(
{
"datetime": [t.strftime("%H:%M") for t in times],
"price": prices,
"vol": rng.integers(100, 9000, 240).astype(float),
}
)
async def get_security_quotes(self, stocks: list[tuple[Any, str]]) -> pd.DataFrame:
"""批量五档快照(POST /quotes + QuoteStreamer SSE 共用)。"""
return pd.DataFrame([_quote_dict(m, c) for m, c in stocks])
async def get_market_stat(self) -> pd.DataFrame:
"""全市场涨跌统计(看板「市场统计」卡,单行 df)。"""
rng = np.random.default_rng(20260901)
up = int(rng.integers(1800, 2800))
down = int(rng.integers(1800, 2800))
return pd.DataFrame(
[
{
"up_count": up,
"down_count": down,
"neutral_count": int(rng.integers(100, 300)),
"suspended_count": int(rng.integers(10, 60)),
"total_count": up + down + 300,
"total_amount": float(rng.uniform(7e11, 1.1e12)),
"total_volume": float(rng.uniform(6e11, 9e11)),
"total_market_cap": float(rng.uniform(6e13, 8.5e13)),
"limit_up_count": int(rng.integers(30, 80)),
"limit_down_count": int(rng.integers(5, 40)),
}
]
)
async def get_transaction_data(
self, market: Any, code: str, start: int = 0, count: int = 800
) -> pd.DataFrame:
"""当日逐笔(个股弹窗用;简化为合成 tick 序列)。"""
rng = np.random.default_rng(_seed(market_enum_key(market), code) ^ 0x7777)
closes = _synth_closes(market_enum_key(market), code, 30)
n = max(1, min(count, 200))
return pd.DataFrame(
{
"time": [f"10:{i % 60:02d}" for i in range(n)],
"price": closes[-1] * (1.0 + rng.normal(0.0, 0.002, n)),
"vol": rng.integers(1, 500, n).astype(float),
"buyorsell": rng.integers(0, 2, n).astype(int),
}
)
class MockMacClient:
"""AsyncMacClient 的合成数据替身(覆盖 web 路由用到的方法)。"""
async def close(self) -> None:
"""lifespan 关闭时调用(无真实连接,空操作)。"""
async def get_stock_kline(
self,
market: Any,
code: str,
period: Any,
start: int = 0,
count: int = 800,
times: int = 1,
*,
adjust: Any = None,
bar_time: str = "start",
) -> pd.DataFrame:
"""个股 K 线(/bars 主路径)。market 为 intMAC 协议约定)。
返回 MAC 契约列:datetime(含 00:00 时分)+ OHLC + vol/amount +
float_shares(会被 bars 路由的 _normalize_mac_df 丢弃/规整)。
"""
df = _page_bars(
_synth_ohlcv(market_enum_key(_int_market_to_enum(int(market))), code, _HISTORY_BARS),
start,
count,
)
out = df.copy()
out["float_shares"] = 1.5e9
return out
async def get_symbol_info(self, *, market: Any, code: str) -> pd.DataFrame:
"""证券名称快照(/mac/symbol-info,自选补名用)。"""
return pd.DataFrame(
[{"market": int(market), "code": code, "name": _display_name(market, code)}]
)
async def get_stock_quotes_list(
self,
*,
category: Any = None,
start: int = 0,
count: int = 80,
sort_type: Any = None,
sort_order: Any = None,
exclude_flags: Any = None,
) -> pd.DataFrame:
"""排行行情(/mac/quote-list,看板涨跌榜/分布懒加载)。
固定 40 只合成标的,按 close/pre_close 排序返回 count 根(封顶 200,
避免分布懒加载的 3000 只请求生成过大 df)。
"""
rng = np.random.default_rng(0xE2E5)
n = 40
codes = [f"6{i:05d}" for i in range(n // 2)] + [f"0{i:05d}" for i in range(n - n // 2)]
rows = []
for i, code in enumerate(codes):
closes = _synth_closes("SH" if code.startswith("6") else "SZ", code, 30)
rows.append(
{
"market": 1 if code.startswith("6") else 0,
"code": code,
"name": _display_name("SH" if code.startswith("6") else "SZ", code),
"close": float(closes[-1]),
"pre_close": float(closes[-2]),
"vol": float(rng.integers(10_000, 900_000)),
"amount": float(closes[-1]) * float(rng.integers(10_000, 900_000)),
"turnover_rate": round(float(rng.uniform(0.1, 25.0)), 2),
}
)
df = pd.DataFrame(rows)
df["_pct"] = df["close"] / df["pre_close"] - 1.0
df = df.sort_values("_pct", ascending=(str(sort_order).upper() == "ASC"))
df = df.drop(columns=["_pct"]).iloc[start : start + min(count, 200)]
return df.reset_index(drop=True)
async def get_board_list(
self, *, board_type: Any = None, count: int = 500, sort_column: Any = None
) -> pd.DataFrame:
"""板块列表(/board-mac/list,看板行业/概念热度榜)。"""
rng = np.random.default_rng(0xB0AD)
names = [
"银行",
"证券",
"半导体",
"白酒",
"新能源车",
"光伏",
"军工",
"医药",
"房地产",
"煤炭",
"钢铁",
"传媒",
"计算机",
"通信",
"家电",
"食品饮料",
]
rows = []
for i, name in enumerate(names):
closes = _synth_closes("BOARD", name, 30)
rows.append(
{
"code": f"8810{i % 10}{i:02d}",
"name": name,
"price": float(closes[-1]),
"pre_close": float(closes[-2]),
"sort_value": float(rng.uniform(-5, 5)),
}
)
df = pd.DataFrame(rows)
df["_pct"] = df["price"] / df["pre_close"] - 1.0
df = df.sort_values("_pct", ascending=False).drop(columns=["_pct"])
return df.head(min(count, len(df))).reset_index(drop=True)
async def get_board_members(
self, *, board_symbol: str, count: int = 100, sort_type: Any = None, sort_order: Any = None
) -> pd.DataFrame:
"""板块成分股(板块弹窗)。"""
return await self.get_stock_quotes_list(
count=count, sort_type=sort_type, sort_order=sort_order
)
async def get_belong_board(self, *, market: Any, code: str) -> pd.DataFrame:
"""个股所属板块(个股弹窗)。"""
return pd.DataFrame(
[
{"code": "881001", "name": "银行"},
{"code": "881101", "name": "上证主力"},
]
)
async def get_unusual(
self, *, market: Any = None, start: int = 0, count: int = 50
) -> pd.DataFrame:
"""市场异动流(看板异动雷达)。"""
rng = np.random.default_rng(_seed("UNUSUAL", str(market)) ^ 0x5EED)
descs = ["火箭发射", "大笔买入", "封涨停板", "打开跌停板", "快速反弹", "有大买盘"]
rows = []
for i in range(min(count, 12)):
mkt_int = int(market) if market is not None else 1
prefix = "6" if mkt_int == 1 else "0" # SH→6 开头,SZ→0 开头
code = f"{prefix}{int(rng.integers(0, 99999)):05d}"
hh = 9 + int(i / 12 * 6)
mm = int(rng.integers(0, 60))
rows.append(
{
"time": f"{hh:02d}:{mm:02d}:{int(rng.integers(0, 60)):02d}",
"code": code,
"name": _display_name(market, code),
"desc": str(rng.choice(descs)),
"value": f"+{rng.uniform(2, 11):.1f}%",
}
)
return pd.DataFrame(rows)
# ── 内部辅助 ─────────────────────────────────────────────────────────────────
def _is_daily_category(category: Any) -> bool:
"""日线及以上周期 → True(复用 bars 路由的判定口径)。"""
from easy_tdx._df import _category_to_minutes
return _category_to_minutes(int(category)) is None
def _bars_to_legacy_cols(df: pd.DataFrame, daily: bool) -> pd.DataFrame:
"""把内部 datetime OHLCV 规整为旧 /bars 契约(日线 date / 分钟 datetime)。"""
if df.empty:
return df
out = df.copy()
col = "date" if daily else "datetime"
if daily:
out["datetime"] = pd.to_datetime(out["datetime"]).dt.strftime("%Y-%m-%d")
else:
out["datetime"] = pd.to_datetime(out["datetime"]).dt.strftime("%Y-%m-%d %H:%M:%S")
return out.rename(columns={"datetime": col})
def is_e2e_mock_enabled() -> bool:
"""当前进程是否处于 E2E mock 模式。"""
import os
return os.environ.get(E2E_MOCK_ENV) == "1"
def log_mock_banner() -> None:
"""serve 启动时打一行显式提示(避免误把 mock 数据当真实行情)。"""
logger.warning(
"[E2E-MOCK] EASY_TDX_E2E_MOCK=1 — 行情接口返回合成数据(仅限 Playwright E2E 使用)"
)
+170
View File
@@ -0,0 +1,170 @@
"""E2E 合成数据源(web/e2e_mock.py)单元测试。
守护两件事:
1. mock 客户端与真实客户端的**契约**(方法签名可用、返回列覆盖前端字段);
2. 数据的**确定性与分页语义**(E2E 断言可复现的前提)。
另有一个 TestClient 端到端用例:EASY_TDX_E2E_MOCK=1 下 /bars、/market/stat、
/mac/quote-list 返回合成数据(不连真实服务器)。
"""
from __future__ import annotations
import pandas as pd
import pytest
from easy_tdx.models.enums import Market
from easy_tdx.web.e2e_mock import (
E2E_MOCK_ENV,
MockMacClient,
MockTdxClient,
_page_bars,
_synth_ohlcv,
)
pytest.importorskip("fastapi")
# ── 数据生成内核 ─────────────────────────────────────────────────────────────
def test_synth_ohlcv_deterministic() -> None:
"""同一 (market, code) 两次生成结果逐位一致(E2E 断言可复现的前提)。"""
a = _synth_ohlcv("SH", "600519", 300)
b = _synth_ohlcv("SH", "600519", 300)
pd.testing.assert_frame_equal(a, b)
assert list(a.columns) == ["datetime", "open", "high", "low", "close", "vol", "amount"]
def test_synth_ohlcv_differs_across_symbols() -> None:
"""不同标的应有不同行情(避免看板五指数全长得一样)。"""
a = _synth_ohlcv("SH", "000001", 100)["close"].iloc[-1]
b = _synth_ohlcv("SZ", "000001", 100)["close"].iloc[-1]
assert a != b
def test_page_bars_newest_first_page() -> None:
"""分页语义与真实 /bars 一致:start=0 取最新 count 根,页内升序。"""
df = _synth_ohlcv("SH", "600519", 1000)
page0 = _page_bars(df, 0, 800)
assert len(page0) == 800
assert page0["datetime"].iloc[-1] == df["datetime"].iloc[-1] # 最新一根在页尾
assert page0["datetime"].is_monotonic_increasing
page1 = _page_bars(df, 800, 800)
assert len(page1) == 200
assert page1["datetime"].iloc[-1] < page0["datetime"].iloc[0] # 更早一段
assert len(_page_bars(df, 5000, 800)) == 0 # 越界翻页返回空
# ── Mock 客户端契约 ──────────────────────────────────────────────────────────
async def test_mock_tdx_quotes_contract() -> None:
"""quotes dfmarket 列是 Market 枚举(QuoteStreamer._df_to_dicts 依赖)、
字段覆盖 SSE 白名单(前端行情表 + 看板指数卡)。"""
client = MockTdxClient()
df = await client.get_security_quotes([(Market.SH, "000001"), (Market.SZ, "000001")])
assert len(df) == 2
# pandas 会把 IntEnum 列统一为 int64(真实客户端同样如此);
# QuoteStreamer._df_to_dicts 依赖 IntEnum 哈希相等完成 int → "SH" 映射
assert df["market"].iloc[0] == Market.SH
from easy_tdx.web.quote_streamer import _MARKET_NAMES
assert _MARKET_NAMES.get(df["market"].iloc[0]) == "SH"
for col in ("price", "pre_close", "open", "high", "low", "vol", "amount", "bid1", "ask_vol5"):
assert col in df.columns
# 指数与个股同名代码(SH000001 上证指数 / SZ000001 平安银行)行情不同
assert df["price"].iloc[0] != df["price"].iloc[1]
async def test_mock_tdx_bars_daily_date_column() -> None:
"""日线返回 date 列(旧 /bars 契约),分页 start/count 生效。"""
client = MockTdxClient()
df = await client.get_security_bars(Market.SH, "600519", 4, 0, 50) # 4 = DAY
assert "date" in df.columns and "datetime" not in df.columns
assert len(df) == 50
assert df["date"].is_monotonic_increasing
async def test_mock_tdx_minute_and_stat() -> None:
"""分时 240 点 + 市场统计单行(看板两块数据源)。"""
client = MockTdxClient()
minute = await client.get_minute_time_data(Market.SH, "000001")
assert len(minute) == 240
assert {"datetime", "price", "vol"} <= set(minute.columns)
stat = await client.get_market_stat()
assert len(stat) == 1
for col in ("up_count", "down_count", "total_count", "total_amount"):
assert col in stat.columns
async def test_mock_mac_kline_contract() -> None:
"""MAC get_stock_klinedatetime 列 + float_sharesbars 路由规整依赖)。"""
client = MockMacClient()
df = await client.get_stock_kline(1, "600519", 4, 0, 30, 1, adjust=None)
assert "datetime" in df.columns
assert "float_shares" in df.columns
assert len(df) == 30
async def test_mock_mac_quote_list_sort_and_columns() -> None:
"""排行行情:涨跌幅排序生效、列覆盖前端 RankRow 渲染需求。"""
client = MockMacClient()
desc = await client.get_stock_quotes_list(count=20, sort_order="DESC")
asc = await client.get_stock_quotes_list(count=20, sort_order="ASC")
assert len(desc) == 20
for col in ("market", "code", "name", "close", "pre_close", "amount"):
assert col in desc.columns
d_pct = desc["close"] / desc["pre_close"]
a_pct = asc["close"] / asc["pre_close"]
assert d_pct.iloc[0] >= d_pct.iloc[-1]
assert a_pct.iloc[0] <= a_pct.iloc[-1]
async def test_mock_mac_board_and_unusual() -> None:
"""板块列表/异动流:非空、字段齐(看板热度榜与异动雷达)。"""
client = MockMacClient()
boards = await client.get_board_list(count=500)
assert len(boards) > 0
assert {"code", "name", "price", "pre_close"} <= set(boards.columns)
unusual = await client.get_unusual(market=1, count=60)
assert 0 < len(unusual) <= 12
assert {"time", "code", "name", "desc", "value"} <= set(unusual.columns)
async def test_mock_close_is_noop() -> None:
"""lifespan 关闭路径调用 close() 不抛异常。"""
await MockTdxClient().close()
await MockMacClient().close()
# ── TestClient 端到端(mock 模式 lifespan)───────────────────────────────────
def test_app_serves_synthetic_data_in_mock_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""EASY_TDX_E2E_MOCK=1 时全应用 lifespan 用合成客户端,行情端点 200。"""
from fastapi.testclient import TestClient
from easy_tdx.web import create_app
monkeypatch.setenv(E2E_MOCK_ENV, "1")
app = create_app()
with TestClient(app) as client:
bars = client.get(
"/api/v1/bars",
params={"market": "SH", "code": "600519", "category": "DAY", "count": 30},
)
assert bars.status_code == 200
assert len(bars.json()["data"]) == 30
stat = client.get("/api/v1/market/stat")
assert stat.status_code == 200
assert stat.json()["data"][0]["up_count"] > 0
rank = client.get("/api/v1/mac/quote-list", params={"count": 10})
assert rank.status_code == 200
assert len(rank.json()["data"]) == 10
+57
View File
@@ -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` | 保存策略对话框 + 策略库页可见 |
+54
View File
@@ -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()
})
+23
View File
@@ -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()
})
+25
View File
@@ -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 })
})
+24
View File
@@ -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()
})
+64
View File
@@ -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
View File
@@ -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",
+70
View File
@@ -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 / 系统 pythonCI 态 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,
},
},
})