提交 426c01cd authored 作者: 陈泽健's avatar 陈泽健

perf(executor): 执行耗时优化 P0/P1/P2(重试收紧+超时收紧+快速失败)

10 条 UI 用例(含 7 条失败)执行耗时 24.8 分钟,根因 step_retry_count=2 ×
多策略超时叠加(单条失败用例 ~210s)。三层优化:

- P0: step_retry_count 默认 2→1(execution_service + playwright_executor + recorder)
- P1: has-text 前置等待 25s→10s、表格行轮询 150s→30s、checkbox 轮询 40s→20s、
      iframe 点击 5s→3s
- P2: _selector_exists_fast 快速失败探测(主页面/iframe/micro-app ≤500ms),
      _do_click/_do_fill/_do_check 入口元素不在 DOM 即跳过策略链

单测 9 项 + 全量 352 passed 无回归;deploy_timeout_opt.py 部署三台;
5.44 同一 10 用例定时任务实测 24.8min→10.2min(-59%),通过率 30% 无回归
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 43c86992
# PRD 需求文档 — 执行耗时优化
> **版本**: 1.0
> **日期**: 2026-08-31
> **模块**: 执行中心 / Playwright 执行器
> **状态**: 待实现
---
## 一、问题背景
### 1.1 现象
2026-08-31 在 5.44 生产环境创建了一个**小模块定时任务验证**(10 条 UI 用例),实际执行耗时 **24.8 分钟(1485s)**,通过率仅 30%(3 通过 / 7 失败)。
用户反馈:"10条用例执行要25分钟也太久了"。
### 1.2 时间分解
单条失败用例(以"系统设置-页面访问验证"为例)耗时 **210 秒**,主要浪费在「点击一个选不中的元素」上:
| 环节 | 耗时 | 说明 |
|------|------|------|
| has-text 等待文本渲染 | 25s | `has-text("系统设置")` 前置等待 |
| `_wait_for_element` 主页+iframe | 10s + N×5s | 遍历所有 iframe |
| 主页普通 click | 5s | 超时 |
| force click | 5s | 超时 |
| iframe click × N | N×5s | 每个 iframe 各 5s |
| micro-app click | 5s | 超时 |
| JS click | 5s | 元素不存在直接跳过 |
| 加载遮罩等待 | 10s | 每次操作后等 |
| **单次尝试小计** | **~65-90s** | |
| ×3 重试(`step_retry_count=2`) | **~200-270s** | = 210s 来源 |
### 1.3 根因
1. **重试次数放大**`step_retry_count=2`(共 3 次尝试),选不中的元素每次重试都完整跑一遍全部点击策略。
2. **超时过宽**`has-text` 前置等待 25s、iframe click 5s、表格行轮询上限 150s。
3. **无快速失败**:元素根本不在 DOM 时,仍逐个策略跑满全部超时。
---
## 二、优化目标
| 指标 | 现状 | 目标 |
|------|------|------|
| 10 条用例总耗时 | 25 分钟(1485s) | **10-12 分钟** |
| 单条失败用例耗时 | ~210s | **~70-90s** |
| 通过率 | 30% | 不下降(甚至提升,重试收益低) |
---
## 三、优化方案(3 个层级)
### P0:重试次数收紧
- **改动**`step_retry_count` 默认值 2 → 1(共 2 次尝试)。
- **理由**:选不中的元素,重试后仍选不中(选择器本身过时),第 3 次尝试纯属浪费。
- **预期**:失败步骤耗时 ~30% 提升。
- **风险**:低。仅影响"首次失败但重试成功"的场景(占比极低,因为同一步骤同选择器)。
### P1:超时收紧
| 参数 | 现值 | 改后 | 位置 |
|------|------|------|------|
| has-text 前置等待 | 25s | 10s | `_do_click` 策略5 前 |
| iframe click 超时 | 5s | 3s | `_do_click` / `_do_fill` 等 |
| 表格行轮询上限 | 150s | 30s | `_do_click` 策略5 |
| 复选框勾选轮询 | 40s | 20s | `_do_click` 策略5 |
- **理由**:这些超时是为「异步慢加载表格」设计的兜底,普通菜单/按钮场景 90% 不需要。收紧后慢加载场景仍可命中(10s/30s 仍足够)。
- **预期**:失败步骤耗时 ~20% 提升。
- **风险**:低。极端慢加载(>10s)的表格行可能偶发误判,但不影响成功率(超时后走下一策略)。
### P2:快速失败探测
- **改动**`_do_click` / `_do_fill` 等入口处,先 `page.query_selector` 快速探测(100ms),若主页面 + iframe + micro-app 均无此元素,直接跳过该选择器(走下一个选择器或报错),不再逐个策略跑满超时。
- **理由**:选择器过时 = 元素不在 DOM,探测 100ms 即知,无需等待 65s。
- **预期**:失败步骤耗时 ~15% 提升。
- **风险**:中。micro-app shadow DOM 场景元素可能探测不到但实际存在,需保留完整回退路径(探测失败仍走原策略,不直接报错)。
---
## 四、验收标准
1. **耗时**:10 条用例(含 7 条失败)总耗时 ≤ 12 分钟(本地验证以单条失败用例为准,目标 ≤ 90s)。
2. **通过率**:不下降(本地对比优化前后同一用例集)。
3. **无回归**:既有 `tests/` 全量测试通过;新增超时/重试参数相关单测。
4. **兼容**`config` 传参仍可覆盖(`step_retry_count``timeout` 可外部指定),默认值变更不影响显式传参。
---
## 五、测试计划
- 单测:新增 `tests/test_executor_timeout_optimization.py`,覆盖:
- `step_retry_count` 默认值 = 1
- has-text 前置等待使用新超时
- 表格行轮询上限 30s
- 快速失败探测:元素不存在时跳过策略
- 全量回归:`pytest tests/ -v`(现有 333+ 用例不回归)。
- 部署验证(可选):部署到 5.44,重跑小模块定时任务,对比耗时。
---
## 六、风险评估
| 风险 | 影响 | 缓解 |
|------|------|------|
| 慢加载表格 >10s 误判 | 单步失败 | 仍走后续策略 + micro-app 回退 |
| 重试减少导致偶发失败 | 通过率 -1~2% | 观察,若下降则回退 P0 |
| 部署后线上行为变化 | 执行中心 500 | 先本地全量测试再部署 |
---
## 七、后续工作
- 部署三台服务器(5.44 / 5.202 / 5.60),走 `deploy_watchdog.py` 安全流程。
- 观察定时任务执行耗时,验证目标达成。
- 若通过率因 P0 下降,回退 `step_retry_count=2` 并只保留 P1+P2。
# 执行计划 — 执行耗时优化
> **文档类型**: 执行计划文档
> **来源文档**: `_PRD_需求文档_执行耗时优化.md`
> **创建日期**: 2026-08-31
> **作者**: czj
> **预计工期**: 0.5 天
> **实施状态**: 🔄 进行中
---
## 一、执行概述
### 1.1 目标
优化 Playwright 执行器,将 10 条 UI 用例(含失败场景)执行耗时从 25 分钟降至 10-12 分钟。
### 1.2 改动范围
| 层级 | 文件 | 改动类型 |
|------|------|----------|
| 后端 | `backend/app/executors/playwright_executor.py` | 修改 |
| 后端 | `backend/tests/test_executor_timeout_optimization.py` | 新增 |
### 1.3 依赖项
- Playwright 同步 API(已固化,不改架构)
- `config` 参数传递机制(`step_retry_count``timeout` 可外部覆盖)
---
## 二、执行步骤
### Phase 1: P0 — 重试次数收紧(预计 5 分钟)
#### Step 1.1: `step_retry_count` 默认值 2 → 1
**文件**: `backend/app/executors/playwright_executor.py`
**行号**: 约第 178 行
```python
# 改动前
self.step_retry_count: int = self.config.get("step_retry_count", 2)
# 改动后
self.step_retry_count: int = self.config.get("step_retry_count", 1)
```
**说明**: 默认重试次数从 2 改为 1(共 2 次尝试),失败步骤仍可重试一次但不再 3 次浪费。外部传参可覆盖。
---
### Phase 2: P1 — 超时收紧(预计 10 分钟)
#### Step 2.1: has-text 前置等待 25s → 10s
**文件**: `backend/app/executors/playwright_executor.py`
**行号**: 约第 2319-2330 行
```python
# 改动前
_tm = re.search(r'has-text\("([^"]+)"\)', selector)
if _tm:
self._page.wait_for_selector(f':has-text("{_tm.group(1)}")', timeout=25000)
# 改动后
_tm = re.search(r'has-text\("([^"]+)"\)', selector)
if _tm:
self._page.wait_for_selector(f':has-text("{_tm.group(1)}")', timeout=10000)
```
#### Step 2.2: 表格行轮询上限 150s → 30s
**文件**: `backend/app/executors/playwright_executor.py`
**行号**: 约第 2382 行
```python
# 改动前
while time.time() - row_wait_start < 150:
...
# 改动后
while time.time() - row_wait_start < 30:
...
```
同步更新第 2392 行日志文案中的 `150s``30s`
#### Step 2.3: 复选框勾选轮询 40s → 20s
**文件**: `backend/app/executors/playwright_executor.py`
**行号**: 约第 2396 行
```python
# 改动前
cb_deadline = time.time() + 40
# 改动后
cb_deadline = time.time() + 20
```
同步更新第 2424 行日志文案中的 `40s``20s`
---
### Phase 3: P2 — 快速失败探测(预计 20 分钟)
#### Step 3.1: `_do_click` 快速失败
**文件**: `backend/app/executors/playwright_executor.py`
**行号**: `_do_click` 方法开头(约第 2230 行后,selectors 解析之后)
**改动**: 在 `_resolve_selectors` 循环开始前,对每个选择器快速探测,若主页面 + 所有 iframe + micro-app 均不存在,跳过该选择器(走下一个或报错),不进入完整策略链。
```python
# === 快速失败探测 ===
def _selector_exists(selector: str) -> bool:
"""快速探测选择器是否存在于页面(100ms 超时)"""
try:
if self._page.query_selector(selector):
return True
except Exception:
pass
for frame in self._page.frames:
if frame == self._page.main_frame:
continue
try:
if frame.query_selector(selector):
return True
except Exception:
continue
# micro-app 探测(保留回退路径)
if self._do_in_micro_apps('exists', selector, timeout=500):
return True
return False
```
`for selector in selectors:` 循环内部,语义定位器/iframe 之前插入:
```python
# 快速失败:元素不在 DOM 则跳过此选择器(保留 micro-app 回退)
try:
if not _selector_exists(selector):
# micro-app shadow DOM 场景可能探测不到但实际存在
# 保守策略:仅跳过主页面和 iframe,micro-app 已在 _selector_exists 中探测
last_error = Exception(f"快速探测失败: 选择器不在 DOM 中: {selector}")
logger.debug(f"⚠ 快速探测: {selector} 不在 DOM,跳过")
continue
except Exception:
pass # 探测异常不阻塞,继续完整策略
```
#### Step 3.2: `_do_fill` / `_do_wait` / `_do_check` 同步快速失败
同样在 `_do_fill``_do_wait``_do_check` 方法的 selectors 循环入口处,插入快速探测逻辑(复用 `_selector_exists` 函数或独立探测)。
---
### Phase 4: 单测(预计 10 分钟)
#### Step 4.1: 新增测试文件
**文件**: `backend/tests/test_executor_timeout_optimization.py`
**测试用例**:
| # | 用例 | 验证点 |
|---|------|--------|
| 1 | `test_step_retry_count_default_is_1` | 默认 step_retry_count=1 |
| 2 | `test_step_retry_count_override` | 外部传参可覆盖 |
| 3 | `test_has_text_timeout_reduced` | has-text 等待使用 10s 超时 |
| 4 | `test_table_row_poll_timeout_30s` | 表格行轮询上限 30s |
| 5 | `test_checkbox_poll_timeout_20s` | 复选框勾选轮询 20s |
| 6 | `test_quick_fail_element_not_in_dom` | 元素不在 DOM 时跳过策略 |
| 7 | `test_quick_fail_still_falls_back_micro_app` | micro-app shadow DOM 回退保留 |
---
### Phase 5: 验证(预计 5 分钟)
#### Step 5.1: 全量测试
```bash
cd backend && pytest tests/ -v
```
确保现有 333+ 测试全部通过。
#### Step 5.2: 部署验证(可选)
部署到 5.44,重跑小模块定时任务,对比耗时。
```bash
python scripts/deploy_watchdog.py --server 5.44
```
---
## 三、验收标准
1. **耗时**: 单条失败用例 ≤ 90s(现 ~210s)
2. **全量测试**: `pytest tests/ -v` 通过
3. **配置兼容**: `step_retry_count``timeout` 外部传参仍可覆盖
4. **无回归**: 通过率不下降
---
## 四、风险评估
| 风险 | 影响 | 缓解 |
|------|------|------|
| P0 重试减少导致偶发通过率下降 | -1~2% | 观察,若下降回退 P0 保留 P1+P2 |
| P1 has-text 10s 不够慢加载场景 | 单步失败 | 仍走后续策略 + micro-app |
| P2 探测 shadow DOM 不准 | micro-app 场景误跳过 | 探测失败仍走完整策略链 |
| 部署后线上行为变化 | 执行中心 500 | 先本地全量测试再部署 |
---
## 五、实施记录
| 时间 | 步骤 | 状态 |
|------|------|------|
| 2026-08-31 | Phase 1-4 代码实现 | 🔄 |
| 2026-08-31 | Phase 5 全量测试 | ⏳ |
| 2026-08-31 | 部署三台服务器 | ⏳ |
---
## 六、后续工作
- 部署三台服务器(5.44 / 5.202 / 5.60),走安全部署流程。
- 观察定时任务执行耗时,验证目标达成。
- 若通过率因 P0 下降,回退 `step_retry_count=2` 并只保留 P1+P2。
# HANDOFF — UI自动化测试交接文档
> **生成时间**: 2026-08-29
> **生成时间**: 2026-08-31
> **当前分支**: `platform-auto-test`
> **最近提交**: `72db5e2e` fix(scheduler): 新增僵尸执行常驻看门狗,杜绝执行线程死亡卡死调度(已推送 `origin/platform-auto-test`)
> **状态**: 🟢 **会话44:僵尸执行常驻看门狗上线——5.44 复现「DB 卡 running 29 小时挡死调度」(进程存活、线程死亡,启动恢复无法覆盖),新增 `watchdog_loop` 常驻协程(60s 扫描/30 分钟无进展判定/双信号活性:内存心跳 + DB 结果数增长),部署 5.202/5.60 完成(顺带清理两台 8/27 遗留僵尸),5.44 等当日回归跑完后自动部署(后台长轮询挂起)。会话 43 的 P1/P2 部署待办同时闭环。**
> **最近提交**: `13b4f1b6` perf(executor): 执行耗时优化 P0/P1/P2(重试收紧+超时收紧+快速失败)
> **状态**: 🟢 **会话46:执行耗时优化(24.8min→10.2min,-59%)实现 + 三台部署完成 + 定时任务实测**
---
## 📊 当前状态(会话 46,2026-08-31)
**执行耗时优化(P0/P1/P2)**:10 条 UI 用例(含 7 条失败)执行耗时 24.8 分钟,根因是 `step_retry_count=2` × 多策略超时叠加(单条失败用例 ~210s)。输出 PRD/计划文档后实现三层优化,本地全量测试 352 通过,部署三台服务器;用 5.44 同一 10 用例定时任务实测 **24.8min → 10.2min(-59%)**,达成 ≤12min 目标,通过率 30% 无回归。
### ✅ 优化内容(会话 46,2026-08-31)
| 层 | 内容 | 改动文件 |
|----|------|---------|
| **P0 重试收紧** | `step_retry_count` 默认 2→1(共 2 次尝试,兼顾效率与偶发重试收益) | `execution_service.py` + `playwright_executor.py` + `recorder.py` |
| **P1 超时收紧** | has-text 前置等待 25s→10s;表格行轮询 150s→30s;checkbox 轮询 40s→20s;iframe 点击 5s→3s | `playwright_executor.py` |
| **P2 快速失败** | `_selector_exists_fast()` 探测(主页面/iframe/micro-app 三段,≤500ms);`_do_click`/`_do_fill`/`_do_check` 入口探测到元素不在 DOM 即跳过策略链,失败场景 100ms 即知 | `playwright_executor.py` |
**测试**`tests/test_executor_timeout_optimization.py` 9 项(P0 默认值=1/可覆盖;P2 不在 DOM 跳过策略/存在走完整链/micro-app 回退保留;P1 超时常量源码断言防回归)。**全量 352 passed,无回归**
### ✅ 部署结果(会话 46,2026-08-31,`deploy_timeout_opt.py`)
部署制品:3 个后端文件(`playwright_executor.py` + `execution_service.py` + `recorder.py`
| 服务器 | 结果 | 部署前运行任务 |
|--------|------|---------------|
| 5.60 | ✅ 健康检查/AST/容器内 SHA/执行器验证全通过 | 无 |
| 5.44 | ✅ 同上 | 无 |
| 5.202 | ✅ 同上 | 无 |
### ✅ 生产实测(5.44,同一 10 用例定时任务「小模块定时任务验证」)
| 指标 | 优化前 | 优化后 | 变化 |
|------|--------|--------|------|
| 执行 ID | `exec_0d65e9d7`(会话 44 前) | `exec_92c4b6af996b4a2ab6fc8ee4ecf1cfbe` | - |
| 总耗时 | 1485s(24.8min) | **614.36s(10.2min)** | **-59%** |
| 通过率 | 3 pass / 7 fail | 3 pass / 7 fail(30%) | 无回归 |
**结果**:耗时减半以上,达成 PRD ≤12min 目标;7 条失败为过时选择器真实失败(PRD 已预判,非优化引入)。
**定时任务状态**:「小模块定时任务验证」已启用(`enabled=true`,下轮 2026-08-31 13:55),并手动触发一轮验证执行。
**遗留观察(风险预案)**:P0 把重试从 3 次尝试降到 2 次,若后续通过率下降,回滚 P0 保留 P1+P2(PRD 风险预案)。
---
## 📊 当前状态(会话 45,2026-08-31)
**截图定期清理 + 5.60 磁盘满紧急修复**:5.44 报告 `MySQL "No space left on device"` 导致执行中心返回 500(截图目录累积 8.6GB 占满 `/data` 分区)。用户手动清理后,本次实现截图定期清理循环并完成三台部署。部署过程中发现 5.60 因 `main.py` 残留 `projects` router 引用导致容器崩溃循环,已紧急回滚并重新部署。
### ✅ 修复内容(会话 45,2026-08-31)
| 项目 | 说明 |
|------|------|
| 制品 | `cleanup_service.py`(新增截图清理循环)+ `config.py``SCREENSHOT_RETENTION_DAYS` 配置)+ `main.py`(lifespan 启动/停止截图清理) |
| 清理参数 | 扫描间隔 3600s(1 小时);保留天数 `SCREENSHOT_RETENTION_DAYS=3`(环境变量可覆盖) |
| 清理逻辑 | 按文件修改时间过滤过期截图;只删文件不删子目录;异常不中断循环(下一轮继续);`db=None` 模式仅清理文件不动数据库 |
| 测试 | `tests/test_screenshot_cleanup.py` 10 项测试(过期文件删除 / dry_run / 空目录 / 不存在目录 / 子目录跳过 / 大小计算 / 启动返回 Task / 调用一次 / 日志记录 / 异常不终止) |
### ✅ 部署结果(会话 45,2026-08-31,`deploy_watchdog.py`)
**部署制品**:4 个后端文件(`execution_service.py` + `cleanup_service.py` + `main.py` + `config.py`
| 服务器 | 结果 | 附带清理 |
|--------|------|----------|
| 5.44 | ✅ 看门狗 + 截图清理已启动 | 启动恢复清掉 1 条僵尸执行(167 条用例结果标 failed) |
| 5.202 | ✅ 看门狗 + 截图清理已启动 | 调度正常恢复 |
| 5.60 | ✅ 看门狗 + 截图清理已启动 | 首轮清理删除 **24135** 个过期截图,释放 **8.77GB** |
**5.60 事故处理**
1. 首次部署 `main.py` 时因本地工作树残留 `projects` router 引用(未提交),容器因 `ImportError: cannot import name 'projects'` 崩溃循环
2. 紧急从备份 `.bak-1788141147` 恢复 `main.py`,容器恢复正常
3. 从本地 `main.py` 移除 `projects` import + router 注册(仅保留截图清理改动)
4. 重新部署 4 个制品,全部通过(健康检查 + AST + SHA-256 一致)
**定时任务状态**:三台容器重启后调度引擎自动恢复,到期任务已自动触发补跑;`auto_report=True` 均已开启,执行完成后自动生成 HTML 报告。
---
......@@ -414,6 +487,9 @@ button:has-text('确定')
| 会话 | 日期 | 主题 | 结果 |
|------|------|------|------|
| 46 | 08-31 | **执行耗时优化 P0/P1/P2**`step_retry_count` 默认 2→1;has-text 25s→10s / 表格行 150s→30s / checkbox 40s→20s / iframe 5s→3s;新增 `_selector_exists_fast` 快速失败探测;9 项单测 + 全量 352 通过;`deploy_timeout_opt.py` 部署三台;5.44 同一 10 用例定时任务 24.8min→10.2min(-59%),通过率 30% 无回归 | ✅ 三台部署 + 生产实测达标 |
| 45 | 08-31 | **截图定期清理 + 5.60 磁盘满修复**`cleanup_service.py` 新增 `cleanup_screenshots_loop`(3600s 间隔/3 天保留)+ `config.py` 新增 `SCREENSHOT_RETENTION_DAYS` + `main.py` lifespan 启动/停止;10 项单元测试;5.60 因 `projects` router 引用崩溃→回滚→移除→重部署;三台部署完成(5.60 首轮清理释放 8.77GB) | ✅ 三台 6/6 验证通过 |
| 44 | 08-29 | **僵尸执行常驻看门狗**:新增 `watchdog_loop`(60s 扫描/30 分钟无进展判僵尸/双信号活性判定:内存心跳 + DB 结果增长)+ `deploy_watchdog.py`;5.202/5.60 部署完成(清理 8/27 僵尸) | ✅ 看门狗已启动 |
| 43 | 08-27 | **薄弱模块 R7 执行器修复部署三台**`playwright_executor.py`(commit `a63d090c`)按安全流程部署 5.44/5.202/5.60,三台容器内 SHA 一致、健康检查通过;`deploy_executor_r7.py` 新增 `--artifact` 参数;会话 40 用例数据 + 本次代码修复完整生效,远端薄弱模块 NO-BODY 解除 | ✅ 三台 6/6 验证全通过 |
| 42 | 08-27 | **定时任务僵尸执行 + 重叠守卫修复并部署三台**:新增 `recover_interrupted_executions()` 启动恢复 + DB 级重叠兜底守卫 + lifespan 调用;清理三台 running 僵尸记录(5.44=18/5.202=14/5.60=68 条) | ✅ 全量测试 324 passed、三台部署验证通过 |
| 41 | 08-26 | **预定数据用例覆盖盲区补齐**:生成 6 个 navigate 直达用例(页面访问/图表数量/筛选切换/时间筛选/满意度/通知统计)至 `module_parent_yuding20`;沉淀 `generate_yuding_cases.py`(幂等);确认预定数据入口=数据统计页。**已同步三台**(5.44/5.202 393→399、5.60 394→400) | ✅ 本地 6/6 R1 一次通过;三端已同步 |
......@@ -504,6 +580,7 @@ cd frontend && npm run build
| 主 PRD | `Docs/PRD/需求文档/总览/_PRD_平台自动化测试可视化系统需求文档.md` |
| 语义化用例执行 PRD/计划 | `Docs/PRD/需求文档/用例管理/_PRD_复杂用例通用执行机制_语义化用例执行.md` |
| 定时任务模块 PRD/计划 | `Docs/PRD/需求文档/定时任务/_PRD_需求文档_UI自动化定时任务模块与系统配置被测系统URL.md` |
| 执行耗时优化 PRD/计划 | `Docs/PRD/需求文档/执行中心/_PRD_需求文档_执行耗时优化.md` / `_执行计划_执行耗时优化.md` |
| P1 遗留待办 PRD/计划 | `Docs/PRD/需求文档/用例管理/_PRD_需求文档_P1遗留_验证步骤前端适配与定位器优化.md` |
| 新建会议问题处理 | `Docs/PRD/问题处理/会议管理/_问题处理_新建会议用例执行失败_页面直达后会议室表格checkbox不可见.md` |
| HANDOFF 总交接 | `HANDOFF.md` |
......@@ -516,7 +593,8 @@ cd frontend && npm run build
### P2(低优先级)
- [ ] **排查搜索确定按钮 88s 卡点**(会话 38 新增资产闭环 Step 16/25 各 88s,功能通过但拉长总时长 254.9s)
- [ ] **观察 5.44 定时任务通过率**(会话 46 P0 把重试 3→2 次尝试;若后续通过率下降,回滚 P0 保留 P1+P2)
- [ ] **排查搜索确定按钮 88s 卡点**(会话 38 新增资产闭环 Step 16/25 各 88s,功能通过但拉长总时长 254.9s;会话 46 超时收紧后应有改善,待复测)
- [ ] **预定2.0(meetingV2)独立页面探测与用例生成**(首页无独立直达入口,需从功能中心 Tab/`[data-id]` 入口探测)
- [x] ~~**定时任务僵尸执行 + 重叠守卫**~~(会话 42 已修复并部署三台:`recover_interrupted_executions()` 启动恢复 + DB 级重叠兜底守卫 + lifespan 调用;清理 5.44/5.202/5.60 的 18/14/68 条 running 僵尸,对应 2606/1814/401 用例结果标 failed;324 测试全绿)
- [x] ~~**薄弱模块 R7 修复部署三台服务器(5.44/5.202/5.60)**~~(会话 43 完成:`playwright_executor.py` commit `a63d090c` 按安全流程部署三台,容器内 SHA 一致、健康检查通过;会话 40 用例数据 + 本次代码修复完整生效,远端薄弱模块 NO-BODY 解除。`deploy_executor_r7.py` 新增 `--artifact` 参数支持指定部署制品)
......@@ -536,4 +614,4 @@ cd frontend && npm run build
---
*本文档由 Claude Code 于 2026-08-27 更新(会话 43:薄弱模块 R7 执行器修复正式部署三台——`playwright_executor.py`(commit `a63d090c`)按安全流程(备份→上传→SHA-256→原子替换→docker restart→健康检查→容器内 AST/SHA 比对)部署 5.44/5.202/5.60,三台 6/6 验证全通过、容器内 SHA 均一致;`deploy_executor_r7.py` 新增 `--artifact` 参数;至此「执行器代码修复未部署」遗留闭环,远端薄弱模块 NO-BODY 解除,P1/P2 执行器代码仍为本地待部署)。*
\ No newline at end of file
*本文档由 Claude Code 于 2026-08-31 更新(会话 46:执行耗时优化 P0/P1/P2——`step_retry_count` 默认 2→1 + has-text/表格行/checkbox/iframe 超时收紧 + `_selector_exists_fast` 快速失败探测;9 项单测 + 全量 352 通过;`deploy_timeout_opt.py` 部署 5.44/5.202/5.60;5.44 同一 10 用例定时任务 24.8min→10.2min(-59%),通过率 30% 无回归;「小模块定时任务验证」已启用)。*
\ No newline at end of file
......@@ -175,7 +175,7 @@ class PlaywrightExecutor:
self.retry_count: int = self.config.get("retry", 0)
self.ignore_https_errors: bool = self.config.get("ignore_https_errors", True)
self.auto_login: bool = self.config.get("auto_login", False)
self.step_retry_count: int = self.config.get("step_retry_count", 2)
self.step_retry_count: int = self.config.get("step_retry_count", 1)
# SelectorMapper 实例(用于 page_key + element_key 解析)
self._selector_mapper: SelectorMapper = SelectorMapper()
......@@ -873,6 +873,45 @@ class PlaywrightExecutor:
logger.debug(f"⚠ micro-app 内未找到/操作失败: {selector} (action={action})")
return False if action != 'text' else None
def _selector_exists_fast(self, selector: str) -> bool:
"""
快速探测选择器是否存在于主页面 / iframe / micro-app(≤500ms)
用于 P2 快速失败:元素根本不在 DOM 时跳过完整策略链,避免逐个策略跑满超时。
micro-app 使用 500ms 轮询(内部 JS evaluate 穿透 shadow DOM)。
Args:
selector (str): CSS/XPath 选择器
Returns:
bool: 是否存在
"""
# 1. 主页面 query_selector(~0ms,纯 DOM 查询)
try:
if self._page.query_selector(selector):
return True
except Exception:
pass
# 2. 遍历 iframe query_selector(每个 ~0ms)
for frame in self._page.frames:
if frame == self._page.main_frame:
continue
try:
if frame.query_selector(selector):
return True
except Exception:
continue
# 3. micro-app shadow DOM(使用 evaluate 穿透,500ms 轮询)
try:
if self._do_in_micro_apps('exists', selector, timeout=500):
return True
except Exception:
pass
return False
def _wait_for_element(
self,
selector: str,
......@@ -2315,15 +2354,26 @@ class PlaywrightExecutor:
# 语义定位器失败,继续尝试下一个选择器
continue
# ==================== P2 快速失败探测 ====================
# 元素不在 DOM 时跳过此选择器(micro-app shadow DOM 已在探测中覆盖)
# 避免逐个策略跑满 5s×N 超时(~65s),失败场景 100ms 即知
try:
if not self._selector_exists_fast(selector):
last_error = Exception(f"快速探测失败: 选择器不在 DOM 中: {selector}")
logger.debug(f"⚠ P2 快速探测: {selector} 不在 DOM,跳过")
continue
except Exception:
pass # 探测异常不阻塞,继续完整策略链
# ==================== CSS/XPath 选择器 ====================
# ⚠️ 表格异步加载兜底:点击含 :has-text("X") 的选择器前,先等目标文本 X 出现(25s)。
# ⚠️ 表格异步加载兜底:点击含 :has-text("X") 的选择器前,先等目标文本 X 出现(10s)。
# 目标系统(192.168.5.44)列表/会议室表格异步加载极慢且不稳,直接 page.click 会因
# "目标行暂不存在"失败(智能定位已验证:先等 has-text 文本渲染再点击可稳定命中)。
# 对普通按钮(如 button:has-text("登录")),文本本就存在,等待立即返回,无额外开销。
try:
_tm = re.search(r'has-text\("([^"]+)"\)', selector)
if _tm:
self._page.wait_for_selector(f':has-text("{_tm.group(1)}")', timeout=25000)
self._page.wait_for_selector(f':has-text("{_tm.group(1)}")', timeout=10000)
self._page.wait_for_timeout(300)
logger.debug(f"✓ 目标文本已渲染: {_tm.group(1)}")
except Exception:
......@@ -2363,10 +2413,10 @@ class PlaywrightExecutor:
checkbox_sel = f'.el-table__row:has-text("{target_text}") .el-checkbox'
row_sel = f'.el-table__row:has-text("{target_text}")'
# 1. 轮询等待表格行渲染(覆盖 SPA 表格懒加载 12s-130s+ 的波动;state=attached 覆盖 is-hidden)
# 之前单次 30s 等待在慢加载场景不足导致失败,改为每 3s 探测一次、上限 150s
# 1. 轮询等待表格行渲染(覆盖 SPA 表格懒加载波动;state=attached 覆盖 is-hidden)
# 每 3s 探测一次、上限 30s(优化后收紧,兼顾效率与慢加载场景)
# 1.1 非表格场景快速路径:目标选择器本身(菜单/按钮/链接)可直接点击时,
# 直接 force 点击返回,避免误入表格行轮询(150s)白等。checkbox 选择器
# 直接 force 点击返回,避免误入表格行轮询(30s)白等。checkbox 选择器
# 除外——隐藏 checkbox force 点击不可靠,保留原表格行回退逻辑。
if 'checkbox' not in selector.lower():
try:
......@@ -2379,7 +2429,7 @@ class PlaywrightExecutor:
pass
row_found = False
row_wait_start = time.time()
while time.time() - row_wait_start < 150:
while time.time() - row_wait_start < 30:
try:
if len(self._page.query_selector_all(row_sel)) > 0:
row_found = True
......@@ -2389,11 +2439,11 @@ class PlaywrightExecutor:
last_error = e5
self._page.wait_for_timeout(3000)
if not row_found:
logger.debug(f"⚠ 表格行未在 150s 内渲染 {row_sel}: {last_error}")
logger.debug(f"⚠ 表格行未在 30s 内渲染 {row_sel}: {last_error}")
# 2. 轮询勾选目标行 checkbox(force 绕过 is-hidden;每次校验 is-checked 防假通过)
if row_found:
cb_deadline = time.time() + 40
cb_deadline = time.time() + 20
checked_ok = False
while time.time() < cb_deadline:
try:
......@@ -2421,7 +2471,7 @@ class PlaywrightExecutor:
if checked_ok:
self._wait_for_loading_overlay()
return
logger.debug(f"⚠ checkbox 40s 内未勾选成功(假通过风险),继续后续策略: {checkbox_sel}")
logger.debug(f"⚠ checkbox 20s 内未勾选成功(假通过风险),继续后续策略: {checkbox_sel}")
# 3. 行点击末位兜底(部分表格仅行点击可选中;保持原逻辑避免回归)
for row_sel_candidate in (row_sel, f'tr:has-text("{target_text}")'):
......@@ -2530,8 +2580,8 @@ class PlaywrightExecutor:
if frame == self._page.main_frame:
continue
try:
frame.wait_for_selector(selector, state="visible", timeout=5000)
frame.click(selector, timeout=5000, force=True)
frame.wait_for_selector(selector, state="visible", timeout=3000)
frame.click(selector, timeout=3000, force=True)
logger.debug(f"✓ iframe 中点击成功: {selector}")
self._wait_for_loading_overlay()
return
......@@ -2661,6 +2711,16 @@ class PlaywrightExecutor:
# 语义定位器失败,继续尝试下一个选择器
continue
# ==================== P2 快速失败探测 ====================
# 元素不在 DOM 时跳过此选择器(micro-app shadow DOM 已在探测中覆盖)
try:
if not self._selector_exists_fast(selector):
last_error = Exception(f"快速探测失败: 选择器不在 DOM 中: {selector}")
logger.debug(f"⚠ P2 快速探测: {selector} 不在 DOM,跳过")
continue
except Exception:
pass # 探测异常不阻塞,继续完整策略链
# ==================== CSS/XPath 选择器 ====================
# 先在主页面尝试
try:
......@@ -2883,6 +2943,15 @@ class PlaywrightExecutor:
last_error: Optional[Exception] = None
for selector in selectors:
# ==================== P2 快速失败探测 ====================
try:
if not self._selector_exists_fast(selector):
last_error = Exception(f"快速探测失败: 选择器不在 DOM 中: {selector}")
logger.debug(f"⚠ P2 快速探测: {selector} 不在 DOM,跳过")
continue
except Exception:
pass # 探测异常不阻塞,继续完整策略链
# 主页面 / iframe
try:
self._wait_for_element(selector, timeout=10000, state="visible")
......
......@@ -306,7 +306,7 @@ async def save_as_case(
"config": {
"timeout": 30000,
"screenshot": True,
"step_retry_count": 2,
"step_retry_count": 1,
},
}
......
......@@ -721,8 +721,8 @@ class ExecutionService:
executor_config = exec_config or {}
# auto_login 由用例自身配置决定,不强制覆盖
# 透传单步重试次数(默认 2
executor_config.setdefault("step_retry_count", exec_config.get("step_retry_count", 2) if exec_config else 2)
# 透传单步重试次数(默认 1,共 2 次尝试,兼顾效率与偶发重试收益
executor_config.setdefault("step_retry_count", exec_config.get("step_retry_count", 1) if exec_config else 1)
# 传入被测系统 base_url(从配置动态获取,确保切换目标环境后无需改代码)
executor_config.setdefault("base_url", settings.TARGET_URL)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_executor_timeout_optimization.py
模块描述:执行耗时优化单元测试(P0 重试 / P1 超时 / P2 快速失败)
覆盖:
- P0: step_retry_count 默认值 = 1,外部传参可覆盖
- P1: has-text 前置等待 10s / 表格行轮询 30s / 复选框勾选轮询 20s
- P2: 快速失败探测——元素不在 DOM 时跳过策略;micro-app 回退保留
作者:czj
创建日期:2026-08-31
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from app.executors.playwright_executor import PlaywrightExecutor
def make_executor(**overrides) -> PlaywrightExecutor:
"""构造执行器(默认空配置,可覆盖 _page / _selector_exists_fast 等)"""
ex = PlaywrightExecutor({})
for k, v in overrides.items():
setattr(ex, k, v)
return ex
class TestStepRetryCount:
"""P0: 重试次数收紧"""
def test_step_retry_count_default_is_1(self):
ex = make_executor()
assert ex.step_retry_count == 1
def test_step_retry_count_override(self):
ex = make_executor(step_retry_count=3)
assert ex.step_retry_count == 3
class TestP2QuickFail:
"""P2: 快速失败探测"""
def test_quick_fail_element_not_in_dom_skips_strategy(self):
"""元素不在 DOM 时,直接跳过选择器,不调用 _page.click"""
ex = make_executor()
ex._page = MagicMock()
ex._page.frames = [ex._page.main_frame] # 只有一个主 frame,快速跳过
ex._selector_exists_fast = MagicMock(return_value=False)
ex._wait_for_loading_overlay = MagicMock()
with pytest.raises(Exception, match="无法点击元素"):
ex._do_click({"selectors": [".not-exist-elem"]})
# 探测走了:应返回 False 且不触发任何点击策略
ex._selector_exists_fast.assert_called_once_with(".not-exist-elem")
ex._page.click.assert_not_called()
def test_quick_fail_present_element_enters_strategy(self):
"""元素存在时进入完整策略链(探测返回 True 后走正常点击)"""
ex = make_executor()
fake_el = MagicMock()
fake_el.scroll_into_view_if_needed = MagicMock()
mock_page = MagicMock()
mock_page.query_selector.return_value = fake_el
mock_frame = MagicMock()
mock_page.main_frame = mock_frame
mock_page.frames = [mock_frame]
ex._page = mock_page
ex._selector_exists_fast = MagicMock(return_value=True)
ex._wait_for_loading_overlay = MagicMock()
# 主页面普通点击成功 -> 直接 return
ex._do_click({"selector": ".present-elem"})
ex._selector_exists_fast.assert_called_once_with(".present-elem")
ex._page.click.assert_called()
def test_quick_fail_still_falls_back_micro_app(self):
"""micro-app shadow DOM:主页面/iframe 探测不到但 micro-app 存在时,不跳过"""
ex = make_executor()
# 模拟主页面/iframe 无元素,micro-app 探测成功
mock_page = MagicMock()
mock_page.query_selector.return_value = None # 主页面探测无结果
mock_frame = MagicMock()
mock_page.main_frame = mock_frame
mock_page.frames = [mock_frame] # 只有一个主 frame
ex._page = mock_page
ex._do_in_micro_apps = MagicMock(return_value=True)
result = ex._selector_exists_fast(".shadow-elem")
assert result is True
ex._do_in_micro_apps.assert_called_once_with("exists", ".shadow-elem", timeout=500)
class TestP1Timeouts:
"""P1: 超时收紧(通过源码断言,防止回归)"""
def test_has_text_timeout_reduced(self):
import inspect
src = inspect.getsource(PlaywrightExecutor._do_click)
assert "timeout=10000" in src
assert "timeout=25000" not in src
def test_table_row_poll_timeout_30s(self):
import inspect
src = inspect.getsource(PlaywrightExecutor._do_click)
assert "row_wait_start < 30" in src
assert "row_wait_start < 150" not in src
# 日志文案同步(仅检查非注释行,注释中可能保留历史说明)
code_lines = [l for l in src.split('\n') if l.strip() and not l.strip().startswith('#')]
code_text = '\n'.join(code_lines)
assert "150s" not in code_text
def test_checkbox_poll_timeout_20s(self):
import inspect
src = inspect.getsource(PlaywrightExecutor._do_click)
assert "cb_deadline = time.time() + 20" in src
assert "cb_deadline = time.time() + 40" not in src
def test_iframe_click_timeout_3s(self):
import inspect
src = inspect.getsource(PlaywrightExecutor._do_click)
# iframe 策略 timeout=3000
assert "frame.wait_for_selector(selector, state=\"visible\", timeout=3000)" in src
assert "frame.click(selector, timeout=3000, force=True)" in src
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论