Skip to content

Commit ad76cd0

Browse files
committed
fix: 修复插件fetch failed+WebSocket进度丢失+L2诊断JSON截断
三个关键问题一次性修复: 1. HTTP fetch failed (5分钟断开) - engineClient.ts: 用Node.js原生http模块替代fetch - 设置socket.setTimeout(0)+setKeepAlive(true,30s) - 彻底解决undici bodyTimeout 300s导致长测试中途断开 2. WebSocket进度推送不到WebView (步骤日志全部丢失) - engineClient.ts: 新增ensureWsConnected()方法 - sidebarProvider.ts: 所有测试入口开始前主动确保WS已连接 - engineClient.ts: connectWs()增加残留连接清理 3. WebSocket完整报告后备机制 (双保险) - websocket.py: send_test_done增加full_report参数 - routes.py: Web蓝本测试完成后通过WS推送完整报告dict - sidebarProvider.ts: onProgress收到test_done+report时直接渲染 - 即使HTTP断开,WebView也能通过WS拿到完整测试结果 4. L2诊断JSON解析截断 - ai_hub.py: 正则从非贪婪.*?改为贪婪.* - 修复recover_selector含CSS选择器时被截断 - L1/L2统一中文引号容错,去掉破坏CSS选择器的replace
1 parent 8457a8d commit ad76cd0

5 files changed

Lines changed: 109 additions & 24 deletions

File tree

extension/src/engineClient.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,11 @@ export class EngineClient {
244244
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
245245
return;
246246
}
247+
// 清理残留连接
248+
if (this._ws) {
249+
try { this._ws.close(); } catch { /* ignore */ }
250+
this._ws = null;
251+
}
247252

248253
try {
249254
this._ws = new WebSocket(this.wsUrl);
@@ -265,17 +270,33 @@ export class EngineClient {
265270

266271
this._ws.on("close", () => {
267272
console.log("[TestPilot AI] WebSocket 断开,5秒后重连");
273+
this._ws = null;
268274
this._scheduleReconnect();
269275
});
270276

271277
this._ws.on("error", (err) => {
272278
console.error("[TestPilot AI] WebSocket 错误:", err.message);
279+
// error后通常会触发close,但保险起见也清理
273280
});
274281
} catch {
282+
this._ws = null;
275283
this._scheduleReconnect();
276284
}
277285
}
278286

287+
/** 确保 WebSocket 已连接(测试开始前调用) */
288+
ensureWsConnected(): void {
289+
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
290+
return;
291+
}
292+
// 取消定时重连,立即重连
293+
if (this._reconnectTimer) {
294+
clearTimeout(this._reconnectTimer);
295+
this._reconnectTimer = null;
296+
}
297+
this.connectWs();
298+
}
299+
279300
/** 断开 WebSocket */
280301
disconnectWs(): void {
281302
if (this._reconnectTimer) {
@@ -321,16 +342,54 @@ export class EngineClient {
321342

322343
private async _post<T>(path: string, body: unknown): Promise<T> {
323344
const url = `${this.httpUrl}${path}`;
324-
const resp = await fetch(url, {
325-
method: "POST",
326-
headers: { "Content-Type": "application/json" },
327-
body: JSON.stringify(body),
328-
signal: AbortSignal.timeout(600_000),
345+
const payload = JSON.stringify(body);
346+
347+
// 使用 Node.js http 模块替代 fetch,避免 undici 的 bodyTimeout (300s)
348+
// 导致长时间测试(6分钟+)中途 "fetch failed"
349+
return new Promise<T>((resolve, reject) => {
350+
const parsed = new URL(url);
351+
const http = require("http");
352+
const req = http.request(
353+
{
354+
hostname: parsed.hostname,
355+
port: parsed.port,
356+
path: parsed.pathname + parsed.search,
357+
method: "POST",
358+
headers: {
359+
"Content-Type": "application/json",
360+
"Content-Length": Buffer.byteLength(payload),
361+
},
362+
timeout: 900_000, // 15分钟连接超时
363+
},
364+
(res: any) => {
365+
const chunks: Buffer[] = [];
366+
res.on("data", (chunk: Buffer) => chunks.push(chunk));
367+
res.on("end", () => {
368+
const text = Buffer.concat(chunks).toString("utf-8");
369+
if (res.statusCode && res.statusCode >= 400) {
370+
reject(new Error(`HTTP ${res.statusCode}: ${text}`));
371+
} else {
372+
try {
373+
resolve(JSON.parse(text) as T);
374+
} catch {
375+
reject(new Error(`JSON解析失败: ${text.substring(0, 200)}`));
376+
}
377+
}
378+
});
379+
},
380+
);
381+
req.on("timeout", () => {
382+
req.destroy();
383+
reject(new Error("请求超时(15分钟)"));
384+
});
385+
req.on("error", (err: Error) => reject(err));
386+
// 禁用 socket 空闲超时,防止长测试期间连接被断开
387+
req.on("socket", (socket: any) => {
388+
socket.setTimeout(0);
389+
socket.setKeepAlive(true, 30_000);
390+
});
391+
req.write(payload);
392+
req.end();
329393
});
330-
if (!resp.ok) {
331-
const text = await resp.text();
332-
throw new Error(`HTTP ${resp.status}: ${text}`);
333-
}
334-
return resp.json() as Promise<T>;
335394
}
336395
}

extension/src/sidebarProvider.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
132132
projectPath: string;
133133
}): Promise<void> {
134134
try {
135+
this._client.ensureWsConnected();
135136
this._postMessage({ command: "testStarted" });
136137

137138
const config = vscode.workspace.getConfiguration("testpilotAI");
@@ -160,6 +161,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
160161
mobile_session_id?: string;
161162
}): Promise<void> {
162163
try {
164+
// 测试前确保 WebSocket 已连接,保证步骤进度能实时推送到 WebView
165+
this._client.ensureWsConnected();
163166
this._postMessage({ command: "testStarted" });
164167

165168
const platform = (msg.platform || "web").toLowerCase();
@@ -209,6 +212,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
209212
mobile_session_id?: string;
210213
}): Promise<void> {
211214
try {
215+
this._client.ensureWsConnected();
212216
this._postMessage({ command: "testStarted" });
213217

214218
// 依次执行每个蓝本,汇总结果(用户停止时中断后续蓝本)
@@ -2108,6 +2112,12 @@ ${commonRules}`;
21082112
screenshotImg.src = "data:image/png;base64," + wsMsg.data.image;
21092113
return;
21102114
}
2115+
// test_done 含完整报告时,直接渲染结果(HTTP后备)
2116+
// 即使 HTTP fetch failed,WebView 也能通过 WS 拿到完整结果
2117+
if (wsMsg.type === "test_done" && wsMsg.data?.report) {
2118+
onTestResult(wsMsg.data.report);
2119+
return;
2120+
}
21112121
const level = typeMap[wsMsg.type] || "info";
21122122
const text = wsMsg.data?.message || wsMsg.type;
21132123
addLog(text, level);

src/api/routes.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,6 @@ async def _on_step(step: int, status: str, desc: str) -> None:
506506
try:
507507
await ws_manager.send_log(f"蓝本测试开始: {blueprint.app_name}")
508508
report = await runner.run(blueprint)
509-
await ws_manager.send_test_done(
510-
report.passed_steps / report.total_steps * 100 if report.total_steps > 0 else 0,
511-
len(report.bugs),
512-
)
513509

514510
# v1.3:保存记忆(Bug模式提取)
515511
if memory_store and report.bugs:
@@ -524,14 +520,15 @@ async def _on_step(step: int, status: str, desc: str) -> None:
524520
stopped = test_controller.was_stopped
525521
if stopped:
526522
test_controller.reset()
527-
return TestReportResponse(
523+
pass_rate = report.passed_steps / report.total_steps * 100 if report.total_steps > 0 else 0
524+
response = TestReportResponse(
528525
test_name=report.test_name,
529526
url=report.url,
530527
total_steps=report.total_steps,
531528
passed_steps=report.passed_steps,
532529
failed_steps=report.failed_steps,
533530
bug_count=len(report.bugs),
534-
pass_rate=report.passed_steps / report.total_steps * 100 if report.total_steps > 0 else 0,
531+
pass_rate=pass_rate,
535532
duration_seconds=report.duration_seconds,
536533
report_markdown=report.report_markdown,
537534
stopped=stopped,
@@ -560,6 +557,16 @@ async def _on_step(step: int, status: str, desc: str) -> None:
560557
for b in report.bugs
561558
],
562559
)
560+
561+
# 通过 WebSocket 推送完整报告(作为 HTTP response 的后备)
562+
# 即使 HTTP 连接中途断开,WebView 也能通过 WS 拿到结果
563+
try:
564+
report_dict = response.model_dump()
565+
except AttributeError:
566+
report_dict = response.dict()
567+
await ws_manager.send_test_done(pass_rate, len(report.bugs), full_report=report_dict)
568+
569+
return response
563570
except Exception as e:
564571
logger.error("蓝本测试执行失败: {}", e)
565572
raise HTTPException(status_code=500, detail=f"蓝本测试执行失败: {e}")

src/api/websocket.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,19 @@ async def send_repair_done(self, bug_title: str, success: bool) -> None:
146146
"message": f"修复{status_text}: {bug_title}",
147147
})
148148

149-
async def send_test_done(self, pass_rate: float, bug_count: int) -> None:
150-
"""发送测试完成通知。"""
151-
await self.broadcast("test_done", {
149+
async def send_test_done(
150+
self, pass_rate: float, bug_count: int,
151+
full_report: dict[str, Any] | None = None,
152+
) -> None:
153+
"""发送测试完成通知(含完整报告作为HTTP后备)。"""
154+
data: dict[str, Any] = {
152155
"pass_rate": pass_rate,
153156
"bug_count": bug_count,
154157
"message": f"测试完成 | 通过率 {pass_rate:.0f}% | Bug {bug_count} 个",
155-
})
158+
}
159+
if full_report is not None:
160+
data["report"] = full_report
161+
await self.broadcast("test_done", data)
156162

157163
# ── v2.0 新增推送 ────────────────────────────────
158164

src/testing/ai_hub.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,11 +310,14 @@ async def _try_l1_popup_heal(self, ctx: StepContext) -> HubDecision:
310310
logger.debug("AI中枢L1弹窗检测返回(前300字): {}", response[:300])
311311

312312
# 3. 解析 JSON
313-
json_match = re.search(r"\{.*?\}", response, re.DOTALL)
313+
json_match = re.search(r"\{.*\}", response, re.DOTALL)
314314
if not json_match:
315315
return HubDecision(action=HubAction.NONE, reason="AI返回无JSON", ai_cost_ms=cost_ms)
316316

317-
result = json.loads(json_match.group().replace("'", '"'))
317+
raw_json = json_match.group()
318+
raw_json = raw_json.replace("\u201c", '"').replace("\u201d", '"')
319+
raw_json = raw_json.replace("\u2018", "'").replace("\u2019", "'")
320+
result = json.loads(raw_json)
318321
if not result.get("popup"):
319322
return HubDecision(action=HubAction.NONE, reason="未检测到弹窗", ai_cost_ms=cost_ms)
320323

@@ -419,14 +422,14 @@ async def _try_l2_diagnose(self, ctx: StepContext) -> HubDecision:
419422
cost_ms = (time.time() - start) * 1000
420423
logger.info(" 🧠 AI中枢L2诊断返回(前400字): {}", response[:400])
421424

422-
json_match = re.search(r"\{.*?\}", response, re.DOTALL)
425+
json_match = re.search(r"\{.*\}", response, re.DOTALL)
423426
if not json_match:
424427
return HubDecision(action=HubAction.NONE, reason="L2返回无JSON", ai_cost_ms=cost_ms)
425428

426429
# 容错:去掉中文引号、修复常见JSON格式问题
427430
raw_json = json_match.group()
428431
raw_json = raw_json.replace("\u201c", '"').replace("\u201d", '"') # 中文引号
429-
raw_json = raw_json.replace("'", '"')
432+
raw_json = raw_json.replace("\u2018", "'").replace("\u2019", "'") # 中文单引号→英文单引号
430433
result = json.loads(raw_json)
431434
diagnosis = result.get("diagnosis", "")
432435
suggestion = result.get("suggestion", "none")

0 commit comments

Comments
 (0)