提交 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
import os
import re
import asyncio
import time
from typing import Optional, Callable, Dict, Any, List
from datetime import datetime
from dataclasses import dataclass, field
......@@ -1475,6 +1476,17 @@ class PlaywrightExecutor:
except Exception:
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:
"""
点击元素(多选择器回退 + 语义定位器 + 强制点击 + JS点击回退)
......@@ -1577,6 +1589,19 @@ class PlaywrightExecutor:
continue
# ==================== 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")
......@@ -1602,32 +1627,78 @@ class PlaywrightExecutor:
# ==================== 策略5(提前): Element UI 表格行选择回退 ====================
# Element UI 表格的选择列复选框带 is-hidden class(隐藏列),直接点击
# .el-checkbox:has-text("X") 会失败。从选择器中提取目标文本,
# 点击包含该文本的表格行或行内复选框(行点击会触发选中)。
# 放在策略2(force点击)之前执行,避免 force 点击隐藏 checkbox 假成功
# 优先等待表格懒加载渲染完成(SPA 可达 10-40s),直接 force 勾选目标行
# checkbox 并校验 is-checked 状态,行点击仅作末位兜底(避免行点击不触发
# 勾选导致的假通过)。放在策略2(force点击)之前执行。
m = re.search(r':has-text\("([^"]+)"\)', selector)
if m:
target_text = m.group(1)
row_selectors = [
# 优先:点击行内复选框(含文本的行内的复选框)
f'.el-table__row:has-text("{target_text}") .el-checkbox',
# 次选:点击整行
f'.el-table__row:has-text("{target_text}")',
# 最后:点击 tr
f'tr:has-text("{target_text}")',
]
for row_sel in row_selectors:
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。
row_found = False
row_wait_start = time.time()
while time.time() - row_wait_start < 150:
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:
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:
row_el.scroll_into_view_if_needed()
self._page.wait_for_timeout(200)
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()
return
except Exception as e5:
last_error = e5
logger.debug(f"⚠ 表格行点击失败 {row_sel}: {e5}")
logger.debug(f"⚠ 表格行点击失败 {row_sel_candidate}: {e5}")
# 策略6(提前): 若选择器是隐藏复选框,尝试点击其所在表格行
# 覆盖 .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 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论