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

feat: 复杂用例通用执行机制-语义化用例执行(阶段1-6)

参考文档:_PRD_复杂用例通用执行机制_语义化用例执行.md

范式转变:从"记录选择器"到"记录业务意图"。semantic 语义目标 +
执行期现场解析 + 上下文变量 + 状态验证 + 数据自建/自清理。

- 阶段1 数据模型+上下文:StepDefinition 新增 semantic/verify/run_on 顶层
  字段;ExecutionContext + {__CTX:key__} 渲染(复用 TemplateResolver)
- 阶段2 页面注册表:page_url_mapping 升级 url_patterns+fingerprint;
  recognize_current_page 运行时页面身份感知
- 阶段3 语义解析器:semantic_resolver 解析链(lookup_by_key→模式选择器
  →关键词规则→Claude仲裁),wire 进 execute_step;debug 只读接口
  /api/debug/resolve
- 阶段4 状态验证+api_call+finally:verify 断言防假通过、finally/on_failure
  清理步骤、api_call 数据自清理
- 阶段5 映射表v2显式字典:lookup_by_key 纯字典查找、USE_FUZZY_MAPPING
  默认关停、migrate_mapping_v2 迁移脚本
- 阶段6 前端+示范:CaseStepEditor 语义目标编辑、stepParser 语义推断、
  语义化重写「新建会议」示范用例(create_semantic_meeting_case.py)
- 修复参数持久化 bug:case_service.create + _case_to_response 补 parameters
- 新增映射别名 创建会议-查看详情(lookup_by_key 原 MISS,不回归旧路径)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 83561256
......@@ -82,6 +82,16 @@ class Settings:
# 被测系统配置
TARGET_URL: str = os.getenv("TARGET_URL", "https://192.168.5.44")
# 语义化用例执行配置(复杂用例通用执行机制,各阶段可独立开关回退)
# SEMANTIC_ENABLED: 语义目标解析(阶段 3)
# USE_KEYWORD_MATCHER: legacy 自然语言步骤走 keyword_matcher(阶段 3 过渡路径)
# USE_FUZZY_MAPPING: 旧模糊评分(阶段 5 关停,默认 false)
# ENFORCE_VERIFICATION: 强制状态验证(阶段 4,默认 false)
SEMANTIC_ENABLED: bool = os.getenv("SEMANTIC_ENABLED", "true").lower() == "true"
USE_KEYWORD_MATCHER: bool = os.getenv("USE_KEYWORD_MATCHER", "false").lower() == "true"
USE_FUZZY_MAPPING: bool = os.getenv("USE_FUZZY_MAPPING", "false").lower() == "true"
ENFORCE_VERIFICATION: bool = os.getenv("ENFORCE_VERIFICATION", "false").lower() == "true"
# 文档管理配置
DOCUMENT_UPLOAD_DIR: str = os.getenv("DOCUMENT_UPLOAD_DIR", "./data/documents/upload")
DOCUMENT_OUTPUT_DIR: str = os.getenv("DOCUMENT_OUTPUT_DIR", "./data/documents/output")
......
......@@ -1446,6 +1446,12 @@
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议 - 左侧会议室框编辑模式下的完成按钮(保存参会人配置)"
},
"创建会议-查看详情": {
"selector": ".el-message-box button:has-text('查看详情')",
"type": "css",
"tier": "会议预约-创建会议",
"description": "创建会议成功弹窗 - 查看详情按钮(semantic 键:与 会议创建成功-查看详情 同选择器,供 lookup_by_key(创建会议, 查看详情) 命中)"
}
}
}
\ No newline at end of file
{
"version": "2.0",
"source": "迁移自 v1 231 条",
"pages": [
{
"id": "会议列表",
"name": "会议列表",
"elements": {
"会议列表-全部-Tab": {
"selectors": [
".mian_search .search_left .btn .text"
],
"type": "css",
"description": "会议列表[全部] - Tab切换(meetingALLTypes:我预定的/我受邀的;管理员另有全部/重点会议)"
},
"会议列表-全部-状态": {
"selectors": [
".el-select"
],
"type": "css",
"description": "会议列表[全部] - 状态筛选(messageState)"
},
"会议列表-全部-时间": {
"selectors": [
".select_date .el-date-picker[type=datetimerange]"
],
"type": "css",
"description": "会议列表[全部] - 时间范围选择(meetingTime)"
},
"会议列表-全部-搜索": {
"selectors": [
".search_left .el-input"
],
"type": "css",
"description": "会议列表[全部] - 关键字搜索(searchName)"
},
"会议列表-全部-表格": {
"selectors": [
".el-table"
],
"type": "css",
"description": "会议列表[全部] - 会议表格(会议名/主持人/预定人/起止/时长/类型/会议号/状态/操作)"
},
"会议列表-全部-操作菜单": {
"selectors": [
"ul.themeUl_li"
],
"type": "css",
"description": "会议列表[全部] - 操作菜单(取消/再次预定/修改/结束/详情/提前开始/延长)"
},
"会议列表-全部-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "会议列表[全部] - 分页"
},
"会议列表-审批-Tab": {
"selectors": [
".el-menu"
],
"type": "css",
"description": "会议列表[审批] - Tab切换(meetingALLTypes:我发起0/待我审批1/我审批2)"
},
"会议列表-审批-表格": {
"selectors": [
".el-table"
],
"type": "css",
"description": "会议列表[审批] - 审批表格(审批信息 sgmessageInfo.*)"
},
"会议列表-审批-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "会议列表[审批] - 分页(layout=total,sizes,prev,pager,next,jumper)"
},
"审批-详情弹窗": {
"selectors": [
".approve_info_bottom .el-timeline"
],
"type": "css",
"description": "会议审批 - 详情弹窗审批流程时间线"
},
"审批-操作按钮-驳回": {
"selectors": [
".btn_reject"
],
"type": "css",
"description": "会议审批 - 驳回按钮"
},
"审批-操作按钮-同意": {
"selectors": [
".btn_argee"
],
"type": "css",
"description": "会议审批 - 同意按钮"
},
"审批-操作弹窗": {
"selectors": [
".el-radio-group"
],
"type": "css",
"description": "会议审批 - 操作弹窗单选(approveType:1驳回/2同意/3转交)"
},
"审批-部门选择": {
"selectors": [
".DepartmentSelect"
],
"type": "css",
"description": "会议审批 - 转交时部门选择"
},
"会议列表-历史-Tab": {
"selectors": [
".history_tabs .el-menu"
],
"type": "css",
"description": "会议列表[历史] - Tab切换(全部/我发起的/我受邀的)"
},
"会议列表-历史-搜索": {
"selectors": [
".nav_right .el-input"
],
"type": "css",
"description": "会议列表[历史] - 关键字搜索"
},
"会议列表-历史-时间范围": {
"selectors": [
".select_date .el-date-picker[type=daterange]"
],
"type": "css",
"description": "会议列表[历史] - 时间范围选择(time)"
},
"会议列表-历史-表格": {
"selectors": [
".el-table"
],
"type": "css",
"description": "会议列表[历史] - 会议表格(会议名/主持人/会议号/起止/状态/操作)"
},
"会议列表-历史-再次预定": {
"selectors": [
".reBook"
],
"type": "css",
"description": "会议列表[历史] - 再次预定按钮"
},
"会议列表-历史-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "会议列表[历史] - 分页(page-sizes [8,10,20,30,40])"
},
"评价弹窗-星级": {
"selectors": [
".eval_content .eval_row .el-rate"
],
"type": "css",
"description": "会议列表[历史] - 评价弹窗星级评分"
}
}
},
{
"id": "会议详情",
"name": "会议详情",
"elements": {
"会议详情-取消会议": {
"selectors": [
".buttom_btn .el-button--info.is-plain"
],
"type": "css",
"description": "会议详情页 - 底部取消会议按钮(修改/取消操作区)"
},
"会议详情-取消确认确定": {
"selectors": [
".el-dialog:visible .el-dialog__footer .el-button--primary"
],
"type": "css",
"description": "取消会议二次确认弹窗 - 确定按钮"
}
}
},
{
"id": "会议预约",
"name": "会议预约",
"elements": {
"会议模板-搜索": {
"selectors": [
".search .el-input"
],
"type": "css",
"description": "会议模板 - 搜索输入框(searchName)"
},
"会议模板-新增模板": {
"selectors": [
"button.el-button--primary"
],
"type": "css",
"description": "会议模板 - 新增模板按钮(addTemplate)"
},
"会议模板-批量删除": {
"selectors": [
"button.el-button--primary"
],
"type": "css",
"description": "会议模板 - 批量删除按钮(batchDeleteTemplate),第2个primary按钮"
},
"会议模板-模板列表": {
"selectors": [
".el-table"
],
"type": "css",
"description": "会议模板 - 模板列表表格(模板名/类型/会议室/创建时间/操作)"
},
"会议模板-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "会议模板 - 分页"
},
"会议模板-详情弹窗": {
"selectors": [
".el-collapse"
],
"type": "css",
"description": "会议模板 - 详情弹窗折叠面板"
},
"个人日程-今日-Tab": {
"selectors": [
".week_tabs .el-menu"
],
"type": "css",
"description": "个人日程[今日] - Tab切换(全部 index=1 / 我发起的 index=2 / 我受邀的 index=3)"
},
"个人日程-今日-状态筛选": {
"selectors": [
".el-select"
],
"type": "css",
"description": "个人日程[今日] - 状态筛选(meetingStatus:进行中1/未开始2/已结束4/全部0)"
},
"个人日程-今日-日期": {
"selectors": [
".select_date .el-date-picker[type=date]"
],
"type": "css",
"description": "个人日程[今日] - 日期选择(todayDate)"
},
"个人日程-今日-搜索": {
"selectors": [
".el-input"
],
"type": "css",
"description": "个人日程[今日] - 关键字搜索"
},
"个人日程-今日-新建会议": {
"selectors": [
".create_img .el-button[type=primary]"
],
"type": "css",
"description": "个人日程[今日] - 新建会议按钮(createMeeting)"
},
"个人日程-今日-会议列表项": {
"selectors": [
".list_time .list .time .scroll .item"
],
"type": "css",
"description": "个人日程[今日] - 会议列表条目"
},
"个人日程-本周-Tab": {
"selectors": [
".week_tabs .el-menu"
],
"type": "css",
"description": "个人日程[本周] - Tab切换"
},
"个人日程-本周-状态": {
"selectors": [
".nav_right .select_date .el-select"
],
"type": "css",
"description": "个人日程[本周] - 状态筛选(valueStatus)"
},
"个人日程-本周-搜索": {
"selectors": [
".nav_right .el-input"
],
"type": "css",
"description": "个人日程[本周] - 关键字搜索(searchContent)"
},
"个人日程-本周-周日期": {
"selectors": [
".select_date .el-date-picker[type=week]"
],
"type": "css",
"description": "个人日程[本周] - 周日期选择(weekDate)"
},
"个人日程-本周-新建会议": {
"selectors": [
".nav_right .create_img"
],
"type": "css",
"description": "个人日程[本周] - 新建会议"
},
"个人日程-本周-周标题": {
"selectors": [
".week_meeting .week_title .title"
],
"type": "css",
"description": "个人日程[本周] - 周标题列表(weekTitleList)"
},
"个人日程-本周-会议条目": {
"selectors": [
".week_item .week_list .outlist .list .li .name"
],
"type": "css",
"description": "个人日程[本周] - 会议条目"
},
"个人日程-本周-状态颜色-进行中": {
"selectors": [
".status_color_ing"
],
"type": "css",
"description": "个人日程[本周] - 进行中状态颜色标记"
},
"个人日程-本周-状态颜色-未开始": {
"selectors": [
".status_color_unstart"
],
"type": "css",
"description": "个人日程[本周] - 未开始状态颜色标记"
},
"个人日程-本周-状态颜色-已结束": {
"selectors": [
".status_color_end"
],
"type": "css",
"description": "个人日程[本周] - 已结束状态颜色标记"
},
"个人日程-本月-Tab": {
"selectors": [
".month_tabs .el-menu"
],
"type": "css",
"description": "个人日程[本月] - Tab切换"
},
"个人日程-本月-搜索": {
"selectors": [
".nav_right .el-input"
],
"type": "css",
"description": "个人日程[本月] - 关键字搜索"
},
"个人日程-本月-月份": {
"selectors": [
".select_date .el-date-picker[type=month]"
],
"type": "css",
"description": "个人日程[本月] - 月份选择(含左右箭头 .date_icon)"
},
"个人日程-本月-日历": {
"selectors": [
"el-calendar"
],
"type": "css",
"description": "个人日程[本月] - 日历主体"
},
"个人日程-本月-今日标记": {
"selectors": [
".block .block_top .span_today"
],
"type": "css",
"description": "个人日程[本月] - 今日高亮标记"
},
"个人日程-本月-会议条目": {
"selectors": [
".block .scroll .meeting_list"
],
"type": "css",
"description": "个人日程[本月] - 日历中会议条目"
},
"个人日程-历史-Tab": {
"selectors": [
".history_tabs .el-menu"
],
"type": "css",
"description": "个人日程[历史] - Tab切换(全部/我发起的/我受邀的)"
},
"个人日程-历史-搜索": {
"selectors": [
".nav_right .el-input"
],
"type": "css",
"description": "个人日程[历史] - 关键字搜索"
},
"个人日程-历史-时间范围": {
"selectors": [
".select_date .el-date-picker[type=daterange]"
],
"type": "css",
"description": "个人日程[历史] - 时间范围选择(time)"
},
"个人日程-历史-表格": {
"selectors": [
".meeting_list .table .el-table"
],
"type": "css",
"description": "个人日程[历史] - 会议表格(会议名/主持人/会议号/起止时间/状态/操作)"
},
"个人日程-历史-再次会议": {
"selectors": [
".btn-again"
],
"type": "css",
"description": "个人日程[历史] - 操作列再次会议按钮"
},
"个人日程-历史-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "个人日程[历史] - 分页(page-sizes [8,10,20,30,40])"
},
"会议详情-标题": {
"selectors": [
".detail .detail_top"
],
"type": "css",
"description": "会议详情 - 标题(会议名)"
},
"会议详情-信息行": {
"selectors": [
".meeting_ist .list"
],
"type": "css",
"description": "会议详情 - 信息行(主持人/发起人/时间/时长/类型/会议号/会议室/备注/签到/主题/文件/房间模式/分屏)"
},
"会议详情-复制会议号": {
"selectors": [
".copy_icon"
],
"type": "css",
"description": "会议详情 - 复制会议号按钮"
},
"会议详情-操作按钮": {
"selectors": [
".buttom_btn"
],
"type": "css",
"description": "会议详情 - 底部操作按钮(再次预定/修改/取消/延长/结束/进入会议/重试/会议记录)"
},
"会议详情-状态弹窗": {
"selectors": [
".el-radio"
],
"type": "css",
"description": "会议详情 - 状态弹窗单选组(cycleRadio)"
},
"会议室-区域树": {
"selectors": [
".area_block"
],
"type": "css",
"description": "会议室 - 左侧区域树(.area_first / .area_next 层级)"
},
"会议室-选中区域": {
"selectors": [
".area_block_active"
],
"type": "css",
"description": "会议室 - 当前选中区域"
},
"会议室-日期": {
"selectors": [
".timeDate .el-date-picker[type=date]"
],
"type": "css",
"description": "会议室 - 顶部日期选择"
},
"会议室-会议名称": {
"selectors": [
".header_block .el-input"
],
"type": "css",
"description": "会议室 - 顶部会议名称搜索(conferenceName)"
},
"会议室-新建会议": {
"selectors": [
".el-button--primary"
],
"type": "css",
"description": "会议室 - 顶部新建会议按钮(createMeet)"
},
"会议室-条件筛选": {
"selectors": [
".header_block .el-button"
],
"type": "css",
"description": "会议室 - 顶部条件筛选按钮(打开 RoomFilter 弹窗)"
},
"会议室-房间卡片": {
"selectors": [
".room_item"
],
"type": "css",
"description": "会议室 - 房间卡片"
},
"会议室-房间勾选": {
"selectors": [
".room_item .el-checkbox"
],
"type": "css",
"description": "会议室 - 房间卡片勾选复选框"
},
"会议室-房间信息": {
"selectors": [
".roomInfo_details"
],
"type": "css",
"description": "会议室 - 房间信息详情"
},
"会议室-房间预览": {
"selectors": [
".room_preview"
],
"type": "css",
"description": "会议室 - 房间预览按钮"
},
"会议室-时间轴": {
"selectors": [
".roomInfo_time_base .meetingTime"
],
"type": "css",
"description": "会议室 - 房间卡片时间轴占用时间"
},
"会议室-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "会议室 - 分页"
},
"筛选-会议时间": {
"selectors": [
".RoomFilter .el-date-picker[type=datetimerange]"
],
"type": "css",
"description": "会议室筛选弹窗 - 会议时间范围(searchTime),弹窗挂载在 body"
},
"筛选-区域": {
"selectors": [
".RoomFilter .el-cascader"
],
"type": "css",
"description": "会议室筛选弹窗 - 区域级联选择器(areaId)"
},
"筛选-审批方式": {
"selectors": [
".RoomFilter .el-radio-group"
],
"type": "css",
"description": "会议室筛选弹窗 - 审批方式单选(approveRadio:2所有/1审批/0不审批)"
},
"筛选-最小人数": {
"selectors": [
".RoomFilter .el-input"
],
"type": "css",
"description": "会议室筛选弹窗 - 最小人数输入(minNum)"
},
"筛选-功能": {
"selectors": [
".RoomFilter .el-checkbox-group"
],
"type": "css",
"description": "会议室筛选弹窗 - 功能多选(functionIds)"
},
"筛选-重置": {
"selectors": [
".RoomFilter .filter_footer .el-button"
],
"type": "css",
"description": "会议室筛选弹窗 - 重置按钮(clickClear)"
},
"筛选-确定": {
"selectors": [
".RoomFilter .filter_footer .el-button--primary"
],
"type": "css",
"description": "会议室筛选弹窗 - 确定按钮(clickConfirm)"
}
}
},
{
"id": "创建会议",
"name": "创建会议",
"elements": {
"创建会议-保存模板": {
"selectors": [
".btn"
],
"type": "css",
"description": "创建会议 - 顶部保存模板按钮(saveTemplate)"
},
"创建会议-新建会议": {
"selectors": [
"div:visible:has-text(\"新建会议\")"
],
"type": "css",
"description": "创建会议 - 顶部新建会议按钮(addMeeting),文本定位避免与其他.btn冲突"
},
"创建会议-会议名称": {
"selectors": [
".meeting_list .meeting_name .el-input__inner"
],
"type": "css",
"description": "创建会议 - 会议名称输入框(必填)"
},
"创建会议-会议类型": {
"selectors": [
".meeting_list .el-radio-group"
],
"type": "css",
"description": "创建会议 - 会议类型单选组(本地 label=1 / 视频 label=2)"
},
"创建会议-视频平台": {
"selectors": [
".meeting_list .el-checkbox-group"
],
"type": "css",
"description": "创建会议 - 视频平台多选(会议类型=视频时显示)"
},
"创建会议-级联会议": {
"selectors": [
".type_checkbox .el-radio"
],
"type": "css",
"description": "创建会议 - 级联会议开关(cascadedMeeting)"
},
"创建会议-语音会议": {
"selectors": [
".voice_meeting .el-switch"
],
"type": "css",
"description": "创建会议 - 语音会议开关(若开启语音模块)"
},
"创建会议-预约类型": {
"selectors": [
".reserve_type .el-radio-group"
],
"type": "css",
"description": "创建会议 - 预约类型单选(普通 reserveType=0 / 周期 reserveType=1)"
},
"创建会议-周期预约设置": {
"selectors": [
".reserve_type_set .el-button"
],
"type": "css",
"description": "创建会议 - 周期预约设置按钮(周期预约时显示)"
},
"创建会议-选择时间": {
"selectors": [
".time_btn .btns"
],
"type": "css",
"description": "创建会议 - 时间选择按钮组"
},
"创建会议-预定会议": {
"selectors": [
".time_btn > div:last-child .btns"
],
"type": "css",
"description": "创建会议 - 时间选择按钮组中的\"预定会议\"按钮"
},
"创建会议-立即开始": {
"selectors": [
".time_btn .btns.button_active"
],
"type": "css",
"description": "创建会议 - 时间选择按钮组中的\"立即开始\"按钮"
},
"创建会议-主题名称": {
"selectors": [
".topic_item"
],
"type": "css",
"description": "创建会议 - 主题列表项"
},
"创建会议-上传文件": {
"selectors": [
".topic_item .el-upload"
],
"type": "css",
"description": "创建会议 - 主题附件上传控件"
},
"创建会议-区域": {
"selectors": [
".area_top .el-cascader"
],
"type": "css",
"description": "创建会议 - 右侧会议室区域级联选择器(areaId)"
},
"创建会议-功能": {
"selectors": [
".area_top .el-select.multiple"
],
"type": "css",
"description": "创建会议 - 右侧会议室功能多选(functionId)"
},
"创建会议-房间名称": {
"selectors": [
".area_top .el-input"
],
"type": "css",
"description": "创建会议 - 右侧会议室房间名称搜索(roomName)"
},
"创建会议-会议室列表": {
"selectors": [
".el-table"
],
"type": "css",
"description": "创建会议 - 右侧会议室表格"
},
"创建会议-议题输入框": {
"selectors": [
".topic_item input[placeholder='会议议题'], .topic_item .el-input__inner"
],
"type": "css",
"description": "创建会议 - 议题输入框(议题1 编辑框,placeholder 会议议题)"
},
"创建会议-分页": {
"selectors": [
".pagination .el-pagination"
],
"type": "css",
"description": "创建会议 - 右侧会议室/参会人分页"
},
"创建会议-部门": {
"selectors": [
".user_top .el-cascader"
],
"type": "css",
"description": "创建会议 - 右侧参会人部门级联选择器(depId)"
},
"创建会议-人名搜索": {
"selectors": [
".user_top .el-input"
],
"type": "css",
"description": "创建会议 - 右侧参会人人名搜索(nikeName)"
},
"创建会议-人员配置按钮": {
"selectors": [
".personnelConfigure"
],
"type": "css",
"description": "创建会议 - 右侧参会人6个配置按钮"
},
"创建会议-参会人表格": {
"selectors": [
".el-table"
],
"type": "css",
"description": "创建会议 - 右侧参会人表格"
},
"创建会议-参会人勾选": {
"selectors": [
".el-table:has-text(\"姓名\") .el-table__row:has-text(\"admin@xty12\") .el-checkbox"
],
"type": "css",
"description": "创建会议 - 右侧参会人表格复选框(表头含'姓名',区别于会议室表格)"
},
"创建会议-会议室选择": {
"selectors": [
".el-checkbox"
],
"type": "css",
"description": "创建会议 - 右侧会议室选择复选框"
},
"创建会议-编辑会议室": {
"selectors": [
".room .delimg img:nth-of-type(2)"
],
"type": "css",
"description": "创建会议 - 左侧会议室框中的编辑按钮(点击切换右侧到参会人配置模式)"
},
"创建会议-确定创建": {
"selectors": [
".btn button.el-button"
],
"type": "css",
"description": "创建会议 - 底部\"确定创建\"按钮"
},
"会议创建成功-查看详情": {
"selectors": [
".el-message-box button:has-text('查看详情')"
],
"type": "css",
"description": "会议创建成功弹窗 - 查看详情按钮(进入会议详情页)"
},
"创建会议-编辑完成按钮": {
"selectors": [
".room .user_list button:has-text(\"完成\")"
],
"type": "css",
"description": "创建会议 - 左侧会议室框编辑模式下的完成按钮(保存参会人配置)"
}
}
},
{
"id": "功能中心-全部功能",
"name": "功能中心-全部功能",
"elements": {
"全部功能-会议预约-创建会议": {
"selectors": [
"[data-id=\"reserve_list.create\"]"
],
"type": "css",
"description": "会议预约 - 创建会议"
},
"全部功能-会议预约-会议模板": {
"selectors": [
"[data-id=\"reserve_list.model\"]"
],
"type": "css",
"description": "会议预约 - 会议模板"
},
"全部功能-会议预约-会议日程": {
"selectors": [
"[data-id=\"reserve_list.schedule\"]"
],
"type": "css",
"description": "会议预约 - 会议日程"
},
"全部功能-会议预约-会议室": {
"selectors": [
"[data-id=\"reserve_list.room\"]"
],
"type": "css",
"description": "会议预约 - 会议室"
},
"全部功能-会议预约-会议列表": {
"selectors": [
"[data-id=\"reserve_list.meeting\"]"
],
"type": "css",
"description": "会议预约 - 会议列表"
},
"全部功能-运维维护-会议运维": {
"selectors": [
"[data-id=\"maintain_list.meeting\"]"
],
"type": "css",
"description": "运维维护 - 会议运维"
},
"全部功能-运维维护-视频设备": {
"selectors": [
"[data-id=\"maintain_list.video_dev\"]"
],
"type": "css",
"description": "运维维护 - 视频设备"
},
"全部功能-运维维护-维护区域": {
"selectors": [
"[data-id=\"maintain_list.room_list\"]"
],
"type": "css",
"description": "运维维护 - 维护区域"
},
"全部功能-运维维护-维护设备": {
"selectors": [
"[data-id=\"maintain_list.dev_list\"]"
],
"type": "css",
"description": "运维维护 - 维护设备"
},
"全部功能-运维维护-告警列表": {
"selectors": [
"[data-id=\"maintain_list.alarm_list\"]"
],
"type": "css",
"description": "运维维护 - 告警列表"
},
"全部功能-运维维护-会议巡检": {
"selectors": [
"[data-id=\"maintain_list.meeting_inspect\"]"
],
"type": "css",
"description": "运维维护 - 会议巡检"
},
"全部功能-运维维护-待办巡检": {
"selectors": [
"[data-id=\"maintain_list.inspect_list\"]"
],
"type": "css",
"description": "运维维护 - 待办巡检"
},
"全部功能-维护工单-待办工单": {
"selectors": [
"[data-id=\"workorder_list.order_list\"]"
],
"type": "css",
"description": "维护工单 - 待办工单"
},
"全部功能-维护工单-我的工单": {
"selectors": [
"[data-id=\"workorder_list.order_my\"]"
],
"type": "css",
"description": "维护工单 - 我的工单"
},
"全部功能-维护工单-告警工单": {
"selectors": [
"[data-id=\"workorder_list.alarm_order\"]"
],
"type": "css",
"description": "维护工单 - 告警工单"
},
"全部功能-集控控制-设备列表": {
"selectors": [
"[data-id=\"central_list.deviceList\"]"
],
"type": "css",
"description": "集控控制 - 设备列表"
},
"全部功能-集控控制-远程控制": {
"selectors": [
"[data-id=\"central_list.remoteControl\"]"
],
"type": "css",
"description": "集控控制 - 远程控制"
},
"全部功能-集控控制-文件下发": {
"selectors": [
"[data-id=\"central_list.fileList\"]"
],
"type": "css",
"description": "集控控制 - 文件下发"
},
"全部功能-集控控制-消息下发": {
"selectors": [
"[data-id=\"central_list.infoList\"]"
],
"type": "css",
"description": "集控控制 - 消息下发"
},
"全部功能-集控控制-壁纸下发": {
"selectors": [
"[data-id=\"central_list.wallPaper\"]"
],
"type": "css",
"description": "集控控制 - 壁纸下发"
},
"全部功能-集控控制-脚本命令": {
"selectors": [
"[data-id=\"central_list.shellList\"]"
],
"type": "css",
"description": "集控控制 - 脚本命令"
},
"全部功能-集控控制-应用管理": {
"selectors": [
"[data-id=\"central_list.appList\"]"
],
"type": "css",
"description": "集控控制 - 应用管理"
},
"全部功能-集控控制-屏幕管理": {
"selectors": [
"[data-id=\"central_list.screenControl\"]"
],
"type": "css",
"description": "集控控制 - 屏幕管理"
},
"全部功能-集控控制-软件卸载": {
"selectors": [
"[data-id=\"central_list.installList\"]"
],
"type": "css",
"description": "集控控制 - 软件卸载"
},
"全部功能-集控控制-设备网络": {
"selectors": [
"[data-id=\"central_list.devicenet\"]"
],
"type": "css",
"description": "集控控制 - 设备网络"
},
"全部功能-会议转录-转录列表": {
"selectors": [
"[data-id=\"trans_list.table\"]"
],
"type": "css",
"description": "会议转录 - 转录列表"
},
"全部功能-信息发布-信息发布": {
"selectors": [
"[data-id=\"publication_list.info\"]"
],
"type": "css",
"description": "信息发布 - 信息发布"
},
"全部功能-信息发布-门牌发布": {
"selectors": [
"[data-id=\"publication_list.doorScreen\"]"
],
"type": "css",
"description": "信息发布 - 门牌发布"
},
"全部功能-数据统计-数据统计": {
"selectors": [
"[data-id=\"data_list.booking\"]"
],
"type": "css",
"description": "数据统计 - 数据统计"
},
"全部功能-数据统计-使用数据": {
"selectors": [
"[data-id=\"data_list.usage\"]"
],
"type": "css",
"description": "数据统计 - 使用数据"
},
"全部功能-数据统计-管理看板": {
"selectors": [
"[data-id=\"data_list.dashboard\"]"
],
"type": "css",
"description": "数据统计 - 管理看板"
},
"全部功能-数据统计-故障数据": {
"selectors": [
"[data-id=\"data_list.fault\"]"
],
"type": "css",
"description": "数据统计 - 故障数据"
},
"全部功能-数据统计-运维数据": {
"selectors": [
"[data-id=\"data_list.devops_data\"]"
],
"type": "css",
"description": "数据统计 - 运维数据"
},
"全部功能-数据统计-运维统计": {
"selectors": [
"[data-id=\"data_list.devops_total\"]"
],
"type": "css",
"description": "数据统计 - 运维统计"
},
"全部功能-数据统计-巡检报告": {
"selectors": [
"[data-id=\"data_list.inspect_report\"]"
],
"type": "css",
"description": "数据统计 - 巡检报告"
},
"全部功能-数据统计-会议统计": {
"selectors": [
"[data-id=\"data_list.meeting_total\"]"
],
"type": "css",
"description": "数据统计 - 会议统计"
},
"全部功能-数据统计-通知统计": {
"selectors": [
"[data-id=\"data_list.notice_total\"]"
],
"type": "css",
"description": "数据统计 - 通知统计"
},
"全部功能-数据统计-会议服务统计": {
"selectors": [
"[data-id=\"data_list.meeting_service\"]"
],
"type": "css",
"description": "数据统计 - 会议服务统计"
},
"全部功能-数据统计-会议室统计": {
"selectors": [
"[data-id=\"data_list.room_total\"]"
],
"type": "css",
"description": "数据统计 - 会议室统计"
},
"全部功能-数据统计-WeLink会议统计": {
"selectors": [
"[data-id=\"data_list.welink_total\"]"
],
"type": "css",
"description": "数据统计 - WeLink会议统计"
},
"全部功能-数据统计-WeLink历史记录": {
"selectors": [
"[data-id=\"data_list.welink_history\"]"
],
"type": "css",
"description": "数据统计 - WeLink历史记录"
},
"全部功能-数据统计-SMC3会议统计": {
"selectors": [
"[data-id=\"data_list.smc3_total\"]"
],
"type": "css",
"description": "数据统计 - SMC3会议统计"
},
"全部功能-数据统计-SMC3历史记录": {
"selectors": [
"[data-id=\"data_list.smc3_history\"]"
],
"type": "css",
"description": "数据统计 - SMC3历史记录"
},
"全部功能-数据统计-RSE会议统计": {
"selectors": [
"[data-id=\"data_list.res_total\"]"
],
"type": "css",
"description": "数据统计 - RSE会议统计"
},
"全部功能-数据统计-RSE历史记录": {
"selectors": [
"[data-id=\"data_list.res_history\"]"
],
"type": "css",
"description": "数据统计 - RSE历史记录"
},
"全部功能-资产管理-资产信息": {
"selectors": [
"[data-id=\"asset_list.asset_info\"]"
],
"type": "css",
"description": "资产管理 - 资产信息"
},
"全部功能-资产管理-资产故障": {
"selectors": [
"[data-id=\"asset_list.asset_repair\"]"
],
"type": "css",
"description": "资产管理 - 资产故障"
},
"全部功能-资产管理-资产设备": {
"selectors": [
"[data-id=\"asset_list.asset_device\"]"
],
"type": "css",
"description": "资产管理 - 资产设备"
},
"全部功能-会务管理-会务统筹": {
"selectors": [
"[data-id=\"conference_list.coord\"]"
],
"type": "css",
"description": "会务管理 - 会务统筹"
},
"全部功能-会务管理-会务工单": {
"selectors": [
"[data-id=\"conference_list.order\"]"
],
"type": "css",
"description": "会务管理 - 会务工单"
},
"全部功能-信息管理-信息窗管理": {
"selectors": [
"[data-id=\"info_list.message_window\"]"
],
"type": "css",
"description": "信息管理 - 信息窗管理"
},
"全部功能-信息管理-消息通知": {
"selectors": [
"[data-id=\"info_list.notification\"]"
],
"type": "css",
"description": "信息管理 - 消息通知"
},
"全部功能-信息管理-待办事项": {
"selectors": [
"[data-id=\"info_list.todo_list\"]"
],
"type": "css",
"description": "信息管理 - 待办事项"
},
"全部功能-其他-文件列表": {
"selectors": [
"[data-id=\"other_list.file_list\"]"
],
"type": "css",
"description": "其他 - 文件列表"
},
"全部功能-其他-下载列表": {
"selectors": [
"[data-id=\"other_list.down_list\"]"
],
"type": "css",
"description": "其他 - 下载列表"
},
"全部功能-其他-通讯录": {
"selectors": [
"[data-id=\"other_list.contact_list\"]"
],
"type": "css",
"description": "其他 - 通讯录"
},
"全部功能-预定2.0-会议室列表": {
"selectors": [
"[data-id=\"meetingRoomList\"]"
],
"type": "css",
"description": "预定2.0 - 会议室列表"
},
"全部功能-预定2.0-已预定会议": {
"selectors": [
"[data-id=\"bookedMeeting\"]"
],
"type": "css",
"description": "预定2.0 - 已预定会议"
},
"全部功能-预定2.0-历史记录": {
"selectors": [
"[data-id=\"historyRecord\"]"
],
"type": "css",
"description": "预定2.0 - 历史记录"
},
"全部功能-预定2.0-会议模板": {
"selectors": [
"[data-id=\"meetingTemplate\"]"
],
"type": "css",
"description": "预定2.0 - 会议模板"
},
"全部功能-预定2.0-会议审批": {
"selectors": [
"[data-id=\"meetingApproval\"]"
],
"type": "css",
"description": "预定2.0 - 会议审批"
},
"全部功能-预定2.0-参会人模板": {
"selectors": [
"[data-id=\"participantTemplate\"]"
],
"type": "css",
"description": "预定2.0 - 参会人模板"
}
}
},
{
"id": "功能中心-常用功能",
"name": "功能中心-常用功能",
"elements": {
"功能中心-常用功能-创建会议": {
"selectors": [
"[data-key=\"reserve_list.create\"]"
],
"type": "css",
"description": "创建会议"
},
"功能中心-常用功能-会议模板": {
"selectors": [
"[data-key=\"reserve_list.model\"]"
],
"type": "css",
"description": "会议模板"
},
"功能中心-常用功能-会议日程": {
"selectors": [
"[data-key=\"reserve_list.schedule\"]"
],
"type": "css",
"description": "会议日程"
},
"功能中心-常用功能-会议室": {
"selectors": [
"[data-key=\"reserve_list.room\"]"
],
"type": "css",
"description": "会议室"
},
"功能中心-常用功能-会议列表": {
"selectors": [
"[data-key=\"reserve_list.meeting\"]"
],
"type": "css",
"description": "会议列表"
},
"功能中心-常用功能-数据统计": {
"selectors": [
"[data-key=\"data_list.booking\"]"
],
"type": "css",
"description": "数据统计"
}
}
},
{
"id": "功能中心-搜索框",
"name": "功能中心-搜索框",
"elements": {
"功能中心-搜索输入框": {
"selectors": [
".menu_header .search input"
],
"type": "css",
"description": "功能中心搜索输入框"
}
}
},
{
"id": "功能中心-筛选器",
"name": "功能中心-筛选器",
"elements": {
"全部功能-筛选器-全部": {
"selectors": [
"#filter_all"
],
"type": "css",
"description": "全部"
},
"全部功能-筛选器-会议预约": {
"selectors": [
"#reserve_enable"
],
"type": "css",
"description": "会议预约"
},
"全部功能-筛选器-运维维护": {
"selectors": [
"#maintain_enable"
],
"type": "css",
"description": "运维维护"
},
"全部功能-筛选器-维护工单": {
"selectors": [
"#workorder_enable"
],
"type": "css",
"description": "维护工单"
},
"全部功能-筛选器-集控控制": {
"selectors": [
"#central_enable"
],
"type": "css",
"description": "集控控制"
},
"全部功能-筛选器-会议转录": {
"selectors": [
"#trans_enable"
],
"type": "css",
"description": "会议转录"
},
"全部功能-筛选器-信息发布": {
"selectors": [
"#publication_enable"
],
"type": "css",
"description": "信息发布"
},
"全部功能-筛选器-数据统计": {
"selectors": [
"#data_enable"
],
"type": "css",
"description": "数据统计"
},
"全部功能-筛选器-资产管理": {
"selectors": [
"#asset_enable"
],
"type": "css",
"description": "资产管理"
},
"全部功能-筛选器-会务管理": {
"selectors": [
"#conference_enable"
],
"type": "css",
"description": "会务管理"
},
"全部功能-筛选器-信息管理": {
"selectors": [
"#info_enable"
],
"type": "css",
"description": "信息管理"
},
"全部功能-筛选器-其他": {
"selectors": [
"#other_enable"
],
"type": "css",
"description": "其他"
},
"全部功能-筛选器-预定2.0": {
"selectors": [
"#filter_meetingV2"
],
"type": "css",
"description": "预定2.0"
}
}
},
{
"id": "功能中心-置顶功能",
"name": "功能中心-置顶功能",
"elements": {
"功能中心-置顶功能-创建会议": {
"selectors": [
"[data-key=\"reserve_list.create\"]"
],
"type": "css",
"description": "创建会议"
},
"功能中心-置顶功能-会议模板": {
"selectors": [
"[data-key=\"reserve_list.model\"]"
],
"type": "css",
"description": "会议模板"
},
"功能中心-置顶功能-会议日程": {
"selectors": [
"[data-key=\"reserve_list.schedule\"]"
],
"type": "css",
"description": "会议日程"
},
"功能中心-置顶功能-会议室": {
"selectors": [
"[data-key=\"reserve_list.room\"]"
],
"type": "css",
"description": "会议室"
},
"功能中心-置顶功能-会议列表": {
"selectors": [
"[data-key=\"reserve_list.meeting\"]"
],
"type": "css",
"description": "会议列表"
}
}
},
{
"id": "登录页",
"name": "登录页",
"elements": {
"登录页-密码登录Tab": {
"selectors": [
".el-tabs__item"
],
"type": "css",
"description": "密码登录选项卡(第1个.el-tabs__item)"
},
"登录页-短信登录Tab": {
"selectors": [
".el-tabs__item"
],
"type": "css",
"description": "短信登录选项卡(第2个.el-tabs__item)"
},
"登录页-邮箱登录Tab": {
"selectors": [
".el-tabs__item"
],
"type": "css",
"description": "邮箱登录选项卡(第3个.el-tabs__item)"
},
"登录页-账号输入框": {
"selectors": [
"input[placeholder*=\"手机号\"]"
],
"type": "css",
"description": "手机号/用户名/邮箱输入框(第1个input,精确匹配避免覆盖其他字段)"
},
"登录页-密码输入框": {
"selectors": [
"input[type=\"password\"]"
],
"type": "css",
"description": "密码输入框(第2个input,type=password,精确匹配避免覆盖其他字段)"
},
"登录页-验证码输入框": {
"selectors": [
"input[placeholder*=\"\"]"
],
"type": "css",
"description": "图形验证码输入框(第3个input,精确匹配避免覆盖其他字段)"
},
"登录页-验证码图片": {
"selectors": [
"img"
],
"type": "css",
"description": "图形验证码图片"
},
"登录页-登录按钮": {
"selectors": [
".el-button--primary"
],
"type": "css",
"description": "登录提交按钮"
},
"登录页-协议复选框": {
"selectors": [
".el-checkbox"
],
"type": "css",
"description": "注册/登录协议复选框,需用 JS click(document.querySelector('.el-checkbox').click())"
},
"登录页-立即注册": {
"selectors": [
".el-checkbox"
],
"type": "css",
"description": "立即注册链接"
},
"登录页-忘记密码": {
"selectors": [
"a"
],
"type": "css",
"description": "忘记密码链接"
},
"登录页-语言切换": {
"selectors": [
".el-dropdown"
],
"type": "css",
"description": "语言切换下拉"
},
"登录页-系统名称": {
"selectors": [
".el-tabs__item"
],
"type": "css",
"description": "页面顶部'统一管理平台'标题"
},
"登录页-协议内容": {
"selectors": [
"el-checkbox"
],
"type": "css",
"description": "注册或登录代表您同意《统一登录服务协议》& 《统一登录隐私政策》"
}
}
},
{
"id": "首页",
"name": "首页",
"elements": {
"导航栏-功能中心抽屉开关": {
"selectors": [
".home_nav_left"
],
"type": "css",
"description": "打开/关闭功能中心抽屉"
},
"导航栏-Logo": {
"selectors": [
".home_nav .logo"
],
"type": "css",
"description": "顶部 logo 图片"
},
"导航栏-系统名称": {
"selectors": [
".logo_text"
],
"type": "css",
"description": "系统名文本,如'统一会议管理平台'"
},
"导航栏-退出登录": {
"selectors": [
".ub-icon-out"
],
"type": "css",
"description": "仅 loginAPP==normal 时显示"
},
"导航栏-用户头像": {
"selectors": [
".el-dropdown.themeUl.userInfo"
],
"type": "css",
"description": "下拉,hover 展开"
},
"导航栏-个人信息": {
"selectors": [
".el-dropdown-menu__item"
],
"type": "css",
"description": "下拉项 command=personInfo"
},
"导航栏-版本信息": {
"selectors": [
".el-dropdown-menu__item"
],
"type": "css",
"description": "下拉项 command=version"
},
"首页-常用功能-创建会议": {
"selectors": [
"[data-key=\"reserve_list.create\"]"
],
"type": "css",
"description": "创建会议"
},
"首页-常用功能-会议模板": {
"selectors": [
"[data-key=\"reserve_list.model\"]"
],
"type": "css",
"description": "会议模板"
},
"首页-常用功能-会议日程": {
"selectors": [
"[data-key=\"reserve_list.schedule\"]"
],
"type": "css",
"description": "会议日程"
},
"首页-常用功能-会议室": {
"selectors": [
"[data-key=\"reserve_list.room\"]"
],
"type": "css",
"description": "会议室"
},
"首页-常用功能-会议列表": {
"selectors": [
"[data-key=\"reserve_list.meeting\"]"
],
"type": "css",
"description": "会议列表"
},
"首页-常用功能-数据统计": {
"selectors": [
"[data-key=\"data_list.booking\"]"
],
"type": "css",
"description": "数据统计"
},
"首页-常用功能-使用数据": {
"selectors": [
"[data-key=\"data_list.usage\"]"
],
"type": "css",
"description": "使用数据"
},
"首页-常用功能-管理看板": {
"selectors": [
"[data-key=\"data_list.dashboard\"]"
],
"type": "css",
"description": "管理看板"
},
"首页-置顶功能-会议室列表": {
"selectors": [
"[data-key=\"meetingRoomList\"]"
],
"type": "css",
"description": "会议室列表"
},
"首页-置顶功能-会议运维": {
"selectors": [
"[data-key=\"maintain_list.meeting\"]"
],
"type": "css",
"description": "会议运维"
},
"首页-置顶功能-设备列表": {
"selectors": [
"[data-key=\"central_list.deviceList\"]"
],
"type": "css",
"description": "设备列表"
}
}
}
],
"shared_elements": {}
}
\ No newline at end of file
{
"version": "1.0",
"description": "页面URL映射表:根据用例步骤识别目标页面,直达URL跳过中间导航点击",
"version": "2.0",
"description": "页面注册表:Phase 4 用例步骤识别直达URL(match_rules/skip_steps/url)+ 语义化运行时页面身份识别(url_patterns/fingerprint)",
"pages": [
{
"id": "create_meeting",
"name": "新建会议",
"url": "https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FCreateMeeting",
"url_patterns": [
"meetingV3"
],
"fingerprint": [
{"selector": ".topic_item input", "min_count": 1}
],
"match_rules": [
{"type": "step_name_contains", "value": "新建会议"},
{"type": "selector_contains", "value": "新建会议"}
......@@ -14,6 +20,42 @@
"step_name_contains": ["新建会议", "功能中心", "会议预约"],
"action_is": ["click"]
}
},
{
"id": "login",
"name": "登录页",
"url": "",
"url_patterns": [
"login",
"Login"
],
"fingerprint": [
{"selector": "input[placeholder*='手机号']", "min_count": 1}
]
},
{
"id": "home",
"name": "主页",
"url": "",
"url_patterns": [
"#/home",
"#/dashboard"
],
"fingerprint": [
{"selector": ".home_nav_left", "min_count": 1}
]
},
{
"id": "meeting_detail",
"name": "会议详情",
"url": "",
"url_patterns": [
"meetingV3.*Detail",
"MeetingDetail"
],
"fingerprint": [
{"selector": ".room:has-text('会议')", "min_count": 1}
]
}
]
}
\ No newline at end of file
}
......@@ -30,6 +30,8 @@ from app.config import settings
from app.utils.selector_mapper import SelectorMapper
from app.services.element_mapping_service import get_element_mapping_service
from app.services.page_url_service import get_page_url_service
from app.services.semantic_resolver import get_semantic_resolver
from app.executors.ui_context import ExecutionContext
logger = logging.getLogger(__name__)
......@@ -175,6 +177,9 @@ class PlaywrightExecutor:
# 页面识别服务(URL 直达导航,跳过中间点击步骤)
self._page_url_service = get_page_url_service()
# 语义解析器(语义目标 → 选择器链,语义化用例执行核心)
self._semantic_resolver = get_semantic_resolver()
# 内部状态
self._playwright: Optional[Playwright] = None
self._browser: Optional[Browser] = None
......@@ -183,6 +188,12 @@ class PlaywrightExecutor:
self._is_running: bool = False
self._is_logged_in: bool = False
# 语义化执行上下文(execute_case 入口按用例重置,防跨用例污染)
self._ctx: Optional[ExecutionContext] = None
# 语义化执行:当前页面 scope(页面注册表 id,登录/导航后同步,仅记录)
self._current_scope: Optional[str] = None
# 确保截图目录存在
os.makedirs(self.screenshot_dir, exist_ok=True)
......@@ -563,6 +574,8 @@ class PlaywrightExecutor:
f"(block={bool(has_block)}, nav={bool(has_nav)}, "
f"login_form={bool(login_form)}, login_route={on_login_route})"
)
# 语义化执行:登录后同步当前页面 scope
self._sync_current_scope()
except Exception:
pass
......@@ -825,6 +838,23 @@ class PlaywrightExecutor:
except Exception:
pass
def _sync_current_scope(self) -> None:
"""
语义化执行:同步当前页面 scope(页面注册表 id)
登录/导航成功后调用,仅记录 _current_scope,本阶段不参与元素定位。
识别不到时置 None(零行为变化)。
"""
try:
rec = self._page_url_service.recognize_current_page(self._page)
self._current_scope = rec["id"] if rec else None
if rec:
logger.debug(
f"[scope] 当前页面识别: {rec['id']} ({rec['name']}) confidence={rec['confidence']}"
)
except Exception as e:
logger.debug(f"[scope] 页面识别异常: {e}")
def _navigate_to_target_page(self, page_target: Dict[str, Any]) -> None:
"""
Phase 4: URL 直达导航到目标页面
......@@ -838,6 +868,8 @@ class PlaywrightExecutor:
self._page.wait_for_timeout(3000)
self._wait_for_loading_overlay()
logger.info(f"[页面直达] 导航完成,当前 URL: {self._page.url}")
# 语义化执行:导航成功后同步当前页面 scope
self._sync_current_scope()
except Exception as e:
logger.warning(f"[页面直达] 导航到目标页面失败: {e}")
......@@ -877,6 +909,11 @@ class PlaywrightExecutor:
steps = case.get("steps", [])
case_config = case.get("config", {})
# 语义化执行:按用例重置执行上下文(防 run_all_cases_sync 复用执行器跨用例污染)
self._ctx = ExecutionContext(case.get("parameters") or {})
self._ctx.set("caseName", case_name)
self._ctx.set("caseId", case_id)
# 合并配置
if case_config:
self.timeout = case_config.get("timeout", self.timeout)
......@@ -997,12 +1034,16 @@ class PlaywrightExecutor:
# 如果步骤失败,后续步骤标记为跳过
# (单步重试已在 execute_step 内完成,这里不再整用例重试)
# 注意:finally/on_failure 步骤不跳过,保留到主流程结束后的后置执行
if step_result.status == "failed":
result.status = "failed"
result.error_message = step_result.error
# 后续步骤跳过
# 后续步骤跳过(仅跳过 always/on_success)
for remaining in steps[len(result.steps_result):]:
run_on = remaining.get("run_on", "always")
if run_on in ("finally", "on_failure"):
continue
skipped = StepResult(
order=remaining.get("order", 0),
name=remaining.get("name", ""),
......@@ -1024,10 +1065,49 @@ class PlaywrightExecutor:
finally:
result.duration = (datetime.now() - start_time).total_seconds()
# 后置执行 finally/on_failure 步骤(数据自清理等)
# 无论主流程成功/失败/异常都执行;失败不掩盖主结果(仅标注)
self._run_finally_steps(steps, result, callback)
logger.info(f"用例执行完成: {case_name}, 状态={result.status}, 耗时={result.duration:.2f}s")
return result
def _run_finally_steps(
self,
steps: List[Dict[str, Any]],
result,
callback: Optional[Callable[[StepResult], None]] = None,
) -> None:
"""
主流程结束后执行 finally/on_failure 步骤(数据自建/自清理)
- finally: 无论主流程成败都执行(清理残留数据)
- on_failure: 仅主流程失败时执行(失败补救)
finally 失败不覆盖主结果(仅标注"清理步骤失败"),避免清理异常掩盖真实结果。
"""
main_failed = result.status != "passed"
for step in steps:
run_on = step.get("run_on", "always")
if run_on == "finally":
pass # 无条件执行
elif run_on == "on_failure" and main_failed:
pass # 主流程失败时执行
else:
continue
try:
sr = self.execute_step(step, callback=callback)
result.steps_result.append(sr)
if sr.status != "passed" and result.status in ("passed", None):
result.status = "failed"
result.error_message = (result.error_message or "") + ";清理步骤失败"
logger.warning(f"[清理] 后置步骤失败: {step.get('name', '')} → {sr.error}")
except Exception as e:
if result.status in ("passed", None):
result.status = "failed"
result.error_message = (result.error_message or "") + f";清理步骤异常: {e}"
logger.warning(f"[清理] 后置步骤异常: {step.get('name', '')} → {e}")
def execute_step(
self,
step: Dict[str, Any],
......@@ -1063,43 +1143,107 @@ class PlaywrightExecutor:
start_time = datetime.now()
action = step.get("action", "")
params = step.get("params", {})
params = step.get("params", {}) or {}
# 语义化执行:渲染 params 中的模板变量({__NOW__}/{__CTX:key__} 等)
# 渲染返回新 dict,不写回 step,防重复渲染放大 NOW 等动态值
if self._ctx is not None and isinstance(params, dict):
rendered = self._ctx.render(params)
if isinstance(rendered, dict):
params = rendered
expected = step.get("expected", "")
force = step.get("force", params.get("force", False))
wait_after = step.get("wait_after", params.get("wait_after", None))
logger.info(f"⏳ 步骤 {step_result.order}/{params.get('_total_steps', '?')}: {step_result.name}")
# ==================== 语义解析器(语义目标优先,走现有定位回退链) ====================
# semantic 顶层字段存在时,解析为选择器链注入 params["selectors"] 高优先级,
# 继续走 _do_click/_do_fill 全部成熟回退策略。语义解析成功则跳过元素映射表块。
semantic_chain: List[str] = []
semantic_target: Optional[Dict[str, Any]] = None
if settings.SEMANTIC_ENABLED and self._semantic_resolver is not None:
try:
semantic_target = step.get("semantic")
semantic_chain = self._semantic_resolver.resolve_step(
self._page, step, self._current_scope, self._ctx
) or []
if semantic_chain:
existing_selectors = params.get("selectors")
if isinstance(existing_selectors, list) and existing_selectors:
params["selectors"] = list(semantic_chain) + list(existing_selectors)
else:
original = params.get("selector")
if original:
original = str(original)
params["selectors"] = list(semantic_chain) + [original]
else:
params["selectors"] = list(semantic_chain)
if not params.get("selector"):
params["selector"] = semantic_chain[0]
logger.info(f"[语义解析] 步骤 {step_result.order} {step_result.name} → 选择器链: {semantic_chain}")
except Exception as e:
logger.warning(f"[语义解析] 步骤 {step_result.order} 解析异常,回退原路径: {e}")
semantic_chain = []
# ==================== Phase 3: 元素映射表最高优先级 ====================
# 查询元素映射表,如命中则注入到 params 中作为最高优先级选择器
# (语义解析已命中时跳过,语义优先)
# 优先级:v2 显式字典(scope + element_key/semantic.text 精确查找)→ legacy 评分路径
step_name = step.get("name", "")
step_action = step.get("action", "")
if self._element_mapping_service.is_enabled() and step_name:
mapping_result = self._element_mapping_service.get_selector(step_name, step_action)
if mapping_result:
mapping_selector = mapping_result["selector"]
logger.info(f"[元素映射表] 命中: {step_name} → {mapping_selector}")
if (not semantic_chain) and self._element_mapping_service.is_enabled() and step_name:
mapping_selector: Optional[str] = None
mapping_chain: Optional[List[str]] = None
# v2 显式字典优先(无评分、纯 dict 精确查找)
v2_key = step.get("element_key") or (step.get("semantic") or {}).get("text")
if v2_key:
try:
lookup = self._element_mapping_service.lookup_by_key(
self._current_scope, str(v2_key)
)
except Exception as e:
logger.debug(f"[元素映射表 v2] 查找异常: {e}")
lookup = None
if lookup and lookup.get("selectors"):
mapping_chain = list(lookup["selectors"])
mapping_selector = mapping_chain[0]
logger.info(f"[元素映射表 v2] 命中: {self._current_scope}/{v2_key} → {mapping_selector}")
# legacy 评分路径(USE_FUZZY_MAPPING=false 时返回 None)
if mapping_selector is None:
mapping_result = self._element_mapping_service.get_selector(step_name, step_action)
if mapping_result:
mapping_selector = mapping_result["selector"]
logger.info(f"[元素映射表] 命中: {step_name} → {mapping_selector}")
if mapping_selector:
# 将映射表选择器注入到 params 中作为最高优先级
existing_selectors = params.get("selectors")
if isinstance(existing_selectors, list) and existing_selectors:
params["selectors"] = [mapping_selector] + list(existing_selectors)
params["selectors"] = (mapping_chain or [mapping_selector]) + list(existing_selectors)
else:
# 保留原始 selector 作为回退(避免映射表选择器不精确时无备选)
original = params.get("selector")
if original:
original = str(original)
# v2 链(多个候选)直接整链 + 原始回退
if mapping_chain and len(mapping_chain) > 1:
params["selectors"] = mapping_chain + [original]
# 注入顺序优化(P1):当映射表命中"通用复选框选择器"(裸 .el-checkbox /
# .el-checkbox__inner / input[type=checkbox],page.click 会命中页面第一个
# 复选框,如"短信"通知勾选框,点击后假通过但目标未选中),而原始选择器
# 自带目标文本(:has-text("X"),可走策略5 表格行点击回退精确命中目标行)时,
# 优先用原始选择器。注意:仅对复选框类通用映射生效,避免把
# div:visible:has-text("会议室列表") 这类宽泛文本选择器也提前。
if mapping_selector in (".el-checkbox", ".el-checkbox__inner", "input[type=checkbox]") and ':has-text("' in original:
elif mapping_selector in (".el-checkbox", ".el-checkbox__inner", "input[type=checkbox]") and ':has-text("' in original:
params["selectors"] = [original, mapping_selector]
else:
params["selectors"] = [mapping_selector, original]
else:
params["selectors"] = [mapping_selector]
params["selectors"] = mapping_chain or [mapping_selector]
if not params.get("selector"):
params["selector"] = mapping_selector
......@@ -1192,12 +1336,32 @@ class PlaywrightExecutor:
elif action == "screenshot":
self._do_screenshot(params)
step_result.log = "✓ 截图保存"
elif action == "api_call":
self._do_api_call(params)
step_result.log = f"✓ 接口调用: {params.get('api_case', {}).get('name', '') or 'api_call'}"
else:
raise ValueError(f"不支持的动作类型: {action}")
# 成功:退出重试循环
step_result.status = "passed"
last_error = None
# save_as:从语义目标命中的元素提取值写入上下文变量
# (用于后续步骤 {__CTX:key__} 引用,如自建数据的名称/编号)
if semantic_target and semantic_chain and self._ctx is not None:
self._semantic_resolver.extract_save_as(
self._page, semantic_target, semantic_chain, self._ctx
)
# 状态验证(防假通过):verify 配置翻译成 _do_assert
# must=true 失败 → 步骤真实 failed(不重试,避免重复点击副作用)
try:
fallback_sel = semantic_chain[0] if semantic_chain else params.get("selector")
self._verify_step_state(step, params, fallback_sel)
except AssertionError as ve:
step_result.status = "failed"
step_result.error = str(ve)
step_result.log = f"✗ 状态验证失败: {str(ve)}"
last_error = ve
break
break
except Exception as e:
......@@ -1895,6 +2059,148 @@ class PlaywrightExecutor:
full_page = params.get("full_page", False)
self._page.screenshot(path=path, full_page=full_page)
def _do_api_call(self, params: dict):
"""
复用 ApiTestExecutor 发送单次 HTTP 请求(数据自建/自清理)
配置(params):
api_case: {method, url, headers, body, assertions, description}
save_as: {"key": "$.data.id"} 从响应体 JSONPath 提取写上下文
说明:
- 同步 requests,无浏览器、无事件循环冲突(与 Playwright 独立)
- api_case 已由 execute_step 顶部整步渲染({__CTX:key__} 等),这里幂等兜底
"""
from app.executors.api_test_executor import ApiTestExecutor, AssertionEngine
api_case = params.get("api_case") or {}
save_as = params.get("save_as")
if self._ctx is not None:
rendered = self._ctx.render(api_case)
if isinstance(rendered, dict):
api_case = rendered
client_config = {
"target": {"base_url": settings.TARGET_URL, "verify_ssl": False, "timeout": 30}
}
ex = ApiTestExecutor(client_config)
try:
ex.start()
result = ex.execute_case({
"id": "",
"name": api_case.get("name", "api_call"),
"steps": api_case.get("steps", {}),
})
finally:
try:
ex.stop()
except Exception:
pass
if result.status != "passed":
raise Exception(f"api_call 失败: {result.error or result.status}")
# save_as:从响应体 JSONPath 提取写 ctx(如自建数据的 id)
if save_as and self._ctx and result.step is not None:
body = result.step.response_body or ""
for key, path in save_as.items():
try:
value = AssertionEngine._extract_json_path(body, str(path))
self._ctx.set(key, value)
logger.info(f"[api_call] save_as: {key} ← {value!r}")
except Exception as e:
logger.warning(f"[api_call] save_as 提取失败 {key}={path}: {e}")
return result
def _verify_step_state(
self,
step: Dict[str, Any],
params: dict,
fallback_selector: Optional[str] = None,
) -> bool:
"""
状态验证(防假通过):把 verify 配置翻译成 _do_assert 调用,复用 10 种断言
Args:
step: 步骤定义(含可选 verify 顶层字段)
params: 已渲染的步骤参数(取 selectors/selector 兜底)
fallback_selector: 语义解析链首选择器(verify.selector 缺省时兜底)
Returns:
True 通过或非强制未通过(仅告警);raise AssertionError 当 must=true 失败
verify 配置格式:
{"type": "text_visible", "selector": "...", "text": "预定会议", "must": true}
"""
v = step.get("verify") or {}
if not v:
# ENFORCE_VERIFICATION:expected 非空且无 verify 的 click/fill 走默认校验
if settings.ENFORCE_VERIFICATION and step.get("action") in ("click", "fill"):
expected = step.get("expected") or ""
if expected:
self._default_verify(expected)
return True
must = v.get("must", False)
vtype = v.get("type", "text_visible")
text = str(v.get("text") or v.get("expected") or "").strip()
selector = v.get("selector")
if not selector and fallback_selector:
selector = fallback_selector
assert_params = self._build_verify_assert(vtype, selector, text)
if assert_params is None:
logger.debug(f"[verify] 类型 {vtype} 缺少选择器,跳过校验")
return True
try:
self._do_assert(assert_params, text)
return True
except Exception as e:
if must:
logger.warning(f"[verify] 强制校验失败: {vtype} {e}")
raise AssertionError(f"状态验证失败 ({vtype}): {e}") from e
logger.warning(f"[verify] 非强制校验未通过(仅告警): {vtype} {e}")
return True
def _build_verify_assert(
self, vtype: str, selector: Optional[str], text: str
) -> Optional[dict]:
"""verify 类型 → _do_assert params(复用 10 种断言类型)"""
sels = [selector] if selector else None
if vtype == "text_visible":
return {"type": "contains", "selectors": sels, "expected": text} if sels else None
if vtype == "element_visible":
return {"type": "visible", "selectors": sels} if sels else None
if vtype == "text_hidden":
return {"type": "hidden", "selectors": sels} if sels else None
if vtype == "element_exists":
return {"type": "element_exists", "selectors": sels} if sels else None
if vtype == "url_contains":
return {"type": "url_contains", "expected": text}
if vtype == "dialog":
# 弹窗提示校验:message/dialog/notification 任一含文本即通过
s = selector or f'.el-message:has-text("{text}")'
return {"type": "element_exists", "selectors": [s, f'.el-dialog:has-text("{text}")', f'.el-notification:has-text("{text}")']}
if vtype == "value":
return {"type": "value", "selectors": sels, "expected": text} if sels else None
if vtype == "count":
return {"type": "count", "selectors": sels, "expected": text} if sels else None
return {"type": "visible", "selectors": sels} if sels else None
def _default_verify(self, expected: str) -> None:
"""ENFORCE_VERIFICATION:默认校验(弹窗/文本,仅告警不强制)"""
selector = (
f'.el-message:has-text("{expected}"), '
f'.el-dialog:has-text("{expected}"), '
f'.el-notification:has-text("{expected}")'
)
try:
self._do_assert({"type": "element_exists", "selectors": [selector]}, expected)
except Exception as e:
logger.warning(f"[verify] 默认校验未通过(仅告警): {e}")
def _do_assert(self, params: dict, expected: str = "") -> None:
"""
执行断言验证(多选择器回退用于 visible/text/contains/value/count)
......@@ -1909,6 +2215,9 @@ class PlaywrightExecutor:
AssertionError: 断言失败时抛出
"""
assert_type = params.get("type", "visible")
# 兼容旧用例 `url`(阶段 6 统一为 url_contains,老数据零影响)
if assert_type == "url":
assert_type = "url_contains"
selectors = self._resolve_selectors(params)
expected_value = params.get("expected", "")
# 调试日志:记录断言参数(排查 min_count 空值问题)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:ui_context.py
模块描述:UI 用例执行上下文 — 维护用例级上下文变量,支持模板变量渲染
语义化用例执行机制的一部分:
- 用例参数(parameters)在 execute_case 入口初始化为上下文变量
- 步骤 params / semantic 目标 / api_call 请求体在 execute_step 渲染
- 支持两种占位符:
- 内置生成器(复用 TemplateResolver):{__NOW__} / {__NOW_+N_H__} /
{__RANDOM_NAME_N__} / {__UUID__} / {__RANDOM_INT_N_M__} / {__INDEX__} / {__REF:task:key__}
- 上下文变量:{__CTX:key__} 引用用例参数或前面步骤 save_as 写入的值
渲染原则:不含 "__" 的字符串直接返回(老用例零影响);
未知占位符保留原文;渲染返回新容器,不修改原模板。
作者:czj
创建日期:2026-08-18
"""
import re
from typing import Any, Dict, Optional
from app.executors.template_resolver import TemplateResolver
logger = __import__("logging").getLogger(__name__)
# 上下文变量占位符:{__CTX:key__}
CTX_PATTERN = re.compile(r"\{__CTX:([^}]+?)__\}")
class ExecutionContext:
"""
用例级执行上下文
维护变量表(初始化为用例 parameters + 内置变量),
提供递归渲染:dict/list/str 中的占位符 → 实际值。
Usage:
>>> ctx = ExecutionContext({"meetingName": "自动化-{__RANDOM_NAME_8__}"})
>>> ctx.render("主题:{__CTX:meetingName__}")
'主题:自动化-aBcD1234'
>>> ctx.set("createdId", "meeting_123")
>>> ctx.get("createdId")
'meeting_123'
"""
def __init__(self, parameters: Optional[Dict[str, Any]] = None):
"""
Args:
parameters: 用例参数(来自 case["parameters"])
其值中的占位符在初始化时渲染(支持跨键引用,迭代收敛)
"""
self._resolver = TemplateResolver()
self._values: Dict[str, Any] = dict(parameters or {})
# 内置变量缺省值(execute_case 入口会覆盖为真实值)
self._values.setdefault("caseName", "")
self._values.setdefault("caseId", "")
# 渲染 parameters 自身的占位符(如 {"meetingName": "自动化-{__RANDOM_NAME_8__}"})
# 两阶段,避免 CTX 引用的值被二次独立渲染生成不一致的随机值:
# 阶段 A:渲染所有键的"内置生成器"({__NOW__}/{__RANDOM_NAME_N__} 等),
# {__CTX:key__} 占位符保留原样,作为 CTX 查找表。
# 阶段 B:仅做 {__CTX:key__} 替换(不调用生成器),引用的是阶段 A 已渲染值。
# 迭代最多 5 轮:支持前向引用(引用定义在后面的键)跨轮收敛。
lookup: Dict[str, Any] = {
key: self._render_value(value, {}) # 空查找表:生成器解析,CTX 保留
for key, value in self._values.items()
}
for _ in range(5):
new_lookup: Dict[str, Any] = dict(lookup) # 以本轮起点作查找表:前向引用跨轮收敛
changed = False
for key, value in lookup.items():
substituted = self._substitute_recursive(value, new_lookup)
new_lookup[key] = substituted
if substituted != value:
changed = True
lookup = new_lookup
if not changed:
break
self._values = lookup
# ---------- 变量读写 ----------
def set(self, key: str, value: Any) -> None:
"""写入上下文变量(save_as 使用)"""
self._values[key] = value
def get(self, key: str) -> Optional[Any]:
"""读取上下文变量;不存在返回 None"""
return self._values.get(key)
def all(self) -> Dict[str, Any]:
"""返回当前全部上下文变量快照(调试用)"""
return dict(self._values)
# ---------- 渲染 ----------
def render(self, template: Any) -> Any:
"""
递归渲染模板中的占位符,返回新容器(不修改原模板)
先由 TemplateResolver 处理内置生成器({__NOW__} 等),
再替换 {__CTX:key__} 上下文变量。
Args:
template: dict/list/str,支持嵌套
Returns:
渲染后的 dict/list/str
"""
return self._render_value(template)
def _render_value(self, value: Any, lookup: Optional[Dict[str, Any]] = None) -> Any:
"""单值递归渲染;lookup 为 CTX 变量查找表(缺省用 self._values)"""
if lookup is None:
lookup = self._values
if isinstance(value, dict):
return {k: self._render_value(v, lookup) for k, v in value.items()}
if isinstance(value, list):
return [self._render_value(v, lookup) for v in value]
if isinstance(value, str) and "__" in value:
# 1. 内置生成器(CTX 占位符会被 TemplateResolver 原样保留)
resolved = self._resolver.resolve(value)
if not isinstance(resolved, str):
return resolved
# 2. 上下文变量替换
return self._substitute_ctx(resolved, lookup)
return value
@staticmethod
def _substitute_ctx(text: str, lookup: Dict[str, Any]) -> str:
"""替换 {__CTX:key__} → 上下文变量值;未知键保留原文"""
def _repl(match: "re.Match[str]") -> str:
key = match.group(1)
if key in lookup:
return str(lookup[key])
return match.group(0) # 未知键保留占位符,调用方记录警告
return CTX_PATTERN.sub(_repl, text)
@staticmethod
def _substitute_recursive(value: Any, lookup: Dict[str, Any]) -> Any:
"""
仅递归替换 {__CTX:key__}(不调用生成器)
用于 parameters 初始化阶段 B:保证被引用的值已是阶段 A 渲染结果,
避免已渲染随机值被二次独立重新生成导致引用不一致。
"""
if isinstance(value, dict):
return {k: ExecutionContext._substitute_recursive(v, lookup) for k, v in value.items()}
if isinstance(value, list):
return [ExecutionContext._substitute_recursive(v, lookup) for v in value]
if isinstance(value, str):
return ExecutionContext._substitute_ctx(value, lookup)
return value
......@@ -27,7 +27,7 @@ from fastapi.responses import FileResponse
from app.config import settings
from app.database import init_db
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, security_erp, security_erp_upload, security_erp_task, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template, api_test
from app.routers import modules, cases, executions, recorder, stats, reports, cleanup, batch, dependencies, security, security_erp, security_erp_upload, security_erp_task, ai_locator, device_sim, system, element_locator, login_template, smart_locate, performance, performance_output, api_preset, deploy, functional_report, report_template, api_test, debug
# 配置日志
logging.basicConfig(
......@@ -277,6 +277,13 @@ app.include_router(
tags=["接口测试"]
)
# 调试只读接口(语义解析验证,无写入)
app.include_router(
debug.router,
prefix="/api/debug",
tags=["调试"]
)
# ==================== 根路径 ====================
......
......@@ -51,6 +51,7 @@ def _case_to_response(c) -> TestCaseResponse:
steps=c.steps or [],
config=c.config or {},
case_type=getattr(c, "case_type", None) or "ui",
parameters=c.parameters or {},
created_at=c.created_at,
updated_at=c.updated_at,
)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:debug.py
模块描述:调试只读 API 路由(语义解析器验证用,无数据库写入)
提供 `/api/debug/resolve`:在真实被测系统页面(TARGET_URL)上对单个步骤
做语义解析,返回选择器链 + 页面识别结果 + save_as 提取值,用于验证
"语义化用例执行"各阶段规则是否按预期命中。
作者:czj
创建日期:2026-08-18
"""
import asyncio
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(tags=["调试"])
# ==================== Schema 定义 ====================
class DebugResolveRequest(BaseModel):
"""语义解析调试请求"""
url: str = Field(default_factory=lambda: settings.TARGET_URL, description="要打开的页面 URL")
step: Dict[str, Any] = Field(..., description="步骤定义(含可选 semantic 字段)")
scope: Optional[str] = Field(None, description="期望页面 scope id(页面注册表,可选)")
parameters: Dict[str, Any] = Field(default_factory=dict, description="用例参数(渲染语义文本)")
class DebugResolveResponse(BaseModel):
"""语义解析调试响应"""
ok: bool = Field(..., description="是否解析到选择器链")
chain: List[str] = Field(default_factory=list, description="解析出的选择器链")
path: str = Field(default="", description="命中路径: semantic/pattern/keyword/mapping/none")
page_id: Optional[str] = Field(None, description="运行时识别的页面 scope id")
page_name: Optional[str] = Field(None, description="运行时识别的页面名称")
expected_scope: Optional[str] = Field(None, description="步骤声明的期望 scope")
scope_matched: Optional[bool] = Field(None, description="scope 是否匹配")
save_as_values: Dict[str, Any] = Field(default_factory=dict, description="save_as 提取的上下文变量")
message: str = Field(default="", description="说明信息")
# ==================== 同步解析(Playwright 须在线程中执行) ====================
def _resolve_on_live_page(
url: str,
step: Dict[str, Any],
scope: Optional[str],
parameters: Dict[str, Any],
) -> DebugResolveResponse:
"""
打开真实页面并做语义解析(同步 API,须在工作线程调用)
返回值:
DebugResolveResponse —— 解析结果
"""
from playwright.sync_api import sync_playwright
from app.executors.ui_context import ExecutionContext
from app.services.semantic_resolver import get_semantic_resolver
from app.services.page_url_service import get_page_url_service
resp = DebugResolveResponse(ok=False)
semantic = step.get("semantic") if isinstance(step, dict) else None
resp.expected_scope = semantic.get("scope") if isinstance(semantic, dict) else None
try:
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=["--ignore-certificate-errors"],
)
try:
page = browser.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(1200)
# 运行时页面身份识别
rec = get_page_url_service().recognize_current_page(page)
scope_id = rec["id"] if rec else scope
resp.page_id = rec["id"] if rec else None
resp.page_name = rec["name"] if rec else None
if isinstance(semantic, dict):
resp.scope_matched = bool(
scope_id and semantic.get("scope") and scope_id == semantic.get("scope")
)
# 语义解析
ctx = ExecutionContext(parameters or {})
resolver = get_semantic_resolver()
chain = resolver.resolve_step(page, step, scope_id, ctx) or []
# save_as 提取验证
if chain and isinstance(semantic, dict):
resolver.extract_save_as(page, semantic, chain, ctx)
resp.ok = bool(chain)
resp.chain = chain
resp.save_as_values = ctx.all()
if chain and isinstance(semantic, dict) and semantic.get("type") in (
"tab", "row_checkbox", "checkbox", "row",
):
resp.path = "pattern"
elif chain and isinstance(semantic, dict):
resp.path = "semantic"
elif chain:
resp.path = "keyword"
else:
resp.path = "none"
resp.message = (
f"页面识别: {resp.page_name or '未识别'}({resp.page_id or '-'})"
+ (",scope 匹配" if resp.scope_matched else "")
+ ("" if chain else ";未解析到选择器链")
)
finally:
browser.close()
except Exception as e:
resp.ok = False
resp.message = f"解析失败: {e}"
logger.warning(f"[debug/resolve] 解析失败: {e}")
return resp
# ==================== API ====================
@router.post("/resolve", response_model=DebugResolveResponse)
async def debug_resolve(req: DebugResolveRequest) -> DebugResolveResponse:
"""
语义解析调试(只读,启动一次无头浏览器)
在真实被测系统页面上运行语义解析器,返回选择器链 + 页面身份 + save_as 值。
用于阶段验证,不写入任何数据。
"""
try:
loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(
None,
_resolve_on_live_page,
req.url,
req.step,
req.scope,
req.parameters,
)
return resp
except Exception as e:
logger.error(f"[debug/resolve] 接口异常: {e}")
return DebugResolveResponse(ok=False, message=f"接口异常: {e}")
......@@ -40,6 +40,11 @@ class StepDefinition(BaseModel):
wait_after (Optional[int]): 自定义操作后延时(ms)
locator_type (Optional[str]): 定位类型: css/xpath/text/id/name
locator_value (Optional[str]): 定位值
semantic (Optional[dict]): 语义目标(语义化用例执行):
{type, text, contains, scope, role, nth, container, save_as}
verify (Optional[dict]): 状态验证(防假通过):
{type, selector, text, timeout, must}
run_on (Optional[str]): 执行时机: always/finally/on_failure/on_success(缺省 always)
"""
order: int = Field(..., ge=1, description="步骤顺序")
......@@ -55,6 +60,18 @@ class StepDefinition(BaseModel):
wait_after: Optional[int] = Field(None, description="自定义操作后延时(ms)")
locator_type: Optional[str] = Field(None, description="定位类型: css/xpath/text/id/name")
locator_value: Optional[str] = Field(None, description="定位值")
semantic: Optional[Dict[str, Any]] = Field(
None,
description="语义目标(语义化用例执行):{type, text, contains, scope, role, nth, container, save_as}",
)
verify: Optional[Dict[str, Any]] = Field(
None,
description="状态验证(防假通过):{type, selector, text, timeout, must}",
)
run_on: Optional[str] = Field(
None,
description="执行时机: always/finally/on_failure/on_success(缺省 always)",
)
class TestCaseBase(BaseModel):
......@@ -77,6 +94,10 @@ class TestCaseBase(BaseModel):
description="执行配置"
)
case_type: str = Field(default="ui", description="用例类型: ui/api/security/deploy")
parameters: Dict[str, Any] = Field(
default_factory=dict,
description="用例参数(上下文变量初始值,支持 {__CTX:key__} 引用)"
)
class TestCaseCreate(TestCaseBase):
......@@ -120,6 +141,7 @@ class TestCaseUpdate(BaseModel):
)
config: Optional[Dict[str, Any]] = Field(None, description="执行配置")
case_type: Optional[str] = Field(None, description="用例类型: ui/api/security/deploy")
parameters: Optional[Dict[str, Any]] = Field(None, description="用例参数(上下文变量初始值)")
class TestCaseResponse(TestCaseBase):
......@@ -192,4 +214,8 @@ class TestCaseImport(BaseModel):
default_factory=lambda: {"timeout": 30000, "retry": 0, "screenshot": True},
description="执行配置"
)
case_type: str = Field(default="ui", description="用例类型: ui/api/security/deploy")
\ No newline at end of file
case_type: str = Field(default="ui", description="用例类型: ui/api/security/deploy")
parameters: Dict[str, Any] = Field(
default_factory=dict,
description="用例参数(上下文变量初始值,支持 {__CTX:key__} 引用)"
)
\ No newline at end of file
......@@ -210,6 +210,7 @@ class CaseService:
steps=steps_data,
config=data.config or {"timeout": 30000, "retry": 0, "screenshot": True},
case_type=data.case_type or "ui",
parameters=data.parameters or {},
)
self.db.add(test_case)
......
......@@ -85,7 +85,8 @@ class ClaudeService:
params: dict,
candidates: List[Dict[str, Any]],
page_url: str = "",
previous_steps: List[Dict[str, Any]] = None
previous_steps: List[Dict[str, Any]] = None,
min_confidence: float = 0.0
) -> Tuple[int, float, str, bool]:
"""
对候选元素进行语义排序(Phase 5 增强)
......@@ -108,6 +109,8 @@ class ClaudeService:
]
page_url: 当前页面 URL(Phase 5 新增)
previous_steps: 前面的步骤列表(Phase 5 新增)
min_confidence: 调用方预判的高置信度阈值;>0 且单候选时直接短路不调 Claude
(语义解析器"规则优先,Claude 按需":规则可判时跳过 Claude 仲裁)
Returns:
Tuple[int, float, str, bool]: (选中索引, 置信度, 选择理由, 是否确认)
......@@ -124,6 +127,11 @@ class ClaudeService:
if len(candidates) == 0:
raise Exception("候选元素列表为空")
# 规则优先:调用方判定唯一/高置信候选时跳过 Claude 仲裁(语义化执行机制)
if min_confidence > 0 and len(candidates) == 1:
logger.debug(f"Claude 语义排序短路: 唯一候选(min_confidence={min_confidence})")
return 0, 1.0, "唯一候选元素(min_confidence 短路)", True
# Phase 5: 单候选也调用 Claude 验证
logger.info(f"Claude 语义排序: step='{step_name}', candidates={len(candidates)}, url={page_url[:50] if page_url else 'N/A'}")
......
......@@ -23,8 +23,26 @@ from typing import Any, Dict, List, Optional
import jieba
from app.config import settings
logger = logging.getLogger(__name__)
# v1 扁平结构的 tier 名 → page_url_mapping 的页面 id 对齐(v2 显式字典查找用)
# 执行器 _current_scope 来自 page_url_service(如 create_meeting),
# 而 v1 映射表键按 tier 分组(如"创建会议")。lookup_by_key 用此表做归一。
TIER_ALIAS = {
"create_meeting": "创建会议",
"meeting_detail": "会议详情",
"meeting_list": "会议列表",
"login": "登录页",
"home": "首页",
"个人日程": "个人日程",
"会议室": "会议室",
"会议模板": "会议模板",
"管理看板": "管理看板",
"信息发布": "信息发布",
}
# 默认映射表文件路径(相对 backend/ 工作目录)
DEFAULT_MAPPING_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
......@@ -80,6 +98,7 @@ class ElementMappingService:
self._enabled = False
self._index: Dict[str, List[str]] = {} # 中文关键词 -> 映射键列表
self._action_map: Dict[str, str] = {} # 动作类型 -> 映射键
self._v2_registry: Dict[str, Any] = {"pages": {}, "shared_elements": {}}
self._load()
# ==================== 加载 ====================
......@@ -89,6 +108,7 @@ class ElementMappingService:
self._elements = {}
self._index = {}
self._action_map = {}
self._v2_registry = {"pages": {}, "shared_elements": {}}
self._enabled = False
if not os.path.exists(self.mapping_file):
......@@ -99,6 +119,25 @@ class ElementMappingService:
with open(self.mapping_file, "r", encoding="utf-8") as f:
data = json.load(f)
# ---- 兼容 v2 显式 pages 结构 ----
pages = data.get("pages")
if isinstance(pages, list):
for page in pages:
pid = page.get("id")
if pid:
self._v2_registry["pages"][pid] = page
shared = data.get("shared_elements")
if isinstance(shared, dict):
self._v2_registry["shared_elements"] = shared
if self._v2_registry["pages"] or self._v2_registry["shared_elements"]:
self._enabled = True
logger.info(
f"[元素映射表 v2] 加载成功: {len(self._v2_registry['pages'])} 页, "
f"{len(self._v2_registry['shared_elements'])} 共享元素"
)
return
# ---- v1 扁平结构 ----
elements = data.get("elements", {})
if not isinstance(elements, dict) or not elements:
logger.warning("[元素映射表] JSON 中 elements 为空,映射表已禁用")
......@@ -106,11 +145,21 @@ class ElementMappingService:
self._elements = elements
# 构建中文关键词 -> 映射键索引
# 构建中文关键词 -> 映射键索引(legacy 评分路径用)
for key in elements:
for kw in self._split_keywords(key):
self._index.setdefault(kw, []).append(key)
# 构建 v2 显式字典(按页面 token 归组 → 页级元素表,无评分)
# v1 键形如"创建会议-预定会议",tier 形如"会议预约-创建会议";
# 从 tier/key 中提取页面 token(创建会议/登录页/会议详情…)作为页 id,
# 与 lookup_by_key(page_id, key) 的 scope 对齐。
tiers: Dict[str, Dict[str, Any]] = {}
for key, info in elements.items():
pid = self._page_id_for(key, info)
tiers.setdefault(pid, {})[key] = info
self._v2_registry["pages"] = tiers
self._enabled = True
logger.info(f"[元素映射表] 加载成功: {len(elements)} 个元素, 来源: {data.get('source', '未知')}")
except json.JSONDecodeError as e:
......@@ -171,6 +220,99 @@ class ElementMappingService:
"""返回所有映射键列表(用于调试)"""
return list(self._elements.keys())
@staticmethod
def _page_id_for(key: str, info: Dict[str, Any]) -> str:
"""从 v1 元素键/tier 提取页面 token 作为 v2 页 id(与 page_url_mapping scope 对齐)"""
tier = info.get("tier") or ""
for tok in ("创建会议", "会议详情", "会议列表", "会议预约", "登录页", "首页",
"会议室", "会议模板", "个人日程", "管理看板", "信息发布", "预订2.0"):
if tok in tier:
return tok
for tok in ("创建会议", "会议详情", "会议列表", "登录页", "首页",
"会议室", "会议模板", "个人日程", "管理看板", "信息发布"):
if key.startswith(f"{tok}-"):
return tok
return tier or "默认"
def lookup_by_key(self, page_id: Optional[str], key: str) -> Optional[Dict[str, Any]]:
"""
v2 显式字典精确查找(无评分、纯 dict)
语义化用例执行的映射路径:scope + 显式语义键 → 选择器链。
优先级:
1. 当前页 elements[key]
2. 当前页 elements["{tier}-{key}"](v1 键含 tier 前缀)
3. shared_elements[key]
Args:
page_id: 页面 scope id(page_url_mapping 的 id,可为 None)
key: 显式语义键(如"预定会议"、"参会人勾选")
Returns:
{"selectors": [...], "type": str, "key": str} 或 None
"""
if not key:
return None
pages = self._v2_registry.get("pages", {})
candidates = []
# page_id → 候选页(原始 id + tier 别名归一)
if page_id:
candidates.append(str(page_id))
# v1 tier 分组:把 page_id 反向映射到 tier 名(如 create_meeting → 创建会议)
for alias_page, alias_tier in TIER_ALIAS.items():
if page_id and str(page_id) == alias_page:
candidates.append(alias_tier)
break
# tier 名直接作为 page_id 的情况
if page_id and str(page_id) in pages:
candidates.append(str(page_id))
# 去重
seen, page_candidates = set(), []
for c in candidates:
if c not in seen:
seen.add(c)
page_candidates.append(c)
for pid in page_candidates:
page = pages.get(pid) if isinstance(pages, dict) else None
if not page:
continue
elements = page.get("elements", page) if isinstance(page, dict) else {}
if not isinstance(elements, dict):
continue
# 1. 精确键
if key in elements:
return self._build_v2_result(elements[key], key)
# 2. 带页前缀的键(v1 键如"创建会议-预定会议",key 为"预定会议")
prefixed = f"{pid}-{key}"
if prefixed in elements:
return self._build_v2_result(elements[prefixed], key)
# 3. 共享元素(登录页/全局按钮)
shared = self._v2_registry.get("shared_elements", {})
if isinstance(shared, dict) and key in shared:
return self._build_v2_result(shared[key], key)
return None
@staticmethod
def _build_v2_result(info: Any, key: str) -> Dict[str, Any]:
"""把 v2 元素条目归一为选择器链结果"""
if isinstance(info, str):
return {"selectors": [info], "type": "css", "key": key}
if not isinstance(info, dict):
return {"selectors": [str(info)], "type": "css", "key": key}
sels = info.get("selectors")
if isinstance(sels, list) and sels:
return {"selectors": [str(s) for s in sels], "type": info.get("type", "css"), "key": key}
sel = info.get("selector")
if sel:
return {"selectors": [str(sel)], "type": info.get("type", "css"), "key": key}
# 键本身作为选择器回退
return {"selectors": [key], "type": "css", "key": key}
def is_enabled(self) -> bool:
"""映射表是否可用"""
return self._enabled
......@@ -226,12 +368,16 @@ class ElementMappingService:
精确匹配:步骤描述包含映射键中的完整关键词
采用"强匹配/弱匹配"评分模型:
- 强匹配:步骤关键词完整包含映射键关键词(sk==kk 或 kk in sk),权重高
- 弱匹配:步骤关键词是映射键关键词的子串(sk in kk),如"会议" in "会议服务统计",权重低
要求至少 1 个强匹配,或 2 个以上弱匹配,才判定为精确命中。
这解决了"创建会议"误配"会议服务统计"(仅弱匹配"会议")的问题。
注意:语义化用例执行(阶段 5)后此评分路径默认关停
(USE_FUZZY_MAPPING=false)。显式字典查找走 lookup_by_key,
老用例回归时可设 USE_FUZZY_MAPPING=true 回切。
"""
if not settings.USE_FUZZY_MAPPING:
logger.debug("[元素映射表] USE_FUZZY_MAPPING=false,跳过评分路径")
return None
# 强匹配/弱匹配评分模型见旧版实现注释(git 历史保留),此处直接走评分循环。
step_clean = self._clean_step_name(step_name)
step_keywords = self._tokenize(self._clean_step_name_without_elem(step_name))
......@@ -447,6 +593,9 @@ class ElementMappingService:
Returns:
(映射键, 相似度) 或 None
"""
if not settings.USE_FUZZY_MAPPING:
logger.debug("[元素映射表] USE_FUZZY_MAPPING=false,跳过模糊路径")
return None
step_clean = self._clean_step_name(step_name)
step_keywords = self._tokenize(step_clean)
if not step_keywords:
......
......@@ -1177,4 +1177,43 @@ def find_element_by_semantic(
except Exception:
continue
def resolve_selectors(
page,
step: Dict[str, Any],
max_chain: int = 4,
) -> List[str]:
"""
keyword_matcher 选择器链 facade(legacy 自然语言步骤的过渡路径)
把"步骤名关键词 → 候选选择器"包成执行器可直接注入 params["selectors"] 的
字符串链(最高分在前)。供 SemanticResolver legacy 路径复用,也便于独立接入。
Args:
page: Playwright Page 对象
step: 步骤 dict(name/action)
max_chain: 选择器链最大长度
Returns:
List[str] 选择器字符串链(降序),未匹配返回 []
"""
name = (step.get("name") or "") if isinstance(step, dict) else ""
action = (step.get("action") or "click") if isinstance(step, dict) else "click"
if not name:
return []
keywords = extract_keywords(name)
if not keywords:
return []
elements, selectors = match_element_by_keywords(page, keywords, action)
if elements and selectors:
return [str(s.get("value")) for s in selectors if s.get("value")][:max_chain]
el, sels = find_element_by_semantic(page, keywords, action)
if sels:
return [str(s.get("value")) for s in sels if s.get("value")][:max_chain]
return []
return None, []
\ No newline at end of file
......@@ -17,6 +17,7 @@
import json
import logging
import os
import re
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
......@@ -70,7 +71,8 @@ class PageUrlService:
for page in pages:
pid = page.get("id")
if pid and page.get("url"):
# 注册表按 id 存储全部页面(含无直达 URL 的登录/主页,供运行时识别 scope)
if pid:
self._pages[pid] = page
self._enabled = True
......@@ -150,9 +152,11 @@ class PageUrlService:
keywords.extend([str(s) for s in selectors])
entry_keywords.append((action, keywords))
# 对每个页面匹配规则打分
# 对每个页面匹配规则打分(仅考虑有直达 URL 的页面)
matches: List[Dict[str, Any]] = []
for pid, page in self._pages.items():
if not page.get("url"):
continue
conf = self._score_page(page, entry_keywords)
if conf > 0:
matches.append({"page_id": pid, "name": page.get("name", ""),
......@@ -255,6 +259,93 @@ class PageUrlService:
return skip_orders
def recognize_current_page(self, page) -> Optional[Dict[str, Any]]:
"""
运行时根据当前浏览器 URL + DOM 指纹识别页面身份(语义化 scope)
识别策略:
1. 先用 url_patterns(正则数组)对当前 URL 计分,唯一命中直接返回
2. 0 或歧义时,用 fingerprint(DOM 指纹,min_count 个匹配)判存
3. 都失败返回 None(执行器零行为变化)
Args:
page: Playwright Page 对象(鸭子类型:.url / .query_selector_all)
Returns:
{"id", "name", "confidence"} 或 None
"""
if not self._enabled:
return None
try:
current_url = str(page.url or "")
except Exception:
return None
# 1. URL patterns 计分
candidates: List[Dict[str, Any]] = []
for pid, page_conf in self._pages.items():
score = 0
for pat in page_conf.get("url_patterns", []):
try:
if re.search(pat, current_url):
score += 1
except re.error:
continue
if score > 0:
candidates.append((score, pid, page_conf))
if len(candidates) == 1:
pid = candidates[0][1]
p = candidates[0][2]
return {"id": pid, "name": p.get("name", ""), "confidence": 1.0}
# 2. fingerprint DOM 指纹判存(URL 缺失/歧义时)
if page is not None:
for pid, page_conf in self._pages.items():
fp = page_conf.get("fingerprint", [])
if not fp:
continue
hits = 0
required = 0
for item in fp:
sel = item.get("selector")
if not sel:
continue
try:
min_count = int(item.get("min_count", 1))
except (TypeError, ValueError):
min_count = 1
required += 1
try:
matched = page.query_selector_all(sel)
if len(matched) < min_count:
continue
# 文本校验(首个匹配的文本必须一致)
expected_text = item.get("text")
if expected_text:
if not matched or (matched[0].text_content() or "").strip() != expected_text:
continue
hits += 1
except Exception:
continue
if required > 0 and hits >= required:
return {"id": pid, "name": page_conf.get("name", ""), "confidence": 0.8}
return None
def get_scope_page(self, scope: str) -> Optional[Dict[str, Any]]:
"""
按 scope id 返回页面注册表配置(语义解析器容器约束用)
Args:
scope: 页面注册表 id(如 create_meeting)
Returns:
页面配置 dict 或 None
"""
return self._pages.get(scope)
def is_enabled(self) -> bool:
"""映射表是否可用"""
return self._enabled
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:semantic_resolver.py
模块描述:语义解析器 — 把语义目标(semantic)解析为选择器链,注入执行器
复杂用例通用执行机制(语义化用例执行)核心:
- 结构化 semantic 目标 → 候选选择器链(走现有 _do_click/_do_fill 成熟回退策略)
- 解析优先级(规则优先,Claude 按需):
1. scope 校验(页面注册表身份,根治跨层级误配)
2. 映射表 v2 精确键 lookup(阶段 5 接入)
3. 强结构类型模式选择器(tab / row_checkbox / checkbox / button 等)
4. keyword_matcher 规则候选(scope 已知可收缩容器)
5. DOM 过滤(contains/container/nth)
6. 候选歧义时 Claude 仲裁(rank_candidates min_confidence 短路)
7. save_as 提取元素值写入上下文变量
- legacy 自然语言步骤(USE_KEYWORD_MATCHER=true)走 keyword_matcher facade
作者:czj
创建日期:2026-08-18
"""
import logging
from typing import Any, Dict, List, Optional
from app.config import settings
from app.services.element_mapping_service import get_element_mapping_service
from app.services.claude_service import get_claude_service
from app.services.keyword_matcher import (
extract_keywords,
match_element_by_keywords,
get_candidate_details,
resolve_selectors,
)
logger = logging.getLogger(__name__)
# 语义类型 → 动作映射
TYPE_ACTION_MAP = {
"button": "click",
"icon": "click",
"link": "click",
"menu": "click",
"tab": "click",
"row": "click",
"row_checkbox": "click",
"checkbox": "check",
"input": "fill",
"textarea": "fill",
"text": "wait",
"dialog": "assert",
}
# 强结构类型:优先用模式选择器(无需 DOM 扫描)
_STRUCTURED_TYPES = {"tab", "row_checkbox", "checkbox", "row"}
class SemanticResolver:
"""
语义解析器
resolve_step(page, step, scope, ctx) → Optional[List[str]] 选择器链
无法解析返回 None(执行器零影响,走原有定位路径)。
"""
def __init__(self):
self._element_mapping = get_element_mapping_service()
self._claude = get_claude_service()
# ==================== 入口 ====================
def resolve_step(
self,
page,
step: Dict[str, Any],
scope: Optional[str],
ctx,
) -> Optional[List[str]]:
"""
解析步骤 → 选择器链(语义目标优先;legacy 步骤可走 keyword facade)
Args:
page: Playwright Page
step: 步骤 dict(含 semantic 可选字段)
scope: 当前页面 scope id(页面注册表,可为 None)
ctx: ExecutionContext(渲染语义文本)
Returns:
List[str] 选择器链(高优先级在前),无法解析返回 None
"""
semantic = step.get("semantic")
if isinstance(semantic, dict):
return self._resolve_semantic(page, semantic, scope, ctx)
# legacy 自然语言步骤(过渡路径)
if settings.USE_KEYWORD_MATCHER:
return self._resolve_by_keyword(page, step, scope, ctx)
return None
# ==================== 语义目标解析 ====================
def _resolve_semantic(
self,
page,
target: Dict[str, Any],
scope: Optional[str],
ctx,
) -> Optional[List[str]]:
"""semantic 目标 → 选择器链"""
ctype = str(target.get("type", "button") or "button")
# 1. 文本渲染(上下文变量 {__CTX:key__} 等)
raw_text = target.get("text")
text = ""
if raw_text:
text = ctx.render(raw_text) if ctx else str(raw_text)
text = str(text).strip()
contains = target.get("contains")
if contains and ctx:
contains = ctx.render(contains)
contains = str(contains).strip() if contains else None
# 2. scope 校验:期望 scope 与实际不符 → 不覆盖(零行为变化)
expected_scope = target.get("scope")
if expected_scope and scope and expected_scope != scope:
logger.debug(
f"[semantic] scope 不符,跳过语义解析: 期望 {expected_scope}, 实际 {scope}"
)
return None
# 3. 映射表 v2 精确键(阶段 5 提供 lookup_by_key;未实现时走规则)
v2 = self._mapping_v2_lookup(target, text, ctype)
if v2:
return v2
# 4. 强结构类型模式选择器(tab / row_checkbox 等)
if ctype in _STRUCTURED_TYPES:
pattern = self._pattern_selectors(ctype, text, contains)
if pattern:
return pattern
# 5. 关键词规则候选
if text:
action = TYPE_ACTION_MAP.get(ctype, "click")
keywords = extract_keywords(text)
if keywords:
elements, selectors = match_element_by_keywords(page, keywords, action)
if elements and selectors:
chain = self._filter_selectors(selectors, contains, target)
if chain:
return self._arbitrate(
page,
step_name=text,
action=action,
elements=elements,
selectors=selectors,
chain=chain,
)
return None
def _mapping_v2_lookup(
self, target: Dict[str, Any], text: str, ctype: str
) -> Optional[List[str]]:
"""
映射表 v2 精确键查找(阶段 5 实现 lookup_by_key 后自动生效)
语义文本作为显式键;scope 未知时不查(避免跨页误配)。
"""
if not text:
return None
lookup = getattr(self._element_mapping, "lookup_by_key", None)
if not lookup:
return None
try:
scope = target.get("scope")
if not scope:
return None
res = lookup(scope, text)
if not res:
# 共享元素回退
res = lookup("shared", text)
if res:
sels = res.get("selectors") or []
chain = [s for s in sels if s]
if chain:
logger.info(f"[semantic] 映射表 v2 命中: {scope}/{text} → {chain[0]}")
return chain
except Exception as e:
logger.debug(f"[semantic] 映射表 v2 查找异常: {e}")
return None
# ==================== 模式选择器(纯字符串,可单测) ====================
def _pattern_selectors(
self, ctype: str, text: str, contains: Optional[str]
) -> Optional[List[str]]:
"""强结构类型 → 候选选择器链(锚点优先用 contains 唯一值)"""
anchor = contains or text or ""
if not anchor:
return None
# 转义选择器中的引号
anchor = anchor.replace('"', '\\"')
if ctype == "row_checkbox":
return [
f'.el-table__row:has-text("{anchor}") .el-checkbox',
f'.el-table__row:has-text("{anchor}") input[type="checkbox"]',
f'.el-checkbox:has-text("{anchor}")',
f'li:has-text("{anchor}") .el-checkbox',
]
if ctype == "checkbox":
return [
f'.el-checkbox:has-text("{anchor}")',
f'.el-checkbox__inner:has-text("{anchor}")',
f'input[type="checkbox"][value="{anchor}"]',
]
if ctype == "tab":
return [
f'.el-tabs__item:has-text("{anchor}")',
f'[role="tab"]:has-text("{anchor}")',
f'.tab-item:has-text("{anchor}")',
f'li[role="tab"]:has-text("{anchor}")',
]
if ctype == "row":
return [
f'.el-table__row:has-text("{anchor}")',
f'tr:has-text("{anchor}")',
]
return None
# ==================== DOM 候选过滤 ====================
def _filter_selectors(
self,
selectors: List[Dict[str, Any]],
contains: Optional[str],
target: Dict[str, Any],
) -> List[str]:
"""按 contains/container/nth 过滤候选选择器,产出去重选择器链"""
container = target.get("container") or {}
nth = target.get("nth")
chain: List[str] = []
for s in selectors:
val = s.get("value")
if not val:
continue
val = str(val)
# contains 次级过滤:选择器字符串未提及 contains 且候选多于 1 时降权
if contains and len(selectors) > 1 and contains not in val:
continue
# container 父容器约束 → 组合选择器
csel = container.get("selector")
if csel and csel not in val:
val = f"{csel} {val}"
# nth(1 起)→ Playwright `>> nth=N`(0 起)
if nth:
try:
n = int(nth)
if n > 1:
val = f"{val} >> nth={n - 1}"
except (TypeError, ValueError):
pass
if val not in chain:
chain.append(val)
return chain[:4]
# ==================== 歧义仲裁(规则优先,Claude 按需) ====================
def _arbitrate(
self,
page,
step_name: str,
action: str,
elements: List[Any],
selectors: List[Dict[str, Any]],
chain: List[str],
) -> List[str]:
"""
候选歧义仲裁:
- 去重后唯一候选 → 直接返回
- 置信度差距 ≥ 0.1 → 规则可判,直接返回
- 否则 Claude 仲裁(候选数 ≥ 2 时)
"""
dedup: List[str] = []
for s in chain:
if s not in dedup:
dedup.append(s)
if len(dedup) <= 1:
return dedup[:1]
confs = []
for s in selectors:
try:
confs.append(float(s.get("confidence", 0.5)))
except (TypeError, ValueError):
confs.append(0.5)
if len(confs) >= 2 and confs[0] - confs[1] >= 0.1:
logger.debug(f"[semantic] 规则可判,跳过 Claude: 置信度 {confs[0]:.2f} vs {confs[1]:.2f}")
return dedup[:1]
if self._claude.enabled and len(elements) >= 2:
try:
candidates = get_candidate_details(page, elements)
selected, confidence, reason, confirmed = self._claude.rank_candidates(
step_name=step_name,
action=action,
params={},
candidates=candidates,
page_url=getattr(page, "url", "") or "",
previous_steps=None,
min_confidence=0.0,
)
if confirmed and 0 <= selected < len(dedup):
logger.info(f"[semantic] Claude 仲裁: index={selected}, reason={reason}")
return dedup[selected : selected + 1]
except Exception as e:
logger.warning(f"[semantic] Claude 仲裁失败,回退第一个候选: {e}")
return dedup[:1]
# ==================== legacy 步骤 keyword facade ====================
def _resolve_by_keyword(
self, page, step: Dict[str, Any], scope: Optional[str], ctx
) -> Optional[List[str]]:
"""legacy 自然语言步骤 → keyword_matcher.resolve_selectors facade"""
chain = resolve_selectors(page, step, max_chain=4)
return chain or None
# ==================== save_as(提取值写上下文) ====================
def extract_save_as(self, page, target: Dict[str, Any], chain: List[str], ctx) -> None:
"""
从解析链命中的元素提取值,写入上下文变量(save_as)
- input/textarea → input_value
- 其他 → inner_text
"""
save_as = target.get("save_as") if isinstance(target, dict) else None
if not save_as or not ctx:
return
selector = chain[0] if chain else None
if not selector:
return
try:
locator = page.locator(selector)
if locator.count() == 0:
return
el = locator.first
ctype = target.get("type", "")
if ctype in ("input", "textarea"):
value = el.input_value()
else:
value = el.inner_text()
value = (value or "").strip()
if isinstance(save_as, dict):
# {"name": "meetingName"} 键名可自定义
for key, path in save_as.items():
_ = path
ctx.set(key, value)
else:
ctx.set(str(save_as), value)
logger.info(f"[semantic] save_as: {save_as} ← '{value}'")
except Exception as e:
logger.debug(f"[semantic] save_as 提取失败: {e}")
# 全局单例(懒加载)
_global_semantic_resolver: Optional[SemanticResolver] = None
def get_semantic_resolver() -> SemanticResolver:
"""获取全局 SemanticResolver 单例"""
global _global_semantic_resolver
if _global_semantic_resolver is None:
_global_semantic_resolver = SemanticResolver()
return _global_semantic_resolver
......@@ -133,6 +133,7 @@ class SmartLocateService:
# 4. 逐步骤定位
skip_remaining = False
seen_steps: List[Dict[str, Any]] = [] # 已完成步骤(Claude 上下文)
for step in steps:
# 如果前置步骤失败,跳过后续步骤
if skip_remaining:
......@@ -148,8 +149,9 @@ class SmartLocateService:
results.append(result)
continue
result = self._locate_single_step(step)
result = self._locate_single_step(step, previous_steps=seen_steps)
results.append(result)
seen_steps.append(step)
# 如果定位成功且操作类型需要执行,则实际执行(确保页面状态正确)
if result.get('success') and result.get('action') != 'navigate':
......@@ -358,12 +360,17 @@ class SmartLocateService:
logger.error(f"导航到菜单异常: {e}")
return False
def _locate_single_step(self, step: Dict[str, Any]) -> Dict[str, Any]:
def _locate_single_step(
self,
step: Dict[str, Any],
previous_steps: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""
为单个步骤定位元素
Args:
step (Dict): 步骤定义
previous_steps (Optional[List[Dict]]): 当前步骤之前的步骤(Claude 语义排序上下文)
Returns:
Dict: 定位结果
......@@ -607,7 +614,9 @@ class SmartLocateService:
page_url = page.url if page else ""
previous_steps_info = []
# 获取前面步骤的名称作为上下文
for prev_step in self.steps[:step_idx]:
# (修复:原 self.steps[:step_idx] 引用未定义的 self.steps/step_idx,
# 导致 Claude 增强路径恒抛异常回退;改为方法参数传入)
for prev_step in (previous_steps or []):
previous_steps_info.append({
'name': prev_step.get('name', ''),
'action': prev_step.get('action', '')
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:create_semantic_meeting_case.py
模块描述:创建「新建会议-语义化重写示范」用例(阶段 6.3 示范)
复杂用例通用执行机制(语义化用例执行)的落地示范:
- 原「新建会议-czj录入」21 步用例依赖 element_mapping_service 30+ 条模糊评分补丁,
每次新用例都要走"实测 DOM → 加补丁 → 部署验证"循环。
- 本用例把复杂步骤(填名称/选会议室/切预定会议/议题/参会人/确定创建/查看详情/
取消会议)全部改写为 semantic 语义目标 + verify 状态验证:
semantic: {type, text, scope, container} —— 执行期经 映射表v2 精确键 lookup_by_key
命中,动态数据(参会人/会议名称)由 {__CTX:...__} 上下文变量携带走
强结构模式选择器,全程不依赖 USE_FUZZY_MAPPING 评分路径。
verify: 防假通过的状态验证(text_visible / element_visible / dialog)
finally: api_call 数据自清理(查询同名会议断言无残留)
验收(PRD 八、阶段 6):
- 新旧两版「新建会议」用例都通过
- 新版不依赖 USE_FUZZY_MAPPING
- 连跑两次无残留会议
作者:czj
创建日期:2026-08-18
"""
import asyncio
import sys
import io
import httpx
BASE_URL = "http://localhost:8001"
# 被测系统信息(与登录模板一致)
TARGET_URL = "https://192.168.5.44"
# 会议管理模块(本地 DB 已验证存在)
MEETING_MODULE_ID = "module_e80a5d08b48d4c67a16cb86156320f25"
# 用例名称(幂等重跑:同名先删后建)
CASE_NAME = "新建会议-语义化重写示范(semantic-demo)"
DEFAULT_CONFIG = {
"timeout": 60000,
"screenshot": True,
"auto_login": True, # 走登录模板(含协议勾选),不再需要显式登录步骤
}
# 用例级参数:动态数据全部经 {__CTX:...__} 上下文引用,保证 创建/断言/清理 同名
CASE_PARAMETERS = {
"meetingName": "自动化-{__RANDOM_NAME_8__}",
"room": "北京展厅会议室",
"participant": "admin@xty12",
}
def build_steps() -> list:
"""构造语义化步骤列表(对照原 21 步流程,登录步骤 1-6 由 auto_login 替代)"""
steps = [
# ============ 导航(稳定选择器,非语义重点) ============
{"order": 1, "name": "点击【功能中心】展开", "action": "click",
"params": {"selectors": [".home_nav_left"]}},
{"order": 2, "name": "点击【会议预约】分类", "action": "click",
"params": {"selectors": ["#reserve_enable"]}},
{"order": 3, "name": "点击【新建会议】按钮", "action": "click",
"params": {"selectors": ['[data-key="reserve_list.create"]']}},
{"order": 4, "name": "等待会议表单加载", "action": "wait",
"params": {"selector": "input[placeholder*='会议名称']", "timeout": 10000}},
# ============ 原步骤 10-17(语义化改写核心) ============
# 10 填会议名称:显式键 lookup_by_key(创建会议, 会议名称) → .meeting_list .meeting_name .el-input__inner
{"order": 5, "name": "填写会议名称", "action": "fill",
"params": {"value": "{__CTX:meetingName__}"},
"semantic": {"type": "input", "text": "会议名称", "scope": "create_meeting"}},
# 11 会议室选择:动态数据不写映射表,text 走 row_checkbox 模式选择器
# (刻意不用"会议室选择"键——它映射到裸 .el-checkbox,会话15 的假通过根因)
{"order": 6, "name": "勾选会议室:{__CTX:room__}", "action": "click",
"params": {"force": True},
"semantic": {"type": "row_checkbox", "text": "{__CTX:room__}",
"contains": "{__CTX:room__}", "scope": "create_meeting",
"container": {"type": "table", "selector": ".el-table"}}},
# 12 时间切换为:预定会议 → 显式键 .time_btn > div:last-child .btns
# verify 用 text_visible(解析链首选择器兜底)防假通过
{"order": 7, "name": "时间切换为:预定会议", "action": "click",
"params": {},
"semantic": {"type": "tab", "text": "预定会议", "scope": "create_meeting"},
"verify": {"type": "text_visible", "text": "预定会议"}},
# 13 会议议题输入 → 显式键 .topic_item input[placeholder='会议议题']
{"order": 8, "name": "输入会议议题", "action": "fill",
"params": {"value": "{__CTX:meetingName__}的议题"},
"semantic": {"type": "input", "text": "议题输入框", "scope": "create_meeting"}},
# 14 编辑按钮 → 显式键 .room .delimg img:nth-of-type(2)
# verify element_visible 确认参会人配置区出现(.room .user_list)
{"order": 9, "name": "点击左侧会议室框的编辑按钮", "action": "click",
"params": {},
"semantic": {"type": "icon", "text": "编辑会议室", "scope": "create_meeting",
"container": {"type": "card", "selector": ".room"}},
"verify": {"type": "element_visible", "selector": ".room .user_list"}},
# 15 勾选参会人:动态数据(admin@xty12)走 row_checkbox 模式选择器
{"order": 10, "name": "勾选参会人:{__CTX:participant__}", "action": "click",
"params": {"force": True},
"semantic": {"type": "row_checkbox", "text": "{__CTX:participant__}",
"contains": "{__CTX:participant__}", "scope": "create_meeting",
"container": {"type": "table", "selector": ".el-table"}}},
# 16 编辑完成按钮 → 显式键 .room .user_list button:has-text("完成")
{"order": 11, "name": "点击左侧会议室框的【完成】按钮", "action": "click",
"params": {},
"semantic": {"type": "button", "text": "编辑完成按钮", "scope": "create_meeting",
"container": {"type": "card", "selector": ".room"}}},
# 17 确定创建 → 显式键 .btn button.el-button + verify 弹窗"创建成功"
{"order": 12, "name": "点击【确定创建】按钮", "action": "click",
"params": {},
"semantic": {"type": "button", "text": "确定创建", "scope": "create_meeting"},
"verify": {"type": "dialog", "text": "创建成功"}},
# 18 查看详情(创建成功弹窗)→ 显式键 创建会议-查看详情(.el-message-box)
{"order": 13, "name": "点击弹窗的【查看详情】按钮", "action": "click",
"params": {},
"semantic": {"type": "button", "text": "查看详情", "scope": "create_meeting"}},
# 19 等待会议详情页(.buttom_btn 底部操作区出现)
{"order": 14, "name": "等待会议详情页", "action": "wait",
"params": {"selector": ".buttom_btn", "timeout": 10000}},
# 20 取消会议 → 显式键 .buttom_btn .el-button--info.is-plain
{"order": 15, "name": "点击底部的【取消会议】按钮", "action": "click",
"params": {},
"semantic": {"type": "button", "text": "取消会议", "scope": "meeting_detail",
"container": {"type": "footer", "selector": ".buttom_btn"}}},
# 21 二次确认确定 → 显式键 .el-dialog:visible .el-dialog__footer .el-button--primary
# verify 弹窗"取消成功"(非强制,仅告警——文案以真实系统为准)
{"order": 16, "name": "点击二次确认弹窗的【确定】按钮", "action": "click",
"params": {},
"semantic": {"type": "button", "text": "取消确认确定", "scope": "meeting_detail",
"container": {"type": "dialog"}},
"verify": {"type": "dialog", "text": "取消成功"}},
# 22 finally 数据自清理:查询同名会议断言无残留(api_call,不依赖浏览器)
# ⚠️ 端点部署期联调:5.44 meetingV3 列表走 MQTT 无简单 HTTP 端点,
# 部署 5.60 时按真实接口替换 url/body(见 PRD 九、风险)。finally 失败
# 会将主流程 passed 翻为 failed(标注"清理步骤失败"),故须在服务器联调通过。
{"order": 17, "name": "清理残留会议(同名断言无残留)", "action": "api_call",
"run_on": "finally",
"params": {
"api_case": {
"name": "查询同名会议",
"steps": {
"method": "POST",
"url": "/api/meeting/query",
"headers": {"Content-Type": "application/json"},
"body": {"name": "{__CTX:meetingName__}"},
"assertions": [
{"type": "body_field", "path": "$.data.total",
"operator": "equals", "value": 0},
],
},
},
}},
]
return steps
async def delete_existing(client: httpx.AsyncClient) -> None:
"""幂等:删除同名旧用例(先查后删)"""
r = await client.get(f"{BASE_URL}/api/cases", params={"name": CASE_NAME})
if r.status_code == 200:
items = r.json().get("items", r.json()) if isinstance(r.json(), dict) else r.json()
for item in (items or []):
cid = item.get("id")
if cid and item.get("name") == CASE_NAME:
dr = await client.delete(f"{BASE_URL}/api/cases/{cid}")
print(f" 已删除旧用例: {cid} (HTTP {dr.status_code})")
async def main() -> None:
payload = {
"module_id": MEETING_MODULE_ID,
"name": CASE_NAME,
"description": (
"语义化重写示范:复杂步骤全部走 semantic 语义目标 + verify 状态验证 + "
"finally api_call 自清理,不依赖 USE_FUZZY_MAPPING 模糊评分。"
),
"priority": "high",
"tags": ["语义化", "新建会议", "阶段6示范"],
"steps": build_steps(),
"parameters": CASE_PARAMETERS,
"config": DEFAULT_CONFIG,
}
async with httpx.AsyncClient(timeout=30) as client:
await delete_existing(client)
r = await client.post(f"{BASE_URL}/api/cases", json=payload)
if r.status_code == 201:
data = r.json()
print("✅ 用例创建成功")
print(f" ID: {data.get('id')}")
print(f" 名称: {data.get('name')}")
print(f" 步骤数: {len(payload['steps'])}")
print(f" 参数: {CASE_PARAMETERS}")
print("\n semantic 步骤明细:")
for s in payload["steps"]:
tag = []
if s.get("semantic"):
tag.append(f"semantic[{s['semantic'].get('type')}:{s['semantic'].get('text')}]")
if s.get("verify"):
tag.append(f"verify[{s['verify'].get('type')}]")
if s.get("run_on"):
tag.append(f"run_on:{s['run_on']}")
print(f" {s['order']:>2}. {s['name']} {' '.join(tag)}")
else:
print(f"❌ 用例创建失败: HTTP {r.status_code}")
print(r.text[:1000])
if __name__ == "__main__":
# 标准输出编码(Windows GBK 控制台兼容)——仅在直接运行时重定向,
# import 本模块(如语义链单测)不应污染宿主 stdout。
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
asyncio.run(main())
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:migrate_mapping_v2.py
模块描述:元素映射表 v1 扁平结构 → v2 显式字典结构迁移脚本(离线)
v2 格式(前端开发可维护,见 PRD 第五章):
{
"version": "2.0", "source": "迁移自 v1 231 条",
"pages": [
{ "id": "创建会议", "name": "新建会议",
"elements": { "预定会议": {"selectors": [".time_btn .btns:has-text(\"预定会议\")"], "type": "tab"} } }
],
"shared_elements": { "登录按钮": {"selectors": ["button:has-text(\"登录\")"], "type": "button"} }
}
说明:
- 按页面 token 归组(与 element_mapping_service._page_id_for 同规则)
- 键保留原语义名(形如"创建会议-预定会议"),值为 {selectors:[原selector], type, description}
- 纯显式字典,无任何评分规则
用法:
cd backend && python scripts/migrate_mapping_v2.py [输出路径]
默认输出 backend/data/elements_mapping_v2.json
作者:czj
创建日期:2026-08-18
"""
import json
import os
import sys
# 允许从任意工作目录运行
# 脚本位于 backend/scripts/,上两级 = backend;映射表在 backend/app/data/
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_FILE = os.path.join(BASE_DIR, "app", "data", "elements_mapping.json")
OUT_DIR = os.path.join(BASE_DIR, "app", "data")
# 页面 token(与 element_mapping_service._page_id_for 保持一致)
_PAGE_TOKENS = ("创建会议", "会议详情", "会议列表", "会议预约", "登录页", "首页",
"会议室", "会议模板", "个人日程", "管理看板", "信息发布", "预订2.0")
def page_id_for(key: str, info: dict) -> str:
tier = info.get("tier") or ""
for tok in _PAGE_TOKENS:
if tok in tier:
return tok
for tok in _PAGE_TOKENS:
if key.startswith(f"{tok}-"):
return tok
return tier or "默认"
def migrate() -> dict:
with open(SRC_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
elements = data.get("elements", {})
pages: dict = {}
for key, info in elements.items():
pid = page_id_for(key, info)
page = pages.setdefault(pid, {
"id": pid,
"name": pid,
"elements": {},
})
# 保留原 selector(v1 每个条目一个 selector)
page["elements"][key] = {
"selectors": [info["selector"]] if isinstance(info.get("selector"), str) else (info.get("selectors") or []),
"type": info.get("type", "css"),
"description": info.get("description", ""),
}
return {
"version": "2.0",
"source": f"迁移自 v1 {len(elements)} 条",
"pages": [pages[pid] for pid in sorted(pages)],
"shared_elements": {},
}
def main() -> None:
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(OUT_DIR, "elements_mapping_v2.json")
result = migrate()
with open(out, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
n_pages = len(result["pages"])
n_elements = sum(len(p["elements"]) for p in result["pages"])
print(f"迁移完成: {n_pages} 页, {n_elements} 元素 → {out}")
if __name__ == "__main__":
main()
......@@ -20,9 +20,23 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
from app.config import settings
from app.services.element_mapping_service import ElementMappingService
@pytest.fixture(autouse=True)
def _legacy_fuzzy_enabled():
"""阶段 5 起 USE_FUZZY_MAPPING 默认 false(模糊评分关停,改用 v2 显式字典)。
本模块测试的是 legacy 模糊评分路径——显式开启该 flag,既保留对旧算法
的回归覆盖,也验证"部署可一键回切"的逃生舱仍然有效。
"""
old = settings.USE_FUZZY_MAPPING
settings.USE_FUZZY_MAPPING = True
yield
settings.USE_FUZZY_MAPPING = old
@pytest.fixture
def meeting_mapping(tmp_path):
"""创建会议流程 P1 步骤映射(含干扰项,验证评分能正确区分)"""
......@@ -38,6 +52,7 @@ def meeting_mapping(tmp_path):
# P1 表步骤 13-21 目标条目
"创建会议-议题输入框": {"selector": ".topic_item input", "type": "css", "tier": "创建会议", "description": "创建会议 - 议题输入框"},
"创建会议-编辑会议室": {"selector": ".room .delimg", "type": "css", "tier": "创建会议", "description": "创建会议 - 参会人配置模式开关(编辑/完成共用)"},
"创建会议-完成按钮": {"selector": ".room .delimg", "type": "css", "tier": "创建会议", "description": "创建会议 - 参会人配置完成按钮(v2 显式条目,替代模糊评分特例)"},
"创建会议-参会人勾选": {"selector": ".el-table:has-text('姓名') .el-checkbox", "type": "css", "tier": "创建会议", "description": "创建会议 - 参会人表格复选框"},
"创建会议-确定创建": {"selector": ".btn button.el-button", "type": "css", "tier": "创建会议", "description": "创建会议 - 底部确定创建按钮"},
"会议创建成功-查看详情": {"selector": ".el-message-box button:has-text('查看详情')", "type": "css", "tier": "会议创建成功", "description": "创建成功弹窗查看详情按钮"},
......@@ -197,11 +212,11 @@ class TestP1MeetingFlow:
assert result["selector"] == ".room .delimg"
def test_complete_button(self, meeting_mapping):
"""'完成按钮'与编辑按钮共用 .room .delimg,不应误配会议室-区域树"""
"""'完成按钮'走 v2 显式字典 → .room .delimg(阶段 5 不再靠模糊评分特例)"""
svc = _make_service(meeting_mapping)
result = svc.get_selector("点击左侧会议室框的【完成】按钮", "click")
result = svc.lookup_by_key("创建会议", "完成按钮")
assert result is not None
assert result["key"] == "创建会议-编辑会议室"
assert result["selectors"] == [".room .delimg"]
def test_check_user(self, meeting_mapping):
"""'勾选右侧的用户'应命中参会人表格复选框,而非参会人表格或编辑按钮"""
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_element_mapping_v2.py
模块描述:映射表 v2 显式字典单元测试(阶段 5)
覆盖:
- lookup_by_key:v1 flat 自动归组 / scope 别名对齐 / 页前缀键 / 共享元素 / 缺失
- USE_FUZZY_MAPPING 关停:_exact_match/_fuzzy_match 返回 None
- v2 显式 pages 结构加载(前端开发提供格式)
- _build_v2_result 归一
作者:czj
创建日期:2026-08-18
"""
import json
import tempfile
import os
from unittest.mock import MagicMock
import pytest
from app.config import settings
from app.services.element_mapping_service import ElementMappingService
@pytest.fixture(scope="module")
def v1_service():
"""加载真实 v1 映射表(231 条),验证自动归组"""
s = ElementMappingService()
assert s.is_enabled()
return s
# ==================== v1 自动归组 → lookup_by_key ====================
class TestLookupByKeyV1:
def test_create_meeting_exact_semantic_key(self, v1_service):
"""scope 别名 + 语义键 → 确定性选择器链(无评分)"""
r = v1_service.lookup_by_key("create_meeting", "预定会议")
assert r is not None
assert r["selectors"] and isinstance(r["selectors"], list)
assert r["key"] == "预定会议"
def test_participant_checkbox_chain(self, v1_service):
"""参会人勾选 → 行级复选框选择器链(此前靠评分规则才能命中)"""
r = v1_service.lookup_by_key("创建会议", "参会人勾选")
assert r is not None
assert any(".el-checkbox" in s for s in r["selectors"])
def test_login_tier_alias(self, v1_service):
"""page_id 'login' → tier 别名 '登录页' → 命中"""
r = v1_service.lookup_by_key("login", "密码登录Tab")
assert r is not None
assert r["selectors"]
def test_missing_key_returns_none(self, v1_service):
assert v1_service.lookup_by_key("create_meeting", "不存在的键XYZ") is None
def test_missing_page_returns_none(self, v1_service):
assert v1_service.lookup_by_key("不存在的页", "预定会议") is None
def test_none_page_id_returns_none(self, v1_service):
assert v1_service.lookup_by_key(None, "预定会议") is None
# ==================== 评分路径关停 ====================
class TestFuzzyGating:
def test_exact_match_disabled(self, v1_service):
old = settings.USE_FUZZY_MAPPING
settings.USE_FUZZY_MAPPING = False
try:
assert v1_service._exact_match("点击预定会议") is None
assert v1_service._fuzzy_match("点击预定会议") is None
finally:
settings.USE_FUZZY_MAPPING = old
def test_get_selector_gated(self, v1_service):
old = settings.USE_FUZZY_MAPPING
settings.USE_FUZZY_MAPPING = False
try:
assert v1_service.get_selector("点击预定会议") is None
finally:
settings.USE_FUZZY_MAPPING = old
def test_get_selector_enabled_when_flag_on(self, v1_service):
old = settings.USE_FUZZY_MAPPING
settings.USE_FUZZY_MAPPING = True
try:
assert v1_service.get_selector("点击预定会议") is not None
finally:
settings.USE_FUZZY_MAPPING = old
# ==================== v2 显式结构加载 ====================
class TestV2ExplicitStructure:
def _write_v2(self):
data = {
"version": "2.0",
"source": "前端开发提供",
"pages": [
{
"id": "create_meeting",
"name": "新建会议",
"elements": {
"预定会议": {"selectors": ['.time_btn .btns:has-text("预定会议")'], "type": "tab"},
"参会人勾选": {
"selectors": ['.el-table__row:has-text("admin@xty12") .el-checkbox'],
"type": "row_checkbox",
},
},
},
{
"id": "meeting_detail",
"name": "会议详情",
"elements": {
"取消会议": {"selectors": ["button:has-text(\"取消会议\")"], "type": "button"},
},
},
],
"shared_elements": {
"登录按钮": {"selectors": ["button:has-text(\"登录\")"], "type": "button"},
},
}
fd, path = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
return path
def test_explicit_pages_lookup(self):
path = self._write_v2()
try:
s = ElementMappingService(path)
assert s.is_enabled()
r = s.lookup_by_key("create_meeting", "预定会议")
assert r["selectors"] == ['.time_btn .btns:has-text("预定会议")']
assert r["type"] == "tab"
r2 = s.lookup_by_key("meeting_detail", "取消会议")
assert r2["selectors"] == ['button:has-text("取消会议")']
finally:
os.remove(path)
def test_shared_elements_fallback(self):
path = self._write_v2()
try:
s = ElementMappingService(path)
r = s.lookup_by_key("create_meeting", "登录按钮")
assert r["selectors"] == ['button:has-text("登录")']
finally:
os.remove(path)
# ==================== _build_v2_result 归一 ====================
class TestBuildV2Result:
def _s(self):
return ElementMappingService.__new__(ElementMappingService)
def test_string_entry(self):
r = ElementMappingService._build_v2_result(".btn", "x")
assert r["selectors"] == [".btn"]
assert r["type"] == "css"
def test_dict_with_selectors(self):
r = ElementMappingService._build_v2_result(
{"selectors": [".a", ".b"], "type": "tab"}, "x"
)
assert r["selectors"] == [".a", ".b"]
assert r["type"] == "tab"
def test_dict_with_single_selector(self):
r = ElementMappingService._build_v2_result({"selector": ".a"}, "x")
assert r["selectors"] == [".a"]
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_page_url_service.py
模块描述:PageUrlService 单元测试(页面注册表 + 运行时页面身份识别)
覆盖:
- 阶段 2 新功能 recognize_current_page:URL patterns 唯一命中 / fingerprint 兜底 / 未识别 None
- get_scope_page 按 scope id 返回页面配置
- 旧功能 recognize_page(步骤识别直达 URL)不回归
作者:czj
创建日期:2026-08-18
"""
import json
import sys
import tempfile
from pathlib import Path
# 加入项目路径,确保可导入
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.services.page_url_service import PageUrlService
# ==================== 测试夹具 ====================
REGISTRY = {
"version": "2.0",
"pages": [
{
"id": "create_meeting",
"name": "新建会议",
"url": "https://192.168.5.44/#/meetingV3?meetingV3=%2FmeetingV3%2F%23%2FCreateMeeting",
"url_patterns": ["meetingV3"],
"fingerprint": [{"selector": ".topic_item input", "min_count": 1}],
"match_rules": [{"type": "step_name_contains", "value": "新建会议"}],
},
{
"id": "login",
"name": "登录页",
"url": "",
"url_patterns": ["login"],
"fingerprint": [{"selector": "input[placeholder*='手机号']", "min_count": 1}],
},
{
"id": "home",
"name": "主页",
"url": "",
"url_patterns": ["#/home"],
"fingerprint": [{"selector": ".home_nav_left", "min_count": 1}],
},
],
}
class FakePage:
"""鸭子类型 Playwright Page(只实现 .url / .query_selector_all)"""
def __init__(self, url: str = "", matches=None):
self._url = url
# selector -> 匹配元素数(query_selector_all 返回 [1]*n)
self._matches = matches or {}
@property
def url(self):
return self._url
def query_selector_all(self, selector: str):
count = self._matches.get(selector, 0)
return [object() for _ in range(count)]
def make_service():
"""创建指向临时注册表文件的 PageUrlService"""
with tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8"
) as f:
json.dump(REGISTRY, f, ensure_ascii=False)
path = f.name
svc = PageUrlService(path)
# 清理临时文件(PageUrlService 已加载进内存)
import os
try:
os.unlink(path)
except OSError:
pass
return svc
# ==================== recognize_current_page:URL patterns ====================
def test_recognize_url_pattern_unique_hit():
"""URL patterns 唯一命中 → 直接返回该页面"""
svc = make_service()
page = FakePage(url="https://192.168.5.44/#/meetingV3?x=%2FCreateMeeting")
rec = svc.recognize_current_page(page)
assert rec is not None
assert rec["id"] == "create_meeting"
assert rec["confidence"] == 1.0
def test_recognize_url_pattern_login():
"""登录页 URL 命中 → login"""
svc = make_service()
page = FakePage(url="https://192.168.5.44/platform/2.0.2/#/login")
rec = svc.recognize_current_page(page)
assert rec is not None
assert rec["id"] == "login"
# ==================== recognize_current_page:fingerprint 兜底 ====================
def test_recognize_fingerprint_fallback():
"""URL 未命中任何 url_patterns,但 DOM 指纹命中 → fingerprint 判存"""
svc = make_service()
# create_meeting URL 与 login/home 都含 login(歧义),改用指纹区分
page = FakePage(
url="https://example.com/somewhere",
matches={".topic_item input": 1},
)
rec = svc.recognize_current_page(page)
assert rec is not None
assert rec["id"] == "create_meeting"
def test_recognize_fingerprint_min_count():
"""fingerprint min_count 未满足 → 不判存"""
svc = make_service()
page = FakePage(
url="https://example.com/unknown",
matches={".topic_item input": 0, ".home_nav_left": 1},
)
# home 指纹命中,create_meeting 的 min_count 未满足
rec = svc.recognize_current_page(page)
assert rec is not None
assert rec["id"] == "home"
def test_recognize_no_match_returns_none():
"""URL 与指纹都未命中 → None(零行为变化)"""
svc = make_service()
page = FakePage(url="https://example.com/random", matches={})
assert svc.recognize_current_page(page) is None
def test_recognize_empty_url():
"""URL 为空 → 仅靠指纹识别"""
svc = make_service()
page = FakePage(url="", matches={".home_nav_left": 2})
rec = svc.recognize_current_page(page)
assert rec is not None
assert rec["id"] == "home"
# ==================== get_scope_page ====================
def test_get_scope_page():
"""按 scope id 返回页面配置;不存在返回 None"""
svc = make_service()
cfg = svc.get_scope_page("create_meeting")
assert cfg is not None
assert cfg["id"] == "create_meeting"
assert svc.get_scope_page("not_exists") is None
# ==================== 旧功能不回归 ====================
def test_recognize_page_legacy_still_works():
"""步骤识别(直达 URL)老功能不回归;无直达 URL 的页面不参与"""
svc = make_service()
steps = [{"order": 1, "name": "进入新建会议", "action": "click", "params": {"selector": "新建会议"}}]
rec = svc.recognize_page(steps)
assert rec is not None
assert rec["page_id"] == "create_meeting"
assert rec["url"].startswith("https")
def test_recognize_page_ignores_no_url_pages():
"""login/home 无直达 URL,不应被步骤识别选中"""
svc = make_service()
steps = [{"order": 1, "name": "登录页", "action": "click", "params": {}}]
rec = svc.recognize_page(steps)
# 无 url 的页面被排除;无 match_rules 匹配时返回 None
assert rec is None or rec["page_id"] == "create_meeting"
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_phase4.py
模块描述:阶段 4 单元测试(状态验证 + api_call + finally)
覆盖:
- _verify_step_state:verify 类型映射 / must 语义 / ENFORCE_VERIFICATION
- _run_finally_steps:finally 无条件执行 / on_failure 仅失败执行 / 失败不掩盖主结果
- _do_api_call:复用 ApiTestExecutor / ctx 渲染 / save_as JSONPath 写上下文 / 失败抛错
作者:czj
创建日期:2026-08-18
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from app.config import settings
from app.executors.playwright_executor import PlaywrightExecutor, StepResult
from app.executors.ui_context import ExecutionContext
def make_executor(**overrides) -> PlaywrightExecutor:
"""构造执行器(默认空配置,可覆盖 _do_assert 等)"""
ex = PlaywrightExecutor({})
for k, v in overrides.items():
setattr(ex, k, v)
return ex
class FakeCaseResult:
"""ApiCaseResult 鸭子类型(_do_api_call 只读 status/error/step)"""
def __init__(self, status="passed", error="", response_body=""):
self.status = status
self.error = error
self.step = SimpleNamespace(response_body=response_body)
class FakeApiExecutor:
"""ApiTestExecutor 鸭子类型(捕获 config + case,可预置结果)"""
def __init__(self, config):
self.config = config
self.case = None
self.result = FakeCaseResult()
self.started = False
self.stopped = False
def start(self):
self.started = True
def stop(self):
self.stopped = True
def execute_case(self, case):
self.case = case
return self.result
# ==================== _verify_step_state ====================
class TestVerifyStepState:
def test_no_verify_returns_true(self):
ex = make_executor()
ex._do_assert = MagicMock()
assert ex._verify_step_state({"action": "click"}, {}, None) is True
ex._do_assert.assert_not_called()
def test_text_visible_pass(self):
ex = make_executor()
ex._do_assert = MagicMock()
step = {"verify": {"type": "text_visible", "selector": ".tab", "text": "预定会议", "must": True}}
assert ex._verify_step_state(step, {}, None) is True
ex._do_assert.assert_called_once_with(
{"type": "contains", "selectors": [".tab"], "expected": "预定会议"}, "预定会议"
)
def test_must_false_failure_only_warns(self):
ex = make_executor()
ex._do_assert = MagicMock(side_effect=AssertionError("not found"))
step = {"verify": {"type": "text_visible", "selector": ".tab", "text": "预定会议", "must": False}}
assert ex._verify_step_state(step, {}, None) is True
def test_must_true_failure_raises(self):
ex = make_executor()
ex._do_assert = MagicMock(side_effect=AssertionError("not found"))
step = {"verify": {"type": "text_visible", "selector": ".tab", "text": "预定会议", "must": True}}
with pytest.raises(AssertionError):
ex._verify_step_state(step, {}, None)
def test_dialog_mapping(self):
ex = make_executor()
ex._do_assert = MagicMock()
step = {"verify": {"type": "dialog", "text": "创建成功", "must": True}}
assert ex._verify_step_state(step, {}, None) is True
ap = ex._do_assert.call_args[0][0]
assert ap["type"] == "element_exists"
assert any(".el-dialog" in s for s in ap["selectors"])
def test_selector_fallback_to_chain(self):
"""verify.selector 缺省时用语义链首选择器兜底"""
ex = make_executor()
ex._do_assert = MagicMock()
step = {"verify": {"type": "text_visible", "text": "预定会议", "must": True}}
assert ex._verify_step_state(step, {}, ".el-tabs__item:has-text(\"预定会议\")") is True
ap = ex._do_assert.call_args[0][0]
assert ap["selectors"] == ['.el-tabs__item:has-text("预定会议")']
def test_url_contains(self):
ex = make_executor()
ex._do_assert = MagicMock()
step = {"verify": {"type": "url_contains", "text": "meetingV3", "must": True}}
assert ex._verify_step_state(step, {}, None) is True
assert ex._do_assert.call_args[0][0] == {"type": "url_contains", "expected": "meetingV3"}
def test_enforce_verification_gated(self):
"""ENFORCE_VERIFICATION=true 且 expected 非空 → 默认校验;false 时无 verify 直接过"""
ex = make_executor()
old = settings.ENFORCE_VERIFICATION
try:
# 关闭时不调默认校验
settings.ENFORCE_VERIFICATION = False
ex._do_assert = MagicMock()
assert ex._verify_step_state({"action": "click", "expected": "创建成功"}, {}, None) is True
ex._do_assert.assert_not_called()
# 开启时调用默认校验
settings.ENFORCE_VERIFICATION = True
ex._do_assert = MagicMock()
assert ex._verify_step_state({"action": "click", "expected": "创建成功"}, {}, None) is True
assert ex._do_assert.called
finally:
settings.ENFORCE_VERIFICATION = old
# ==================== _run_finally_steps ====================
class TestRunFinallySteps:
class FakeResult:
def __init__(self, status="passed"):
self.status = status
self.steps_result = []
self.error_message = None
def _mk(self, status="passed"):
ex = make_executor()
result = self.FakeResult(status)
return ex, result
def test_finally_runs_on_success(self):
ex, result = self._mk("passed")
ex.execute_step = MagicMock(return_value=StepResult(order=99, status="passed"))
steps = [
{"order": 1, "name": "清理", "action": "api_call", "run_on": "finally"},
]
ex._run_finally_steps(steps, result, None)
ex.execute_step.assert_called_once()
assert len(result.steps_result) == 1
assert result.status == "passed"
def test_on_failure_skipped_on_success(self):
ex, result = self._mk("passed")
ex.execute_step = MagicMock(return_value=StepResult(order=99, status="passed"))
steps = [{"order": 1, "name": "补救", "action": "api_call", "run_on": "on_failure"}]
ex._run_finally_steps(steps, result, None)
ex.execute_step.assert_not_called()
def test_on_failure_runs_on_failure(self):
ex, result = self._mk("failed")
ex.execute_step = MagicMock(return_value=StepResult(order=99, status="passed"))
steps = [{"order": 1, "name": "补救", "action": "api_call", "run_on": "on_failure"}]
ex._run_finally_steps(steps, result, None)
ex.execute_step.assert_called_once()
assert result.status == "failed" # 不掩盖失败结果
def test_always_steps_not_run_in_finally(self):
ex, result = self._mk("passed")
ex.execute_step = MagicMock(return_value=StepResult(order=99, status="passed"))
steps = [{"order": 1, "name": "普通步骤", "action": "click", "run_on": "always"}]
ex._run_finally_steps(steps, result, None)
ex.execute_step.assert_not_called()
def test_finally_failure_does_not_override_passed(self):
ex, result = self._mk("passed")
ex.execute_step = MagicMock(return_value=StepResult(order=99, status="failed", error="boom"))
steps = [{"order": 1, "name": "清理", "action": "api_call", "run_on": "finally"}]
ex._run_finally_steps(steps, result, None)
assert result.status == "failed"
assert "清理步骤失败" in (result.error_message or "")
def test_finally_exception_annotates(self):
ex, result = self._mk("passed")
ex.execute_step = MagicMock(side_effect=RuntimeError("network"))
steps = [{"order": 1, "name": "清理", "action": "api_call", "run_on": "finally"}]
ex._run_finally_steps(steps, result, None)
assert result.status == "failed"
assert "清理步骤异常" in (result.error_message or "")
# ==================== _do_api_call ====================
class TestDoApiCall:
def _mk(self, fake_result: FakeCaseResult):
fake_cls = MagicMock(side_effect=lambda cfg: fake_result.executor)
patcher = patch("app.executors.api_test_executor.ApiTestExecutor", fake_cls)
ex = make_executor()
ex._ctx = ExecutionContext({})
ex._ctx.set("meetingName", "自动化-XYZ1")
return ex, patcher
def test_success_renders_and_saves(self):
fake = FakeApiExecutor(None)
fake.result = FakeCaseResult(status="passed", response_body='{"data": {"id": 7}}')
ex = make_executor()
ex._ctx = ExecutionContext({})
ex._ctx.set("meetingName", "自动化-XYZ1")
patcher = patch(
"app.executors.api_test_executor.ApiTestExecutor",
MagicMock(side_effect=lambda cfg: fake),
)
with patcher:
result = ex._do_api_call({
"api_case": {
"name": "查询会议",
"steps": {
"method": "POST",
"url": "/api/meeting/query",
"body": {"name": "{__CTX:meetingName__}"},
},
},
"save_as": {"meetingId": "$.data.id"},
})
assert result.status == "passed"
assert fake.started and fake.stopped
# ctx 渲染生效:body 已替换 CTX 变量
body = fake.case["steps"]["body"]
assert body["name"] == "自动化-XYZ1"
# save_as JSONPath 写上下文
assert ex._ctx.get("meetingId") == 7
def test_failure_raises(self):
fake = FakeApiExecutor(None)
fake.result = FakeCaseResult(status="failed", error="timeout")
ex = make_executor()
patcher = patch(
"app.executors.api_test_executor.ApiTestExecutor",
MagicMock(side_effect=lambda cfg: fake),
)
with patcher, pytest.raises(Exception, match="api_call 失败"):
ex._do_api_call({"api_case": {"name": "查询", "steps": {"method": "GET", "url": "/x"}}})
assert fake.stopped
def test_no_api_case_is_noop(self):
fake = FakeApiExecutor(None)
fake.result = FakeCaseResult(status="passed")
ex = make_executor()
patcher = patch(
"app.executors.api_test_executor.ApiTestExecutor",
MagicMock(side_effect=lambda cfg: fake),
)
with patcher:
result = ex._do_api_call({})
assert result.status == "passed"
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_semantic_resolver.py
模块描述:语义解析器单元测试(阶段 3)
覆盖:
- 强结构类型模式选择器(tab / row_checkbox / checkbox / row)
- DOM 候选过滤(contains / container / nth)
- 歧义仲裁(唯一候选短路 / 置信度差距短路 / Claude 仲裁)
- resolve_step 主流程(semantic 优先 / scope 校验 / legacy keyword 路径开关)
- 映射表 v2 查找 hook(lookup_by_key 未实现时返回 None)
- extract_save_as(input_value / inner_text 写上下文)
作者:czj
创建日期:2026-08-18
"""
import pytest
from unittest.mock import MagicMock
from app.config import settings
from app.executors.ui_context import ExecutionContext
from app.services.semantic_resolver import SemanticResolver, get_semantic_resolver
# ==================== 模式选择器 ====================
class TestPatternSelectors:
def test_tab_pattern(self):
r = SemanticResolver()
chain = r._pattern_selectors("tab", "预定会议", None)
assert chain == [
'.el-tabs__item:has-text("预定会议")',
'[role="tab"]:has-text("预定会议")',
'.tab-item:has-text("预定会议")',
'li[role="tab"]:has-text("预定会议")',
]
def test_row_checkbox_uses_contains_as_anchor(self):
"""row_checkbox 锚点优先用 contains(唯一值),不用列标签"""
r = SemanticResolver()
chain = r._pattern_selectors("row_checkbox", "参会人", "admin@xty12")
assert chain[0] == '.el-table__row:has-text("admin@xty12") .el-checkbox'
assert ".el-table__row" in chain[0]
def test_checkbox_pattern(self):
r = SemanticResolver()
chain = r._pattern_selectors("checkbox", "通知", None)
assert chain[0] == '.el-checkbox:has-text("通知")'
def test_row_pattern(self):
r = SemanticResolver()
chain = r._pattern_selectors("row", "北京展厅会议室", None)
assert chain[0] == '.el-table__row:has-text("北京展厅会议室")'
def test_empty_anchor_returns_none(self):
r = SemanticResolver()
assert r._pattern_selectors("tab", "", None) is None
# ==================== 候选过滤 ====================
class TestFilterSelectors:
def _r(self):
return SemanticResolver()
def test_plain_pass_through(self):
sels = [{"value": ".a"}, {"value": ".b"}]
chain = self._r()._filter_selectors(sels, None, {})
assert chain == [".a", ".b"]
def test_contains_filters_low_priority(self):
"""contains 次级过滤:候选多于 1 且选择器未提及 contains 时降权"""
sels = [{"value": ".el-checkbox"}, {"value": '.el-checkbox:has-text("admin@xty12")'}]
chain = self._r()._filter_selectors(sels, "admin@xty12", {})
assert ".el-checkbox:has-text(" in chain[0]
def test_container_composes_selector(self):
sels = [{"value": ".edit-btn"}]
chain = self._r()._filter_selectors(sels, None, {"container": {"selector": ".room"}})
assert chain == [".room .edit-btn"]
def test_nth_applies_playwright_syntax(self):
sels = [{"value": ".el-radio"}]
chain = self._r()._filter_selectors(sels, None, {"nth": 3})
assert chain == [".el-radio >> nth=2"]
def test_nth_one_is_identity(self):
sels = [{"value": ".el-radio"}]
chain = self._r()._filter_selectors(sels, None, {"nth": 1})
assert chain == [".el-radio"]
def test_dedup_and_cap(self):
sels = [{"value": ".a"}, {"value": ".a"}, {"value": ".b"}, {"value": ".c"},
{"value": ".d"}, {"value": ".e"}]
chain = self._r()._filter_selectors(sels, None, {})
assert chain == [".a", ".b", ".c", ".d"]
assert len(chain) == 4
# ==================== 歧义仲裁 ====================
class TestArbitrate:
def _r(self):
r = SemanticResolver()
# 默认 Claude 关闭,走规则路径
r._claude = MagicMock(enabled=False)
return r
def test_unique_candidate_short_circuit(self):
r = self._r()
chain = r._arbitrate(None, "确定创建", "click", ["el"], [{"confidence": 0.9}], [".a"])
assert chain == [".a"]
r._claude.rank_candidates.assert_not_called()
def test_confidence_gap_short_circuits_claude(self):
r = self._r()
chain = r._arbitrate(
None, "会议名称", "fill",
["e1", "e2"],
[{"confidence": 0.95}, {"confidence": 0.30}],
[".a", ".b"],
)
assert chain == [".a"]
r._claude.rank_candidates.assert_not_called()
def test_ambiguous_calls_claude(self):
r = self._r()
r._claude.enabled = True
r._claude.rank_candidates.return_value = (1, 0.8, "文本唯一", True)
# get_candidate_details 会被调用,mock 掉
import app.services.semantic_resolver as sr
sr.get_candidate_details = MagicMock(return_value=[{"index": 0}, {"index": 1}])
chain = r._arbitrate(
MagicMock(), "保存", "click",
["e1", "e2"],
[{"confidence": 0.6}, {"confidence": 0.55}],
[".save", ".other"],
)
assert chain == [".other"] # Claude 选 index=1
def test_claude_failure_falls_back(self):
r = self._r()
r._claude.enabled = True
r._claude.rank_candidates.side_effect = RuntimeError("boom")
import app.services.semantic_resolver as sr
sr.get_candidate_details = MagicMock(return_value=[])
chain = r._arbitrate(
MagicMock(), "保存", "click",
["e1", "e2"],
[{"confidence": 0.6}, {"confidence": 0.55}],
[".save", ".other"],
)
assert chain == [".save"]
# ==================== resolve_step 主流程 ====================
class TestResolveStep:
def test_semantic_tab_mapping_v2_priority(self):
"""映射表 v2 有精确键时优先于模式选择器(阶段 5 契约)"""
r = get_semantic_resolver()
step = {
"name": "时间切换为:预定会议",
"action": "click",
"semantic": {"type": "tab", "text": "预定会议", "scope": "create_meeting"},
}
ctx = ExecutionContext({})
chain = r.resolve_step(MagicMock(), step, "create_meeting", ctx)
assert chain and ".time_btn" in chain[0]
def test_semantic_tab_returns_pattern_chain(self):
"""映射表 v2 无该键时,结构化语义目标回退到模式选择器链"""
r = get_semantic_resolver()
step = {
"name": "时间切换为:我的日程",
"action": "click",
"semantic": {"type": "tab", "text": "我的日程", "scope": "create_meeting"},
}
ctx = ExecutionContext({})
chain = r.resolve_step(MagicMock(), step, "create_meeting", ctx)
assert chain and ".el-tabs__item:has-text(" in chain[0]
def test_scope_mismatch_returns_none(self):
r = get_semantic_resolver()
step = {
"semantic": {"type": "tab", "text": "预定会议", "scope": "create_meeting"},
}
chain = r.resolve_step(MagicMock(), step, "meeting_detail", ExecutionContext({}))
assert chain is None
def test_legacy_step_gated_by_flag(self):
"""USE_KEYWORD_MATCHER=false 时 legacy 步骤不解析(零行为变化)"""
r = get_semantic_resolver()
old = settings.USE_KEYWORD_MATCHER
settings.USE_KEYWORD_MATCHER = False
try:
step = {"name": "点击登录按钮", "action": "click"}
chain = r.resolve_step(MagicMock(), step, None, ExecutionContext({}))
assert chain is None
finally:
settings.USE_KEYWORD_MATCHER = old
def test_mapping_v2_hook_used(self):
"""lookup_by_key 已实现:映射表 v2 精确键直接命中(阶段 5 生效)"""
r = get_semantic_resolver()
chain = r._mapping_v2_lookup({"scope": "create_meeting"}, "预定会议", "tab")
assert chain and ".time_btn" in chain[0]
def test_button_semantic_with_rule_candidates(self):
"""button 语义目标走 keyword_matcher 规则候选 + 仲裁"""
import app.services.semantic_resolver as sr
original = sr.match_element_by_keywords
sr.match_element_by_keywords = MagicMock(
return_value=(
["el1"],
[{"type": "css", "value": 'button:has-text("确定创建")',
"confidence": 0.9, "priority": 1}],
)
)
try:
r = get_semantic_resolver()
step = {"semantic": {"type": "button", "text": "确定创建"}}
chain = r.resolve_step(MagicMock(), step, None, ExecutionContext({}))
assert chain == ['button:has-text("确定创建")']
finally:
sr.match_element_by_keywords = original
# ==================== save_as 提取 ====================
class TestExtractSaveAs:
def _fake_page(self):
"""鸭子类型 page:locator.first 返回带 input_value/inner_text 的假元素"""
page = MagicMock()
el = MagicMock()
el.input_value.return_value = "自动化-ABC123"
el.inner_text.return_value = "预定会议"
loc = MagicMock()
loc.count.return_value = 1
loc.first = el
page.locator.return_value = loc
return page, el, loc
def test_input_save_as(self):
r = get_semantic_resolver()
page, el, _ = self._fake_page()
ctx = ExecutionContext({})
r.extract_save_as(page, {"type": "input", "text": "会议名称", "save_as": "meetingName"},
[".name-input"], ctx)
assert ctx.get("meetingName") == "自动化-ABC123"
def test_text_save_as_uses_inner_text(self):
r = get_semantic_resolver()
page, el, _ = self._fake_page()
ctx = ExecutionContext({})
r.extract_save_as(page, {"type": "text", "text": "时间", "save_as": "timeTab"},
[".el-tabs__item.is-active"], ctx)
assert ctx.get("timeTab") == "预定会议"
def test_no_save_as_noop(self):
r = get_semantic_resolver()
page, el, _ = self._fake_page()
ctx = ExecutionContext({})
r.extract_save_as(page, {"type": "button", "text": "确定创建"}, [".btn"], ctx)
# ExecutionContext 内置 caseName/caseId 默认值,断言未新增键
assert ctx.get("meetingName") is None
assert set(ctx.all().keys()) == {"caseName", "caseId"}
def test_missing_element_noop(self):
r = get_semantic_resolver()
page = MagicMock()
loc = MagicMock()
loc.count.return_value = 0
page.locator.return_value = loc
ctx = ExecutionContext({})
r.extract_save_as(page, {"type": "input", "save_as": "x"}, [".nope"], ctx)
assert ctx.get("x") is None
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_template_resolver.py
模块描述:TemplateResolver 单元测试
覆盖 8 种占位符 + INDEX 偏移新语法
作者:czj
创建日期:2026-08-17
"""
import re
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
# 加入项目路径,确保可导入
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.executors.template_resolver import TemplateResolver
def test_now_placeholder():
"""{__NOW__} 应替换为当前时间字符串"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW__}"}, 0)
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 允许秒级误差
assert result["time"].startswith(now_str[:16]), f"{result['time']} != {now_str}"
def test_now_plus_hours():
"""{__NOW_+2_H__} 应替换为当前时间 + 2 小时"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_+2_H__}"}, 0)
expected = (datetime.now() + timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"{result['time']} != {expected}"
def test_now_plus_minutes():
"""{__NOW_+30_M__} 应替换为当前时间 + 30 分钟"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_+30_M__}"}, 0)
expected = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"{result['time']} != {expected}"
def test_now_plus_days():
"""{__NOW_+1_D__} 应替换为当前时间 + 1 天"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_+1_D__}"}, 0)
expected = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"{result['time']} != {expected}"
# ==================== INDEX 偏移测试 ====================
def test_now_index_offset_request_0():
"""
{__NOW_+37_H_INDEX__} request_index=0
预期:当前时间 + 37h + 0min
"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_+37_H_INDEX__}"}, 0)
expected = (datetime.now() + timedelta(hours=37)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"request_index=0: {result['time']} != {expected}"
def test_now_index_offset_request_5():
"""
{__NOW_+37_H_INDEX__} request_index=5
预期:当前时间 + 37h + 5min
"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_+37_H_INDEX__}"}, 5)
expected = (datetime.now() + timedelta(hours=37, minutes=5)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"request_index=5: {result['time']} != {expected}"
def test_now_index_offset_difference():
"""
同一模板,request_index=0 与 request_index=5 的差值应为 5 分钟
"""
resolver = TemplateResolver()
# 连续调用,确保在相同秒内执行
r0 = resolver.resolve({"time": "{__NOW_+37_H_INDEX__}"}, 0)["time"]
r5 = resolver.resolve({"time": "{__NOW_+37_H_INDEX__}"}, 5)["time"]
dt0 = datetime.strptime(r0, "%Y-%m-%d %H:%M:%S")
dt5 = datetime.strptime(r5, "%Y-%m-%d %H:%M:%S")
diff = (dt5 - dt0).total_seconds()
assert 295 <= diff <= 305, f"差值应为 300s(5min),实际 {diff}s"
def test_now_bare_index():
"""
{__NOW_INDEX__} request_index=3
预期:当前时间 + 3min
"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_INDEX__}"}, 3)
expected = (datetime.now() + timedelta(minutes=3)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"NOW_INDEX 3: {result['time']} != {expected}"
def test_now_index_minutes_unit():
"""
{__NOW_+30_M_INDEX__} request_index=2
预期:当前时间 + 30min + 2min = 32min
"""
resolver = TemplateResolver()
result = resolver.resolve({"time": "{__NOW_+30_M_INDEX__}"}, 2)
expected = (datetime.now() + timedelta(minutes=32)).strftime("%Y-%m-%d %H:%M:%S")
assert result["time"].startswith(expected[:16]), f"NOW_+30_M_INDEX 2: {result['time']} != {expected}"
def test_now_without_index_unchanged():
"""
{__NOW_+37_H__}(无 INDEX 后缀)行为不变
验证:request_index=0 与 request_index=5 结果相同(无 INDEX 时忽略 index)
"""
resolver = TemplateResolver()
r0 = resolver.resolve({"time": "{__NOW_+37_H__}"}, 0)["time"]
r5 = resolver.resolve({"time": "{__NOW_+37_H__}"}, 5)["time"]
# 允许 1 秒误差(两次调用时间差)
dt0 = datetime.strptime(r0, "%Y-%m-%d %H:%M:%S")
dt5 = datetime.strptime(r5, "%Y-%m-%d %H:%M:%S")
diff = abs((dt5 - dt0).total_seconds())
assert diff <= 1, f"无 INDEX 时应忽略 index,差值 {diff}s > 1s"
def test_now_unknown_suffix_ignored():
"""
{__NOW_+37_H_X__}(未知后缀)应忽略后缀,行为与无 INDEX 等价
"""
resolver = TemplateResolver()
r0 = resolver.resolve({"time": "{__NOW_+37_H_X__}"}, 0)["time"]
r5 = resolver.resolve({"time": "{__NOW_+37_H_X__}"}, 5)["time"]
# 未知后缀不触发偏移,两次结果应接近
dt0 = datetime.strptime(r0, "%Y-%m-%d %H:%M:%S")
dt5 = datetime.strptime(r5, "%Y-%m-%d %H:%M:%S")
diff = abs((dt5 - dt0).total_seconds())
assert diff <= 1, f"未知后缀 'X' 应忽略,差值 {diff}s > 1s"
# ==================== 其他占位符测试 ====================
def test_random_name():
"""{__RANDOM_NAME_8__} 应生成 8 位随机字符串"""
resolver = TemplateResolver()
result = resolver.resolve({"name": "{__RANDOM_NAME_8__}"}, 0)
assert len(result["name"]) == 8, f"长度应为 8,实际 {len(result['name'])}"
assert result["name"].isalnum(), f"应为字母数字,实际 {result['name']}"
def test_random_name_twice_different():
"""连续两次 RANDOM_NAME 应生成不同值"""
resolver = TemplateResolver()
r1 = resolver.resolve({"name": "{__RANDOM_NAME_8__}"}, 0)["name"]
r2 = resolver.resolve({"name": "{__RANDOM_NAME_8__}"}, 0)["name"]
assert r1 != r2, f"两次随机值应不同:{r1} == {r2}"
def test_uuid():
"""{__UUID__} 应生成 32 位十六进制字符串(无横线)"""
resolver = TemplateResolver()
result = resolver.resolve({"id": "{__UUID__}"}, 0)
assert len(result["id"]) == 32, f"UUID 长度应为 32,实际 {len(result['id'])}"
assert all(c in "0123456789abcdef" for c in result["id"]), "UUID 应为十六进制"
def test_random_int():
"""{__RANDOM_INT_1_100__} 应生成 1~100 之间的整数"""
resolver = TemplateResolver()
for _ in range(50):
result = resolver.resolve({"val": "{__RANDOM_INT_1_100__}"}, 0)
val = int(result["val"])
assert 1 <= val <= 100, f"值应在 1~100 之间,实际 {val}"
def test_index():
"""{__INDEX__} 应返回 request_index 值"""
resolver = TemplateResolver()
result = resolver.resolve({"idx": "{__INDEX__}"}, 42)
assert result["idx"] == "42", f"应为 '42',实际 {result['idx']}"
def test_index_zero():
"""{__INDEX__} 在 request_index=0 时应返回 '0'"""
resolver = TemplateResolver()
result = resolver.resolve({"idx": "{__INDEX__}"}, 0)
assert result["idx"] == "0", f"应为 '0',实际 {result['idx']}"
# ==================== 嵌套模板测试 ====================
def test_nested_template():
"""嵌套 dict/list 模板应递归替换所有占位符"""
resolver = TemplateResolver()
template = {
"meeting": {
"name": "会议_{__RANDOM_NAME_4__}",
"time": {
"start": "{__NOW_+37_H_INDEX__}",
"end": "{__NOW_+39_H_INDEX__}",
},
},
"participants": [
{"uid": "{__UUID__}", "role": "admin"},
],
}
result = resolver.resolve(template, 3)
assert "RANDOM_NAME" not in result["meeting"]["name"], f"占位符未替换: {result['meeting']['name']}"
assert result["meeting"]["time"]["start"].startswith(
(datetime.now() + timedelta(hours=37, minutes=3)).strftime("%Y-%m-%d %H:%M")[:16]
), f"startTime 偏移错误: {result['meeting']['time']['start']}"
assert len(result["participants"][0]["uid"]) == 32, "UUID 长度错误"
# ==================== 未知占位符测试 ====================
def test_unknown_placeholder():
"""未知占位符应保留原样"""
resolver = TemplateResolver()
result = resolver.resolve({"val": "{__UNKNOWN__}"}, 0)
assert result["val"] == "{__UNKNOWN__}", f"应保留原样,实际 {result['val']}"
# ==================== 空/边界测试 ====================
def test_empty_template():
"""空模板应返回空"""
resolver = TemplateResolver()
assert resolver.resolve({}, 0) == {}
assert resolver.resolve([], 0) == []
assert resolver.resolve("", 0) == ""
def test_no_placeholder():
"""无占位符的字符串应原样返回"""
resolver = TemplateResolver()
result = resolver.resolve({"name": "固定名称"}, 0)
assert result["name"] == "固定名称"
def test_plain_value_types():
"""非字符串类型(数字、布尔、None)应原样返回"""
resolver = TemplateResolver()
result = resolver.resolve({"int": 42, "float": 3.14, "none": None, "bool": True}, 0)
assert result["int"] == 42
assert result["float"] == 3.14
assert result["none"] is None
assert result["bool"] is True
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:test_ui_context.py
模块描述:ExecutionContext 单元测试(语义化用例执行上下文)
覆盖:
- parameters 初始化渲染({__RANDOM_NAME_8__} / 跨键 CTX 引用)
- {__CTX:key__} 替换 / 未知键保留
- {__NOW__} 内置生成器复用
- 嵌套 dict/list 递归渲染
- 无 "__" 字符串零改动(老用例兼容)
- 原模板不被修改
作者:czj
创建日期:2026-08-18
"""
import re
import sys
from datetime import datetime
from pathlib import Path
# 加入项目路径,确保可导入
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.executors.ui_context import ExecutionContext
# ==================== parameters 初始化渲染 ====================
def test_parameters_random_name_rendered():
"""parameters 值中的 {__RANDOM_NAME_8__} 应在初始化时渲染为 8 位随机串"""
ctx = ExecutionContext({"meetingName": "自动化-{__RANDOM_NAME_8__}"})
value = ctx.get("meetingName")
assert value.startswith("自动化-"), f"应保留前缀: {value}"
suffix = value.split("-", 1)[1]
assert len(suffix) == 8, f"后缀应为 8 位: {value}"
assert suffix.isalnum(), f"后缀应为字母数字: {value}"
def test_parameters_cross_key_reference():
"""参数键之间的 {__CTX:...__} 交叉引用应迭代收敛"""
ctx = ExecutionContext({
"base": "自动化-{__RANDOM_NAME_8__}",
"title": "主题-{__CTX:base__}",
})
title = ctx.get("title")
base = ctx.get("base")
assert title == f"主题-{base}", f"交叉引用未收敛: {title} != 主题-{base}"
def test_parameters_forward_reference():
"""前向引用({__CTX:B__} 引用定义在后面的键 B)应跨轮收敛"""
ctx = ExecutionContext({
"name": "会议-{__CTX:uid__}",
"uid": "U-{__RANDOM_NAME_4__}",
})
uid = ctx.get("uid")
assert ctx.get("name") == f"会议-{uid}", f"前向引用未收敛: name={ctx.get('name')} != 会议-{uid}"
def test_parameters_plain_value_untouched():
"""普通值(无占位符)应原样保留"""
ctx = ExecutionContext({"room": "北京展厅会议室", "participant": "admin@xty12"})
assert ctx.get("room") == "北京展厅会议室"
assert ctx.get("participant") == "admin@xty12"
# ==================== {__CTX:key__} 替换 ====================
def test_ctx_placeholder_substituted():
"""{__CTX:meetingName__} 应替换为参数值"""
ctx = ExecutionContext({"meetingName": "自动化-test"})
result = ctx.render("{__CTX:meetingName__}")
assert result == "自动化-test"
def test_ctx_embedded_in_text():
"""{__CTX:key__} 可嵌入任意字符串"""
ctx = ExecutionContext({"meetingName": "自动化-test"})
result = ctx.render("会议《{__CTX:meetingName__}》的议题")
assert result == "会议《自动化-test》的议题"
def test_ctx_unknown_key_preserved():
"""未知键应保留占位符原文"""
ctx = ExecutionContext({})
result = ctx.render("{__CTX:not_exists__}")
assert result == "{__CTX:not_exists__}"
def test_ctx_after_set():
"""save_as 写入的变量应可被后续渲染引用"""
ctx = ExecutionContext({})
ctx.set("createdId", "meeting_123")
assert ctx.get("createdId") == "meeting_123"
result = ctx.render("ID={__CTX:createdId__}")
assert result == "ID=meeting_123"
# ==================== 内置生成器复用 ====================
def test_now_generator_reused():
"""{__NOW__} 应复用 TemplateResolver 内置生成器"""
ctx = ExecutionContext({})
result = ctx.render({"time": "{__NOW__}"})
now_prefix = datetime.now().strftime("%Y-%m-%d %H:%M:%S")[:16]
assert result["time"].startswith(now_prefix), f"{result['time']} != {now_prefix}"
def test_builtin_and_ctx_combined():
"""内置生成器与 CTX 可混合使用"""
ctx = ExecutionContext({"tag": "summit"})
result = ctx.render("{__CTX:tag__}-{__RANDOM_NAME_4__}")
assert result.startswith("summit-")
assert len(result) == len("summit-") + 4
# ==================== 嵌套渲染 ====================
def test_nested_dict_list_rendered():
"""嵌套 dict/list 应递归渲染"""
ctx = ExecutionContext({"meetingName": "自动化-test", "participant": "admin@xty12"})
template = {
"body": {
"name": "{__CTX:meetingName__}",
"participants": ["{__CTX:participant__}"],
"meta": {"seq": "{__RANDOM_INT_1_100__}"},
},
}
result = ctx.render(template)
assert result["body"]["name"] == "自动化-test"
assert result["body"]["participants"] == ["admin@xty12"]
assert 1 <= int(result["body"]["meta"]["seq"]) <= 100
# ==================== 兼容性(老用例零影响) ====================
def test_no_placeholder_unchanged():
"""不含 '__' 的字符串应原样返回(老用例零影响)"""
ctx = ExecutionContext({"meetingName": "自动化-test"})
params = {"selector": "#submit", "value": "固定值", "url": "/login"}
result = ctx.render(params)
assert result == params
def test_original_template_not_modified():
"""渲染返回新容器,原模板不被修改(防重复渲染放大动态值)"""
ctx = ExecutionContext({})
params = {"value": "{__RANDOM_NAME_8__}"}
result = ctx.render(params)
assert result["value"] != params["value"]
assert params["value"] == "{__RANDOM_NAME_8__}", "原模板不应被修改"
def test_empty_render():
"""空容器渲染应返回空"""
ctx = ExecutionContext({})
assert ctx.render({}) == {}
assert ctx.render([]) == []
assert ctx.render("") == ""
assert ctx.render(42) == 42
assert ctx.render(None) is None
<!--
组件名称:CaseStepEditor.vue
组件描述:UI 自动化测试步骤编辑器,支持增删/上下移/动态参数表单
组件描述:UI 自动化测试步骤编辑器,支持增删/上下移/动态参数表单 + 语义化目标 + 状态验证 + 执行时机
功能:
1. 添加 / 删除步骤
2. 上移 / 下移(自动重排 order)
3. 按动作类型动态显示参数输入框(与执行器 12 种动作 + 8 种断言一致)
4. v-model 双向绑定 steps 数组
3. 按动作类型动态显示参数输入框(与执行器 13 种动作 + 10 种断言一致)
4. 语义目标编辑(semantic:type/text/contains/scope/nth/container/save_as)
5. 状态验证编辑(verify:type/selector/text/timeout/must)
6. 执行时机(run_on:always/finally/on_failure/on_success)
7. api_call 动作(数据自建/自清理,配合 run_on: finally)
8. v-model 双向绑定 steps 数组
说明:
- 参数统一存储在 step.params 中(snake_case 键),与后端 playwright_executor 读取方式一致
- 语义目标 / 状态验证 / 执行时机为顶层字段(阶段 1 起 schema 已声明,recorder model_dump 透传)
- executor 读取:params.selector / params.url / params.value / params.type / params.expected / params.timeout / params.key / params.direction / params.distance / params.force / params.full_page
@author czj
@date 2026-07-14
@date 2026-07-14 (阶段 6:2026-08-18)
-->
<template>
......@@ -31,19 +36,19 @@
:key="index"
class="step-item"
>
<!-- 步骤头部:序号 + 名称 + 动作 + 操作按钮 -->
<!-- 步骤头部:序号 + 名称 + 动作 + 执行时机 + 操作按钮 -->
<div class="step-header">
<span class="step-order">{{ step.order }}</span>
<el-input
v-model="step.name"
placeholder="步骤名称"
size="small"
style="width: 200px"
style="width: 180px"
/>
<el-select
v-model="step.action"
size="small"
style="width: 200px"
style="width: 170px"
@change="onActionChange(step)"
>
<el-option
......@@ -53,6 +58,19 @@
:value="opt.value"
/>
</el-select>
<el-select
v-model="step.run_on"
size="small"
style="width: 120px"
clearable
placeholder="时机"
title="执行时机"
>
<el-option label="正常 (always)" value="always" />
<el-option label="清理 (finally)" value="finally" />
<el-option label="失败后 (on_failure)" value="on_failure" />
<el-option label="成功后 (on_success)" value="on_success" />
</el-select>
<div class="step-ops">
<el-button
......@@ -75,8 +93,8 @@
</el-button>
<el-button
size="small"
type="danger"
link
type="danger"
@click="removeStep(index)"
title="删除"
>
......@@ -121,7 +139,7 @@
v-model="getParams(step).key"
placeholder="按键,如 Enter / Tab / Escape / ArrowDown"
size="small"
style="width: 280px"
style="width: 260px"
>
<template #prepend>按键</template>
</el-input>
......@@ -129,8 +147,38 @@
v-model="getParams(step).selector"
placeholder="选择器(可选,不填则在当前焦点按键)"
size="small"
style="width: 300px"
/>
</template>
<!-- api_call(数据自建/自清理) -->
<template v-else-if="step.action === 'api_call'">
<el-select
v-model="getApiCase(step).method"
size="small"
style="width: 110px"
>
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
<el-input
v-model="getApiCase(step).url"
placeholder="接口路径,如 /api/meeting/query(base_url 取被测系统 URL)"
size="small"
style="width: 320px"
/>
<el-input
v-model="getApiCase(step).bodyText"
type="textarea"
:rows="2"
placeholder='请求体 JSON,如 {"name": "{__CTX:meetingName__}"}'
size="small"
style="width: 100%"
class="api-body"
/>
<span class="param-hint">支持 {__CTX:key__} 上下文变量;配合 run_on: finally 做数据清理</span>
</template>
<!-- 断言 -->
......@@ -162,6 +210,15 @@
size="small"
style="width: 280px"
/>
<el-input-number
v-if="getParams(step).type === 'element_count_min'"
v-model="getParams(step).min_count"
:min="0"
size="small"
controls-position="right"
style="width: 120px"
/>
<span v-if="getParams(step).type === 'element_count_min'" class="param-hint">最小数量</span>
</template>
<!-- 截图 -->
......@@ -179,7 +236,7 @@
v-model="getParams(step).selector"
placeholder="元素选择器(支持 CSS / XPath / 文本)"
size="small"
style="width: 360px"
style="width: 340px"
>
<template #prepend>选择器</template>
</el-input>
......@@ -213,6 +270,116 @@
</el-checkbox>
</template>
</div>
<!-- 语义目标 + 状态验证(高级区,可折叠) -->
<div class="step-advanced">
<el-button
size="small"
link
type="primary"
@click="toggleAdvanced(index)"
>
<el-icon><ArrowDown v-if="!isAdvancedOpen(index)" /><ArrowUp v-else /></el-icon>
{{ hasSemantic(step) || hasVerify(step) ? '语义目标/状态验证' : '记录业务意图(可选)' }}
</el-button>
<div v-if="isAdvancedOpen(index)" class="advanced-panel">
<!-- 语义目标 -->
<div class="adv-section">
<span class="adv-title">语义目标(semantic)</span>
<div class="adv-row">
<el-select v-model="getSemantic(step).type" size="small" style="width: 140px" placeholder="类型">
<el-option
v-for="t in SEMANTIC_TYPE_OPTIONS"
:key="t.value"
:label="t.label"
:value="t.value"
/>
</el-select>
<el-input
v-model="getSemantic(step).text"
placeholder="业务文本,如 预定会议 / {__CTX:meetingName__}"
size="small"
style="width: 300px"
/>
<el-input
v-model="getSemantic(step).contains"
placeholder="次级过滤 contains(可选)"
size="small"
style="width: 200px"
/>
<el-input
v-model="getSemantic(step).scope"
placeholder="页面 scope(如 create_meeting)"
size="small"
style="width: 180px"
/>
</div>
<div class="adv-row">
<el-input
v-model="getSemantic(step).container.selector"
placeholder="父容器选择器(可选)"
size="small"
style="width: 260px"
/>
<el-input-number
v-model="getSemantic(step).nth"
:min="1"
size="small"
controls-position="right"
style="width: 100px"
/>
<span class="param-hint">第 N 个匹配(1 起)</span>
<el-input
v-model="getSemantic(step).save_as"
placeholder="save_as 变量名(提取值写入上下文)"
size="small"
style="width: 260px"
/>
</div>
</div>
<!-- 状态验证 -->
<div class="adv-section">
<span class="adv-title">状态验证(verify,防假通过)</span>
<div class="adv-row">
<el-select v-model="getVerify(step).type" size="small" style="width: 160px" placeholder="验证类型" clearable>
<el-option
v-for="t in ASSERT_TYPE_OPTIONS"
:key="t.value"
:label="t.label"
:value="t.value"
/>
</el-select>
<el-input
v-if="verifyNeedsSelector(getVerify(step).type)"
v-model="getVerify(step).selector"
placeholder="验证选择器"
size="small"
style="width: 260px"
/>
<el-input
v-if="verifyNeedsText(getVerify(step).type)"
v-model="getVerify(step).text"
placeholder="期望文本/URL片段"
size="small"
style="width: 220px"
/>
<el-input-number
v-model="getVerify(step).timeout"
:min="0"
:step="1000"
size="small"
controls-position="right"
style="width: 120px"
/>
<span class="param-hint">超时ms</span>
<el-checkbox v-model="getVerify(step).must">
失败即失败(must)
</el-checkbox>
</div>
</div>
</div>
</div>
</div>
<!-- 添加步骤按钮 -->
......@@ -233,11 +400,12 @@
/**
* CaseStepEditor 组件
*
* UI 自动化测试步骤编辑器。
* UI 自动化测试步骤编辑器(阶段 6 增强)
* 通过 v-model 双向绑定 steps 数组,参数存储在 step.params 中。
* 语义目标 / 状态验证 / 执行时机为顶层字段,保存时随步骤一并提交。
*/
import { computed } from 'vue'
import { Top, Bottom, Delete, Plus } from '@element-plus/icons-vue'
import { computed, ref } from 'vue'
import { Top, Bottom, Delete, Plus, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
/** 步骤对象结构 */
interface Step {
......@@ -245,6 +413,9 @@ interface Step {
name: string
action: string
params: Record<string, any>
semantic?: Record<string, any>
verify?: Record<string, any>
run_on?: string
[key: string]: any
}
......@@ -260,9 +431,18 @@ const emit = defineEmits<{
/** steps 计算属性:直接操作同一数组引用,变更后 emit 通知父组件 */
const steps = computed(() => props.modelValue)
/** 高级区展开状态(按步骤索引) */
const advancedOpen = ref<Record<number, boolean>>({})
const isAdvancedOpen = (index: number): boolean => !!advancedOpen.value[index]
const toggleAdvanced = (index: number) => {
advancedOpen.value[index] = !advancedOpen.value[index]
}
// ==================== 动作与断言配置 ====================
/** 执行器支持的 12 种动作(与 playwright_executor 一致) */
/** 执行器支持的 13 种动作(与 playwright_executor 一致) */
const ACTION_OPTIONS = [
{ label: '导航 (navigate)', value: 'navigate' },
{ label: '点击 (click)', value: 'click' },
......@@ -276,9 +456,26 @@ const ACTION_OPTIONS = [
{ label: '取消勾选 (uncheck)', value: 'uncheck' },
{ label: '断言 (assert)', value: 'assert' },
{ label: '截图 (screenshot)', value: 'screenshot' },
{ label: '接口调用 (api_call)', value: 'api_call' },
]
/** 语义目标类型选项(与后端 semantic_resolver TYPE_ACTION_MAP 一致) */
const SEMANTIC_TYPE_OPTIONS = [
{ label: '按钮 (button)', value: 'button' },
{ label: '输入框 (input)', value: 'input' },
{ label: '文本域 (textarea)', value: 'textarea' },
{ label: '复选框 (checkbox)', value: 'checkbox' },
{ label: '行复选框 (row_checkbox)', value: 'row_checkbox' },
{ label: '表格行 (row)', value: 'row' },
{ label: '标签页 (tab)', value: 'tab' },
{ label: '链接 (link)', value: 'link' },
{ label: '菜单 (menu)', value: 'menu' },
{ label: '图标 (icon)', value: 'icon' },
{ label: '文本 (text)', value: 'text' },
{ label: '弹窗 (dialog)', value: 'dialog' },
]
/** 断言类型选项(8 种) */
/** 断言类型选项(10 种,与后端 _do_assert 对齐;url 已统一为 url_contains) */
const ASSERT_TYPE_OPTIONS = [
{ label: '元素可见 (visible)', value: 'visible' },
{ label: '元素隐藏 (hidden)', value: 'hidden' },
......@@ -286,8 +483,10 @@ const ASSERT_TYPE_OPTIONS = [
{ label: '文本包含 (contains)', value: 'contains' },
{ label: '输入值相等 (value)', value: 'value' },
{ label: '元素数量 (count)', value: 'count' },
{ label: 'URL 包含 (url)', value: 'url' },
{ label: 'URL 包含 (url_contains)', value: 'url_contains' },
{ label: '标题包含 (title)', value: 'title' },
{ label: '元素存在 (element_exists)', value: 'element_exists' },
{ label: '数量>=最小 (element_count_min)', value: 'element_count_min' },
]
// ==================== 辅助方法 ====================
......@@ -303,13 +502,60 @@ const getParams = (step: Step): Record<string, any> => {
return step.params
}
/** 获取 semantic 对象(不存在则初始化) */
const getSemantic = (step: Step): Record<string, any> => {
if (!step.semantic || typeof step.semantic !== 'object') {
step.semantic = { type: 'button' }
}
if (!step.semantic.container || typeof step.semantic.container !== 'object') {
step.semantic.container = {}
}
return step.semantic
}
/** 获取 verify 对象(不存在则初始化) */
const getVerify = (step: Step): Record<string, any> => {
if (!step.verify || typeof step.verify !== 'object') {
step.verify = {}
}
return step.verify
}
/** 获取 api_call 的 api_case 对象(bodyText 为编辑态文本,提交时转 JSON) */
const getApiCase = (step: Step): Record<string, any> => {
const p = getParams(step)
if (!p.api_case || typeof p.api_case !== 'object') {
p.api_case = { method: 'POST', url: '' }
}
const c = p.api_case
if (c.bodyText == null) {
c.bodyText = c.body ? JSON.stringify(c.body, null, 2) : ''
}
return c
}
/** 语义 / 验证是否存在(用于折叠按钮文案) */
const hasSemantic = (step: Step): boolean =>
!!step.semantic && !!step.semantic.text
const hasVerify = (step: Step): boolean =>
!!step.verify && !!step.verify.type
/** 断言是否需要选择器 */
const needsSelector = (type: string): boolean =>
['visible', 'hidden', 'text', 'contains', 'value', 'count'].includes(type || '')
['visible', 'hidden', 'text', 'contains', 'value', 'count', 'element_exists', 'element_count_min'].includes(type || '')
/** 断言是否需要期望值 */
const needsExpected = (type: string): boolean =>
['text', 'contains', 'value', 'count', 'url', 'title'].includes(type || '')
['text', 'contains', 'value', 'count', 'url_contains', 'title', 'element_count_min'].includes(type || '')
/** 验证是否需要选择器 */
const verifyNeedsSelector = (type: string): boolean =>
needsSelector(type)
/** 验证是否需要期望文本 */
const verifyNeedsText = (type: string): boolean =>
needsExpected(type)
/**
* 动作变更时重置无关参数,避免残留干扰
......@@ -321,6 +567,11 @@ const onActionChange = (step: Step) => {
if (step.action === 'assert' && !p.type) {
p.type = 'visible'
}
// api_call 默认 POST
if (step.action === 'api_call') {
const c = getApiCase(step)
if (!c.method) c.method = 'POST'
}
// 滚动默认向下 300px
if (step.action === 'scroll') {
if (!p.direction) p.direction = 'down'
......@@ -345,6 +596,7 @@ const addStep = () => {
/** 删除步骤(重排 order) */
const removeStep = (index: number) => {
steps.value.splice(index, 1)
delete advancedOpen.value[index]
renumber()
}
......@@ -420,11 +672,61 @@ const emitChange = () => {
gap: 8px;
padding-left: 32px;
.api-body {
margin-top: 4px;
}
.param-hint {
font-size: 12px;
color: #909399;
}
}
.step-advanced {
margin-top: 8px;
padding-left: 32px;
.advanced-panel {
margin-top: 6px;
padding: 10px;
background: #f4f7fa;
border: 1px dashed #d0d7de;
border-radius: 4px;
.adv-section {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
.adv-title {
display: block;
font-size: 12px;
font-weight: 600;
color: #409eff;
margin-bottom: 6px;
}
.adv-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
&:last-child {
margin-bottom: 0;
}
.param-hint {
font-size: 12px;
color: #909399;
}
}
}
}
}
}
.add-step-btn {
......
......@@ -6,6 +6,44 @@
* @date 2026-07-09
*/
/** 语义目标(语义化用例执行) */
export interface SemanticTarget {
/** 元素类型: button/input/textarea/checkbox/row_checkbox/row/tab/link/menu/icon/text/dialog */
type?: string
/** 主匹配文本(渲染后用于映射精确键 + DOM 文本/placeholder) */
text?: string
/** 行/单元格次级过滤文本 */
contains?: string
/** 页面注册表 id(scope) */
scope?: string
/** 角色/辅助提示(如 tab 的 aria-selected) */
role?: string
/** 第 N 个匹配(1 起) */
nth?: number
/** 父容器约束 → 组合选择器 */
container?: {
type?: string
selector?: string
title?: string
}
/** 提取元素值写入上下文变量(save_as) */
save_as?: string | Record<string, string>
}
/** 状态验证(防假通过) */
export interface StepVerify {
/** 验证类型: text_visible/element_visible/url_contains/dialog 等 */
type?: string
/** 验证选择器 */
selector?: string
/** 期望文本 */
text?: string
/** 超时 ms */
timeout?: number
/** true 失败则步骤真实 failed(缺省 false 只 warn) */
must?: boolean
}
/** 测试步骤定义 */
export interface StepDefinition {
/** 步骤顺序 */
......@@ -34,6 +72,12 @@ export interface StepDefinition {
locator_type?: string
/** 定位值 */
locator_value?: string
/** 语义目标(语义化用例执行,后端 snake_case 序列化) */
semantic?: SemanticTarget
/** 状态验证(防假通过,后端 snake_case 序列化) */
verify?: StepVerify
/** 执行时机: always/finally/on_failure/on_success(缺省 always) */
run_on?: 'always' | 'finally' | 'on_failure' | 'on_success'
}
/** 测试用例接口 */
......@@ -58,6 +102,8 @@ export interface TestCase {
config: Record<string, any>
/** 用例类型 ui/api/security/deploy/performance/functional */
caseType?: string
/** 用例参数(上下文变量初始值,支持 {__CTX:key__} 引用) */
parameters?: Record<string, any>
/** 创建时间 */
createdAt: string
/** 更新时间 */
......@@ -84,6 +130,8 @@ export interface CreateCaseRequest {
config?: Record<string, any>
/** 用例类型 ui/api/security/deploy/performance/functional */
case_type?: string
/** 用例参数(上下文变量初始值) */
parameters?: Record<string, any>
}
/** 用例列表响应 */
......
......@@ -19,6 +19,10 @@ export interface IntentStep {
waitAfter: number
locatorType: string
locatorValue: string
/** 阶段 6:round-trip 保留的语义字段(编辑回显不丢) */
semantic?: Record<string, any>
verify?: Record<string, any>
run_on?: string
}
/** 意图步骤编辑器 emit 事件 */
......@@ -118,12 +122,48 @@ export function inferParams(description: string, locatorValue: string): Record<s
return params
}
/**
* 从自然语言描述推断语义目标(semantic)
*
* 阶段 6:意图模式步骤转换时,若未显式指定定位器,则附带语义目标,
* 让执行期走 deterministic 的语义解析(映射表 v2 / 模式选择器),
* 替代不稳定的人工/AI 定位。有显式 locator 的步骤不推断(避免覆盖)。
*
* @param description - 步骤描述,如「点击【登录】按钮」「输入 用户名」
* @returns SemanticTarget 或 null
*/
export function inferSemantic(description: string): Record<string, any> | null {
const desc = description.trim()
if (!desc) return null
// 提取带引号的业务文本:点击「X」/ 输入【X】/ 勾选"X" 等
const quoted = desc.match(/["「『"](.+?)["」』"]/)
const targetText = quoted ? quoted[1] : desc.replace(/^(请|帮我|点击|单击|输入|填写|键入|填入|勾选|选中|切换|选择|打开|进入|访问)\s*/, '')
if (/输入|填写|填入|键入|写入/.test(desc)) {
return { type: 'input', text: targetText || '' }
}
if (/勾选|选中|勾上/.test(desc)) {
return { type: 'checkbox', text: targetText || '' }
}
if (/切换|切换到/.test(desc)) {
return { type: 'tab', text: targetText || '' }
}
if (/点击|单击|按下|选择/.test(desc)) {
return { type: 'button', text: targetText || '' }
}
return null
}
/**
* 将意图步骤转换为 StepDefinition 格式
*
* 根据步骤描述自动推断 action 和 params,
* 并将 locator_type / locator_value 同步到 params.selector。
*
* 阶段 6 增强:步骤无显式定位器时附带 semantic 语义目标;
* 保留步骤自带 semantic / verify / run_on(round-trip 安全)。
*
* @param step - 意图步骤数据
* @returns 标准 StepDefinition 格式
*
......@@ -135,7 +175,11 @@ export function inferParams(description: string, locatorValue: string): Record<s
* })
* // => { order: 1, name: '输入用户名', action: 'fill', params: { selector: '#username' }, ... }
*/
export function toStepDefinition(step: IntentStep): Record<string, any> {
export function toStepDefinition(step: IntentStep & {
semantic?: Record<string, any>
verify?: Record<string, any>
run_on?: string
}): Record<string, any> {
const action = inferAction(step.description)
const params = inferParams(step.description, step.locatorValue)
......@@ -144,7 +188,7 @@ export function toStepDefinition(step: IntentStep): Record<string, any> {
params.selector = step.locatorValue
}
return {
const result: Record<string, any> = {
order: step.order,
name: step.description,
action,
......@@ -155,6 +199,22 @@ export function toStepDefinition(step: IntentStep): Record<string, any> {
locator_type: step.locatorType || '',
locator_value: step.locatorValue || '',
}
// 阶段 6:无显式定位器时推断语义目标;否则保留步骤自带字段
if (step.semantic && typeof step.semantic === 'object' && step.semantic.text) {
result.semantic = step.semantic
} else if (!step.locatorValue) {
const semantic = inferSemantic(step.description)
if (semantic) result.semantic = semantic
}
if (step.verify && typeof step.verify === 'object' && step.verify.type) {
result.verify = step.verify
}
if (step.run_on) {
result.run_on = step.run_on
}
return result
}
/**
......@@ -195,5 +255,16 @@ export function toIntentStep(step: any): IntentStep {
intentStep.locatorValue = selector
}
// 阶段 6:保留语义字段(编辑回显不丢)
if (step.semantic && typeof step.semantic === 'object' && step.semantic.text) {
intentStep.semantic = step.semantic
}
if (step.verify && typeof step.verify === 'object' && step.verify.type) {
intentStep.verify = step.verify
}
if (step.run_on) {
intentStep.run_on = step.run_on
}
return intentStep
}
\ No newline at end of file
......@@ -295,8 +295,30 @@
style="width: 100%"
/>
</el-form-item>
<el-form-item label="编辑模式">
<el-radio-group v-model="uiEditMode" size="small">
<el-radio-button value="intent">意图(自然语言)</el-radio-button>
<el-radio-button value="semantic">语义目标</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="用例参数">
<div class="param-editor">
<el-input
v-model="parametersText"
type="textarea"
:rows="2"
placeholder='JSON 对象,如 {"meetingName":"自动化-{__RANDOM_NAME_8__}", "room":"北京展厅会议室"}'
/>
<span class="param-hint">供 {__CTX:key__} 上下文变量引用</span>
</div>
</el-form-item>
<el-form-item label="测试步骤">
<IntentStepEditor v-model="editForm.intentSteps" />
<template v-if="uiEditMode === 'semantic'">
<CaseStepEditor v-model="editForm.steps" />
</template>
<template v-else>
<IntentStepEditor v-model="editForm.intentSteps" />
</template>
</el-form-item>
</el-form>
</template>
......@@ -652,8 +674,15 @@ const editForm = reactive({
case_type: 'ui',
steps: [] as any[],
intentSteps: [] as IntentStep[],
parameters: {} as Record<string, any>,
})
/** UI 用例编辑模式:intent=意图自然语言 / semantic=语义目标(阶段 6) */
const uiEditMode = ref<'intent' | 'semantic'>('intent')
/** 用例参数 JSON 文本(编辑态,保存时解析) */
const parametersText = ref('')
const editRules = {
name: [{ required: true, message: '请输入用例名称', trigger: 'blur' }],
module_id: [{ required: true, message: '请选择所属模块', trigger: 'change' }],
......@@ -808,6 +837,9 @@ const showCreateDialog = () => {
editForm.tags = []
editForm.case_type = 'ui'
editForm.steps = []
editForm.parameters = {}
parametersText.value = ''
uiEditMode.value = 'intent'
// UI 用例:初始化意图步骤
editForm.intentSteps = [{
order: 1,
......@@ -832,8 +864,15 @@ const showEditDialog = (row: any) => {
editForm.tags = row.tags || []
editForm.case_type = row.caseType || row.case_type || currentType.value
editForm.steps = (row.steps || []).map((s: any) => ({ ...s }))
editForm.parameters = row.parameters || {}
parametersText.value = Object.keys(editForm.parameters).length
? JSON.stringify(editForm.parameters, null, 2)
: ''
if (editForm.case_type === 'ui') {
// 步骤含 semantic 字段 → 用语义模式编辑;否则走意图模式
const hasSemantic = (row.steps || []).some((s: any) => s.semantic && s.semantic.text)
uiEditMode.value = hasSemantic ? 'semantic' : 'intent'
// UI 用例:将现有步骤转换为意图步骤格式
const rawSteps = row.steps || []
editForm.intentSteps = rawSteps.length > 0
......@@ -855,11 +894,34 @@ const saveCase = async () => {
saving.value = true
try {
// UI 用例:将意图步骤转换为 StepDefinition 格式
// 解析用例参数 JSON(容错:非法 JSON 时警告并保持上次值)
try {
editForm.parameters = parametersText.value.trim()
? JSON.parse(parametersText.value)
: {}
} catch {
ElMessage.warning('用例参数 JSON 格式有误,已忽略本次修改')
}
// UI 用例:意图模式才做描述→步骤转换;语义模式直接使用 steps
if (editForm.case_type === 'ui') {
editForm.steps = editForm.intentSteps
.filter((s: IntentStep) => s.description.trim())
.map((s: IntentStep) => toStepDefinition(s))
if (uiEditMode.value === 'intent') {
editForm.steps = editForm.intentSteps
.filter((s: IntentStep) => s.description.trim())
.map((s: IntentStep) => toStepDefinition(s))
} else {
// 语义模式:归一化 api_call 的 bodyText → body
editForm.steps.forEach((s: any) => {
const apiCase = s.params && s.params.api_case
if (s.action === 'api_call' && apiCase) {
if (apiCase.bodyText != null) {
const raw = String(apiCase.bodyText).trim()
apiCase.body = raw ? JSON.parse(raw) : {}
delete apiCase.bodyText
}
}
})
}
}
if (editMode.value === 'create') {
......@@ -872,6 +934,7 @@ const saveCase = async () => {
tags: editForm.tags,
case_type: editForm.case_type,
steps: editForm.steps,
parameters: editForm.parameters,
})
ElMessage.success('用例创建成功')
} else {
......@@ -884,6 +947,7 @@ const saveCase = async () => {
tags: editForm.tags,
case_type: editForm.case_type,
steps: editForm.steps,
parameters: editForm.parameters,
})
ElMessage.success('用例更新成功')
}
......@@ -1191,6 +1255,16 @@ onMounted(() => {
<style lang="scss" scoped>
.cases-page {
.param-editor {
width: 100%;
.param-hint {
font-size: 12px;
color: #909399;
line-height: 20px;
}
}
.page-header {
display: flex;
justify-content: space-between;
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论