Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd67548052 | ||
|
|
f942705fed | ||
|
|
07b3406b08 |
@@ -99,6 +99,18 @@ class TaskRunner(threading.Thread):
|
||||
self.stop_flag = threading.Event()
|
||||
self.task_dir = os.path.join(TASKS_DIR, task_id)
|
||||
os.makedirs(self.task_dir, exist_ok=True)
|
||||
# 实时状态(供 API 轮询展示)
|
||||
self.current = {
|
||||
'phase': '排队中', 'step': 0, 'reason': '', 'action': '',
|
||||
'target': '', 'value': '', 'detail': '', 'ts': '',
|
||||
'elapsed': 0, 'started': False,
|
||||
}
|
||||
|
||||
def _set_current(self, **kw):
|
||||
self.current.update(kw)
|
||||
self.current['ts'] = time.strftime('%H:%M:%S')
|
||||
if self.current.get('started'):
|
||||
self.current['elapsed'] = int(time.time() - self.current.get('start_ts', time.time()))
|
||||
|
||||
def stop(self):
|
||||
self.stop_flag.set()
|
||||
@@ -118,15 +130,25 @@ class TaskRunner(threading.Thread):
|
||||
def run(self):
|
||||
update_task(self.task_id, status='running', started_at=time.time(),
|
||||
result='running')
|
||||
self._set_current(phase='启动中', started=True, start_ts=time.time())
|
||||
steps = []
|
||||
browser = None
|
||||
try:
|
||||
browser = AgentBrowser(namespace=f'task-{self.task_id}')
|
||||
deadline = time.time() + self.timeout
|
||||
self._set_current(phase='打开页面', detail=self.url)
|
||||
self._log(f'打开页面: {self.url}')
|
||||
browser.open(self.url, timeout=60)
|
||||
browser.wait('--load', 'networkidle', timeout=45)
|
||||
# 等待页面加载:优先 networkidle;若页面依赖的外部 CDN 挂起导致
|
||||
# networkidle 永不满足,降级为等待 load 事件 + 短暂缓冲,不阻断测试
|
||||
try:
|
||||
browser.wait('--load', 'networkidle', timeout=20)
|
||||
except BrowserError:
|
||||
self._log('networkidle 超时(可能外部 CDN 慢),降级等待 load 事件')
|
||||
browser.wait('--load', 'load', timeout=30)
|
||||
time.sleep(2)
|
||||
self._log('页面已打开')
|
||||
self._set_current(phase='页面已打开')
|
||||
|
||||
step_n = 0
|
||||
while step_n < self.max_steps:
|
||||
@@ -139,6 +161,8 @@ class TaskRunner(threading.Thread):
|
||||
|
||||
step_n += 1
|
||||
self._log(f'--- 步骤 {step_n}/{self.max_steps} ---')
|
||||
self._set_current(phase='分析页面', step=step_n,
|
||||
detail=f'正在获取页面元素快照')
|
||||
|
||||
# 1. 快照
|
||||
try:
|
||||
@@ -151,6 +175,8 @@ class TaskRunner(threading.Thread):
|
||||
cur_title = browser.title()
|
||||
|
||||
# 2. LLM 决策
|
||||
self._set_current(phase='AI 决策中', step=step_n,
|
||||
detail='正在分析页面并决定下一步动作...')
|
||||
user_msg = (
|
||||
f'## 测试目标\n{self.goal}\n\n'
|
||||
f'## 当前页面\nURL: {cur_url}\n标题: {cur_title}\n\n'
|
||||
@@ -172,11 +198,18 @@ class TaskRunner(threading.Thread):
|
||||
if action not in ('click', 'fill', 'select', 'press', 'wait',
|
||||
'scroll', 'screenshot', 'assert', 'done', 'fail'):
|
||||
self._log(f'非法动作: {action}')
|
||||
self._set_current(phase='动作异常', step=step_n,
|
||||
action=action, detail=f'非法动作: {action}')
|
||||
steps.append(self._record(step_n, decision, 'error',
|
||||
f'非法动作: {action}'))
|
||||
continue
|
||||
|
||||
# 3. 执行动作(带自愈重试)
|
||||
self._set_current(phase='执行动作', step=step_n,
|
||||
action=action, target=decision.get('target', ''),
|
||||
value=decision.get('value', ''),
|
||||
reason=decision.get('reason', ''),
|
||||
detail=f'{action} {decision.get("target", "")} {decision.get("value", "")}'.strip())
|
||||
result, detail, extra = self._execute(browser, decision)
|
||||
self._log(f'动作 {action} -> {result} {detail}')
|
||||
|
||||
@@ -243,6 +276,9 @@ class TaskRunner(threading.Thread):
|
||||
for attempt in range(MAX_RETRY_SAME_ERROR + 1):
|
||||
if attempt > 0:
|
||||
self._log(f'重试 {attempt}: {action} {target}')
|
||||
self._set_current(phase='自愈重试', step=self.current.get('step', 0),
|
||||
action=action, target=target,
|
||||
detail=f'第 {attempt} 次重试: {action} {target}')
|
||||
try:
|
||||
if action == 'click':
|
||||
browser.click(self._resolve(target))
|
||||
@@ -382,6 +418,8 @@ class TaskRunner(threading.Thread):
|
||||
return bool(r)
|
||||
|
||||
def _finish(self, browser, result, summary, steps):
|
||||
self._set_current(phase='完成', step=len(steps),
|
||||
detail=f'结果: {result} - {summary}')
|
||||
if browser:
|
||||
browser.close()
|
||||
update_task(self.task_id, status='finished', result=result,
|
||||
|
||||
@@ -10,7 +10,7 @@ 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='')
|
||||
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
||||
CORS(app)
|
||||
|
||||
init_db()
|
||||
@@ -84,6 +84,9 @@ def api_get_task(tid):
|
||||
t['created'] = _fmt_time(t.get('created_at'))
|
||||
t['finished'] = _fmt_time(t.get('finished_at'))
|
||||
t['steps_log'] = load_step_logs(tid)
|
||||
runner = _runners.get(tid)
|
||||
if runner:
|
||||
t['current'] = runner.current
|
||||
return jsonify(t)
|
||||
|
||||
|
||||
|
||||
+22
-2
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
|
||||
|
||||
@@ -43,8 +44,27 @@ class AgentBrowser:
|
||||
pass
|
||||
return out
|
||||
|
||||
def open(self, url, timeout=60):
|
||||
return self._run(['open', url], timeout=timeout, check=True)
|
||||
# 瞬时网络故障(服务重启/端口切换等)会自动重试的错误特征
|
||||
RETRYABLE_ERRS = (
|
||||
'ERR_EMPTY_RESPONSE', 'ERR_CONNECTION_REFUSED',
|
||||
'ERR_CONNECTION_RESET', 'ERR_CONNECTION_CLOSED',
|
||||
'ERR_TIMED_OUT', 'ERR_NAME_NOT_RESOLVED', 'ERR_SOCKET_NOT_CONNECTED',
|
||||
'ERR_ADDRESS_UNREACHABLE', 'ERR_NETWORK_CHANGED', 'ERR_INTERNET_DISCONNECTED',
|
||||
)
|
||||
|
||||
def open(self, url, timeout=60, retries=2):
|
||||
"""打开页面;对瞬时连接类错误自动重试(默认最多重试 2 次,间隔 2s/4s)"""
|
||||
last_err = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
return self._run(['open', url], timeout=timeout, check=True)
|
||||
except BrowserError as e:
|
||||
last_err = e
|
||||
if attempt < retries and any(t in str(e) for t in self.RETRYABLE_ERRS):
|
||||
time.sleep(2 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
raise last_err
|
||||
|
||||
def snapshot(self, interactive=True, compact=False, depth=None, timeout=60):
|
||||
args = ['snapshot']
|
||||
|
||||
@@ -7,7 +7,8 @@ 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 "未在运行"
|
||||
PID=$(ss -tlnp 2>/dev/null | grep ":16061 " | grep -oP 'pid=\K[0-9]+' | head -1)
|
||||
if [ -n "$PID" ]; then kill "$PID" && echo "已停止 (PID $PID)"; else echo "未在运行"; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -45,3 +45,36 @@ a.report-link:hover { text-decoration: underline; }
|
||||
.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%; }
|
||||
.btn.danger { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
||||
.btn.danger:hover { background: #fecaca; }
|
||||
/* 任务详情 */
|
||||
.detail-body { width: 96vw; max-width: 1100px; }
|
||||
.detail-content { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
.detail-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 24px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; font-size: 13px; margin-bottom: 14px; }
|
||||
.detail-meta .m-item b { display: block; color: #6b7280; font-size: 11px; font-weight: 500; margin-bottom: 3px; }
|
||||
.detail-meta .m-item span { color: #111; word-break: break-all; }
|
||||
.live-bar { display: flex; align-items: flex-start; gap: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 10px; padding: 12px 16px; margin-bottom: 16px; }
|
||||
.live-bar.hidden { display: none; }
|
||||
.live-dot { width: 10px; height: 10px; border-radius: 50%; background: #3b82f6; margin-top: 5px; animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
|
||||
.live-phase { font-weight: 600; font-size: 14px; color: #1e40af; margin-bottom: 4px; }
|
||||
.live-detail { font-size: 13px; color: #374151; }
|
||||
.live-reason { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
||||
.detail-steps-head { font-size: 14px; font-weight: 600; margin: 18px 0 10px; }
|
||||
.detail-steps { display: flex; flex-direction: column; gap: 8px; }
|
||||
.step-card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px 14px; background: #fff; }
|
||||
.step-card .step-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.step-num { background: #f3f4f6; color: #374151; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
|
||||
.step-action { font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 999px; background: #dbeafe; color: #1e40af; }
|
||||
.step-action.assert { background: #fef3c7; color: #92400e; }
|
||||
.step-action.click { background: #dcfce7; color: #166534; }
|
||||
.step-action.fill { background: #ede9fe; color: #5b21b6; }
|
||||
.step-action.fail { background: #fee2e2; color: #991b1b; }
|
||||
.step-target { font-family: ui-monospace, monospace; font-size: 12px; color: #374151; background: #f9fafb; padding: 2px 8px; border-radius: 6px; }
|
||||
.step-result { margin-left: auto; font-size: 12px; font-weight: 600; }
|
||||
.step-result.ok { color: #16a34a; }
|
||||
.step-result.bad { color: #dc2626; }
|
||||
.step-reason { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
|
||||
.step-shot img { max-width: 320px; border-radius: 8px; border: 1px solid #e5e7eb; display: block; margin-top: 6px; cursor: zoom-in; }
|
||||
.step-detail { font-size: 12px; color: #374151; }
|
||||
.step-time { font-size: 11px; color: #9ca3af; margin-left: 8px; }
|
||||
@@ -46,6 +46,33 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="detail-modal" class="modal hidden">
|
||||
<div class="modal-body detail-body">
|
||||
<div class="modal-head">
|
||||
<span id="detail-title">任务详情</span>
|
||||
<span>
|
||||
<button id="detail-report-btn" class="btn small hidden">📄 完整报告</button>
|
||||
<button id="detail-stop-btn" class="btn small danger hidden">⏹ 停止</button>
|
||||
<button id="detail-close" class="btn small">关闭</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<div class="detail-meta" id="detail-meta"></div>
|
||||
<div class="live-bar hidden" id="live-bar">
|
||||
<span class="live-dot"></span>
|
||||
<div class="live-info">
|
||||
<div class="live-phase" id="live-phase"></div>
|
||||
<div class="live-detail" id="live-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-steps-head">步骤日志</div>
|
||||
<div class="detail-steps" id="detail-steps">
|
||||
<div class="empty">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
+102
-3
@@ -40,8 +40,8 @@ async function loadTasks() {
|
||||
? `<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>`
|
||||
: '—';
|
||||
? `<a class="report-link" href="#" onclick="openReport('${t.id}');return false;">报告</a>`
|
||||
: '';
|
||||
return `<tr>
|
||||
<td>${esc(t.id)}</td>
|
||||
<td>${esc(t.url)}</td>
|
||||
@@ -50,7 +50,7 @@ async function loadTasks() {
|
||||
<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>
|
||||
<td><button class="btn small" onclick="openDetail('${t.id}')">详情</button> ${ops}${report}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
@@ -70,6 +70,105 @@ function openReport(tid) {
|
||||
$('#report-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
/* ========== 任务详情 ========== */
|
||||
let detailTid = null;
|
||||
let detailTimer = null;
|
||||
|
||||
function openDetail(tid) {
|
||||
detailTid = tid;
|
||||
$('#detail-title').textContent = `任务详情 - ${tid}`;
|
||||
$('#detail-steps').innerHTML = '<div class="empty">加载中...</div>';
|
||||
$('#detail-modal').classList.remove('hidden');
|
||||
refreshDetail();
|
||||
detailTimer = setInterval(refreshDetail, 2500);
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailTid = null;
|
||||
if (detailTimer) { clearInterval(detailTimer); detailTimer = null; }
|
||||
$('#detail-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
const ACTION_LABEL = { click: '点击', fill: '填表', select: '选择', press: '按键',
|
||||
wait: '等待', scroll: '滚动', assert: '断言', screenshot: '截图', done: '完成', fail: '失败' };
|
||||
|
||||
async function refreshDetail() {
|
||||
if (!detailTid) return;
|
||||
let t;
|
||||
try {
|
||||
const r = await fetch(API + `/api/tasks/${detailTid}`);
|
||||
t = await r.json();
|
||||
} catch (e) { return; }
|
||||
|
||||
// 元信息
|
||||
const done = t.status === 'finished' || t.status === 'stopped';
|
||||
$('#detail-meta').innerHTML = `
|
||||
<div class="m-item"><b>目标网址</b><span>${esc(t.url)}</span></div>
|
||||
<div class="m-item"><b>测试目标</b><span>${esc(t.goal)}</span></div>
|
||||
<div class="m-item"><b>状态</b><span><span class="status ${esc(t.status)}">${STATUS_MAP[t.status] || esc(t.status)}</span> / <span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></span></div>
|
||||
<div class="m-item"><b>步骤</b><span>${t.steps || 0} / ${t.max_steps}</span></div>
|
||||
<div class="m-item"><b>创建时间</b><span>${esc(t.created)}</span></div>
|
||||
${t.finished ? `<div class="m-item"><b>结束时间</b><span>${esc(t.finished)}</span></div>` : ''}
|
||||
`;
|
||||
|
||||
// 实时状态条
|
||||
const cur = t.current;
|
||||
const liveBar = $('#live-bar');
|
||||
if (!done && cur) {
|
||||
liveBar.classList.remove('hidden');
|
||||
$('#live-phase').textContent = `步骤 ${cur.step || '-'}: ${cur.phase || ''}` +
|
||||
(cur.elapsed ? `(已运行 ${cur.elapsed}s)` : '');
|
||||
let d = cur.detail || '';
|
||||
if (cur.reason) d += `<div class="live-reason">💡 ${esc(cur.reason)}</div>`;
|
||||
$('#live-detail').innerHTML = esc(d);
|
||||
} else {
|
||||
liveBar.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 按钮
|
||||
$('#detail-report-btn').classList.toggle('hidden', !done || t.result === 'error');
|
||||
$('#detail-stop-btn').classList.toggle('hidden', done);
|
||||
|
||||
// 步骤列表
|
||||
const steps = t.steps_log || [];
|
||||
if (!steps.length) {
|
||||
$('#detail-steps').innerHTML = '<div class="empty">还没有步骤记录,AI 正在准备...</div>';
|
||||
} else {
|
||||
$('#detail-steps').innerHTML = steps.map(s => {
|
||||
const act = s.action || '';
|
||||
const actCls = ['assert','click','fill','fail'].includes(act) ? act : '';
|
||||
const shot = s.screenshot
|
||||
? `<div class="step-shot"><img src="${API}/api/tasks/${detailTid}/screenshot/${esc(s.screenshot)}" loading="lazy" onclick="window.open(this.src)"></div>`
|
||||
: '';
|
||||
const resCls = s.result === 'ok' ? 'ok' : (s.result === 'fail' || s.result === 'error' ? 'bad' : '');
|
||||
return `<div class="step-card">
|
||||
<div class="step-top">
|
||||
<span class="step-num">#${s.n}</span>
|
||||
<span class="step-action ${actCls}">${ACTION_LABEL[act] || act}</span>
|
||||
${s.target ? `<span class="step-target">${esc(s.target)}${s.value ? ' ' + esc(s.value) : ''}</span>` : ''}
|
||||
<span class="step-result ${resCls}">${esc(s.result)}</span>
|
||||
<span class="step-time">${esc(s.ts || '')}</span>
|
||||
</div>
|
||||
${s.reason ? `<div class="step-reason">💡 ${esc(s.reason)}</div>` : ''}
|
||||
${s.detail ? `<div class="step-detail">${esc(s.detail)}</div>` : ''}
|
||||
${shot}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 结束后停止轮询
|
||||
if (done) { if (detailTimer) { clearInterval(detailTimer); detailTimer = null; } }
|
||||
}
|
||||
|
||||
$('#detail-close').onclick = closeDetail;
|
||||
$('#detail-modal').onclick = e => { if (e.target === $('#detail-modal')) closeDetail(); };
|
||||
$('#detail-stop-btn').onclick = async () => {
|
||||
if (!detailTid || !confirm('确定停止该任务?')) return;
|
||||
await fetch(API + `/api/tasks/${detailTid}/stop`, { method: 'POST' });
|
||||
refreshDetail(); loadTasks();
|
||||
};
|
||||
$('#detail-report-btn').onclick = () => { if (detailTid) openReport(detailTid); };
|
||||
|
||||
$('#modal-close').onclick = () => $('#report-modal').classList.add('hidden');
|
||||
$('#report-modal').onclick = e => { if (e.target === $('#report-modal')) $('#report-modal').classList.add('hidden'); };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user