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

feat(execution): UI自动化用例串行执行机制 + 修正菜单选择器

P0 问题修复:

1. 实现用例串行执行机制
   - 添加全局执行锁,确保同一时间只有一个UI执行任务运行
   - 新增 is_execution_running() / set_running_execution() 函数
   - 新增 GET /api/executions/running-status API
   - Execution 模型新增 error_message 字段

2. 修正菜单选择器
   - 功能中心抽屉内没有"会议管理"和"通知公告"菜单
   - 修正为实际存在的"会议列表"和"通知统计"
   - 更新 menu_mapping.py 直接菜单列表

改动文件:
- backend/app/services/execution_service.py
- backend/app/models/execution.py
- backend/app/routers/executions.py
- backend/app/utils/menu_mapping.py
- backend/scripts/create_ui_cases_all_modules.py
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3dfd66b7
# PRD — UI 自动化用例串行执行机制
## 需求背景
### 问题现象
并发执行 UI 自动化用例时,通过率仅 **20%**(1/5),单独执行时通过率可达 **60%**(3/5)。
### 根因分析
1. **Playwright 浏览器实例共享页面状态**
- 当前实现中,多个执行记录可以同时运行
- 并发执行时,多个用例共享同一个浏览器页面
- 导致状态互相干扰(如一个用例点击菜单影响另一个用例的页面)
2. **执行服务未做串行控制**
- `execution_service.py` 只实现了单个执行记录内用例串行
- 未对多个执行记录的并发做限制
### 影响范围
- 所有 UI 自动化用例(`case_type='ui'`
- 前端执行页面(用户点击执行按钮)
---
## 解决方案
### 核心设计
`execution_service.py` 中添加全局执行锁,确保同一时间只有一个 UI 执行任务在运行。
### 实现要点
1. **全局执行状态管理**
```python
_execution_lock = threading.Lock()
_running_execution_id: Optional[str] = None
_running_execution_lock = threading.Lock()
```
2. **执行前检查**
- 发起执行请求时,先检查是否有其他任务正在运行
- 如有,直接拒绝并返回错误信息
3. **执行完成后释放锁**
- `finally` 块中释放锁,确保异常时也能释放
4. **新增 API 端点**
- `GET /api/executions/running-status` - 查询当前是否有 UI 任务运行
---
## 改动文件
| 文件 | 改动说明 |
|------|---------|
| `backend/app/services/execution_service.py` | 添加全局执行锁、执行前检查、执行后释放 |
| `backend/app/models/execution.py` | 新增 `error_message` 字段,用于记录执行失败原因 |
| `backend/app/routers/executions.py` | 新增 `/running-status` API 端点,导出 `is_execution_running` |
---
## API 变更
### 新增:GET /api/executions/running-status
**描述**:查询当前是否有 UI 执行任务正在运行
**响应**
```json
{
"is_running": true,
"execution_id": "exec_xxx"
}
```
---
## 错误处理
当有其他任务运行时,新执行请求会返回 400 错误:
```json
{
"detail": "已有 UI 执行任务 exec_xxx 正在运行,请等待完成后再执行"
}
```
---
## 验收标准
1. 同一时间只能有一个 UI 执行任务运行
2. 执行完成后锁被正确释放
3. 异常情况也能正确释放锁
4. API 返回正确的运行状态
---
## 后续优化
1. **前端优化**:执行按钮在有任务运行时禁用,显示等待提示
2. **队列机制**:支持多个执行请求排队等待,而不是直接拒绝
3. **执行优先级**:支持高优先级任务插队
---
## 创建记录
- **创建时间**:2026-08-06
- **创建者**:czj
- **关联问题**:P0 - 实现用例串行执行机制
\ No newline at end of file
...@@ -70,6 +70,11 @@ class Execution(Base): ...@@ -70,6 +70,11 @@ class Execution(Base):
default="pending", default="pending",
comment="状态: pending/running/completed/cancelled/failed" comment="状态: pending/running/completed/cancelled/failed"
) )
error_message: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="错误信息(执行失败时记录)"
)
start_time: Mapped[Optional[datetime]] = mapped_column( start_time: Mapped[Optional[datetime]] = mapped_column(
DateTime, DateTime,
nullable=True, nullable=True,
...@@ -130,6 +135,7 @@ class Execution(Base): ...@@ -130,6 +135,7 @@ class Execution(Base):
"pass_rate": self.pass_rate, "pass_rate": self.pass_rate,
"duration": self.duration, "duration": self.duration,
"status": self.status, "status": self.status,
"error_message": self.error_message,
"start_time": self.start_time.isoformat() if self.start_time else None, "start_time": self.start_time.isoformat() if self.start_time else None,
"end_time": self.end_time.isoformat() if self.end_time else None, "end_time": self.end_time.isoformat() if self.end_time else None,
"environment": self.environment, "environment": self.environment,
......
...@@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock ...@@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.services.execution_service import ExecutionService from app.services.execution_service import ExecutionService, is_execution_running
from app.websocket.manager import manager from app.websocket.manager import manager
from app.config import settings from app.config import settings
...@@ -115,6 +115,23 @@ async def list_executions( ...@@ -115,6 +115,23 @@ async def list_executions(
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}") raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@router.get("/running-status", response_model=dict)
async def get_running_status():
"""
查询当前是否有 UI 执行任务正在运行
用于前端判断是否可以发起执行请求。
Returns:
dict: {"is_running": bool, "execution_id": str|null}
"""
is_running, execution_id = is_execution_running()
return {
"is_running": is_running,
"execution_id": execution_id,
}
@router.post("", response_model=dict, status_code=201) @router.post("", response_model=dict, status_code=201)
async def create_execution( async def create_execution(
data: dict, data: dict,
......
...@@ -39,6 +39,40 @@ from app.config import settings ...@@ -39,6 +39,40 @@ from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ==================== 全局执行锁 ====================
# 确保 UI 自动化用例串行执行,避免并发执行导致浏览器页面状态互相干扰
# 根因:Playwright 浏览器实例共享页面状态,并发执行会导致状态竞争
_execution_lock = threading.Lock()
_running_execution_id: Optional[str] = None
_running_execution_lock = threading.Lock()
def is_execution_running() -> Tuple[bool, Optional[str]]:
"""
检查是否有执行任务正在运行
Returns:
Tuple[bool, Optional[str]]: (是否有任务运行, 运行中的执行ID)
"""
with _running_execution_lock:
return _running_execution_id is not None, _running_execution_id
def set_running_execution(execution_id: Optional[str]) -> None:
"""
设置/清除当前运行中的执行ID
Args:
execution_id: 执行ID,None 表示清除
"""
global _running_execution_id
with _running_execution_lock:
_running_execution_id = execution_id
if execution_id:
logger.info(f"[执行锁] 设置运行中: {execution_id}")
else:
logger.info("[执行锁] 清除运行状态")
class ExecutionService: class ExecutionService:
""" """
...@@ -199,6 +233,7 @@ class ExecutionService: ...@@ -199,6 +233,7 @@ class ExecutionService:
执行测试任务 执行测试任务
异步执行所有关联的测试用例,实时推送执行状态。 异步执行所有关联的测试用例,实时推送执行状态。
UI 自动化用例串行执行,避免并发干扰。
Args: Args:
execution_id (str): 执行记录ID execution_id (str): 执行记录ID
...@@ -206,6 +241,9 @@ class ExecutionService: ...@@ -206,6 +241,9 @@ class ExecutionService:
Returns: Returns:
Execution: 更新后的执行记录 Execution: 更新后的执行记录
Raises:
ValueError: 执行记录不存在或已有其他任务运行中
""" """
execution = await self.get_execution(execution_id) execution = await self.get_execution(execution_id)
if not execution: if not execution:
...@@ -215,6 +253,21 @@ class ExecutionService: ...@@ -215,6 +253,21 @@ class ExecutionService:
if execution.case_type == "security": if execution.case_type == "security":
return await self._run_security_execution(execution_id, config) return await self._run_security_execution(execution_id, config)
# ===== UI 自动化用例串行执行锁检查 =====
# 检查是否有其他 UI 执行任务正在运行
is_running, running_id = is_execution_running()
if is_running and running_id != execution_id:
error_msg = f"已有 UI 执行任务 {running_id} 正在运行,请等待完成后再执行"
logger.warning(f"[执行锁] {error_msg}")
execution.status = "failed"
execution.error_message = error_msg
execution.end_time = datetime.now()
await self.db.flush()
raise ValueError(error_msg)
# 获取执行锁
set_running_execution(execution_id)
# 更新状态为运行中 # 更新状态为运行中
execution.status = "running" execution.status = "running"
execution.start_time = datetime.now() execution.start_time = datetime.now()
...@@ -415,6 +468,9 @@ class ExecutionService: ...@@ -415,6 +468,9 @@ class ExecutionService:
await self.db.flush() await self.db.flush()
finally: finally:
# 释放执行锁
set_running_execution(None)
# 更新执行状态 # 更新执行状态
execution.status = "completed" execution.status = "completed"
execution.end_time = datetime.now() execution.end_time = datetime.now()
......
...@@ -39,11 +39,24 @@ DRAWER_MENU_HIERARCHY = { ...@@ -39,11 +39,24 @@ DRAWER_MENU_HIERARCHY = {
} }
# 直接菜单(无子菜单,点击后直接跳转) # 直接菜单(无子菜单,点击后直接跳转)
# 注意:这些是在功能中心抽屉内实际存在的菜单名称
DIRECT_MENUS = [ DIRECT_MENUS = [
# 首页常用功能卡片(菜单名称)
"信息发布", "数据统计", "预定2.0", "信息发布", "数据统计", "预定2.0",
"会议管理", "数据分析", "运维管理", "数据分析", "运维管理", "管理看板",
"管理看板", "通知公告", "会议室管理", "会议室管理", "系统设置", "其他分类",
"系统设置", "其他分类" # 功能中心抽屉内的具体功能菜单
"会议列表", # 会议管理模块功能入口
"通知统计", # 通知公告模块功能入口
# 首页菜单卡片(子功能)
"新建会议", "会议模板", "个人日程", "会议室列表",
"预定数据", "使用数据", "故障数据", "运维数据",
"运维统计", "巡检报表",
"会议统计", "会议服务统计", "会议室统计",
"Welink会议统计", "历史记录-WeLink",
"SMC3.0会议统计", "历史记录-SMC3.0",
"RSE会议统计", "历史记录-RSE",
"会议概览", "会议室概览",
] ]
......
...@@ -182,10 +182,11 @@ def build_cases(): ...@@ -182,10 +182,11 @@ def build_cases():
]) ])
# ==================== 会议管理模块 ==================== # ==================== 会议管理模块 ====================
# 注意:功能中心抽屉内没有"会议管理"菜单,使用实际存在的菜单如"会议列表"
cases.extend([ cases.extend([
{ {
"name": "会议管理-会议列表查看", "name": "会议管理-会议列表查看",
"description": "验证会议管理页面列表正常显示", "description": "验证会议列表页面正常显示(会议管理模块核心功能)",
"module_id": MODULES["huiyiguanli"], "module_id": MODULES["huiyiguanli"],
"priority": "high", "priority": "high",
"order": 100, "order": 100,
...@@ -194,7 +195,7 @@ def build_cases(): ...@@ -194,7 +195,7 @@ def build_cases():
{"order": 1, "name": "等待首页加载", "action": "wait", "params": {"selector": ".block", "timeout": 15000}}, {"order": 1, "name": "等待首页加载", "action": "wait", "params": {"selector": ".block", "timeout": 15000}},
{"order": 2, "name": "点击功能中心图标", "action": "click", "params": {"selector": "//*[@id='Home']/div[1]/div[1]/i"}}, {"order": 2, "name": "点击功能中心图标", "action": "click", "params": {"selector": "//*[@id='Home']/div[1]/div[1]/i"}},
{"order": 3, "name": "等待抽屉打开", "action": "wait", "params": {"selector": ".el-drawer", "timeout": 15000}}, {"order": 3, "name": "等待抽屉打开", "action": "wait", "params": {"selector": ".el-drawer", "timeout": 15000}},
{"order": 4, "name": "点击会议管理菜单", "action": "click", "params": {"selector": ".el-drawer >> text='会议管理'"}}, {"order": 4, "name": "点击会议列表菜单", "action": "click", "params": {"selector": ".el-drawer >> text='会议列表'"}},
{"order": 5, "name": "等待页面加载", "action": "wait", "params": {"selector": "body", "timeout": 15000}}, {"order": 5, "name": "等待页面加载", "action": "wait", "params": {"selector": "body", "timeout": 15000}},
{"order": 6, "name": "验证页面内容存在", "action": "assert", "params": {"type": "element_exists", "selector": "body"}}, {"order": 6, "name": "验证页面内容存在", "action": "assert", "params": {"type": "element_exists", "selector": "body"}},
], ],
...@@ -342,10 +343,11 @@ def build_cases(): ...@@ -342,10 +343,11 @@ def build_cases():
]) ])
# ==================== 通知公告模块 ==================== # ==================== 通知公告模块 ====================
# 注意:功能中心抽屉内没有"通知公告"菜单,使用实际存在的菜单如"通知统计"
cases.extend([ cases.extend([
{ {
"name": "通知公告-页面访问验证", "name": "通知公告-通知统计查看",
"description": "验证通知公告页面可正常访问", "description": "验证通知统计页面正常显示(通知公告模块核心功能)",
"module_id": MODULES["tongzhigonggao"], "module_id": MODULES["tongzhigonggao"],
"priority": "high", "priority": "high",
"order": 100, "order": 100,
...@@ -354,7 +356,7 @@ def build_cases(): ...@@ -354,7 +356,7 @@ def build_cases():
{"order": 1, "name": "等待首页加载", "action": "wait", "params": {"selector": ".block", "timeout": 15000}}, {"order": 1, "name": "等待首页加载", "action": "wait", "params": {"selector": ".block", "timeout": 15000}},
{"order": 2, "name": "点击功能中心图标", "action": "click", "params": {"selector": "//*[@id='Home']/div[1]/div[1]/i"}}, {"order": 2, "name": "点击功能中心图标", "action": "click", "params": {"selector": "//*[@id='Home']/div[1]/div[1]/i"}},
{"order": 3, "name": "等待抽屉打开", "action": "wait", "params": {"selector": ".el-drawer", "timeout": 15000}}, {"order": 3, "name": "等待抽屉打开", "action": "wait", "params": {"selector": ".el-drawer", "timeout": 15000}},
{"order": 4, "name": "点击通知公告菜单", "action": "click", "params": {"selector": ".el-drawer >> text='通知公告'"}}, {"order": 4, "name": "点击通知统计菜单", "action": "click", "params": {"selector": ".el-drawer >> text='通知统计'"}},
{"order": 5, "name": "等待页面加载", "action": "wait", "params": {"selector": "body", "timeout": 15000}}, {"order": 5, "name": "等待页面加载", "action": "wait", "params": {"selector": "body", "timeout": 15000}},
{"order": 6, "name": "验证页面内容存在", "action": "assert", "params": {"type": "element_exists", "selector": "body"}}, {"order": 6, "name": "验证页面内容存在", "action": "assert", "params": {"type": "element_exists", "selector": "body"}},
], ],
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论