webtest-agent v1.0.0: AI网页测试智能体(LLM决策+agent-browser执行+断言+报告)

This commit is contained in:
2026-08-09 22:41:00 +08:00
commit 12dc9a299f
13 changed files with 1298 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
logs/
data/tasks/
data/*.db
nohup.out
+78
View File
@@ -0,0 +1,78 @@
# webtest-agent · AI 网页测试智能体
基于 **LLM 决策 + 无头浏览器执行** 的端到端网页测试系统。用自然语言描述测试目标,AI 自动打开页面、逐步操作、断言验证、生成带截图的测试报告。
## 架构
```
┌─────────────────────────────────────────────┐
│ HTTP API (Flask) │
│ POST /api/tasks → 发起测试 │
│ GET /api/tasks → 任务列表/状态 │
│ GET /api/tasks/<id>/report → HTML 报告 │
├─────────────────────────────────────────────┤
│ Agent 循环(每任务一线程) │
│ LLM(火山引擎 doubao) 决策下一步动作 │
│ agent-browser CLI 执行(无头 Chrome
│ 断言器 + 截图留证 + 报告生成 │
└─────────────────────────────────────────────┘
```
## 快速开始
```bash
./start.sh # 启动(默认 16051 端口)
./start.sh stop # 停止
```
### API 用法
```bash
# 发起测试
curl -X POST http://localhost:16051/api/tasks \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","goal":"验证标题包含 Example Domain,点击 Learn more 验证跳转","max_steps":12}'
# 查看任务
curl http://localhost:16051/api/tasks/<task_id>
# 查看报告
curl http://localhost:16051/api/tasks/<task_id>/report
# 停止任务
curl -X POST http://localhost:16051/api/tasks/<task_id>/stop
```
## AI 动作协议
LLM 每步输出一个 JSON 动作,由 agent-browser 执行:
| 动作 | 说明 |
|------|------|
| `click` | 点击元素(`@e1``text:按钮文字` |
| `fill` | 输入框填值 |
| `select` | 下拉框选择 |
| `press` | 按键(Enter/Tab 等) |
| `wait` | 等待(毫秒 / `text:xx` / `url:xx` |
| `scroll` | 滚动页面 |
| `assert` | 断言(text/url/title/elementPASS/FAIL |
| `screenshot` | 截图留证 |
| `done` / `fail` | 结束任务并总结 |
## 关键特性
- **自然语言驱动**:测试目标直接用中文描述
- **自我修复**:元素找不到时 LLM 自动换定位策略重试
- **断言验证**:文本/URL/标题/元素存在性,输出 PASS/FAIL
- **证据留存**:关键步骤自动截图,HTML 报告内嵌展示
- **并发控制**:默认最多 2 个任务并发(`config.py` 可调)
## 配置
`config.py`:LLM 端点/模型、最大步数、超时、并发数均可通过环境变量覆盖。
## 依赖
- Python 3.10+Flask 3.0
- agent-browser CLInode 安装):`npm install -g agent-browser`
- 无头 Chromeagent-browser install 自动安装)
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""Agent 循环:LLM 决策 + agent-browser 执行 + 断言 + 报告"""
import os
import threading
import time
import traceback
from browser import AgentBrowser, BrowserError
from config import (TASKS_DIR, DEFAULT_MAX_SNAPSHOT_CHARS, MAX_RETRY_SAME_ERROR,
HISTORY_LIMIT)
from db import update_task, save_step_log, load_step_logs
from llm import chat_json, LLMError
SYSTEM_PROMPT = """你是一个专业的网页自动化测试工程师,正在使用浏览器工具执行端到端测试任务。
你的目标:根据用户给的测试目标,实际操作网页完成测试,并通过断言判断测试是否通过。
## 可用的浏览器能力
- click @e1 点击元素(@e1 是快照里的引用)
- fill @e1 "" 在输入框填入文字(会先清空)
- select @e1 "" 选择下拉框选项
- press "Enter" 按键(Enter/Tab/Escape/Control+a 等)
- wait "2000" 等待毫秒;wait text:"xx" 等待文本出现;wait url:"/path" 等待 URL 变化
- scroll "down 500" 滚动页面
- screenshot 截图留证(重要步骤请调用)
- assert 断言验证(见下)
- done 测试完成(全部通过)
- fail 测试失败(无法继续或断言不通过)
## 断言类型 assert
{"type":"text","expect":"欢迎","present":true} 页面上应出现"欢迎"文本
{"type":"text","expect":"错误提示","present":false} 页面上不应出现"错误提示"
{"type":"url","expect":"/dashboard","present":true} URL 应包含 /dashboard
{"type":"title","expect":"首页","present":true} 页面标题应包含"首页"
{"type":"element","expect":"登录","present":true} 页面上应存在文本为"登录"的元素
断言执行后会返回 PASS 或 FAIL,根据结果决定下一步。
## 输出格式(严格 JSON,不要输出其他内容)
{
"reason": "简要中文说明当前判断和下一步计划",
"action": "click|fill|select|press|wait|scroll|screenshot|assert|done|fail",
"target": "@e1 或 text:按钮文字",
"value": "填入的值/按键名/等待参数",
"assert": {断言对象,action=assert 时必填},
"summary": "测试结论(action=done/fail 时必填,说明验证了什么)"
}
## 工作原则
1. 先理解页面,再逐步操作;每一步只做一件事。
2. 找不到目标元素时,观察快照选择最接近的 ref,或改用 text: 语义定位。
3. 操作后必须用 assert 验证结果,不要盲目继续。
4. 测试失败(断言 FAIL 且无补救)时用 fail 结束,并说明原因。
5. 全部验证通过后用 done 结束,summary 里总结测试覆盖的内容。
6. 快照可能被截断,必要时用 wait 等待页面加载完成再操作。
"""
def _fmt_snapshot(snap, max_chars=DEFAULT_MAX_SNAPSHOT_CHARS):
"""把快照数据转成紧凑文本给 LLM"""
lines = []
refs = (snap or {}).get('refs') or {}
if isinstance(refs, dict):
for ref, info in refs.items():
if isinstance(info, dict):
name = info.get('name') or info.get('text') or info.get('label') or ''
role = info.get('role') or ''
lines.append(f'{ref} [{role}] {name}')
else:
lines.append(f'{ref} {info}')
text = '\n'.join(lines)
if len(text) > max_chars:
text = text[:max_chars] + '\n...(快照过长已截断)'
return text
def _fmt_history(steps):
"""格式化历史步骤给 LLM"""
out = []
for s in steps[-HISTORY_LIMIT:]:
act = s.get('action', '')
tgt = s.get('target', '')
val = s.get('value', '')
res = s.get('result', '')
detail = s.get('detail', '')
line = f"[步骤{s.get('n')}] {act} {tgt} {val if val else ''} -> {res}"
if detail:
line += f' ({detail[:120]})'
out.append(line)
return '\n'.join(out)
class TaskRunner(threading.Thread):
def __init__(self, task_id, url, goal, max_steps, timeout):
super().__init__(daemon=True, name=f'task-{task_id}')
self.task_id = task_id
self.url = url
self.goal = goal
self.max_steps = max_steps
self.timeout = timeout
self.stop_flag = threading.Event()
self.task_dir = os.path.join(TASKS_DIR, task_id)
os.makedirs(self.task_dir, exist_ok=True)
def stop(self):
self.stop_flag.set()
def _log(self, msg):
ts = time.strftime('%H:%M:%S')
print(f'[{ts}][{self.task_id}] {msg}', flush=True)
def _screenshot(self, browser, name):
try:
path = os.path.join(self.task_dir, name)
browser.screenshot(path)
return os.path.basename(path)
except Exception:
return None
def run(self):
update_task(self.task_id, status='running', started_at=time.time(),
result='running')
steps = []
browser = None
try:
browser = AgentBrowser(namespace=f'task-{self.task_id}')
deadline = time.time() + self.timeout
self._log(f'打开页面: {self.url}')
browser.open(self.url, timeout=60)
browser.wait('--load', 'networkidle', timeout=45)
self._log('页面已打开')
step_n = 0
while step_n < self.max_steps:
if self.stop_flag.is_set():
self._finish(browser, 'stopped', '任务被手动停止', steps)
return
if time.time() > deadline:
self._finish(browser, 'error', '任务超时', steps)
return
step_n += 1
self._log(f'--- 步骤 {step_n}/{self.max_steps} ---')
# 1. 快照
try:
snap = browser.snapshot(interactive=True, compact=True, timeout=45)
snap_text = _fmt_snapshot(snap)
except BrowserError as e:
snap_text = f'(快照失败: {e})'
cur_url = browser.url()
cur_title = browser.title()
# 2. LLM 决策
user_msg = (
f'## 测试目标\n{self.goal}\n\n'
f'## 当前页面\nURL: {cur_url}\n标题: {cur_title}\n\n'
f'## 页面可交互元素\n{snap_text}\n\n'
f'## 已执行步骤\n' + (_fmt_history(steps) if steps else '(尚无)') +
f'\n\n请输出下一步动作的 JSON。'
)
try:
decision = chat_json(
[{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_msg}],
temperature=0.2, max_tokens=500)
except LLMError as e:
self._log(f'LLM 错误: {e}')
self._finish(browser, 'error', f'LLM 决策失败: {e}', steps)
return
action = decision.get('action', '')
if action not in ('click', 'fill', 'select', 'press', 'wait',
'scroll', 'screenshot', 'assert', 'done', 'fail'):
self._log(f'非法动作: {action}')
steps.append(self._record(step_n, decision, 'error',
f'非法动作: {action}'))
continue
# 3. 执行动作(带自愈重试)
result, detail, extra = self._execute(browser, decision)
self._log(f'动作 {action} -> {result} {detail}')
if action in ('done', 'fail'):
self._finish(browser,
'pass' if action == 'done' else 'fail',
decision.get('summary', detail), steps)
return
step_rec = self._record(step_n, decision, result, detail, extra)
steps.append(step_rec)
# 关键步骤截图(截图后统一写日志,避免重复)
if result == 'ok' and action in ('click', 'fill', 'assert'):
shot = self._screenshot(browser, f'step{step_n:02d}.png')
if shot:
step_rec['screenshot'] = shot
save_step_log(self.task_id, step_rec)
self._finish(browser, 'error', f'超过最大步数({self.max_steps})未完成', steps)
except BrowserError as e:
self._log(f'浏览器错误: {e}')
self._finish(browser, 'error', f'浏览器错误: {e}', steps)
except Exception as e:
self._log(f'未知异常: {traceback.format_exc()}')
self._finish(browser, 'error', f'异常: {e}', steps)
def _record(self, n, decision, result, detail='', extra=None):
rec = {
'n': n,
'action': decision.get('action'),
'reason': decision.get('reason', ''),
'target': decision.get('target', ''),
'value': decision.get('value', ''),
'assert': decision.get('assert'),
'result': result,
'detail': detail,
'ts': time.strftime('%Y-%m-%d %H:%M:%S'),
}
if extra:
rec.update(extra)
return rec
def _execute(self, browser, decision):
"""执行单个动作,带自愈重试。返回 (result, detail, extra)"""
action = decision.get('action')
target = decision.get('target', '')
value = decision.get('value', '')
assert_obj = decision.get('assert') or {}
if action == 'done':
return 'ok', decision.get('summary', '完成'), {}
if action == 'fail':
return 'ok', decision.get('summary', '失败'), {}
if action == 'screenshot':
shot = self._screenshot(browser, f'shot_{int(time.time())}.png')
return ('ok', f'已截图 {shot}', {'screenshot': shot}) if shot else \
('error', '截图失败', {})
if action == 'assert':
return self._do_assert(browser, assert_obj)
# 带重试的动作
last_err = None
for attempt in range(MAX_RETRY_SAME_ERROR + 1):
if attempt > 0:
self._log(f'重试 {attempt}: {action} {target}')
try:
if action == 'click':
browser.click(self._resolve(target))
return 'ok', '点击成功', {}
elif action == 'fill':
browser.fill(self._resolve(target), value)
return 'ok', '填入成功', {}
elif action == 'select':
browser.select(self._resolve(target), value)
return 'ok', '选择成功', {}
elif action == 'press':
browser.press(value)
return 'ok', f'按键 {value}', {}
elif action == 'wait':
if value.startswith('text:'):
browser.wait('--text', value[5:])
elif value.startswith('url:'):
browser.wait('--url', value[4:])
elif value.isdigit():
browser.wait(value)
else:
browser.wait(value)
return 'ok', f'等待完成', {}
elif action == 'scroll':
parts = value.split()
browser.scroll if hasattr(browser, 'scroll') else None
args = parts if parts else ['down', '500']
self._run_scroll(browser, args)
return 'ok', f'滚动 {value}', {}
except BrowserError as e:
last_err = str(e)
# 失败后重新快照让 LLM 换个策略
try:
snap = browser.snapshot(interactive=True, compact=True, timeout=40)
snap_text = _fmt_snapshot(snap, max_chars=5000)
except Exception:
snap_text = '(快照失败)'
retry_dec = chat_json(
[{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': (
f'## 测试目标\n{self.goal}\n\n'
f'## 刚才执行失败\n动作: {action} 目标: {target} 值: {value}\n'
f'错误: {last_err}\n\n'
f'## 当前页面元素\n{snap_text}\n\n'
f'请换一种方式完成相同意图,输出下一步动作 JSON。'
f'如果确认无法完成,输出 {{"action":"fail","reason":"...","summary":"..."}}'
)}],
temperature=0.2, max_tokens=400)
new_action = retry_dec.get('action', '')
if new_action == 'fail':
return 'error', f'自愈放弃: {retry_dec.get("summary", last_err)}', {}
if new_action in ('click', 'fill', 'select', 'press', 'wait', 'scroll'):
# 更新决策重试
decision = retry_dec
target = decision.get('target', target)
value = decision.get('value', value)
action = new_action
continue
last_err = f'自愈输出非法动作: {new_action}'
return 'error', f'执行失败: {last_err}', {}
def _run_scroll(self, browser, args):
import subprocess
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
import os
env = os.environ.copy()
env['PATH'] = f'{NODE_BIN_DIR}:{env.get("PATH", "")}'
env['XDG_RUNTIME_DIR'] = XDG_RUNTIME_DIR
subprocess.run([AGENT_BROWSER, '--namespace', f'task-{self.task_id}',
'scroll'] + args, capture_output=True, timeout=20, env=env,
check=True)
def _resolve(self, target):
"""把目标转成 agent-browser 定位:@ref 原样,text:xxx 转 find 语法"""
if not target:
raise BrowserError('缺少目标元素')
if target.startswith('@'):
return target
if target.startswith('text:'):
return f'find text "{target[5:]}"'
if target.startswith('role:'):
parts = target[5:].split(':', 1)
if len(parts) == 2:
return f'find role {parts[0]} --name "{parts[1]}"'
return f'find role {parts[0]}'
return target
def _do_assert(self, browser, assert_obj):
atype = assert_obj.get('type', '')
expect = assert_obj.get('expect', '')
present = bool(assert_obj.get('present', True))
try:
if atype == 'text':
found = self._page_has_text(browser, expect)
elif atype == 'element':
found = self._element_exists(browser, expect)
elif atype == 'url':
cur = browser.url()
found = expect in cur
if not found:
return ('fail', f'URL 应为包含"{expect}",实际: {cur}', {})
return 'ok', f'URL 包含 "{expect}" (PASS)', {}
elif atype == 'title':
cur = browser.title()
found = expect in cur
if not found:
return ('fail', f'标题应包含"{expect}",实际: {cur}', {})
return 'ok', f'标题包含 "{expect}" (PASS)', {}
else:
return 'error', f'未知断言类型: {atype}', {}
except BrowserError as e:
return 'error', f'断言执行出错: {e}', {}
if found == present:
tag = 'PASS' if present else 'PASS(确认不存在)'
return 'ok', f'断言通过: {atype}="{expect}" present={present} ({tag})', {}
tag = 'FAIL' if present else 'FAIL(不应出现却出现)'
return 'fail', f'断言失败: {atype}="{expect}" present={present} ({tag})', {}
def _page_has_text(self, browser, text):
r = browser.eval_js(
f'document.body && document.body.innerText.includes({json_dumps(text)})')
if isinstance(r, dict):
d = r.get('data', {})
val = d.get('value') if isinstance(d, dict) else d
return bool(val)
return bool(r)
def _element_exists(self, browser, text):
r = browser.eval_js(
f'!![...document.querySelectorAll("button,a,input,textarea,[role=button]")]'
f'.find(el => (el.innerText||el.value||"").trim().includes({json_dumps(text)}))')
if isinstance(r, dict):
d = r.get('data', {})
val = d.get('value') if isinstance(d, dict) else d
return bool(val)
return bool(r)
def _finish(self, browser, result, summary, steps):
if browser:
browser.close()
update_task(self.task_id, status='finished', result=result,
finished_at=time.time(), steps=len(steps), summary=summary)
report = self._build_report(result, summary, steps)
rpath = os.path.join(self.task_dir, 'report.html')
with open(rpath, 'w', encoding='utf-8') as f:
f.write(report)
update_task(self.task_id, report_path=f'/api/tasks/{self.task_id}/report')
self._log(f'完成: result={result} summary={summary}')
def _build_report(self, result, summary, steps):
"""生成 HTML 报告"""
title_map = {'pass': '✅ 测试通过', 'fail': '❌ 测试失败',
'error': '⚠️ 测试错误', 'stopped': '⏹️ 已停止'}
color_map = {'pass': '#16a34a', 'fail': '#dc2626',
'error': '#d97706', 'stopped': '#6b7280'}
rows = []
for s in steps:
cls = {'ok': 'ok', 'fail': 'bad', 'error': 'bad'}.get(s.get('result'), '')
shot = ''
if s.get('screenshot'):
shot = (f'<div class="shot"><img src="/api/tasks/{self.task_id}/'
f'screenshot/{s["screenshot"]}" loading="lazy"></div>')
rows.append(f'''<tr class="{cls}">
<td>{s.get('n')}</td>
<td>{s.get('action')}</td>
<td>{s.get('target','')} {s.get('value','')}</td>
<td class="reason">{s.get('reason','')}</td>
<td>{s.get('result')} {s.get('detail','')}</td>
<td>{shot}</td></tr>''')
steps_html = '\n'.join(rows) if rows else '<tr><td colspan="6">无步骤记录</td></tr>'
dur = ''
t = get_task_safe(self.task_id)
if t and t.get('started_at') and t.get('finished_at'):
dur = f'{t["finished_at"] - t["started_at"]:.1f}s'
return f'''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<title>{title_map.get(result, result)} - webtest-agent</title>
<style>
body{{font-family:-apple-system,'Segoe UI',sans-serif;margin:0;background:#f3f4f6;color:#111}}
.wrap{{max-width:1100px;margin:0 auto;padding:24px}}
h1{{font-size:22px}}
.badge{{display:inline-block;padding:6px 16px;border-radius:999px;color:#fff;background:{color_map.get(result, '#6b7280')};font-size:14px}}
.meta{{color:#666;font-size:13px;margin:8px 0 20px}}
.summary{{background:#fff;border-radius:10px;padding:16px;margin-bottom:20px;border:1px solid #e5e7eb}}
table{{width:100%;border-collapse:collapse;background:#fff;border-radius:10px;overflow:hidden;font-size:13px}}
th,td{{padding:10px 12px;border-bottom:1px solid #f0f0f0;text-align:left;vertical-align:top}}
th{{background:#f9fafb;font-weight:600}}
tr.bad td{{background:#fef2f2}}
tr.ok td{{background:#f0fdf4}}
.reason{{color:#555;max-width:260px}}
.shot img{{max-width:260px;border-radius:6px;border:1px solid #e5e7eb;display:block}}
</style></head><body><div class="wrap">
<h1>网页测试报告 <span class="badge">{title_map.get(result, result)}</span></h1>
<div class="meta">任务: {self.task_id} | 目标: {self.goal} | 步骤数: {len(steps)} | 耗时: {dur}</div>
<div class="summary"><b>结论:</b>{summary}</div>
<table><thead><tr><th>#</th><th>动作</th><th>目标</th><th>原因</th><th>结果</th><th>截图</th></tr></thead>
<tbody>{steps_html}</tbody></table>
</div></body></html>'''
def json_dumps(s):
import json
return json.dumps(str(s))
def get_task_safe(tid):
from db import get_task
try:
return get_task(tid)
except Exception:
return None
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""webtest-agent - AI 网页测试智能体 (Flask 服务)"""
import os
import threading
from flask import Flask, request, jsonify, send_from_directory, abort
from flask_cors import CORS
import config
from db import init_db, create_task, get_task, list_tasks, load_step_logs, update_task
from agent import TaskRunner
app = Flask(__name__, static_folder='static', static_url_path='')
CORS(app)
init_db()
_runner_lock = threading.Lock()
_runners = {} # task_id -> TaskRunner
_semaphore = threading.Semaphore(config.MAX_CONCURRENT_TASKS)
@app.route('/')
def index():
return send_from_directory('static', 'index.html')
@app.route('/health')
def health():
return jsonify({'status': 'ok', 'version': '1.0.0',
'llm_model': config.LLM_MODEL,
'concurrent': len(_runners)})
@app.route('/api/tasks', methods=['POST'])
def api_create_task():
data = request.get_json(silent=True) or {}
url = (data.get('url') or '').strip()
goal = (data.get('goal') or '').strip()
if not url:
return jsonify({'error': '缺少 url 参数'}), 400
if not goal:
return jsonify({'error': '缺少 goal(测试目标)参数'}), 400
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
max_steps = int(data.get('max_steps') or config.DEFAULT_MAX_STEPS)
timeout = int(data.get('timeout') or config.DEFAULT_TIMEOUT)
max_steps = max(1, min(max_steps, 100))
timeout = max(30, min(timeout, 3600))
tid = create_task(url, goal, max_steps, timeout)
def _launch():
with _semaphore:
if get_task(tid) and get_task(tid).get('status') == 'stopped':
return
runner = TaskRunner(tid, url, goal, max_steps, timeout)
with _runner_lock:
_runners[tid] = runner
runner.start()
runner.join()
with _runner_lock:
_runners.pop(tid, None)
threading.Thread(target=_launch, daemon=True).start()
return jsonify({'task_id': tid, 'status': 'queued',
'url': url, 'goal': goal}), 202
@app.route('/api/tasks', methods=['GET'])
def api_list_tasks():
tasks = list_tasks(limit=50)
for t in tasks:
t['created'] = _fmt_time(t.get('created_at'))
t['finished'] = _fmt_time(t.get('finished_at'))
return jsonify({'tasks': tasks})
@app.route('/api/tasks/<tid>', methods=['GET'])
def api_get_task(tid):
t = get_task(tid)
if not t:
abort(404)
t['created'] = _fmt_time(t.get('created_at'))
t['finished'] = _fmt_time(t.get('finished_at'))
t['steps_log'] = load_step_logs(tid)
return jsonify(t)
@app.route('/api/tasks/<tid>/stop', methods=['POST'])
def api_stop_task(tid):
runner = _runners.get(tid)
if runner:
runner.stop()
update_task(tid, status='stopped', result='stopped')
return jsonify({'ok': True, 'message': '停止请求已发送'})
t = get_task(tid)
if not t:
abort(404)
if t['status'] == 'queued':
update_task(tid, status='stopped', result='stopped')
return jsonify({'ok': True, 'message': '已取消排队任务'})
return jsonify({'ok': False, 'message': '任务不在运行中'})
@app.route('/api/tasks/<tid>/report')
def api_report(tid):
path = os.path.join(config.TASKS_DIR, tid, 'report.html')
if os.path.exists(path):
return send_from_directory(config.TASKS_DIR, f'{tid}/report.html')
abort(404)
@app.route('/api/tasks/<tid>/report.json')
def api_report_json(tid):
t = get_task(tid)
if not t:
abort(404)
return jsonify({
'task': t,
'steps': load_step_logs(tid),
'report_url': f'/api/tasks/{tid}/report',
})
@app.route('/api/tasks/<tid>/screenshot/<name>')
def api_screenshot(tid, name):
if not name or '..' in name or '/' in name:
abort(400)
path = os.path.join(config.TASKS_DIR, tid, name)
if os.path.exists(path):
return send_from_directory(config.TASKS_DIR, f'{tid}/{name}')
abort(404)
def _fmt_time(ts):
if not ts:
return None
import time
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))
if __name__ == '__main__':
print(f'webtest-agent 启动: http://0.0.0.0:{config.PORT}')
app.run(host=config.HOST, port=config.PORT, threaded=True)
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""agent-browser CLI 封装"""
import json
import os
import subprocess
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
class BrowserError(Exception):
pass
class AgentBrowser:
"""每个任务实例一个 namespace,避免 socket 冲突"""
def __init__(self, namespace='default'):
self.namespace = namespace
self.env = os.environ.copy()
self.env['PATH'] = f'{NODE_BIN_DIR}:{self.env.get("PATH", "")}'
self.env['XDG_RUNTIME_DIR'] = XDG_RUNTIME_DIR
os.makedirs(XDG_RUNTIME_DIR, exist_ok=True)
try:
os.chmod(XDG_RUNTIME_DIR, 0o700)
except OSError:
pass
def _run(self, args, timeout=60, check=False):
cmd = [AGENT_BROWSER, '--namespace', self.namespace] + args
try:
p = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, env=self.env)
except subprocess.TimeoutExpired:
raise BrowserError(f'命令超时: {" ".join(args)}')
out = p.stdout.strip()
if check and p.returncode != 0:
err = (p.stderr or '').strip() or out
raise BrowserError(f'命令失败({p.returncode}): {err[:300]}')
if out.startswith('{'):
try:
return json.loads(out)
except json.JSONDecodeError:
pass
return out
def open(self, url, timeout=60):
return self._run(['open', url], timeout=timeout, check=True)
def snapshot(self, interactive=True, compact=False, depth=None, timeout=60):
args = ['snapshot']
if interactive:
args.append('-i')
if compact:
args.append('-c')
if depth:
args += ['-d', str(depth)]
args.append('--json')
data = self._run(args, timeout=timeout, check=True)
if isinstance(data, dict) and data.get('success'):
return data.get('data') or {}
return data
def click(self, target, timeout=30):
return self._run(['click', target], timeout=timeout, check=True)
def fill(self, target, value, timeout=30):
return self._run(['fill', target, value], timeout=timeout, check=True)
def select(self, target, value, timeout=30):
return self._run(['select', target, value], timeout=timeout, check=True)
def press(self, key, timeout=30):
return self._run(['press', key], timeout=timeout, check=True)
def wait(self, *args, timeout=60):
return self._run(['wait'] + list(args), timeout=timeout, check=True)
def get(self, what, target=None, timeout=30):
args = ['get', what]
if target:
args.append(target)
args.append('--json')
return self._run(args, timeout=timeout, check=True)
def eval_js(self, expr, timeout=30):
return self._run(['eval', expr], timeout=timeout, check=True)
def screenshot(self, path, full=False, timeout=30):
args = ['screenshot', path]
if full:
args.append('--full')
return self._run(args, timeout=timeout, check=True)
def is_visible(self, target, timeout=30):
try:
r = self._run(['is', 'visible', target, '--json'], timeout=timeout)
return bool(r.get('data', {}).get('visible')) if isinstance(r, dict) else False
except BrowserError:
return False
def url(self, timeout=20):
try:
r = self.get('url')
if isinstance(r, dict):
d = r.get('data', {})
return d.get('url') or d.get('value') or ''
return ''
except BrowserError:
return ''
def title(self, timeout=20):
try:
r = self.get('title')
if isinstance(r, dict):
d = r.get('data', {})
return d.get('title') or d.get('value') or ''
return ''
except BrowserError:
return ''
def close(self):
try:
self._run(['close'], timeout=15)
except Exception:
pass
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""webtest-agent 配置文件"""
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
TASKS_DIR = os.path.join(DATA_DIR, 'tasks')
DB_PATH = os.path.join(DATA_DIR, 'webtest.db')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
# agent-browser CLI 路径
AGENT_BROWSER = os.environ.get(
'AGENT_BROWSER',
'/home/openclaw/.nvm/versions/node/v24.15.0/bin/agent-browser'
)
NODE_BIN_DIR = os.path.dirname(AGENT_BROWSER)
# agent-browser 需要可写的 socket 目录,固定用 /tmp 下的(系统 XDG_RUNTIME_DIR 可能属于其他用户)
XDG_RUNTIME_DIR = '/tmp/xdg-rt'
# LLM 配置(OpenAI 兼容接口)
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://ark.cn-beijing.volces.com/api/plan/v3')
LLM_API_KEY = os.environ.get('LLM_API_KEY', 'ark-2b06dc9d-8878-4c6e-b201-f422376e79cb-246d9')
LLM_MODEL = os.environ.get('LLM_MODEL', 'doubao-seed-evolving')
LLM_TEMPERATURE = 0.2
LLM_TIMEOUT = 120
# Agent 默认参数
DEFAULT_MAX_STEPS = 30 # 最大动作步数
DEFAULT_TIMEOUT = 600 # 任务总超时(秒)
DEFAULT_MAX_SNAPSHOT_CHARS = 9000 # 快照截断长度
MAX_RETRY_SAME_ERROR = 2 # 相同错误最多重试次数
MAX_CONCURRENT_TASKS = 2 # 最大并发任务数
HISTORY_LIMIT = 16 # 喂给 LLM 的最近历史步数
HOST = '0.0.0.0'
PORT = int(os.environ.get('PORT', '16051'))
for d in (DATA_DIR, TASKS_DIR, LOG_DIR):
os.makedirs(d, exist_ok=True)
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""SQLite 任务持久化"""
import json
import sqlite3
import time
import uuid
from config import DB_PATH
def _conn():
c = sqlite3.connect(DB_PATH, timeout=30)
c.row_factory = sqlite3.Row
return c
def init_db():
c = _conn()
c.execute('''
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
goal TEXT NOT NULL,
status TEXT DEFAULT 'queued',
result TEXT DEFAULT 'pending',
max_steps INTEGER DEFAULT 30,
timeout INTEGER DEFAULT 600,
created_at REAL,
started_at REAL,
finished_at REAL,
steps INTEGER DEFAULT 0,
summary TEXT,
error TEXT,
report_path TEXT
)
''')
c.commit()
c.close()
def create_task(url, goal, max_steps, timeout):
tid = uuid.uuid4().hex[:12]
c = _conn()
c.execute(
'INSERT INTO tasks (id, url, goal, status, max_steps, timeout, created_at) '
'VALUES (?,?,?,?,?,?,?)',
(tid, url, goal, 'queued', max_steps, timeout, time.time()))
c.commit()
c.close()
return tid
def update_task(tid, **fields):
allowed = {'status', 'result', 'started_at', 'finished_at', 'steps',
'summary', 'error', 'report_path'}
sets = [f'{k}=?' for k in fields if k in allowed]
vals = [fields[k] for k in fields if k in allowed]
if not sets:
return
c = _conn()
c.execute(f'UPDATE tasks SET {", ".join(sets)} WHERE id=?', (*vals, tid))
c.commit()
c.close()
def get_task(tid):
c = _conn()
row = c.execute('SELECT * FROM tasks WHERE id=?', (tid,)).fetchone()
c.close()
return dict(row) if row else None
def list_tasks(limit=50):
c = _conn()
rows = c.execute(
'SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?', (limit,)
).fetchall()
c.close()
return [dict(r) for r in rows]
def save_step_log(tid, step):
"""把单个步骤 JSON 追加到任务目录的 steps.jsonl"""
from config import TASKS_DIR
import os
p = os.path.join(TASKS_DIR, tid, 'steps.jsonl')
with open(p, 'a', encoding='utf-8') as f:
f.write(json.dumps(step, ensure_ascii=False) + '\n')
def load_step_logs(tid):
from config import TASKS_DIR
import os
p = os.path.join(TASKS_DIR, tid, 'steps.jsonl')
if not os.path.exists(p):
return []
out = []
with open(p, encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""LLM 客户端(OpenAI 兼容接口)"""
import json
import re
import urllib.request
import urllib.error
from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TEMPERATURE, LLM_TIMEOUT
class LLMError(Exception):
pass
def _extract_json(text):
"""从 LLM 输出中提取 JSON(容忍 markdown 代码块等包裹)"""
if not text:
return None
text = text.strip()
# 去掉 markdown 代码块
fence = re.search(r'```(?:json)?\s*(.*?)```', text, re.S)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# 尝试截取第一个 { 到最后一个 }
s, e = text.find('{'), text.rfind('}')
if s != -1 and e > s:
try:
return json.loads(text[s:e + 1])
except json.JSONDecodeError:
return None
return None
def chat(messages, temperature=None, max_tokens=None, timeout=None):
"""调用 OpenAI 兼容 chat/completions,返回 content 字符串"""
url = f'{LLM_BASE_URL}/chat/completions'
body = {
'model': LLM_MODEL,
'messages': messages,
'temperature': temperature if temperature is not None else LLM_TEMPERATURE,
}
if max_tokens:
body['max_tokens'] = max_tokens
req = urllib.request.Request(
url,
data=json.dumps(body).encode('utf-8'),
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {LLM_API_KEY}',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout or LLM_TIMEOUT) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
detail = e.read().decode('utf-8', 'ignore')[:300]
raise LLMError(f'LLM HTTP {e.code}: {detail}')
except Exception as e:
raise LLMError(f'LLM 调用失败: {e}')
try:
return data['choices'][0]['message']['content']
except (KeyError, IndexError, TypeError):
raise LLMError(f'LLM 响应异常: {str(data)[:300]}')
def chat_json(messages, temperature=None, max_tokens=None, retries=2):
"""调用 LLM 并强制解析 JSON,失败重试"""
last_err = None
for i in range(retries + 1):
try:
content = chat(messages, temperature=temperature, max_tokens=max_tokens)
obj = _extract_json(content)
if obj is not None:
return obj
last_err = f'无法从输出解析 JSON: {content[:200]}'
except LLMError as e:
last_err = str(e)
if i < retries:
messages = messages + [
{'role': 'assistant', 'content': content if 'content' in dir() else ''},
{'role': 'user', 'content': f'刚才的输出不是合法 JSON,请只输出严格的 JSON 对象。错误: {last_err}'},
]
raise LLMError(f'LLM JSON 解析失败: {last_err}')
+2
View File
@@ -0,0 +1,2 @@
flask>=2.3
flask-cors>=4.0
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# webtest-agent 启动脚本
cd "$(dirname "$0")"
export PATH="/home/openclaw/.nvm/versions/node/v24.15.0/bin:$PATH"
export XDG_RUNTIME_DIR=/tmp/xdg-rt
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
mkdir -p logs
if [ -n "$1" ] && [ "$1" = "stop" ]; then
pkill -f "webtest-agent/app.py" && echo "已停止" || echo "未在运行"
exit 0
fi
# 端口占用检查
if ss -tlnp 2>/dev/null | grep -q ":16051 "; then
echo "⚠️ 端口 16051 已被占用,无法启动!"
ss -tlnp | grep ":16051 "
exit 1
fi
nohup /home/hz1/miniconda3/envs/openclaw/bin/python3 app.py > logs/app.log 2>&1 &
echo "✅ webtest-agent 已启动 (PID $!)"
sleep 1
curl -s http://localhost:16051/health && echo
+47
View File
@@ -0,0 +1,47 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f3f4f6; color: #111; }
.topbar { background: #111827; color: #fff; padding: 14px 24px; display: flex; justify-content: space-between; align-items: center; }
.brand { font-size: 17px; font-weight: 600; }
.ver { font-size: 11px; background: #374151; padding: 2px 8px; border-radius: 999px; margin-left: 6px; font-weight: 400; }
.health { font-size: 12px; color: #9ca3af; }
.health.ok { color: #4ade80; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px; }
.card { background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px; border: 1px solid #e5e7eb; }
.card h2 { font-size: 16px; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
.field { display: flex; flex-direction: column; }
.field.span2 { grid-column: span 2; }
.field label { font-size: 13px; color: #374151; margin-bottom: 6px; font-weight: 500; }
.field input, .field textarea { padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; font-family: inherit; }
.field input:focus, .field textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.btn { padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn.primary { background: #2563eb; color: #fff; }
.btn.primary:hover { background: #1d4ed8; }
.btn.primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn.small { padding: 5px 12px; font-size: 12px; background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
.btn.small:hover { background: #e5e7eb; }
.hint { margin-top: 12px; font-size: 12px; color: #6b7280; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; text-align: left; vertical-align: middle; }
th { background: #f9fafb; font-weight: 600; color: #374151; white-space: nowrap; }
td { word-break: break-all; }
td .goal-cell { max-width: 260px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.empty { text-align: center; color: #9ca3af; padding: 24px; }
.status { padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; }
.status.queued { background: #fef3c7; color: #92400e; }
.status.running { background: #dbeafe; color: #1e40af; }
.status.finished { background: #dcfce7; color: #166534; }
.status.stopped { background: #f3f4f6; color: #4b5563; }
.result { padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; }
.result.pass { background: #dcfce7; color: #166534; }
.result.fail { background: #fee2e2; color: #991b1b; }
.result.error { background: #ffedd5; color: #9a3412; }
.result.stopped { background: #f3f4f6; color: #4b5563; }
.result.pending { background: #f3f4f6; color: #9ca3af; }
a.report-link { color: #2563eb; text-decoration: none; }
a.report-link:hover { text-decoration: underline; }
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 100; display: flex; align-items: center; justify-content: center; }
.modal.hidden { display: none; }
.modal-body { width: 90vw; height: 90vh; background: #fff; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.modal-head { padding: 12px 16px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
#report-frame { flex: 1; border: none; width: 100%; }
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 网页测试智能体</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">🌐 AI 网页测试智能体 <span class="ver">v1.0.0</span></div>
<div class="health" id="health">检测服务中...</div>
</header>
<main class="wrap">
<section class="card create-card">
<h2>发起测试任务</h2>
<div class="form-grid">
<div class="field span2">
<label>目标网址</label>
<input id="url" type="text" placeholder="https://example.com" value="https://example.com">
</div>
<div class="field span2">
<label>测试目标(自然语言描述要测什么)</label>
<textarea id="goal" rows="3" placeholder="例如:打开页面后,验证标题为 Example Domain,并点击 Learn more 链接能跳转"></textarea>
</div>
<div class="field">
<label>最大步数</label>
<input id="max_steps" type="number" value="30" min="1" max="100">
</div>
<div class="field">
<label>超时(秒)</label>
<input id="timeout" type="number" value="600" min="30" max="3600">
</div>
</div>
<button id="submit" class="btn primary">🚀 开始测试</button>
<div class="hint">测试由 AI 自动驱动浏览器执行:打开页面 → 逐步操作 → 断言验证 → 生成报告(含截图)</div>
</section>
<section class="card">
<h2>任务列表 <button id="refresh" class="btn small">刷新</button></h2>
<table id="task-table">
<thead><tr><th>任务ID</th><th>目标网址</th><th>测试目标</th><th>状态</th><th>结果</th><th>步骤</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody><tr><td colspan="8" class="empty">加载中...</td></tr></tbody>
</table>
</section>
</main>
<div id="report-modal" class="modal hidden">
<div class="modal-body">
<div class="modal-head"><span id="modal-title">测试报告</span><button id="modal-close" class="btn small">关闭</button></div>
<iframe id="report-frame" src="about:blank"></iframe>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
/* webtest-agent 前端逻辑 */
const $ = s => document.querySelector(s);
const API = '';
async function refreshHealth() {
try {
const r = await fetch(API + '/health');
const d = await r.json();
$('#health').textContent = `服务正常 | 模型: ${d.llm_model} | 运行中任务: ${d.concurrent}`;
$('#health').classList.add('ok');
} catch (e) {
$('#health').textContent = '服务异常';
$('#health').classList.remove('ok');
}
}
const STATUS_MAP = {
queued: '排队中', running: '测试中', finished: '已完成', stopped: '已停止'
};
const RESULT_MAP = {
pending: '待定', pass: '✅ 通过', fail: '❌ 失败', error: '⚠️ 错误', stopped: '⏹️ 停止'
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function loadTasks() {
try {
const r = await fetch(API + '/api/tasks');
const d = await r.json();
const tb = $('#task-table tbody');
if (!d.tasks.length) {
tb.innerHTML = '<tr><td colspan="8" class="empty">暂无任务,先发起一个吧</td></tr>';
return;
}
tb.innerHTML = d.tasks.map(t => {
const ops = t.status === 'running' || t.status === 'queued'
? `<button class="btn small" onclick="stopTask('${t.id}')">停止</button> `
: '';
const report = t.status === 'finished'
? `<a class="report-link" href="#" onclick="openReport('${t.id}');return false;">查看报告</a>`
: '—';
return `<tr>
<td>${esc(t.id)}</td>
<td>${esc(t.url)}</td>
<td><div class="goal-cell" title="${esc(t.goal)}">${esc(t.goal)}</div></td>
<td><span class="status ${esc(t.status)}">${STATUS_MAP[t.status] || esc(t.status)}</span></td>
<td><span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></td>
<td>${t.steps || 0}</td>
<td>${esc(t.created)}</td>
<td>${ops}${report}</td>
</tr>`;
}).join('');
} catch (e) {
$('#task-table tbody').innerHTML = '<tr><td colspan="8" class="empty">加载失败</td></tr>';
}
}
async function stopTask(tid) {
if (!confirm('确定停止该任务?')) return;
await fetch(API + `/api/tasks/${tid}/stop`, { method: 'POST' });
setTimeout(loadTasks, 500);
}
function openReport(tid) {
$('#report-frame').src = API + `/api/tasks/${tid}/report`;
$('#modal-title').textContent = `测试报告 - ${tid}`;
$('#report-modal').classList.remove('hidden');
}
$('#modal-close').onclick = () => $('#report-modal').classList.add('hidden');
$('#report-modal').onclick = e => { if (e.target === $('#report-modal')) $('#report-modal').classList.add('hidden'); };
$('#submit').onclick = async () => {
const url = $('#url').value.trim();
const goal = $('#goal').value.trim();
if (!url || !goal) { alert('请填写网址和测试目标'); return; }
const btn = $('#submit');
btn.disabled = true; btn.textContent = '提交中...';
try {
const r = await fetch(API + '/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url, goal,
max_steps: parseInt($('#max_steps').value) || 30,
timeout: parseInt($('#timeout').value) || 600
})
});
const d = await r.json();
if (d.error) { alert('提交失败: ' + d.error); }
else {
$('#goal').value = '';
loadTasks();
// 轮询直到该任务结束
pollTask(d.task_id);
}
} catch (e) { alert('提交失败: ' + e); }
finally { btn.disabled = false; btn.textContent = '🚀 开始测试'; }
};
function pollTask(tid, count = 0) {
if (count > 400) return;
setTimeout(async () => {
try {
const r = await fetch(API + `/api/tasks/${tid}`);
const t = await r.json();
loadTasks();
if (t.status === 'finished' || t.status === 'stopped') return;
pollTask(tid, count + 1);
} catch (e) { /* ignore */ }
}, 3000);
}
$('#refresh').onclick = loadTasks;
refreshHealth();
loadTasks();
setInterval(refreshHealth, 30000);
setInterval(loadTasks, 5000);