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

feat(smart-locate): 语义化用例生成器阶段D+——auto_login 跳过登录步骤 + 已有选择器步骤语义化升级 + data-key 属性选择器全局优先

- smart_locate_service:_is_login_step 跳过 auto_login 已覆盖的登录步骤(否则登录表单已消失致整条定位链中断);已有选择器执行失败时清空重定位一次(旧坏选择器可升级为 semantic);navigate 步骤优先处理(URL 不当 CSS 解析);STEP_FAIL_DUMP 失败现场诊断
- keyword_matcher:data-key 属性选择器纳入可点击候选 + 容器限定属性选择器全局优先(修复 div:visible:has-text 命中外层容器假成功)
- tests:新增 TestIsLoginStep / TestTryAttachSemantic,47/47 通过
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 c0cd02ae
...@@ -511,6 +511,9 @@ def match_element_by_keywords( ...@@ -511,6 +511,9 @@ def match_element_by_keywords(
# 常见可点击元素(图标、自定义按钮) # 常见可点击元素(图标、自定义按钮)
'i[onclick]:visible, div[onclick]:visible, span[onclick]:visible, ' 'i[onclick]:visible, div[onclick]:visible, span[onclick]:visible, '
'div[class*="btn"]:visible, span[class*="btn"]:visible, ' 'div[class*="btn"]:visible, span[class*="btn"]:visible, '
# ⚠️ data-key 属性元素:抽屉/列表菜单项(如 item_content_item)常带 data-key 但无
# btn/block 等 class,不加入则关键字定位永远匹配不到(如 新建会议 → reserve_list.create)
'[data-key]:visible, '
# 微前端中的常见可点击元素 # 微前端中的常见可点击元素
'.block:visible, p:visible' '.block:visible, p:visible'
) )
...@@ -566,6 +569,7 @@ def match_element_by_keywords( ...@@ -566,6 +569,7 @@ def match_element_by_keywords(
name = el.get_attribute('name') or '' name = el.get_attribute('name') or ''
aria_label = el.get_attribute('aria-label') or '' aria_label = el.get_attribute('aria-label') or ''
data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or '' data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or ''
el_data_key = el.get_attribute('data-key') or ''
class_name = el.get_attribute('class') or '' class_name = el.get_attribute('class') or ''
# ==================== Phase 2: 归一化评分 ==================== # ==================== Phase 2: 归一化评分 ====================
...@@ -722,6 +726,21 @@ def match_element_by_keywords( ...@@ -722,6 +726,21 @@ def match_element_by_keywords(
if container_info: if container_info:
parent_selector = container_info.get('selector', '') parent_selector = container_info.get('selector', '')
container_type = container_info.get('type', '') container_type = container_info.get('type', '')
# ⚠️ data-key 属性组合选择器(最高优先级):
# 文本选择器可能命中外层容器导致"点击无效果"(如首页快捷 block 被遮挡,
# div:visible:has-text 匹配到 main_item 容器而非可点的 item_content_item)。
# 元素有稳定 data-key 且位于容器内时,容器限定的属性选择器最精确
# (实测 .el-drawer [data-key="reserve_list.create"] 点击后进入 CreateMeeting 页面)。
if el_data_key and parent_selector:
attr_sel = f'{parent_selector} [data-key="{el_data_key}"]'
if not any(s['value'] == attr_sel for s in selectors):
selectors.insert(0, {
'type': 'css',
'value': attr_sel,
'confidence': 0.92,
'priority': 1,
'match_type': 'attribute'
})
if parent_selector: if parent_selector:
for sel in list(selectors): # 使用 list() 复制,避免迭代中修改 for sel in list(selectors): # 使用 list() 复制,避免迭代中修改
sel_value = sel.get('value', '') sel_value = sel.get('value', '')
...@@ -779,9 +798,19 @@ def match_element_by_keywords( ...@@ -779,9 +798,19 @@ def match_element_by_keywords(
# 返回所有候选元素和选择器(供 Claude 语义排序使用) # 返回所有候选元素和选择器(供 Claude 语义排序使用)
elements_list = [c['element'] for c in limited_candidates] elements_list = [c['element'] for c in limited_candidates]
all_selectors = [] # ⚠️ data-key 属性选择器(priority=1, match_type=attribute)全局优先:
for c in limited_candidates: # 候选按元素分数降序连接,外层容器(如首页快捷 block)文本选择器分数更高会排前面,
all_selectors.extend(c['selectors']) # 但属性选择器更精确(容器限定),必须排最前,否则被 div:visible:has-text 假成功截断
# (实测 .el-drawer [data-key="reserve_list.create"] 点击进入 CreateMeeting 页面)
attr_selectors = [
s for c in limited_candidates for s in c['selectors']
if s.get('priority') == 1 and s.get('match_type') == 'attribute'
]
rest_selectors = [
s for c in limited_candidates for s in c['selectors']
if not (s.get('priority') == 1 and s.get('match_type') == 'attribute')
]
all_selectors = attr_selectors + rest_selectors
# 去重选择器 # 去重选择器
seen_selectors = set() seen_selectors = set()
......
...@@ -13,6 +13,7 @@ import logging ...@@ -13,6 +13,7 @@ import logging
import os import os
import re import re
import base64 import base64
import json
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from datetime import datetime from datetime import datetime
...@@ -137,6 +138,12 @@ class SmartLocateService: ...@@ -137,6 +138,12 @@ class SmartLocateService:
logger.warning("自动登录失败,可能影响后续步骤定位") logger.warning("自动登录失败,可能影响后续步骤定位")
else: else:
logger.info("自动登录成功") logger.info("自动登录成功")
# ⚠️ 阶段6+:登录后等待首页 SPA 就绪(5.60 生产容器渲染较慢,
# 立即点击功能中心等菜单可能因前端事件尚未挂载而点击失效)
try:
self.executor._page.wait_for_timeout(3000)
except Exception:
pass
# 3. 导航到目标菜单 # 3. 导航到目标菜单
if navigate_menu: if navigate_menu:
...@@ -163,6 +170,24 @@ class SmartLocateService: ...@@ -163,6 +170,24 @@ class SmartLocateService:
results.append(result) results.append(result)
continue continue
# ⚠️ 阶段6+:auto_login 已自动登录,跳过用例中的登录步骤
# (访问登录页/输入账号密码验证码/勾选协议/点击登录)。
# 否则登录表单已消失 → 执行验证失败 → skip_remaining 中断整条定位链,
# 导致表单步骤(如"时间切换为:预定会议")永远无法被定位/升级 semantic。
if auto_login and self._is_login_step(step):
logger.info(f"步骤 {step.get('order')} 已自动登录,跳过登录步骤")
results.append({
'order': step.get('order', 0),
'name': step.get('name', ''),
'success': True,
'action': step.get('action', ''),
'params': step.get('params', {}),
'selectors': {'primary': None, 'candidates': []},
'message': '已自动登录,跳过登录步骤'
})
seen_steps.append(step)
continue
result = self._locate_single_step(step, previous_steps=seen_steps) result = self._locate_single_step(step, previous_steps=seen_steps)
results.append(result) results.append(result)
seen_steps.append(step) seen_steps.append(step)
...@@ -171,7 +196,46 @@ class SmartLocateService: ...@@ -171,7 +196,46 @@ class SmartLocateService:
if result.get('success') and result.get('action') != 'navigate': if result.get('success') and result.get('action') != 'navigate':
exec_success = self._execute_step(result) exec_success = self._execute_step(result)
if not exec_success: if not exec_success:
# ⚠️ 阶段6+:已有选择器执行失败 → 清空选择器重新定位一次
# (旧用例的坏选择器如 div:visible:has-text 存在但不可点/语义无法升级时,
# 直接跳过后链必断;重定位走 keyword 路径产出新选择器 + semantic)
had_existing = bool((step.get('params') or {}).get('selector'))
if had_existing:
logger.warning(f"步骤 {step.get('order')} 已有选择器执行失败,尝试重新定位")
step_wo_sel = dict(step)
step_wo_sel['params'] = dict(step.get('params', {}))
step_wo_sel['params'].pop('selector', None)
try:
retry = self._locate_single_step(step_wo_sel, previous_steps=seen_steps)
except Exception as e:
logger.debug(f"步骤 {step.get('order')} 重新定位异常: {e}")
retry = None
if retry and retry.get('success') and self._execute_step(retry):
result = retry
result['message'] = f"重新定位成功: {result.get('message', '')}"
results[-1] = result
logger.info(f"步骤 {step.get('order')} 重新定位并执行成功")
# 替换结果后继续,不中断后续步骤
continue
# 执行失败,回退标记定位结果为失败 # 执行失败,回退标记定位结果为失败
# 诊断 dump:记录执行失败时页面的真实状态(URL/表单值/表格行数),
# 用于排查异步加载时序导致的"元素暂时不存在"类失败
try:
_page = self.executor._page
_d = {'url': _page.url}
try:
_d['name_val'] = _page.locator('input[placeholder*="会议名称"]').input_value()
except Exception:
_d['name_val'] = 'NA'
try:
_d['table_rows'] = _page.locator('.meeting_check .el-table__row').count()
_d['target_rows'] = _page.locator('.meeting_check .el-table__row:has-text("北京展厅会议室")').count()
_d['table_text'] = (_page.locator('.meeting_check').inner_text() or '')[:150].replace('\n', ' | ')
except Exception as _e:
_d['table_err'] = str(_e)[:80]
logger.warning(f"STEP_FAIL_DUMP: {json.dumps(_d, ensure_ascii=False)}")
except Exception as _e:
logger.warning(f"STEP_FAIL_DUMP_ERR: {_e}")
logger.warning(f"步骤 {step.get('order')} 执行验证失败,回退标记定位为失败") logger.warning(f"步骤 {step.get('order')} 执行验证失败,回退标记定位为失败")
result['success'] = False result['success'] = False
result['exec_success'] = False result['exec_success'] = False
...@@ -419,6 +483,32 @@ class SmartLocateService: ...@@ -419,6 +483,32 @@ class SmartLocateService:
# ⚠️ 关键修复:先获取 page 对象,验证步骤需要使用 # ⚠️ 关键修复:先获取 page 对象,验证步骤需要使用
page = self.executor._page page = self.executor._page
# ⚠️ 阶段6+:navigate 步骤优先处理(selector 可能是 URL 而非 CSS 选择器,
# 不能走 wait_for_selector 校验,否则 URL 被当 CSS 解析报 "Unexpected token /")
if action == 'navigate':
# 检查是否已有 url 参数(params.url 或 params.selector 里的 http 地址)
url = params.get('url', '') or ''
if not url:
sel_candidate = str(params.get('selector', '') or '')
if 'http' in sel_candidate:
url = sel_candidate
if not url:
# 从步骤名称中提取 URL 或使用默认值
import re
url_match = re.search(r'https?://[^\s]+', name)
if url_match:
url = url_match.group(0)
else:
# 使用默认 URL
url = settings.TARGET_URL
result['success'] = True
result['params']['url'] = url
result['selectors'] = {'primary': url, 'candidates': []}
result['message'] = f'导航步骤: {url}'
logger.info(f"步骤 {order} 导航到: {url}")
return result
# ⚠️ 新增:如果步骤已有选择器,直接使用,不重新定位 # ⚠️ 新增:如果步骤已有选择器,直接使用,不重新定位
existing_selector = params.get('selector', '') existing_selector = params.get('selector', '')
if existing_selector: if existing_selector:
...@@ -429,6 +519,12 @@ class SmartLocateService: ...@@ -429,6 +519,12 @@ class SmartLocateService:
'candidates': [{'type': 'css', 'value': existing_selector, 'confidence': 0.95, 'priority': 1}] 'candidates': [{'type': 'css', 'value': existing_selector, 'confidence': 0.95, 'priority': 1}]
} }
result['message'] = f'使用已有选择器: {existing_selector}' result['message'] = f'使用已有选择器: {existing_selector}'
# ⚠️ 阶段6+:已有选择器步骤同样尝试语义化升级(生成 semantic + 前置已验证解析链),
# 让旧用例重跑智能定位即可升级为语义化执行。不改写用户 selector,保留为兜底。
if self._semantic_enabled():
self._try_attach_semantic(result, step, page)
# 验证选择器是否有效(增加等待时间) # 验证选择器是否有效(增加等待时间)
try: try:
page.wait_for_selector(existing_selector, timeout=8000) page.wait_for_selector(existing_selector, timeout=8000)
...@@ -441,27 +537,6 @@ class SmartLocateService: ...@@ -441,27 +537,6 @@ class SmartLocateService:
result['message'] = f'使用已有选择器(验证超时): {existing_selector}' result['message'] = f'使用已有选择器(验证超时): {existing_selector}'
return result return result
# navigate 类型:需要设置 url 参数
if action == 'navigate':
# 检查是否已有 url 参数
url = params.get('url', '')
if not url:
# 从步骤名称中提取 URL 或使用默认值
import re
url_match = re.search(r'https?://[^\s]+', name)
if url_match:
url = url_match.group(0)
else:
# 使用默认 URL
url = settings.TARGET_URL
result['success'] = True
result['params']['url'] = url
result['selectors'] = {'primary': url, 'candidates': []}
result['message'] = f'导航步骤: {url}'
logger.info(f"步骤 {order} 导航到: {url}")
return result
# ⚠️ 新增:登录相关步骤优先使用已验证的选择器 # ⚠️ 新增:登录相关步骤优先使用已验证的选择器
login_selectors = self._get_login_selector(name) login_selectors = self._get_login_selector(name)
if login_selectors: if login_selectors:
...@@ -591,7 +666,53 @@ class SmartLocateService: ...@@ -591,7 +666,53 @@ class SmartLocateService:
logger.debug(f"搜索框选择器 {sel} 未找到: {e}") logger.debug(f"搜索框选择器 {sel} 未找到: {e}")
continue continue
# 如果两种方式都没有找到 # ===== 方式3:目标文本可见验证 =====
# 详情页/展示页场景:无提示语、无搜索框,验证对象是页面上的目标文本
# (如"查看界面会议名称正确显示:自动化新建会议"→ 等待文本"自动化新建会议"出现)
# 从步骤名提取 ":X" 尾部值作为目标文本,等待 :has-text 出现即验证通过
# 适用:创建成功后跳转详情页/展示页,验证动态值(名称/编号)已在页面正确显示
_text_m = re.search(r'[::]\s*([^::]+?)\s*$', name)
if _text_m and _text_m.group(1).strip():
target_text = _text_m.group(1).strip()
# 过滤明显非文本值:过长描述/元素类词/含引号文本不作为目标文本
if (len(target_text) <= 30 and '"' not in target_text and '\\' not in target_text
and not any(w in target_text for w in ('按钮', '弹窗', '搜索框', '下拉框'))):
text_sel = f':has-text("{target_text}")'
try:
page.wait_for_selector(text_sel, timeout=20000)
logger.info(f"找到目标文本(详情页验证): {target_text}")
result['success'] = True
result['action'] = 'wait' # wait 类型:等待目标文本出现即验证通过
result['selectors'] = {
'primary': text_sel,
'candidates': [
{'type': 'css', 'value': text_sel, 'confidence': 0.90, 'priority': 1}
]
}
result['params']['selector'] = text_sel
result['params']['timeout'] = 20000
result['params']['verify_method'] = 'text_visible'
result['params']['expected_text'] = target_text # 记录目标文本
result['message'] = f'验证类步骤(目标文本可见),找到文本: {target_text}'
logger.info(f"步骤 {order} 定位成功(目标文本验证): {text_sel}")
# 截图
try:
screenshot_path = os.path.join(
self.screenshot_dir,
f"smart_locate_{order}_{datetime.now().strftime('%H%M%S')}.png"
)
page.screenshot(path=screenshot_path)
with open(screenshot_path, 'rb') as f:
result['screenshot'] = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
logger.debug(f"截图失败(非致命): {e}")
return result
except Exception as e:
logger.debug(f"目标文本 {target_text} 未出现: {e}")
# 如果三种方式都没有找到
logger.warning(f"步骤 {order} 为验证类但未找到提示元素或搜索框") logger.warning(f"步骤 {order} 为验证类但未找到提示元素或搜索框")
result['success'] = False result['success'] = False
result['message'] = f'验证类步骤,但未找到操作结果提示元素或列表搜索框' result['message'] = f'验证类步骤,但未找到操作结果提示元素或列表搜索框'
...@@ -891,7 +1012,29 @@ class SmartLocateService: ...@@ -891,7 +1012,29 @@ class SmartLocateService:
if 'input:visible' in selector: if 'input:visible' in selector:
click_selector_chain.append(f'button:has-text("{step_name}")') click_selector_chain.append(f'button:has-text("{step_name}")')
# 点击前等待目标文本渲染:列表/表格异步加载兜底
# (旧 case 已有选择器如 .el-checkbox:has-text 常匹配 0 个;若表格数据仍在异步加载,
# 直接点击/重定位都会因"目标行暂不存在"失败——先等 has-text 目标文本出现,
# 确保后续点击链与 Fix5 重定位时表格已就绪)
try:
_tm = re.search(r'has-text\("([^"]+)"\)', selector)
if _tm:
# 用 :has-text 子串匹配更宽容:TD 内文本可能带空白/嵌套,
# 精确 text= 匹配常漏掉导致"表格已加载但等待超时"
# 目标系统(192.168.5.44)表格异步加载极慢且不稳,放宽到 25s
page.wait_for_selector(f':has-text("{_tm.group(1)}")', timeout=25000)
page.wait_for_timeout(300)
logger.debug(f"步骤 {order} 目标文本已渲染: {_tm.group(1)}")
except Exception:
pass
# 逐级尝试点击(每级:普通点击 → force 强点;缩短超时避免 30s 默认卡死) # 逐级尝试点击(每级:普通点击 → force 强点;缩短超时避免 30s 默认卡死)
# 记录点击前 URL,用于判断是否为整页路由跳转(跳转则无需等待弹窗)
url_before = ''
try:
url_before = page.url
except Exception:
pass
click_success = False click_success = False
actual_selector_used = None actual_selector_used = None
for sel in click_selector_chain: for sel in click_selector_chain:
...@@ -914,8 +1057,20 @@ class SmartLocateService: ...@@ -914,8 +1057,20 @@ class SmartLocateService:
actual_selector_used = sel actual_selector_used = sel
break break
except Exception as e2: except Exception as e2:
logger.debug(f"选择器 {sel} 点击失败(普通+force): {e2}") # JS 直接触发 click(绕过 playwright actionability 检测):
continue # Element Plus 表格 checkbox 等元素普通/force 点击常超时
# (实测 .el-checkbox:has-text 匹配 0 个、JS el.click() 可成功勾选),
# 作为最后兜底(取第一个匹配)
try:
page.locator(sel).first.evaluate('el => el.click()')
page.wait_for_timeout(800)
logger.debug(f"点击成功(js): {sel}")
click_success = True
actual_selector_used = sel
break
except Exception as e3:
logger.debug(f"选择器 {sel} 点击失败(普通+force+js): {e3}")
continue
if not click_success: if not click_success:
logger.warning(f"步骤 {order} 所有选择器点击均失败") logger.warning(f"步骤 {order} 所有选择器点击均失败")
...@@ -940,6 +1095,18 @@ class SmartLocateService: ...@@ -940,6 +1095,18 @@ class SmartLocateService:
# 2. 检测弹窗是否出现(如果步骤名称包含弹窗关键词) # 2. 检测弹窗是否出现(如果步骤名称包含弹窗关键词)
if expects_dialog: if expects_dialog:
# 整页路由跳转豁免:步骤名含"新建/创建"等弹窗关键词,但实际点击是页面跳转
# (如 新建会议 → /meetingV3 整页路由,无 .el-dialog/.el-drawer),URL 变化视为成功
url_after = ''
try:
url_after = page.url
except Exception:
pass
url_navigated = bool(url_before and url_after and url_before != url_after)
if url_navigated:
logger.info(f"步骤 '{step_name}' 点击后 URL 变化(页面跳转),豁免弹窗检查: {url_before} -> {url_after}")
return True
dialog_opened = False dialog_opened = False
for dialog_sel in ['.el-dialog', '.el-drawer', '.el-message-box']: for dialog_sel in ['.el-dialog', '.el-drawer', '.el-message-box']:
try: try:
...@@ -1040,6 +1207,82 @@ class SmartLocateService: ...@@ -1040,6 +1207,82 @@ class SmartLocateService:
semantic['nth'] = nth semantic['nth'] = nth
return semantic return semantic
def _try_attach_semantic(self, result: Dict[str, Any], step: Dict[str, Any], page) -> None:
"""
已有选择器步骤的语义化升级:生成 semantic + 前置已验证的解析链
场景:旧用例所有步骤都有 params.selector(智能定位旧版产物),
_locate_single_step 的"已有选择器"分支会 early return,从不生成 semantic,
导致这些步骤永远停留在"裸选择器"执行,遇到 DOM 变更即失败(如自定义 tab)。
本方法仅附加 semantic 目标(不改写用户手动配置的 selector,保留为兜底),
并把语义解析链置于 candidates / params.selector 最前:
- 智能定位自身的 _execute_step 验证先点语义链 → 验证通过,链不中断
- 落库后执行期解析器同样按 semantic 解析 → 与示范用例同机制
Args:
result (Dict): 定位结果(就地更新 selectors/params/semantic/message)
step (Dict): 步骤(name/action/params)
page: Playwright 页面
"""
action = step.get('action', '')
name = step.get('name', '') or ''
order = step.get('order', 0)
if not self._semantic_enabled():
return
semantic = None
# 1) 模式步骤("xxx选择:yyy"/"xxx切换为:yyy")→ 模式语义(text=锚点值 + contains),
# 内部已做 resolve 校验 + row_checkbox→row 降级
pattern_result = self._preprocess_step_pattern(name, action)
if pattern_result and pattern_result['selectors']:
semantic = self._infer_semantic_from_pattern(step, pattern_result, page)
# 2) 非模式静态步骤 → 语义解析优先路径(构造候选 semantic → resolve 命中)
if not semantic:
sem_result = self._try_semantic_execute(page, step, self._get_page_scope(page))
if sem_result:
semantic = sem_result['semantic']
if not semantic:
return
# 解析 semantic → 拿到执行期选择器链,前置到候选(验证/执行都优先走语义链)
chain: List[str] = []
try:
from app.executors.ui_context import ExecutionContext
scope = semantic.get('scope')
ctx = ExecutionContext(dict(self.collected_parameters or {}))
sem_step: Dict[str, Any] = {
'name': str(semantic.get('text') or ''),
'action': 'click',
'params': {},
'semantic': semantic,
}
chain = self._semantic_resolver.resolve_step(page, sem_step, scope, ctx) or []
chain = [sel for sel in chain if sel]
except Exception as e:
logger.debug(f"步骤 {order} 语义解析链获取失败: {e}")
if chain:
existing_sel = result['params'].get('selector', '')
new_cands = [
{'type': 'css', 'value': sel, 'confidence': 0.95, 'priority': i + 1}
for i, sel in enumerate(chain)
]
# 原 selector 保留为最后兜底(去重)
if existing_sel and existing_sel not in chain:
new_cands.append({
'type': 'css', 'value': existing_sel,
'confidence': 0.8, 'priority': len(chain) + 1,
})
result['selectors']['primary'] = chain[0]
result['selectors']['candidates'] = new_cands
result['params']['selector'] = chain[0]
result['semantic'] = semantic
result['message'] = f'使用已有选择器 + 语义化升级: {result["params"].get("selector", "")}'
logger.info(f"步骤 {order} 已有选择器步骤语义化升级: {semantic}")
def _infer_semantic_from_pattern(self, step: Dict[str, Any], pattern_result: Dict[str, Any], page) -> Optional[Dict[str, Any]]: def _infer_semantic_from_pattern(self, step: Dict[str, Any], pattern_result: Dict[str, Any], page) -> Optional[Dict[str, Any]]:
"""模式匹配成功后生成 semantic(type 来自模式类型,contains 来自"xxx:值"锚点值)""" """模式匹配成功后生成 semantic(type 来自模式类型,contains 来自"xxx:值"锚点值)"""
name = step.get('name', '') name = step.get('name', '')
...@@ -1440,6 +1683,34 @@ class SmartLocateService: ...@@ -1440,6 +1683,34 @@ class SmartLocateService:
return None return None
def _is_login_step(self, step: Dict[str, Any]) -> bool:
"""
判断步骤是否为登录流程步骤(auto_login 已自动登录时应跳过)
覆盖两类:
1. navigate 到登录页/目标页(selector 或 url 是 http 链接)
2. 登录表单操作(输入账号/密码/验证码、勾选协议、点击登录)
"""
name = step.get('name', '') or ''
action = step.get('action', '') or ''
params = step.get('params', {}) or {}
# 1. navigate 到 http 地址(登录页/目标页)——URL 不能当 CSS 校验,且 auto_login 已直达
if action == 'navigate':
sel = str(params.get('selector', '') or '')
url = str(params.get('url', '') or '')
if 'http' in sel or 'http' in url:
return True
# 2. 登录表单操作步骤(排除功能中心/首页等菜单导航,避免误跳过业务步骤)
login_kw = ('用户名', '账号', '手机号', '密码', '验证码', '协议', '勾选', '登录')
if any(kw in name for kw in login_kw):
if '功能中心' in name or '首页' in name or '菜单' in name:
return False
return True
return False
def _get_login_selector(self, step_name: str) -> Optional[str]: def _get_login_selector(self, step_name: str) -> Optional[str]:
""" """
获取登录相关步骤和导航步骤的选择器(使用已验证的模板) 获取登录相关步骤和导航步骤的选择器(使用已验证的模板)
......
...@@ -355,3 +355,112 @@ class TestInferSemanticFromPattern: ...@@ -355,3 +355,112 @@ class TestInferSemanticFromPattern:
{"type": "list_checkbox"}, {"type": "list_checkbox"},
_FakePage(['tr:has-text("北京展厅会议室")'])) _FakePage(['tr:has-text("北京展厅会议室")']))
assert sem["type"] == "row_checkbox" assert sem["type"] == "row_checkbox"
# ==================== _is_login_step(auto_login 登录步骤跳过) ====================
class TestIsLoginStep:
def test_navigate_http(self, svc):
"""navigate 到 http 地址 → 登录步骤"""
step = {"name": "访问系统登陆页面", "action": "navigate",
"params": {"selector": "https://192.168.5.44"}}
assert svc._is_login_step(step)
def test_fill_username(self, svc):
assert svc._is_login_step(
{"name": "输入用户名:admin@xty", "action": "fill", "params": {}})
def test_fill_password(self, svc):
assert svc._is_login_step(
{"name": "输入密码", "action": "fill", "params": {}})
def test_captcha(self, svc):
assert svc._is_login_step(
{"name": "输入验证码:csba", "action": "fill", "params": {}})
def test_click_login(self, svc):
assert svc._is_login_step(
{"name": "点击登录", "action": "click", "params": {}})
def test_nav_menu_not_login(self, svc):
"""功能中心/首页等菜单导航不算登录步骤"""
assert not svc._is_login_step(
{"name": "点击【功能中心】展开", "action": "click", "params": {}})
assert not svc._is_login_step(
{"name": "进入首页仪表盘", "action": "click", "params": {}})
def test_normal_form_step_not_login(self, svc):
assert not svc._is_login_step(
{"name": "时间切换为:预定会议", "action": "click", "params": {}})
assert not svc._is_login_step(
{"name": "填写会议名称", "action": "fill", "params": {}})
# ==================== _try_attach_semantic(已有选择器步骤语义化升级) ====================
class TestTryAttachSemantic:
def _mk_result(self):
sel = '.el-tabs__item:has-text("预定会议")'
return {
'success': True,
'params': {'selector': sel},
'selectors': {
'primary': sel,
'candidates': [{'type': 'css', 'value': sel, 'confidence': 0.95, 'priority': 1}],
},
}
def test_pattern_tab_attaches(self, svc):
"""tab 模式步骤:已有选择器也能生成 semantic,解析链前置 + 原 selector 兜底"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver(
{"tab": ['[role="tab"]:has-text("预定会议")', '.time_btn .btns']})
result = self._mk_result()
step = {"order": 12, "name": "时间切换为:预定会议", "action": "click",
"params": {"selector": '.el-tabs__item:has-text("预定会议")'}}
page = _FakePage(['.time_btn .btns', '[role="tab"]:has-text("预定会议")'])
svc._try_attach_semantic(result, step, page)
assert result['semantic']['type'] == 'tab'
assert result['semantic']['text'] == '预定会议'
assert result['semantic']['scope'] == 'create_meeting'
# 解析链前置,原 selector 保留兜底
assert result['params']['selector'] == '[role="tab"]:has-text("预定会议")'
cands = [c['value'] for c in result['selectors']['candidates']]
assert cands[0] == '[role="tab"]:has-text("预定会议")'
assert '.el-tabs__item:has-text("预定会议")' in cands
def test_static_button_attaches(self, svc):
"""非模式静态步骤:走语义解析优先路径附加 semantic"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver(
{"button": ['button:has-text("确定创建")', 'text=确定创建']})
result = {
'success': True,
'params': {'selector': 'button:has-text("确定创建")'},
'selectors': {'primary': 'button:has-text("确定创建")',
'candidates': [{'type': 'css', 'value': 'button:has-text("确定创建")',
'confidence': 0.95, 'priority': 1}]},
}
step = {"order": 20, "name": "点击确定创建", "action": "click",
"params": {"selector": 'button:has-text("确定创建")'}}
page = _FakePage(['button:has-text("确定创建")', 'text=确定创建'])
svc._try_attach_semantic(result, step, page)
assert result['semantic']['type'] == 'button'
assert result['semantic']['text'] == '确定创建'
assert result['params']['selector'] == 'button:has-text("确定创建")'
def test_no_resolution_no_attach(self, svc):
"""解析链无命中 → 不附加 semantic,原 result 不变"""
_with_scope(svc, "create_meeting", "创建会议")
svc._semantic_resolver = _FakeResolver({"tab": []})
result = self._mk_result()
step = {"order": 12, "name": "时间切换为:预定会议", "action": "click",
"params": {"selector": '.el-tabs__item:has-text("预定会议")'}}
svc._try_attach_semantic(result, step, _FakePage([]))
assert 'semantic' not in result
assert result['params']['selector'] == '.el-tabs__item:has-text("预定会议")'
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论