Skip to content

Commit b7813a6

Browse files
committed
fix: StreamingResponse心跳保活+assert_text跳过重复AI调用
1. HTTP连接保活(引擎端,不依赖插件重编译) - routes.py: Web蓝本测试改用StreamingResponse - 每20秒发送心跳空格保持TCP连接活跃 - 测试完成后发送完整JSON结果 - 空格在JSON.parse时自动忽略,前端零改动 2. 减少不必要的AI API调用 - assert_text步骤已用纯文本匹配验证,不再重复截图+调AI - 每个assert_text步骤节省一次截图分析API调用(约8秒+tokens) - 只有screenshot动作和非assert_text的expected字段才调AI 3. WebSocket完整报告后备(上次提交的补充) - websocket.py: send_test_done支持full_report参数 - 测试完成后WS推送完整报告dict
1 parent ad76cd0 commit b7813a6

2 files changed

Lines changed: 90 additions & 66 deletions

File tree

src/api/routes.py

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -503,73 +503,95 @@ async def _on_step(step: int, status: str, desc: str) -> None:
503503
on_step=_on_step,
504504
)
505505

506-
try:
507-
await ws_manager.send_log(f"蓝本测试开始: {blueprint.app_name}")
508-
report = await runner.run(blueprint)
506+
# 使用 StreamingResponse + 心跳保活,防止 HTTP 连接在长测试中断开
507+
# 每 20 秒发送一个空格作为心跳,测试完成后发送完整 JSON 结果
508+
import asyncio as _asyncio
509+
from fastapi.responses import StreamingResponse as _StreamingResponse
509510

510-
# v1.3:保存记忆(Bug模式提取)
511-
if memory_store and report.bugs:
512-
try:
513-
from src.memory.compressor import MemoryCompressor
514-
compressor = MemoryCompressor(memory_store)
515-
compressor.extract_from_report(report)
516-
except Exception as mem_err:
517-
logger.warning("蓝本测试记忆提取失败: {}", mem_err)
511+
result_holder: dict = {}
518512

519-
from src.api.models import StepDetail, BugDetail
520-
stopped = test_controller.was_stopped
521-
if stopped:
522-
test_controller.reset()
523-
pass_rate = report.passed_steps / report.total_steps * 100 if report.total_steps > 0 else 0
524-
response = TestReportResponse(
525-
test_name=report.test_name,
526-
url=report.url,
527-
total_steps=report.total_steps,
528-
passed_steps=report.passed_steps,
529-
failed_steps=report.failed_steps,
530-
bug_count=len(report.bugs),
531-
pass_rate=pass_rate,
532-
duration_seconds=report.duration_seconds,
533-
report_markdown=report.report_markdown,
534-
stopped=stopped,
535-
steps=[
536-
StepDetail(
537-
step=r.step,
538-
action=r.action.value if hasattr(r.action, 'value') else str(r.action),
539-
description=r.description,
540-
status=r.status.value if hasattr(r.status, 'value') else str(r.status),
541-
duration_seconds=r.duration_seconds,
542-
error_message=r.error_message,
543-
screenshot_path=r.screenshot_path,
544-
)
545-
for r in report.step_results
546-
],
547-
bugs=[
548-
BugDetail(
549-
severity=b.severity.value if hasattr(b.severity, 'value') else str(b.severity),
550-
title=b.title,
551-
description=b.description,
552-
category=b.category,
553-
location=b.location,
554-
step_number=b.step_number,
555-
screenshot_path=b.screenshot_path,
556-
)
557-
for b in report.bugs
558-
],
559-
)
560-
561-
# 通过 WebSocket 推送完整报告(作为 HTTP response 的后备)
562-
# 即使 HTTP 连接中途断开,WebView 也能通过 WS 拿到结果
513+
async def _run_test():
563514
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)
515+
await ws_manager.send_log(f"蓝本测试开始: {blueprint.app_name}")
516+
report = await runner.run(blueprint)
568517

569-
return response
570-
except Exception as e:
571-
logger.error("蓝本测试执行失败: {}", e)
572-
raise HTTPException(status_code=500, detail=f"蓝本测试执行失败: {e}")
518+
if memory_store and report.bugs:
519+
try:
520+
from src.memory.compressor import MemoryCompressor
521+
compressor = MemoryCompressor(memory_store)
522+
compressor.extract_from_report(report)
523+
except Exception as mem_err:
524+
logger.warning("蓝本测试记忆提取失败: {}", mem_err)
525+
526+
from src.api.models import StepDetail, BugDetail
527+
stopped = test_controller.was_stopped
528+
if stopped:
529+
test_controller.reset()
530+
pass_rate = report.passed_steps / report.total_steps * 100 if report.total_steps > 0 else 0
531+
response = TestReportResponse(
532+
test_name=report.test_name,
533+
url=report.url,
534+
total_steps=report.total_steps,
535+
passed_steps=report.passed_steps,
536+
failed_steps=report.failed_steps,
537+
bug_count=len(report.bugs),
538+
pass_rate=pass_rate,
539+
duration_seconds=report.duration_seconds,
540+
report_markdown=report.report_markdown,
541+
stopped=stopped,
542+
steps=[
543+
StepDetail(
544+
step=r.step,
545+
action=r.action.value if hasattr(r.action, 'value') else str(r.action),
546+
description=r.description,
547+
status=r.status.value if hasattr(r.status, 'value') else str(r.status),
548+
duration_seconds=r.duration_seconds,
549+
error_message=r.error_message,
550+
screenshot_path=r.screenshot_path,
551+
)
552+
for r in report.step_results
553+
],
554+
bugs=[
555+
BugDetail(
556+
severity=b.severity.value if hasattr(b.severity, 'value') else str(b.severity),
557+
title=b.title,
558+
description=b.description,
559+
category=b.category,
560+
location=b.location,
561+
step_number=b.step_number,
562+
screenshot_path=b.screenshot_path,
563+
)
564+
for b in report.bugs
565+
],
566+
)
567+
try:
568+
report_dict = response.model_dump()
569+
except AttributeError:
570+
report_dict = response.dict()
571+
await ws_manager.send_test_done(pass_rate, len(report.bugs), full_report=report_dict)
572+
result_holder["data"] = report_dict
573+
except Exception as e:
574+
logger.error("蓝本测试执行失败: {}", e)
575+
result_holder["error"] = str(e)
576+
577+
async def _stream_with_heartbeat():
578+
task = _asyncio.create_task(_run_test())
579+
while not task.done():
580+
await _asyncio.sleep(20)
581+
if not task.done():
582+
yield b" " # 心跳空格,保持HTTP连接活跃
583+
await task # 确保异常被传播
584+
if "error" in result_holder:
585+
import json as _json
586+
yield _json.dumps({"detail": result_holder["error"]}).encode("utf-8")
587+
else:
588+
import json as _json
589+
yield _json.dumps(result_holder.get("data", {})).encode("utf-8")
590+
591+
return _StreamingResponse(
592+
_stream_with_heartbeat(),
593+
media_type="application/json",
594+
)
573595

574596
@router.post("/test/mobile-blueprint", response_model=TestReportResponse, tags=["测试"])
575597
async def run_mobile_blueprint_test(req: RunMobileBlueprintRequest) -> TestReportResponse:

src/testing/blueprint_runner.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,9 @@ async def _execute_step(
523523
await asyncio.sleep(step_def.wait_after_ms / 1000.0)
524524

525525
# 只在蓝本写了screenshot动作 或 有expected需要AI验证时才截图
526-
need_screenshot = (step_def.action == "screenshot") or (step_def.expected and self._ai)
526+
# assert_text 已经用纯文本匹配验证过了,不需要再调AI浪费tokens
527+
need_ai_verify = (step_def.expected and self._ai and step_def.action != "assert_text")
528+
need_screenshot = (step_def.action == "screenshot") or need_ai_verify
527529
if need_screenshot:
528530
screenshot_path_obj = await self._browser.screenshot(f"step{step_num}_{step_def.action}")
529531
screenshot_path = str(screenshot_path_obj)
@@ -537,10 +539,10 @@ async def _execute_step(
537539
except Exception as e:
538540
logger.debug("截图推送失败: {}", e)
539541

540-
# AI视觉验证(如果有预期结果描述)
542+
# AI视觉验证(如果有预期结果描述,但 assert_text 已用文本匹配,跳过
541543
ai_verdict = "passed"
542544
ai_detail = ""
543-
if step_def.expected and self._ai and screenshot_path:
545+
if need_ai_verify and screenshot_path:
544546
ai_verdict, ai_detail = await self._ai_verify(screenshot_path, step_def.expected)
545547

546548
elapsed = time.time() - start

0 commit comments

Comments
 (0)