feat: 实现深入功能测试能力
Some checks failed
AI Web Tester CI / test (push) Has been cancelled

核心改进:
- 识别表单: DOM 发现支持 Input, Textarea, Select 元素识别
- 自动填充: 支持自动在输入框填入测试数据并记录
- 业务验证: 自动监听操作后的 Toast/Message (AntD, Element, Vben) 消息
- 报告增强: 报告中展示详细的功能交互日志和验证结果徽标
- 配置优化: 提高 '新增', '搜索', '保存' 等业务按钮的优先探索权重
This commit is contained in:
empty
2025-12-28 22:00:33 +08:00
parent 3b4c6b5296
commit 3447ea340a
2 changed files with 127 additions and 20 deletions

View File

@@ -27,10 +27,10 @@ class FeatureExplorer:
# 默认配置 # 默认配置
self.config = { self.config = {
"max_depth": 3, "max_depth": 3,
"max_clicks": 30, "max_clicks": 50,
"skip_patterns": [], # 完全跳过 "skip_patterns": [],
"dangerous_patterns": ["删除", "移除", "清空", "退出", "注销"], # 记录但不执行 "dangerous_patterns": ["删除", "移除", "清空", "退出", "注销"],
"focus_patterns": [], # 优先探索 "focus_patterns": ["新增", "添加", "创建", "搜索", "查询", "确定", "保存", "提交"],
} }
def explore(self, config: Dict = None) -> Dict[str, Any]: def explore(self, config: Dict = None) -> Dict[str, Any]:
@@ -207,15 +207,35 @@ class FeatureExplorer:
'.tabs-chrome__item', // Vben tabs (右侧标签页) '.tabs-chrome__item', // Vben tabs (右侧标签页)
'.vben-tabs-content > div', // Vben tabs 容器 '.vben-tabs-content > div', // Vben tabs 容器
'.n-tabs-tab', // Naive UI tabs '.n-tabs-tab', // Naive UI tabs
// 表单元素
'input:not([type="hidden"]):not([type="submit"]):not([type="button"])',
'textarea',
'select',
'.ant-input, .ant-select', // 框架特定
'.el-input__inner, .el-select',
]; ];
for (const selector of selectors) { for (const selector of selectors) {
document.querySelectorAll(selector).forEach(el => { document.querySelectorAll(selector).forEach(el => {
const text = el.textContent?.trim().substring(0, 50) || ''; // 改进名称提取:优先使用 textContent其次使用 placeholder/aria-label
const key = text + el.tagName; let text = el.textContent?.trim().substring(0, 50) || '';
if (!text && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT')) {
text = el.getAttribute('placeholder') ||
el.getAttribute('aria-label') ||
el.getAttribute('name') ||
'';
// 尝试通过 label 关联查找名称
if (!text && el.id) {
const label = document.querySelector(`label[for="${el.id}"]`);
if (label) text = label.textContent.trim();
}
}
const key = text + el.tagName + el.className;
if (!text || seen.has(key)) return; if (!text || seen.has(key)) return;
if (text.length < 2 || text.length > 50) return; if (text.length < 1 || text.length > 50) return;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return; if (rect.width <= 0 || rect.height <= 0) return;
@@ -235,11 +255,14 @@ class FeatureExplorer:
cls.includes('ant-tabs-tab') || cls.includes('ant-tabs-tab') ||
cls.includes('el-tabs__item')) type = 'tab'; cls.includes('el-tabs__item')) type = 'tab';
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') type = 'form_input';
if (el.tagName === 'SELECT') type = 'form_select';
elements.push({ elements.push({
name: text, name: text,
type: type, type: type,
tagName: el.tagName, tagName: el.tagName,
priority: type === 'navigation' ? 8 : 5, priority: type === 'navigation' ? 8 : (type.startsWith('form_') ? 7 : 5),
x: Math.round(rect.left + rect.width / 2), x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2) y: Math.round(rect.top + rect.height / 2)
}); });
@@ -379,30 +402,54 @@ class FeatureExplorer:
self.action_log.append(action_record) self.action_log.append(action_record)
return return
# 执行点击 - 优先使用 DOM 发现时预计算的坐标 # 执行操作 (点击或填充)
try: try:
# 优先使用元素自带的坐标DOM 发现时已计算) # 优先使用元素自带的坐标
if "x" in element and "y" in element: if "x" in element and "y" in element:
coords = (element["x"], element["y"]) coords = (element["x"], element["y"])
else: else:
# 回退到名称查找
coords = self._find_element_by_name(name) coords = self._find_element_by_name(name)
if coords: if coords:
if el_type == "form_input":
test_data = f"AutoTest_{name}_{click_num}"
print(f" → 填充表单: {name} = {test_data}")
self.browser.click_at(coords[0], coords[1])
self.browser.wait(200)
self.browser.press_key("Control+A")
self.browser.press_key("Backspace")
self.browser.page.keyboard.type(test_data)
action_record["action_type"] = "fill"
else:
print(f" → 点击 ({coords[0]}, {coords[1]})") print(f" → 点击 ({coords[0]}, {coords[1]})")
self.browser.click_at(coords[0], coords[1]) self.browser.click_at(coords[0], coords[1])
action_record["action_type"] = "click"
self.browser.wait(500) self.browser.wait(500)
action_record["action_taken"] = True action_record["action_taken"] = True
action_record["click_coords"] = coords action_record["click_coords"] = coords
# 操作后的结果验证
result_info = self._check_action_result()
if result_info:
print(f" 📋 结果确认: {result_info['message']}")
action_record["result"] = result_info
if result_info["type"] == "error":
self.bug_list.append({
"type": "business_error",
"element": name,
"message": result_info["message"],
"url": before_url
})
else: else:
print(f" ⚠️ 未找到元素") print(f" ⚠️ 未找到元素")
action_record["success"] = False action_record["success"] = False
except Exception as e: except Exception as e:
print(f"点击失败: {e}") print(f"操作失败: {e}")
action_record["success"] = False action_record["success"] = False
action_record["error"] = str(e) action_record["error"] = str(e)
self.bug_list.append({ self.bug_list.append({
"type": "click_failed", "type": "action_failed",
"element": name, "element": name,
"error": str(e), "error": str(e),
"url": before_url "url": before_url
@@ -426,8 +473,42 @@ class FeatureExplorer:
self.action_log.append(action_record) self.action_log.append(action_record)
def _find_element_by_name(self, name: str) -> Optional[tuple]: def _check_action_result(self) -> Optional[Dict]:
"""通过元素名称/文本查找坐标(使用 DOM 而非 AI更快""" """检查操作后的结果(如消息提示"""
try:
# 检测常见的消息提示组件 (AntD, Element, Vben)
result = self.browser.page.evaluate('''
() => {
// 查找包含成功、错误关键词的消息框
const messageSelectors = [
'.ant-message-notice',
'.el-message',
'.vben-basic-title',
'.n-message',
'[role="alert"]',
'.message-box'
];
for (const sel of messageSelectors) {
const msg = document.querySelector(sel);
if (msg && msg.innerText) {
const text = msg.innerText;
let type = 'info';
if (text.includes('成功') || text.includes('Success')) type = 'success';
if (text.includes('失败') || text.includes('错误') || text.includes('Error') || text.includes('fail')) type = 'error';
return {
message: text,
type: type
};
}
}
return null;
}
''')
return result
except:
return None
# 转义特殊字符 # 转义特殊字符
escaped_name = name.replace("\\", "\\\\").replace('"', '\\"').replace("'", "\\'") escaped_name = name.replace("\\", "\\\\").replace('"', '\\"').replace("'", "\\'")

View File

@@ -295,15 +295,41 @@ class ReportGenerator:
log_html = "" log_html = ""
for log in action_log: for log in action_log:
el = log.get("element", {}) el = log.get("element", {})
status = "⏭️ 跳过" if log.get("skipped") else ("" if log.get("success") else "") action_type = log.get("action_type", "click")
# 推断状态和图标
if log.get("skipped"):
status_icon = "⏭️"
status_text = f"跳过 ({log.get('skip_reason', '')})"
elif not log.get("success"):
status_icon = ""
status_text = "失败"
else:
status_icon = ""
status_text = "填充" if action_type == "fill" else ("跳转" if log.get("url_changed") else "点击")
# 结果反馈
result_info = log.get("result")
result_html = ""
if result_info:
res_type = result_info.get("type", "info")
result_html = f'<div class="result-badge {res_type}">{result_info.get("message")}</div>'
screenshot = log.get("screenshot_after", "") screenshot = log.get("screenshot_after", "")
screenshot_html = f'<img src="data:image/png;base64,{screenshot}" class="log-screenshot">' if screenshot else "" screenshot_html = f'<img src="data:image/png;base64,{screenshot}" class="log-screenshot">' if screenshot else ""
details = ""
if action_type == "fill":
details = f'<div class="log-details">输入: {el.get("name", "")}</div>'
log_html += f''' log_html += f'''
<div class="log-item"> <div class="log-item">
<span class="log-step">{log.get("step", 0)}</span> <span class="log-step">{log.get("step", 0)}</span>
<span class="log-name">{el.get("name", "")}</span> <div class="log-content">
<span class="log-status">{status}</span> <span class="log-name">{el.get("name", "")} {status_icon}</span>
<span class="log-status">{status_text} {result_html}</span>
{details}
</div>
{screenshot_html} {screenshot_html}
</div>''' </div>'''