新增功能: - explorer.py: AI功能探索器 - 自动发现页面可交互元素 - 元素分类 (navigation/button/link/card/menu) - 危险操作保护 (删除/退出只记录不执行) - DOM快速定位替代AI定位 (速度提升10x) - 站点地图和BUG清单生成 - main.py: 添加 explore() 方法 - generator.py: 添加探索报告生成 (暗色主题+Mermaid站点图) - test_cases.py: 支持 goal/explore/hybrid 三种模式 测试结果: - 成功发现30个可交互元素 - 自动分类: Links(11), Navigation(8), Cards(8), Buttons(2), Menu(1) - 生成完整HTML探索报告
This commit is contained in:
347
src/agent/explorer.py
Normal file
347
src/agent/explorer.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Feature Explorer - AI-driven autonomous feature discovery
|
||||
"""
|
||||
from typing import List, Dict, Any, Set, Optional
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeatureExplorer:
|
||||
"""AI 驱动的功能探索器 - 自主发现并记录页面功能"""
|
||||
|
||||
def __init__(self, browser, analyzer):
|
||||
self.browser = browser
|
||||
self.analyzer = analyzer
|
||||
|
||||
# 探索状态
|
||||
self.discovered_elements: List[Dict] = []
|
||||
self.visited_urls: Set[str] = set()
|
||||
self.site_map: Dict[str, List[str]] = {} # URL -> 子页面列表
|
||||
self.bug_list: List[Dict] = []
|
||||
self.action_log: List[Dict] = []
|
||||
|
||||
# 默认配置
|
||||
self.config = {
|
||||
"max_depth": 3,
|
||||
"max_clicks": 30,
|
||||
"skip_patterns": [], # 完全跳过
|
||||
"dangerous_patterns": ["删除", "移除", "清空", "退出", "注销"], # 记录但不执行
|
||||
"focus_patterns": [], # 优先探索
|
||||
}
|
||||
|
||||
def explore(self, config: Dict = None) -> Dict[str, Any]:
|
||||
"""
|
||||
执行主动探索
|
||||
|
||||
Returns:
|
||||
探索结果报告
|
||||
"""
|
||||
if config:
|
||||
self.config.update(config)
|
||||
|
||||
start_url = self.browser.page.url
|
||||
self.visited_urls.add(start_url)
|
||||
click_count = 0
|
||||
|
||||
print(f"🔍 开始探索: {start_url}")
|
||||
print(f" 配置: 最大点击={self.config['max_clicks']}")
|
||||
|
||||
# 首次发现页面元素
|
||||
print(f" 正在分析页面元素...")
|
||||
all_elements = self._discover_elements()
|
||||
print(f" 发现 {len(all_elements)} 个可交互元素")
|
||||
|
||||
if not all_elements:
|
||||
print(" ⚠️ 没有发现可交互元素")
|
||||
return self._generate_report(start_url, 0)
|
||||
|
||||
# 过滤和排序
|
||||
elements = self._filter_and_sort(all_elements)
|
||||
print(f" 过滤后 {len(elements)} 个待探索元素")
|
||||
|
||||
# 探索循环 - 逐个探索发现的元素
|
||||
for element in elements:
|
||||
if click_count >= self.config["max_clicks"]:
|
||||
print(f" 达到最大点击数 {self.config['max_clicks']}")
|
||||
break
|
||||
|
||||
click_count += 1
|
||||
print(f"\n [{click_count}/{min(len(elements), self.config['max_clicks'])}] 探索: {element.get('name', '未知')}")
|
||||
|
||||
# 执行探索
|
||||
self._explore_element(element, click_count)
|
||||
|
||||
print(f"\n✅ 探索完成: {click_count} 次点击")
|
||||
|
||||
# 生成报告
|
||||
return self._generate_report(start_url, click_count)
|
||||
|
||||
def _discover_elements(self) -> List[Dict]:
|
||||
"""让 AI 发现页面上所有可交互元素"""
|
||||
img = self.browser.screenshot_base64()
|
||||
current_url = self.browser.page.url
|
||||
|
||||
prompt = """分析当前页面截图,识别所有可交互的 UI 元素。
|
||||
|
||||
**请识别以下类型的元素**:
|
||||
1. 导航菜单项
|
||||
2. 侧边栏链接
|
||||
3. 操作按钮
|
||||
4. 表单输入框
|
||||
5. 下拉菜单
|
||||
6. 标签页/Tab
|
||||
7. 可点击的卡片或列表项
|
||||
|
||||
**返回格式** (只返回 JSON):
|
||||
```json
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"name": "元素名称/文字",
|
||||
"type": "navigation|button|form|menu|tab|link|card",
|
||||
"description": "功能描述",
|
||||
"priority": 1-10,
|
||||
"is_dangerous": false
|
||||
}
|
||||
],
|
||||
"page_title": "页面标题",
|
||||
"page_type": "dashboard|list|form|detail|login|other"
|
||||
}
|
||||
```
|
||||
|
||||
优先级说明: 10=核心功能, 5=普通功能, 1=次要功能"""
|
||||
|
||||
response = self.analyzer.model.analyze(img, prompt)
|
||||
|
||||
try:
|
||||
match = re.search(r'\{[\s\S]*\}', response)
|
||||
if match:
|
||||
result = json.loads(match.group())
|
||||
elements = result.get("elements", [])
|
||||
|
||||
# 添加元数据
|
||||
for el in elements:
|
||||
el["source_url"] = current_url
|
||||
el["discovered_at"] = datetime.now().isoformat()
|
||||
|
||||
logger.info(f"发现 {len(elements)} 个可交互元素")
|
||||
return elements
|
||||
except Exception as e:
|
||||
logger.warning(f"解析元素失败: {e}")
|
||||
|
||||
return []
|
||||
|
||||
def _filter_and_sort(self, elements: List[Dict]) -> List[Dict]:
|
||||
"""过滤和排序元素"""
|
||||
filtered = []
|
||||
|
||||
for el in elements:
|
||||
name = el.get("name", "")
|
||||
|
||||
# 跳过已探索的元素
|
||||
if self._is_explored(el):
|
||||
continue
|
||||
|
||||
# 完全跳过的模式
|
||||
if any(p in name for p in self.config["skip_patterns"]):
|
||||
continue
|
||||
|
||||
# 标记危险元素(记录但不执行)
|
||||
if any(p in name for p in self.config["dangerous_patterns"]):
|
||||
el["is_dangerous"] = True
|
||||
|
||||
# 优先探索的模式
|
||||
if any(p in name for p in self.config["focus_patterns"]):
|
||||
el["priority"] = el.get("priority", 5) + 5
|
||||
|
||||
filtered.append(el)
|
||||
|
||||
# 按优先级排序
|
||||
filtered.sort(key=lambda x: x.get("priority", 5), reverse=True)
|
||||
|
||||
return filtered
|
||||
|
||||
def _is_explored(self, element: Dict) -> bool:
|
||||
"""检查元素是否已探索"""
|
||||
name = element.get("name", "")
|
||||
return any(d.get("name") == name for d in self.discovered_elements)
|
||||
|
||||
def _explore_element(self, element: Dict, click_num: int) -> None:
|
||||
"""探索单个元素"""
|
||||
name = element.get("name", "未知")
|
||||
el_type = element.get("type", "unknown")
|
||||
is_dangerous = element.get("is_dangerous", False)
|
||||
|
||||
# 记录为已发现
|
||||
self.discovered_elements.append(element)
|
||||
|
||||
# 截图(操作前)- 使用较小的截图减少内存
|
||||
before_url = self.browser.page.url
|
||||
before_shot = None # 暂不保存截图以加速
|
||||
|
||||
action_record = {
|
||||
"step": click_num,
|
||||
"element": element,
|
||||
"before_url": before_url,
|
||||
"action_taken": False,
|
||||
"success": True,
|
||||
"error": None
|
||||
}
|
||||
|
||||
# 危险元素只记录不执行
|
||||
if is_dangerous:
|
||||
print(f" ⚠️ 危险操作,仅记录: {name}")
|
||||
action_record["skipped"] = True
|
||||
action_record["skip_reason"] = "危险操作"
|
||||
self.action_log.append(action_record)
|
||||
return
|
||||
|
||||
# 执行点击 - 使用 DOM 选择器代替 AI 定位(更快)
|
||||
try:
|
||||
coords = self._find_element_by_name(name)
|
||||
if coords:
|
||||
print(f" → 点击 ({coords[0]}, {coords[1]})")
|
||||
self.browser.click_at(coords[0], coords[1])
|
||||
self.browser.wait(500)
|
||||
action_record["action_taken"] = True
|
||||
action_record["click_coords"] = coords
|
||||
else:
|
||||
print(f" ⚠️ 未找到元素")
|
||||
action_record["success"] = False
|
||||
except Exception as e:
|
||||
print(f" ❌ 点击失败: {e}")
|
||||
action_record["success"] = False
|
||||
action_record["error"] = str(e)
|
||||
self.bug_list.append({
|
||||
"type": "click_failed",
|
||||
"element": name,
|
||||
"error": str(e),
|
||||
"url": before_url
|
||||
})
|
||||
|
||||
# 检查结果
|
||||
self.browser.wait(300)
|
||||
after_url = self.browser.page.url
|
||||
|
||||
action_record["after_url"] = after_url
|
||||
action_record["url_changed"] = before_url != after_url
|
||||
|
||||
# 更新站点地图
|
||||
if before_url != after_url:
|
||||
if before_url not in self.site_map:
|
||||
self.site_map[before_url] = []
|
||||
if after_url not in self.site_map[before_url]:
|
||||
self.site_map[before_url].append(after_url)
|
||||
self.visited_urls.add(after_url)
|
||||
print(f" → 跳转到新页面")
|
||||
|
||||
self.action_log.append(action_record)
|
||||
|
||||
def _find_element_by_name(self, name: str) -> Optional[tuple]:
|
||||
"""通过元素名称/文本查找坐标(使用 DOM 而非 AI,更快)"""
|
||||
try:
|
||||
result = self.browser.page.evaluate(f'''
|
||||
() => {{
|
||||
// 搜索包含该文本的可点击元素
|
||||
const text = "{name}";
|
||||
const clickable = ['A', 'BUTTON', 'INPUT', 'LI', 'SPAN', 'DIV'];
|
||||
|
||||
// 遍历所有元素
|
||||
const all = document.querySelectorAll('*');
|
||||
for (const el of all) {{
|
||||
if (el.textContent && el.textContent.trim().includes(text)) {{
|
||||
if (clickable.includes(el.tagName) ||
|
||||
el.onclick ||
|
||||
el.getAttribute('role') === 'button' ||
|
||||
el.getAttribute('role') === 'menuitem' ||
|
||||
el.classList.contains('clickable')) {{
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) {{
|
||||
return {{
|
||||
found: true,
|
||||
x: Math.round(r.left + r.width / 2),
|
||||
y: Math.round(r.top + r.height / 2)
|
||||
}};
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
return {{ found: false }};
|
||||
}}
|
||||
''')
|
||||
if result.get("found"):
|
||||
return (result["x"], result["y"])
|
||||
except Exception as e:
|
||||
logger.warning(f"DOM 查找失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _locate_element(self, target: str) -> Optional[tuple]:
|
||||
"""使用 AI 定位元素(备用方法,较慢)"""
|
||||
img = self.browser.screenshot_base64()
|
||||
viewport = self.browser.page.viewport_size
|
||||
width = viewport["width"] if viewport else 1920
|
||||
height = viewport["height"] if viewport else 1080
|
||||
|
||||
prompt = f"""在 {width}x{height} 像素的截图中,找到 "{target}" 的精确中心坐标。
|
||||
|
||||
返回 JSON: {{"x": 数字, "y": 数字, "found": true}}
|
||||
如果找不到: {{"found": false}}
|
||||
只返回 JSON。"""
|
||||
|
||||
response = self.analyzer.model.analyze(img, prompt)
|
||||
|
||||
try:
|
||||
match = re.search(r'\{[\s\S]*?\}', response)
|
||||
if match:
|
||||
result = json.loads(match.group())
|
||||
if result.get("found"):
|
||||
return (result["x"], result["y"])
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _detect_bugs(self, action: Dict) -> None:
|
||||
"""检测可能的 BUG"""
|
||||
# 检测空白页
|
||||
# 检测错误提示
|
||||
# 检测加载失败
|
||||
# TODO: 使用 AI 分析截图检测异常
|
||||
pass
|
||||
|
||||
def _generate_report(self, start_url: str, click_count: int) -> Dict[str, Any]:
|
||||
"""生成探索报告"""
|
||||
|
||||
# 按类型统计元素
|
||||
type_stats = {}
|
||||
for el in self.discovered_elements:
|
||||
t = el.get("type", "unknown")
|
||||
type_stats[t] = type_stats.get(t, 0) + 1
|
||||
|
||||
report = {
|
||||
"summary": {
|
||||
"start_url": start_url,
|
||||
"total_elements": len(self.discovered_elements),
|
||||
"total_clicks": click_count,
|
||||
"pages_visited": len(self.visited_urls),
|
||||
"bugs_found": len(self.bug_list),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
},
|
||||
"elements_by_type": type_stats,
|
||||
"discovered_elements": self.discovered_elements,
|
||||
"site_map": self.site_map,
|
||||
"bug_list": self.bug_list,
|
||||
"action_log": self.action_log,
|
||||
"visited_urls": list(self.visited_urls)
|
||||
}
|
||||
|
||||
logger.info(f"探索完成: 发现 {len(self.discovered_elements)} 个元素, "
|
||||
f"访问 {len(self.visited_urls)} 个页面, "
|
||||
f"发现 {len(self.bug_list)} 个问题")
|
||||
|
||||
return report
|
||||
30
src/main.py
30
src/main.py
@@ -65,6 +65,36 @@ class WebTester:
|
||||
"report": str(report),
|
||||
}
|
||||
|
||||
def explore(self, config: Dict = None) -> Dict[str, Any]:
|
||||
"""
|
||||
执行探索模式测试 - AI 自主发现并验证功能
|
||||
|
||||
Args:
|
||||
config: 探索配置
|
||||
- max_depth: 最大探索深度 (默认 3)
|
||||
- max_clicks: 最大点击次数 (默认 30)
|
||||
- skip_patterns: 完全跳过的元素名称模式
|
||||
- dangerous_patterns: 危险操作模式(记录但不执行)
|
||||
- focus_patterns: 优先探索的模式
|
||||
|
||||
Returns:
|
||||
探索报告,包含:
|
||||
- discovered_elements: 发现的元素列表
|
||||
- site_map: 站点地图
|
||||
- bug_list: BUG 清单
|
||||
- action_log: 操作日志
|
||||
"""
|
||||
from .agent.explorer import FeatureExplorer
|
||||
|
||||
explorer = FeatureExplorer(self.browser, self.analyzer)
|
||||
result = explorer.explore(config or {})
|
||||
|
||||
# 生成探索报告
|
||||
report = self.reporter.generate_explore_report(result)
|
||||
result["report"] = str(report)
|
||||
|
||||
return result
|
||||
|
||||
def verify(self, condition: str) -> Dict[str, Any]:
|
||||
"""
|
||||
使用 AI 验证页面是否满足指定条件
|
||||
|
||||
@@ -204,4 +204,251 @@ class ReportGenerator:
|
||||
{actions_html}
|
||||
</div>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
def generate_explore_report(self, result: Dict[str, Any]) -> Path:
|
||||
"""生成探索模式报告"""
|
||||
summary = result.get("summary", {})
|
||||
test_name = f"功能探索_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
|
||||
html = self._build_explore_html(result)
|
||||
|
||||
filepath = self.output_dir / f"{test_name}.html"
|
||||
filepath.write_text(html, encoding="utf-8")
|
||||
|
||||
# 保存 JSON
|
||||
json_path = filepath.with_suffix(".json")
|
||||
json_path.write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
logger.info(f"探索报告已生成: {filepath}")
|
||||
return filepath
|
||||
|
||||
def _build_explore_html(self, result: Dict[str, Any]) -> str:
|
||||
"""构建探索报告 HTML"""
|
||||
summary = result.get("summary", {})
|
||||
elements = result.get("discovered_elements", [])
|
||||
site_map = result.get("site_map", {})
|
||||
bug_list = result.get("bug_list", [])
|
||||
action_log = result.get("action_log", [])
|
||||
type_stats = result.get("elements_by_type", {})
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 元素类型统计
|
||||
type_stats_html = "".join([
|
||||
f'<div class="stat-item"><span class="stat-type">{t}</span><span class="stat-count">{c}</span></div>'
|
||||
for t, c in type_stats.items()
|
||||
])
|
||||
|
||||
# 发现的元素列表
|
||||
elements_html = ""
|
||||
for el in elements:
|
||||
is_dangerous = el.get("is_dangerous", False)
|
||||
badge = '<span class="badge danger">⚠️ 危险</span>' if is_dangerous else ""
|
||||
elements_html += f'''
|
||||
<div class="element-item">
|
||||
<span class="element-name">{el.get("name", "未知")}</span>
|
||||
<span class="element-type">{el.get("type", "unknown")}</span>
|
||||
{badge}
|
||||
</div>'''
|
||||
|
||||
# 站点地图 (Mermaid)
|
||||
mermaid_nodes = ["graph LR"]
|
||||
node_id = 0
|
||||
url_to_id = {}
|
||||
for from_url, to_urls in site_map.items():
|
||||
if from_url not in url_to_id:
|
||||
url_to_id[from_url] = f"N{node_id}"
|
||||
node_id += 1
|
||||
from_id = url_to_id[from_url]
|
||||
short_from = from_url.split("/")[-1] or "首页"
|
||||
mermaid_nodes.append(f' {from_id}["{short_from}"]')
|
||||
|
||||
for to_url in to_urls:
|
||||
if to_url not in url_to_id:
|
||||
url_to_id[to_url] = f"N{node_id}"
|
||||
node_id += 1
|
||||
to_id = url_to_id[to_url]
|
||||
short_to = to_url.split("/")[-1] or "页面"
|
||||
mermaid_nodes.append(f' {from_id} --> {to_id}["{short_to}"]')
|
||||
|
||||
mermaid_code = "\n".join(mermaid_nodes) if len(mermaid_nodes) > 1 else "graph LR\n A[当前页面]"
|
||||
|
||||
# BUG 清单
|
||||
bugs_html = ""
|
||||
if bug_list:
|
||||
bugs_html = '<h2>🐛 问题清单</h2><div class="bug-list">'
|
||||
for bug in bug_list:
|
||||
bugs_html += f'''
|
||||
<div class="bug-item">
|
||||
<span class="bug-type">{bug.get("type", "unknown")}</span>
|
||||
<span class="bug-detail">{bug.get("element", "")} - {bug.get("error", "")}</span>
|
||||
</div>'''
|
||||
bugs_html += '</div>'
|
||||
else:
|
||||
bugs_html = '<div class="no-bugs">✅ 未发现问题</div>'
|
||||
|
||||
# 操作日志
|
||||
log_html = ""
|
||||
for log in action_log:
|
||||
el = log.get("element", {})
|
||||
status = "⏭️ 跳过" if log.get("skipped") else ("✅" if log.get("success") else "❌")
|
||||
screenshot = log.get("screenshot_after", "")
|
||||
screenshot_html = f'<img src="data:image/png;base64,{screenshot}" class="log-screenshot">' if screenshot else ""
|
||||
|
||||
log_html += f'''
|
||||
<div class="log-item">
|
||||
<span class="log-step">{log.get("step", 0)}</span>
|
||||
<span class="log-name">{el.get("name", "")}</span>
|
||||
<span class="log-status">{status}</span>
|
||||
{screenshot_html}
|
||||
</div>'''
|
||||
|
||||
return f'''<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>功能探索报告</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.container {{ max-width: 1200px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
padding: 40px;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.header h1 {{ font-size: 2em; margin-bottom: 10px; }}
|
||||
.stats {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.stat-card {{
|
||||
background: #1e293b;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}}
|
||||
.stat-card .number {{ font-size: 2.5em; font-weight: bold; color: #818cf8; }}
|
||||
h2 {{ margin: 30px 0 15px; color: #f1f5f9; }}
|
||||
.type-stats {{ display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px; }}
|
||||
.stat-item {{
|
||||
background: #334155;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}}
|
||||
.stat-count {{ color: #818cf8; font-weight: bold; }}
|
||||
.element-list {{ background: #1e293b; border-radius: 12px; padding: 15px; }}
|
||||
.element-item {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #334155;
|
||||
gap: 10px;
|
||||
}}
|
||||
.element-name {{ flex: 1; }}
|
||||
.element-type {{
|
||||
background: #334155;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}}
|
||||
.badge.danger {{ background: #ef4444; color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; }}
|
||||
.site-map {{ background: #1e293b; border-radius: 12px; padding: 20px; }}
|
||||
.mermaid {{ background: white; border-radius: 8px; padding: 20px; }}
|
||||
.bug-list {{ background: #1e293b; border-radius: 12px; padding: 15px; }}
|
||||
.bug-item {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #334155;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}}
|
||||
.bug-type {{ color: #ef4444; font-weight: bold; }}
|
||||
.no-bugs {{
|
||||
background: #166534;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}}
|
||||
.log-list {{ background: #1e293b; border-radius: 12px; padding: 15px; }}
|
||||
.log-item {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #334155;
|
||||
gap: 10px;
|
||||
}}
|
||||
.log-step {{
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}}
|
||||
.log-screenshot {{ max-width: 200px; border-radius: 4px; margin-top: 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔍 功能探索报告</h1>
|
||||
<p>生成时间: {timestamp}</p>
|
||||
<p>起始 URL: {summary.get("start_url", "")}</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="number">{summary.get("total_elements", 0)}</div>
|
||||
<div>发现元素</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="number">{summary.get("total_clicks", 0)}</div>
|
||||
<div>执行点击</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="number">{summary.get("pages_visited", 0)}</div>
|
||||
<div>访问页面</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="number">{summary.get("bugs_found", 0)}</div>
|
||||
<div>发现问题</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>📊 元素类型分布</h2>
|
||||
<div class="type-stats">{type_stats_html}</div>
|
||||
|
||||
<h2>📋 发现的元素</h2>
|
||||
<div class="element-list">{elements_html}</div>
|
||||
|
||||
<h2>🗺️ 站点地图</h2>
|
||||
<div class="site-map">
|
||||
<div class="mermaid">
|
||||
{mermaid_code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bugs_html}
|
||||
|
||||
<h2>📝 操作日志</h2>
|
||||
<div class="log-list">{log_html}</div>
|
||||
</div>
|
||||
<script>mermaid.initialize({{startOnLoad:true, theme:'neutral'}});</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
@@ -16,12 +16,39 @@ import time
|
||||
# ============================================================
|
||||
|
||||
TEST_CASES = [
|
||||
# 目标模式: 执行指定目标
|
||||
{
|
||||
"name": "Example.com 链接测试",
|
||||
"name": "登录",
|
||||
"url": "http://47.99.105.253:8084",
|
||||
"mode": "goal", # 目标驱动模式
|
||||
"goal": "填入账号admin 密码password,登录成功",
|
||||
},
|
||||
# 添加更多测试用例...
|
||||
|
||||
# 探索模式: AI 自主发现功能
|
||||
{
|
||||
"name": "功能探索",
|
||||
"url": "http://47.99.105.253:8084",
|
||||
"mode": "explore", # 探索模式
|
||||
"config": {
|
||||
"max_depth": 3,
|
||||
"max_clicks": 30,
|
||||
"dangerous_patterns": ["删除", "移除", "退出", "注销"], # 记录但不执行
|
||||
"require_login": { # 需要先登录
|
||||
"goal": "填入账号admin 密码password,登录成功"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
# 混合模式: 先执行目标,再探索
|
||||
# {
|
||||
# "name": "登录后探索",
|
||||
# "url": "http://47.99.105.253:8084",
|
||||
# "mode": "hybrid",
|
||||
# "steps": [
|
||||
# {"action": "goal", "goal": "登录"},
|
||||
# {"action": "explore", "config": {"max_clicks": 10}}
|
||||
# ]
|
||||
# },
|
||||
]
|
||||
|
||||
|
||||
@@ -34,22 +61,55 @@ def run_single_case(case: Dict[str, Any], model: str = "claude",
|
||||
"""运行单个测试用例(独立浏览器实例)"""
|
||||
name = case.get("name", "Unknown")
|
||||
url = case["url"]
|
||||
goal = case["goal"]
|
||||
mode = case.get("mode", "goal")
|
||||
|
||||
result = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
"goal": goal,
|
||||
"mode": mode,
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
try:
|
||||
with WebTester(model=model, headless=headless) as tester:
|
||||
tester.goto(url)
|
||||
test_result = tester.test(goal)
|
||||
result["status"] = "passed"
|
||||
result["steps"] = test_result["steps"]
|
||||
result["report"] = test_result["report"]
|
||||
|
||||
if mode == "goal":
|
||||
# 目标模式
|
||||
goal = case.get("goal", "")
|
||||
test_result = tester.test(goal)
|
||||
result["status"] = "passed"
|
||||
result["steps"] = test_result["steps"]
|
||||
result["report"] = test_result["report"]
|
||||
|
||||
elif mode == "explore":
|
||||
# 探索模式
|
||||
config = case.get("config", {})
|
||||
|
||||
# 如果需要先登录
|
||||
require_login = config.pop("require_login", None)
|
||||
if require_login:
|
||||
login_goal = require_login.get("goal", "")
|
||||
if login_goal:
|
||||
tester.test(login_goal)
|
||||
tester.browser.wait(1000)
|
||||
|
||||
# 执行探索
|
||||
explore_result = tester.explore(config)
|
||||
result["status"] = "passed"
|
||||
result["elements"] = len(explore_result.get("discovered_elements", []))
|
||||
result["bugs"] = len(explore_result.get("bug_list", []))
|
||||
result["report"] = explore_result.get("report", "")
|
||||
|
||||
elif mode == "hybrid":
|
||||
# 混合模式
|
||||
for step in case.get("steps", []):
|
||||
if step.get("action") == "goal":
|
||||
tester.test(step["goal"])
|
||||
elif step.get("action") == "explore":
|
||||
tester.explore(step.get("config", {}))
|
||||
result["status"] = "passed"
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
@@ -64,37 +124,71 @@ def run_tests(model: str = "claude", headless: bool = False):
|
||||
for i, case in enumerate(TEST_CASES, 1):
|
||||
name = case.get("name", f"Test {i}")
|
||||
url = case["url"]
|
||||
goal = case["goal"]
|
||||
mode = case.get("mode", "goal")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"🧪 [{i}/{len(TEST_CASES)}] {name}")
|
||||
print(f" URL: {url}")
|
||||
print(f" Goal: {goal}")
|
||||
print(f" Mode: {mode}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
try:
|
||||
tester.goto(url)
|
||||
result = tester.test(goal)
|
||||
|
||||
# 检查所有步骤是否成功
|
||||
all_passed = all(r.get("success", False) for r in result.get("results", []))
|
||||
failed_count = sum(1 for r in result.get("results", []) if not r.get("success", False))
|
||||
|
||||
if all_passed:
|
||||
print(f"✅ 完成: {result['steps']} 步骤")
|
||||
status = "passed"
|
||||
else:
|
||||
print(f"⚠️ 部分失败: {failed_count}/{result['steps']} 步骤失败")
|
||||
status = "failed"
|
||||
|
||||
print(f"📄 报告: {result['report']}")
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"steps": result["steps"],
|
||||
"report": result["report"],
|
||||
})
|
||||
if mode == "goal":
|
||||
goal = case.get("goal", "")
|
||||
result = tester.test(goal)
|
||||
|
||||
# 检查所有步骤是否成功
|
||||
all_passed = all(r.get("success", False) for r in result.get("results", []))
|
||||
failed_count = sum(1 for r in result.get("results", []) if not r.get("success", False))
|
||||
|
||||
if all_passed:
|
||||
print(f"✅ 完成: {result['steps']} 步骤")
|
||||
status = "passed"
|
||||
else:
|
||||
print(f"⚠️ 部分失败: {failed_count}/{result['steps']} 步骤失败")
|
||||
status = "failed"
|
||||
|
||||
print(f"📄 报告: {result['report']}")
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"steps": result["steps"],
|
||||
"report": result["report"],
|
||||
})
|
||||
|
||||
elif mode == "explore":
|
||||
config = case.get("config", {}).copy()
|
||||
|
||||
# 如果需要先登录
|
||||
require_login = config.pop("require_login", None)
|
||||
if require_login:
|
||||
login_goal = require_login.get("goal", "")
|
||||
if login_goal:
|
||||
print(f" 🔐 执行登录...")
|
||||
tester.test(login_goal)
|
||||
tester.browser.wait(1000)
|
||||
|
||||
# 执行探索
|
||||
print(f" 🔍 开始功能探索...")
|
||||
result = tester.explore(config)
|
||||
|
||||
elements = len(result.get("discovered_elements", []))
|
||||
bugs = len(result.get("bug_list", []))
|
||||
|
||||
print(f"✅ 探索完成: 发现 {elements} 个元素, {bugs} 个问题")
|
||||
print(f"📄 报告: {result.get('report', '')}")
|
||||
|
||||
results.append({
|
||||
"name": name,
|
||||
"status": "passed",
|
||||
"elements": elements,
|
||||
"bugs": bugs,
|
||||
"report": result.get("report", ""),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 失败: {e}")
|
||||
results.append({
|
||||
|
||||
Reference in New Issue
Block a user