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

fix(smart-locate): 新建会议用例 step 11/17 执行失败修复 + 生产验收通过

P0 修复「新建会议用例执行失败」(case_13650e0406e64779b66e6fa5de35c24a):

根因(两个独立缺陷,均 MCP/Playwright 实测确认):
1. step 11 会议室勾选失败:会议室表格懒加载慢(12s-130s+)+ 表格仅列出
   空闲会议室(被占用即从列表消失)+ checkbox 位于 is-hidden 隐藏列
2. step 17 结束会议失败:step 12 切「预定会议」→ 会议「未开始」→ 详情页
   无「结束会议」按钮

修复(保留页面直达,否决走点击链方案):
- 执行器策略5增强(playwright_executor.py):
  * 行等待改轮询(query_selector_all,最长 150s,覆盖表格懒加载波动)
  * checkbox 循环 force 勾选 + _checkbox_is_checked 校验(防假通过)+ JS 兜底
- 用例 step 12「预定会议」→「保持立即开始」+ 选择器修正为
  .time_btn > div:first-child(原 .time_btn > div:first-child .btns 零匹配)
  保持立即开始 → 会议进行中 → 详情页有结束会议

验收:本地连跑 3 次 18/18 全绿零残留;5.60 生产部署后连跑 3 次全绿,
MySQL case_results 三条 passed,容器内残留检查 0。

新增脚本:fix_step12.py(更新用例 step12)/ run_case_verify.py(触发+拉结果)/
check_residue.py(残留检查)/ deploy_fix_560.py(一键部署验收)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 9a777cdc
# 执行计划 - 修复新建会议用例执行失败(step 11 会议室勾选超时 / step 17 结束会议失败)
> **文档类型**: 计划执行文档
> **创建日期**: 2026-08-19
> **作者**: czj
> **优先级**: P0
> **状态**: ✅ 已完成,生产验收通过
> **关联文档**: `_问题处理_新建会议用例执行失败_页面直达后会议室表格checkbox不可见.md`
---
## 一、执行概述
「新建会议」用例(`case_13650e0406e64779b66e6fa5de35c24a`)智能定位通过 17/18 后执行仍失败,连续 3 次失败在 step 11 会议室勾选。根因定位后确认**两个独立缺陷**
1. **step 11**:会议室表格懒加载慢(12s-130s+)+ 表格仅列出空闲会议室(被占用即消失)+ checkbox 位于 `is-hidden` 隐藏列;
2. **step 17**:step 12 切「预定会议」→ 会议「未开始」→ 详情页无「结束会议」按钮。
**最终方案**:保留页面直达 + 增强执行器策略 5(轮询等待 + force 勾选 + 校验防假通过)+ 用例 step 12 改为保持「立即开始」。**否决**了"禁用页面直达走手动点击链"的方案(手动路径在会议室被占用时同样无该行可选,且更脆弱)。
---
## 二、任务分解与实施步骤(实际执行)
### Step 1:根因深挖(已完成)
- MCP 实测会议生命周期:立即开始→进行中→详情有结束会议;预定会议→未开始→无结束会议;
- MCP 实测房间可用性:结束占用会议后「北京展厅会议室」立即重新出现在列表第一行;
- 定位 step 12 选择器零匹配坑(`.time_btn > div:first-child .btns`)。
### Step 2:增强执行器策略 5(已完成)
**文件**`backend/app/executors/playwright_executor.py`
- 行等待:`wait_for_selector(row, attached, 30s)``while` 轮询 `query_selector_all`,最长 **150s**
- checkbox 点击:`force` 点击 → `_checkbox_is_checked()` 校验 → JS 兜底 → 每 2s 重试,最长 **40s**
- 保留策略 3(点击整行)兜底。
### Step 3:修复用例 step 12(已完成)
- 新脚本 `backend/scripts/fix_step12.py`,通过 `PUT /api/cases/{id}` 更新:
- name:`时间切换为:预定会议``时间保持为:立即开始`
- selector:`.time_btn > div:last-child .btns``.time_btn > div:first-child`(修正零匹配坑)
### Step 4:本地全量验证(已完成)
- `run_case_verify.py --case case_862b...` 连跑 3 次:**18/18 全绿**,step 7/8/9 SKIP(页面直达预期),残留 0。
### Step 5:部署 5.60 生产 + 验收(已完成)
- `deploy_fix_560.py`:scp 上传执行器 + 脚本 → 服务器 `fix_step12.py` 更新生产用例 → `docker restart plat-auto-test-app` → 连跑 3 次验收;
- 生产结果:MySQL `case_results` 三条 `passed`,每次 18 步全绿,容器内残留检查 0。
---
## 三、验收标准(对照)
| 测试项 | 预期结果 | 实际 |
|--------|----------|------|
| 执行「新建会议」用例 | step 7/8/9 跳过(页面直达)、step 10/11 通过 | ✅ 本地 + 生产均通过 |
| step 11 勾选北京展厅会议室 | 通过 | ✅ 通过 |
| 全 18 步 | 全部通过 | ✅ 本地 3 次 + 生产 3 次 |
| 连续执行 2-3 次 | 全绿、零残留 | ✅ 残留 0 |
---
## 四、风险与影响
| 风险 | 影响 | 缓解 |
|------|------|------|
| 会议室被占用时该房间不在列表 | step 11 需等待 150s 后仍失败 | 依赖「每次执行结束会议释放房间」+ 验收连跑确认;若人工占用了目标房间,需先释放 |
| 隐藏列 checkbox 点击假通过 | 勾选未生效但步骤显示 PASS | 策略 5 点击后 `_checkbox_is_checked()` 校验,防假通过 |
| 执行器轮询等待加长 | 极端情况单步骤最长 150s | 仅会议室表格场景触发;正常路径不受影响 |
---
*本文档由 Claude Code 生成,遵循项目计划执行文档规范。*
# 问题处理文档 - 新建会议用例执行失败(step 11 会议室勾选超时 / step 17 结束会议失败)
> **文档类型**: 问题处理文档
> **创建日期**: 2026-08-19
> **作者**: czj
> **优先级**: P0
> **状态**: ✅ 已修复并部署 5.60 生产,验收通过(连跑 3 次全绿 + 零残留)
> **受影响用例**: `case_13650e0406e64779b66e6fa5de35c24a`(新建会议 18 步)
---
## 一、问题描述
### 1.1 现象
对「新建会议」用例执行智能定位(smart-locate)验证达到 17/18 通过,再执行用例仍然失败,且连续多次(exec_2ca4e6c8、exec_c9da4587、exec_ed5f84b6)都失败在同一个步骤:
- **step 11「会议室选择:北京展厅会议室」**`.el-table__row:has-text("北京展厅会议室") .el-checkbox` 超时(5s),重试 2 次后仍失败,整个执行耗时 254s。
失败日志(exec_ed5f84b6c42049d8ba2e8b73e1715a9b):
```
[10] passed 会议名称输入:自动化新建会议 || ✓ 填充输入框: input[placeholder*="会议名称"] = 自动化新建会议
[11] failed 会议室选择:北京展厅会议室 || ✗ 无法点击元素(已尝试 1 个选择器):
['.el-table__row:has-text("北京展厅会议室") .el-checkbox'] 最后错误: Timeout 5000ms exceeded.
```
另确认 **step 17「点击底部的【结束会议】按钮」** 在旧用例下也必然失败(见 2.5)。
### 1.2 复现步骤
1. 用例管理 > 对 `case_13650e04...` 执行智能定位 → 保存(17/18 通过,selector 已回填)
2. 执行该用例
3. step 7/8/9(功能中心/会议预约/新建会议)显示「⏭ 页面直达,跳过中间导航步骤」
4. step 10 填会议名称成功,step 11 会议室勾选超时失败
---
## 二、根因分析
### 2.1 页面直达优化跳过了中间导航步骤
`playwright_executor.py` Phase 4(页面识别 + URL 直达):登录成功后识别到目标页 `create_meeting`,直接 `page.goto(url)``...#/meetingV3?...CreateMeeting`,并将 step 7/8/9 标记为 `skipped`(跳过中间导航点击)。此优化**予以保留**(走手动点击链的方案实测更脆弱,见「三、修复方案」决策说明)。
### 2.2 直达 URL 后表单 SPA 懒加载缓慢
| 方式 | 表格加载耗时 |
|------|------------|
| 手动点击链(功能中心→会议预约→新建) | ~2s |
| 直达 `goto(CreateMeeting URL)` | 12s-130s+ 不等(会议室表格懒加载,变量极大) |
step 10 填会议名称能成功,是因为 Playwright `fill`**自动等待元素出现**(最长 30s),撑过了懒加载期。
### 2.3 checkbox 位于隐藏列(is-hidden)
直达后页面 DOM 中北京展厅会议室行**存在**`<tr class="el-table__row">`),但其选择复选框在 Element UI 的**隐藏选择列**里:
```html
<td class="el-table_1_column_1 el-table-column--selection is-hidden el-table__cell">
<div class="cell"><label class="el-checkbox">
<span class="el-checkbox__input"><span class="el-checkbox__inner"></span>
<input type="checkbox" class="el-checkbox__original">
</label></div>
</td>
```
`is-hidden` 使该列 checkbox **不可见**。Playwright 默认 `click` 等待元素可见可操作,隐藏 checkbox 永不满足 → 超时。**修复方式是执行器强制点击(force)绕过可见性检查 + 点击后校验勾选态**(防假通过),而不是依赖默认可见性检查。
### 2.4(核心)会议室表格仅列出「空闲/可预约」会议室——被占用即从列表消失
**实测确认**:创建页会议室表格**排除了当前被会议占用/预约的会议室**。当有残留会议占用「北京展厅会议室」时,该行从可选列表**消失**,step 11 选择器 `:has-text("北京展厅会议室")` 永远无法命中。
- 结束/取消占用会议 → 房间立即重新出现在列表第一行。
- 这解释了为何验收标准要求「连跑 2-3 次零残留」:每次执行的 step 17 结束会议释放房间,供下一次执行选择。
### 2.5(step 17 根因)step 12 切「预定会议」→ 会议「未开始」→ 无「结束会议」按钮
**实测确认会议生命周期**
| 创建方式 | 会议状态 | 详情页操作按钮 |
|----------|----------|----------------|
| 立即开始(默认) | 进行中 | 延长会议 / **结束会议** |
| 预定会议 | 未开始 | 修改会议 / 取消会议 / 提前开始(**无结束会议**) |
预定会议**到点也不会自动开始**。旧用例 step 12 点击「预定会议」tab → 会议创建后为「未开始」→ 详情页没有「结束会议」→ **step 17 必然失败**
### 2.6(step 12 选择器坑)`.time_btn > div:first-child .btns` 零匹配
第一次改 step 12 为保持「立即开始」时用了 `.time_btn > div:first-child .btns`,实测超时。DOM 结构为:
```html
<div class="time_btn">
<div class="btns button_active">立即开始</div> <!-- first-child 就是 .btns 自身 -->
<div><div class="btns">预定会议</div></div> <!-- last-child 是包裹 div,内含 .btns 子孙 -->
</div>
```
`.time_btn > div:first-child .btns` 要求 first-child 的 `.btns` **子孙**,但 first-child 自身就是 `.btns`,无子孙 → **零匹配**。原 `.time_btn > div:last-child .btns` 能命中是因为 last-child 是包裹 div 且内含 `.btns`。修正为 `.time_btn > div:first-child`(直接命中「立即开始」tab div 自身)。
---
## 三、修复方案(已实施)
### 3.1 决策:保留页面直达 + 增强执行器(否决"禁用直达走点击链")
曾计划禁用 create_meeting 页面直达、改走手动点击链,但实测**手动路径在会议室被占用时同样无该行可选**,且点击链在会议列表/新建页切换时不稳定。最终采用:
1. **保留页面直达**`page_url_mapping.json` 不改回,语义化 scope 识别不受影响);
2. **增强执行器策略 5**`backend/app/executors/playwright_executor.py`):
- **行等待改轮询**`wait_for_selector(row, attached, 30s)``while` 轮询 `query_selector_all(row).length > 0`,最长 **150s**(覆盖会议室表格 12s-130s+ 的懒加载);
- **checkbox 点击改循环 + 校验**`force` 点击 → `_checkbox_is_checked()` 校验 → 失败再 `JS el.click()` 兜底 → 每 2s 重试,最长 **40s**
- 保留原策略 3(点击整行)兜底。
3. **修复 step 17**:用例 step 12 由「时间切换为:预定会议」改为「时间保持为:立即开始」,选择器 `.time_btn > div:last-child .btns``.time_btn > div:first-child`(修正零匹配坑)。保持立即开始 → 会议为「进行中」→ 详情页有「结束会议」→ step 17 可点。
### 3.2 改动文件清单
| 文件 | 改动 |
|------|------|
| `backend/app/executors/playwright_executor.py` | 策略 5 行等待轮询 + checkbox 循环 force 点击 + 校验防假通过 |
| `backend/scripts/fix_step12.py` | 新脚本:API 更新用例 step 12(保持立即开始 + 修正选择器) |
| `backend/scripts/run_case_verify.py` | 验证脚本(触发执行 + 拉结果) |
| `backend/scripts/check_residue.py` | 残留会议检查脚本 |
| `backend/scripts/deploy_fix_560.py` | 一键部署 5.60(scp + fix + 重启 + 验收) |
---
## 四、验收结果(已完成)
### 4.1 本地(backend,SQLite)
连跑 3 次 `run_case_verify.py --case case_862b...`**18/18 全绿**(含 step 11 勾选、step 17 结束会议),3 次 skipped 均为 7/8/9(页面直达预期跳过),残留会议 **0**
### 4.2 生产 5.60(容器,MySQL)
- 已部署执行器 + 用例修复,重启容器 `plat-auto-test-app`
- 目标用例 `case_13650e0406e64779b66e6fa5de35c24a` 连跑 3 次(exec_c8c35813、exec_6bdb170c、exec_c0f5fffc):
- MySQL `case_results` 三条均 `passed`
- 每次 18 步,step 7/8/9 SKIP(页面直达)、step 11/12/17/18 全部 PASS;
- 容器内残留检查:**残留会议「自动化新建会议」数量 0**
---
## 五、验收标准(对照)
| 测试项 | 预期结果 | 实际 |
|--------|----------|------|
| 执行「新建会议」用例 | step 7/8/9 跳过(页面直达)、step 10/11 通过 | ✅ 本地 + 生产均通过 |
| step 11 勾选北京展厅会议室 | 通过(懒加载轮询 + force 勾选) | ✅ 通过 |
| step 12 保持立即开始 | 通过 | ✅ 通过 |
| step 17 结束会议 | 通过(立即开始→进行中→有结束会议) | ✅ 通过 |
| 全 18 步 | 全部通过 | ✅ 本地 3 次 + 生产 3 次 |
| 连续执行 2-3 次 | 全绿、零残留 | ✅ 残留 0 |
---
*本文档由 Claude Code 生成,遵循项目问题处理文档规范。*
...@@ -13,6 +13,7 @@ import logging ...@@ -13,6 +13,7 @@ import logging
import os import os
import re import re
import asyncio import asyncio
import time
from typing import Optional, Callable, Dict, Any, List from typing import Optional, Callable, Dict, Any, List
from datetime import datetime from datetime import datetime
from dataclasses import dataclass, field from dataclasses import dataclass, field
...@@ -1475,6 +1476,17 @@ class PlaywrightExecutor: ...@@ -1475,6 +1476,17 @@ class PlaywrightExecutor:
except Exception: except Exception:
return False return False
def _checkbox_is_checked(self, el) -> bool:
"""判断 Element UI 复选框是否已勾选(.is-checked 类),用于勾选后校验防假通过"""
try:
return bool(self._page.evaluate(
"(el) => { const c = el.closest('.el-checkbox');"
" return c ? c.classList.contains('is-checked') : false; }",
el,
))
except Exception:
return False
def _do_click(self, params: dict, force: bool = False) -> None: def _do_click(self, params: dict, force: bool = False) -> None:
""" """
点击元素(多选择器回退 + 语义定位器 + 强制点击 + JS点击回退) 点击元素(多选择器回退 + 语义定位器 + 强制点击 + JS点击回退)
...@@ -1577,6 +1589,19 @@ class PlaywrightExecutor: ...@@ -1577,6 +1589,19 @@ class PlaywrightExecutor:
continue continue
# ==================== CSS/XPath 选择器 ==================== # ==================== CSS/XPath 选择器 ====================
# ⚠️ 表格异步加载兜底:点击含 :has-text("X") 的选择器前,先等目标文本 X 出现(25s)。
# 目标系统(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_timeout(300)
logger.debug(f"✓ 目标文本已渲染: {_tm.group(1)}")
except Exception:
pass
# 等待元素可见(宽松等待,不阻塞后续操作) # 等待元素可见(宽松等待,不阻塞后续操作)
self._wait_for_element(selector, timeout=10000, state="visible") self._wait_for_element(selector, timeout=10000, state="visible")
...@@ -1602,32 +1627,78 @@ class PlaywrightExecutor: ...@@ -1602,32 +1627,78 @@ class PlaywrightExecutor:
# ==================== 策略5(提前): Element UI 表格行选择回退 ==================== # ==================== 策略5(提前): Element UI 表格行选择回退 ====================
# Element UI 表格的选择列复选框带 is-hidden class(隐藏列),直接点击 # Element UI 表格的选择列复选框带 is-hidden class(隐藏列),直接点击
# .el-checkbox:has-text("X") 会失败。从选择器中提取目标文本, # .el-checkbox:has-text("X") 会失败。从选择器中提取目标文本,
# 点击包含该文本的表格行或行内复选框(行点击会触发选中)。 # 优先等待表格懒加载渲染完成(SPA 可达 10-40s),直接 force 勾选目标行
# 放在策略2(force点击)之前执行,避免 force 点击隐藏 checkbox 假成功 # checkbox 并校验 is-checked 状态,行点击仅作末位兜底(避免行点击不触发
# 勾选导致的假通过)。放在策略2(force点击)之前执行。
m = re.search(r':has-text\("([^"]+)"\)', selector) m = re.search(r':has-text\("([^"]+)"\)', selector)
if m: if m:
target_text = m.group(1) target_text = m.group(1)
row_selectors = [ checkbox_sel = f'.el-table__row:has-text("{target_text}") .el-checkbox'
# 优先:点击行内复选框(含文本的行内的复选框) row_sel = f'.el-table__row:has-text("{target_text}")'
f'.el-table__row:has-text("{target_text}") .el-checkbox',
# 次选:点击整行 # 1. 轮询等待表格行渲染(覆盖 SPA 表格懒加载 12s-130s+ 的波动;state=attached 覆盖 is-hidden)
f'.el-table__row:has-text("{target_text}")', # 之前单次 30s 等待在慢加载场景不足导致失败,改为每 3s 探测一次、上限 150s。
# 最后:点击 tr row_found = False
f'tr:has-text("{target_text}")', row_wait_start = time.time()
] while time.time() - row_wait_start < 150:
for row_sel in row_selectors: try:
if len(self._page.query_selector_all(row_sel)) > 0:
row_found = True
logger.debug(f"✓ 表格行已渲染: {row_sel}")
break
except Exception as e5:
last_error = e5
self._page.wait_for_timeout(3000)
if not row_found:
logger.debug(f"⚠ 表格行未在 150s 内渲染 {row_sel}: {last_error}")
# 2. 轮询勾选目标行 checkbox(force 绕过 is-hidden;每次校验 is-checked 防假通过)
if row_found:
cb_deadline = time.time() + 40
checked_ok = False
while time.time() < cb_deadline:
try:
cb_el = self._page.query_selector(checkbox_sel)
if cb_el:
# 方式A:force 点击 checkbox 本身
cb_el.click(force=True)
self._page.wait_for_timeout(300)
if self._checkbox_is_checked(cb_el):
checked_ok = True
logger.debug(f"✓ Element UI checkbox 直接勾选成功: {checkbox_sel}")
break
# 方式B:JS 点击兜底(绕过事件/遮挡)
self._page.evaluate('(el) => el.click()', cb_el)
self._page.wait_for_timeout(300)
if self._checkbox_is_checked(cb_el):
checked_ok = True
logger.debug(f"✓ Element UI checkbox JS 勾选成功: {checkbox_sel}")
break
logger.debug(f"⚠ checkbox 勾选后状态未变化,2s 后重试: {checkbox_sel}")
except Exception as e5:
last_error = e5
logger.debug(f"⚠ checkbox 勾选失败,重试中: {e5}")
self._page.wait_for_timeout(2000)
if checked_ok:
self._wait_for_loading_overlay()
return
logger.debug(f"⚠ checkbox 40s 内未勾选成功(假通过风险),继续后续策略: {checkbox_sel}")
# 3. 行点击末位兜底(部分表格仅行点击可选中;保持原逻辑避免回归)
for row_sel_candidate in (row_sel, f'tr:has-text("{target_text}")'):
try: try:
row_el = self._page.query_selector(row_sel) self._page.wait_for_selector(row_sel_candidate, state="attached", timeout=8000)
row_el = self._page.query_selector(row_sel_candidate)
if row_el: if row_el:
row_el.scroll_into_view_if_needed() row_el.scroll_into_view_if_needed()
self._page.wait_for_timeout(200) self._page.wait_for_timeout(200)
row_el.click(force=True) row_el.click(force=True)
logger.debug(f"✓ Element UI 表格行点击成功: {row_sel}") logger.debug(f"✓ Element UI 表格行点击成功: {row_sel_candidate}")
self._wait_for_loading_overlay() self._wait_for_loading_overlay()
return return
except Exception as e5: except Exception as e5:
last_error = e5 last_error = e5
logger.debug(f"⚠ 表格行点击失败 {row_sel}: {e5}") logger.debug(f"⚠ 表格行点击失败 {row_sel_candidate}: {e5}")
# 策略6(提前): 若选择器是隐藏复选框,尝试点击其所在表格行 # 策略6(提前): 若选择器是隐藏复选框,尝试点击其所在表格行
# 覆盖 .el-checkbox 无 :has-text 的情况(如勾选用户 admin@xty12) # 覆盖 .el-checkbox 无 :has-text 的情况(如勾选用户 admin@xty12)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""检查「自动化新建会议」残留:登录会议列表,统计该名称的会议行数与状态。"""
import sys, time
sys.stdout.reconfigure(encoding="utf-8")
from app.executors.playwright_executor import PlaywrightExecutor
LIST_URL = "https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FmeetingAll"
NAME = "自动化新建会议"
executor = PlaywrightExecutor(config={"headless": True, "timeout": 60000, "screenshot": False, "auto_login": True})
executor.start()
executor.do_login()
page = executor._page
page.goto(LIST_URL, wait_until="domcontentloaded", timeout=30000)
# 等待表格加载
for i in range(30):
time.sleep(1)
cnt = page.locator('.el-table__row').count()
if cnt > 0:
break
# 统计该名称行 + 每行状态
data = page.evaluate(f"""() => {{
const rows = [];
document.querySelectorAll('.el-table__row').forEach(tr => {{
const t = (tr.innerText||'');
if (t.includes('{NAME}')) {{
const statusCell = tr.querySelector('td:last-child');
rows.push({{
name: (tr.querySelector('td:nth-child(2)')||{{innerText:'?'}}).innerText.trim().slice(0,30),
status: statusCell ? statusCell.innerText.trim().slice(0,20) : '?',
}});
}}
}});
return rows;
}}""")
print(f"残留会议「{NAME}」数量: {len(data)}")
for r in data:
print(" -", r)
executor.stop()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:deploy_fix_560.py
模块描述:把「新建会议 step 11/17 修复」部署到生产 5.60。
部署内容:
1. scp 覆盖 backend/app/executors/playwright_executor.py(策略5 轮询勾选修复)
2. scp 上传 scripts/fix_step12.py / run_case_verify.py
3. 服务器上运行 fix_step12.py 更新生产用例 step 12(保持立即开始)
4. docker restart plat-auto-test-app(加载新执行器)
5. 触发生产用例执行 2-3 次验收
用法:
python scripts/deploy_fix_560.py [--runs 3]
作者:czj
创建日期:2026-08-19
"""
import argparse
import os
import sys
import time
sys.stdout.reconfigure(encoding="utf-8")
import paramiko
HOST = "192.168.5.60"
USER = "ubains"
PASS = "Ubains@123"
APP_DIR = "/data/third_party/plat-auto-test"
BACKEND_HOST = "/data/third_party/plat-auto-test/backend"
CONTAINER = "plat-auto-test-app"
PROD_CASE = "case_13650e0406e64779b66e6fa5de35c24a"
# 本地 → 服务器路径
UPLOADS = [
("app/executors/playwright_executor.py", BACKEND_HOST + "/app/executors/playwright_executor.py"),
("scripts/fix_step12.py", BACKEND_HOST + "/scripts/fix_step12.py"),
("scripts/run_case_verify.py", BACKEND_HOST + "/scripts/run_case_verify.py"),
]
LOCAL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # backend/
def ssh_exec(client, cmd, timeout=300):
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
return out, err
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--runs", type=int, default=3, help="生产验收执行次数")
parser.add_argument("--no-restart", action="store_true", help="跳过 docker restart")
parser.add_argument("--no-fix", action="store_true", help="跳过 fix_step12 更新")
args = parser.parse_args()
print(f"== 连接 {HOST} ==")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=30)
# 1. 上传文件
print("\n== 1. scp 上传 ==")
sftp = client.open_sftp()
for local_rel, remote in UPLOADS:
local = os.path.join(LOCAL_ROOT, local_rel)
sftp.put(local, remote)
size = os.path.getsize(local)
print(f" {local_rel} -> {remote} ({size}B)")
sftp.close()
# 2. 更新生产用例 step 12
if not args.no_fix:
print("\n== 2. 更新生产用例 step 12 ==")
cmd = (
f"cd {BACKEND_HOST} && python3 scripts/fix_step12.py "
f"--base http://localhost --case {PROD_CASE}"
)
out, err = ssh_exec(client, cmd, timeout=120)
print(out)
if err.strip():
print("[stderr]", err[:500])
# 3. 重启容器加载新执行器
if not args.no_restart:
print("\n== 3. docker restart ==")
out, err = ssh_exec(client, f"docker restart {CONTAINER}", timeout=180)
print(out.strip() or err.strip()[:200])
time.sleep(12)
# 健康检查
for i in range(10):
out, _ = ssh_exec(client, "curl -s http://localhost/health", timeout=30)
if out.strip():
print(f" 健康检查({i+1}): {out.strip()[:100]}")
break
time.sleep(5)
else:
print(" !! 健康检查未通过")
# 4. 生产验收执行
print(f"\n== 4. 生产验收:执行 {args.runs} 次 ==")
for i in range(1, args.runs + 1):
print(f"\n----- 第 {i}/{args.runs} 次 -----")
cmd = (
f"cd {BACKEND_HOST} && python3 scripts/run_case_verify.py "
f"--base http://localhost --case {PROD_CASE}"
)
out, err = ssh_exec(client, cmd, timeout=600)
print(out)
if err.strip():
print("[stderr]", err[:300])
client.close()
print("\n== 部署验收完成 ==")
if __name__ == "__main__":
main()
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:fix_step12.py
模块描述:把用例「新建会议」step 12 从「预定会议」改为保持「立即开始」。
根因(2026-08-19 确认):
- step 12 切到「预定会议」→ 会议为「未开始」→ 详情页无「结束会议」按钮 → step 17 必然失败。
- 保持「立即开始」→ 会议为「进行中」→ 详情页有「结束会议」→ step 17 可用。
改动:
- step 12 name: 时间切换为:预定会议 → 时间保持为:立即开始
- params.selector / locator_value: `.time_btn > div:last-child .btns` → `.time_btn > div:first-child`
(立即开始 tab 就是 first-child div 自身(class `btns button_active`),不能用 `.time_btn > div:first-child .btns`
——那要求 first-child 的子孙,匹配 0 个元素。直接用 `.time_btn > div:first-child` 命中该 tab div)
用法:
python scripts/fix_step12.py --base http://localhost:8001 --case case_xxx [--dry]
作者:czj
创建日期:2026-08-19
"""
import argparse
import json
import sys
import urllib.request
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
STEP12_SELECTOR_OLD = ".time_btn > div:last-child .btns"
STEP12_SELECTOR_NEW = ".time_btn > div:first-child"
def api(base: str, path: str, method: str = "GET", body: dict = None, timeout: int = 60):
url = base + path
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Accept": "application/json",
"Content-Type": "application/json" if body is not None else "text/plain",
})
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://localhost:8001")
parser.add_argument("--case", required=True)
parser.add_argument("--dry", action="store_true", help="只打印不提交")
args = parser.parse_args()
base = args.base.rstrip("/")
cid = args.case
case = api(base, f"/api/cases/{cid}")
if not case or "steps" not in case:
print(f"!! 获取用例失败: {cid}")
sys.exit(1)
steps = case.get("steps", [])
changed = False
for s in steps:
if s.get("order") == 12:
old_name = s.get("name", "")
p = s.get("params", {}) or {}
old_sel = p.get("selector", "")
print(f"step12 当前: name={old_name} | selector={old_sel}")
s["name"] = "时间保持为:立即开始"
p["selector"] = STEP12_SELECTOR_NEW
s["params"] = p
s["locator_value"] = STEP12_SELECTOR_NEW
changed = True
print(f"step12 改为: name={s['name']} | selector={p['selector']}")
print(f" (选择器已修正:`.time_btn > div:first-child .btns` 是零匹配,改为 `.time_btn > div:first-child`)")
break
if not changed:
print("!! 未找到 step 12,用例结构可能不同")
sys.exit(2)
if args.dry:
print("[dry] 未提交")
return 0
# PUT 提交(传整个 case 对象)
resp = api(base, f"/api/cases/{cid}", method="PUT", body=case, timeout=30)
if resp.get("id") or resp.get("case_id"):
print(f"[OK] 已更新 {cid}")
else:
print(f"[?] 响应: {json.dumps(resp, ensure_ascii=False)[:300]}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:run_case_verify.py
模块描述:触发指定用例执行并轮询结果(新建会议 step 11 修复验收用)
用法:
python scripts/run_case_verify.py --base http://localhost:8001 --case case_xxx [--name 展示名]
流程:POST /api/executions → POST /api/executions/{id}/run(同步阻塞)→
GET /api/executions/{id}/results/{case_id} 拉步骤明细
输出:执行结果摘要 + 失败步骤详情(便于验收 step 7/8/9 不再跳过、step 11 通过)
作者:czj
创建日期:2026-08-19
"""
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
# Windows 控制台默认 GBK,强制 UTF-8 输出(避免 ✓/⏭ 等字符崩溃)
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
def api(base: str, path: str, method: str = "GET", body: dict = None, timeout: int = 600) -> dict:
url = base + path
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Accept": "application/json",
"Content-Type": "application/json" if body is not None else "text/plain",
})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except Exception as e:
print(f"[API错误] {method} {path}: {e}")
return {}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--base", default="http://localhost:8001", help="后端地址")
parser.add_argument("--case", required=True, help="用例 ID")
parser.add_argument("--name", default="", help="展示名(可选)")
args = parser.parse_args()
base = args.base.rstrip("/")
case_id = args.case
print(f"== 触发执行: {args.name or case_id} @ {base} ==")
# 1. 创建执行记录
created = api(base, "/api/executions", method="POST", body={"case_ids": [case_id]}, timeout=30)
exec_id = created.get("id") or created.get("execution_id")
if not exec_id:
print(f"!! 创建执行失败: {created}")
sys.exit(1)
print(f"执行记录: {exec_id}")
# 2. 同步执行(阻塞,可能数分钟)
t0 = time.time()
run_res = api(base, f"/api/executions/{exec_id}/run", method="POST", timeout=600)
elapsed = time.time() - t0
print(f"执行完成: {run_res.get('status')}(用时 {elapsed:.1f}s)")
# 3. 拉取结果
result = api(base, f"/api/executions/{exec_id}/results/{case_id}", timeout=30)
if not result:
print("!! 未获取到执行结果")
sys.exit(1)
status = result.get("status")
steps = result.get("steps_result") or result.get("steps") or []
print(f"\n结果: status={status} | 步骤数={len(steps)}")
print("-" * 80)
failed = []
for s in steps:
s_status = s.get("status")
log = (s.get("log") or "").replace("\n", " ")[:90]
mark = {"passed": "[PASS]", "failed": "[FAIL]", "skipped": "[SKIP]"}.get(s_status, "[?]")
print(f"[{s.get('order', '?'):>2}] {mark} {s_status:<7} {s.get('name', '')} || {log}")
if s_status == "failed":
failed.append(s)
print("-" * 80)
if failed:
print(f"!! 失败步骤: {len(failed)} 个")
for s in failed:
print(f" order={s.get('order')} {s.get('name')}")
sys.exit(2)
else:
skipped = [s for s in steps if s.get("status") == "skipped"]
print(f"✅ 全部通过({len(steps)} 步,skipped={len(skipped)})")
return 0
if __name__ == "__main__":
sys.exit(main())
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论