提交 a9074f8c authored 作者: 陈泽健's avatar 陈泽健

fix(executor): 修复FastAPI端点执行Playwright报asyncio检测错误

- playwright_executor.py: 在工作线程中设置 asyncio.set_event_loop(None) 绕过 Playwright 的 asyncio 事件循环检测
- execution_service.py: 优化 run_all_cases_sync 日志输出,清理调试 print 语句
- CLAUDE.md: 新增踩坑记录 #9,更新 Playwright 正确模式第 5 条
- HANDOFF.md: 更新状态为已修复,重构文档结构

根因:Playwright sync_playwright().start() 内部使用 asyncio.get_running_loop() 检测,
在 FastAPI run_in_executor 工作线程中需将事件循环设为 None 才能绕过检测
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 001a68a9
......@@ -110,6 +110,7 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。**
2. `playwright_executor.py` 使用 `sync_playwright` 同步 API
3. `execution_service.py` 通过 `loop.run_in_executor()` 在线程池中执行
4. **每个测试用例创建独立的 `PlaywrightExecutor` 实例**(共享实例在 `stop()``_is_running=False`,下次 `start()` 会失败)
5. **`playwright_executor.py` 的 `start()` 方法中必须 `asyncio.set_event_loop(None)`**——Playwright 内部用 `asyncio.get_running_loop()` 检测 asyncio 循环,在工作线程中必须将事件循环设为 None 才能绕过检测
### 2. 前端 useRoute() 必须在 setup 顶层
......@@ -145,6 +146,7 @@ Windows 上 Playwright 异步 API 在 FastAPI asyncio 循环中无法启动。**
| 6 | `claude` 命令不在 PATH | Windows 未配全局 PATH | `which claude` 找路径,补 `.cmd` 后缀 |
| 7 | 前端页面空白 | `useRoute()` 在 computed 内调用 | 移到 setup 顶层 |
| 8 | `NOT NULL constraint failed: error_message` | 字段 nullable=False 但成功时为 None | 改为 nullable=True |
| 9 | Playwright Sync API inside asyncio loop | `run_in_executor` 线程中 Playwright 检测到 asyncio 循环 | `asyncio.set_event_loop(None)` 在工作线程中 |
详见 `HANDOFF.md` 第五节。
......
此差异已折叠。
......@@ -11,6 +11,7 @@
import logging
import os
import asyncio
from typing import Optional, Callable, Dict, Any, List
from datetime import datetime
from dataclasses import dataclass, field
......@@ -178,6 +179,18 @@ class PlaywrightExecutor:
RuntimeError: 当启动失败时抛出
"""
try:
# 在 Windows 上,当从 run_in_executor 调用时,需要绕过 asyncio 检测
# Playwright sync_playwright().start() 内部会检测是否有事件循环
# 关键:必须确保当前线程没有关联任何 asyncio 事件循环
# asyncio.get_event_loop() 可能返回主线程的循环,所以必须用
# asyncio.get_running_loop() 检测或直接设置 None
import threading
if threading.current_thread() is not threading.main_thread():
# 非主线程(run_in_executor 环境)
# 将当前线程的事件循环设为 None,这样 Playwright 的检测就不会发现 asyncio 循环
# 注意:不能设为新的 event_loop,因为新循环也可能被 Playwright 检测到
asyncio.set_event_loop(None)
self._playwright = sync_playwright().start()
# 启动浏览器
......@@ -895,8 +908,6 @@ class PlaywrightExecutor:
break
except Exception as e:
import sys as _sys
print(f"===DEBUG step异常: name={step_result.name} action={action} err={type(e).__name__}: {e}===", file=_sys.stderr, flush=True)
last_error = e
step_result.status = "failed"
step_result.error = str(e)
......
......@@ -10,6 +10,7 @@
"""
import logging
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
......@@ -336,3 +337,69 @@ async def get_knowledge_base():
total_vulns=len(historical_vulns),
total_redlines=len(huawei_redlines),
)
# ==================== 报告 ====================
@router.get("/executions/{execution_id}/report", summary="获取安全测试报告")
async def get_security_report(
execution_id: str,
db: AsyncSession = Depends(get_db)
):
"""
获取安全测试 Markdown 报告内容
Args:
execution_id: 执行ID
Returns:
dict: 报告内容
"""
from app.services.security_report_service import SecurityReportService
try:
report_service = SecurityReportService()
markdown = await report_service.get_report_content(execution_id, db)
return {
"execution_id": execution_id,
"content": markdown,
"format": "markdown",
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"获取安全测试报告失败: {e}")
raise HTTPException(status_code=500, detail=f"获取报告失败: {str(e)}")
@router.get("/executions/{execution_id}/report/download", summary="下载安全测试报告")
async def download_security_report(
execution_id: str,
db: AsyncSession = Depends(get_db)
):
"""
下载安全测试 Markdown 报告文件
Args:
execution_id: 执行ID
Returns:
FileResponse: 报告文件
"""
from fastapi.responses import FileResponse
from app.services.security_report_service import SecurityReportService
try:
report_service = SecurityReportService()
file_path = await report_service.generate_report(execution_id, db)
filename = os.path.basename(file_path)
return FileResponse(
file_path,
media_type="text/markdown",
filename=filename,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"下载安全测试报告失败: {e}")
raise HTTPException(status_code=500, detail=f"下载报告失败: {str(e)}")
......@@ -259,6 +259,8 @@ class ExecutionService:
ev_loop: 主事件循环(用于线程安全广播)
broadcast_fn: 异步广播函数(async broadcast)
"""
logger.info(f"[run_all_cases_sync] 开始执行, 用例数: {len(cases_list)}")
executor_config = exec_config or {}
# auto_login 由用例自身配置决定,不强制覆盖
# 透传单步重试次数(默认 2)
......@@ -269,12 +271,14 @@ class ExecutionService:
try:
executor.start()
logger.info(f"[run_all_cases_sync] 执行器启动成功")
# 第一个用例会自动登录
for i, case_dict in enumerate(cases_list):
case_id = case_dict.get("id", "")
case_name = case_dict.get("name", "")
logger.info(f"⏳ 执行用例 {i+1}/{len(cases_list)}: {case_name}")
steps_count = len(case_dict.get("steps", []))
logger.info(f"⏳ 执行用例 {i+1}/{len(cases_list)}: {case_name} (steps={steps_count})")
# 构造线程安全的步骤回调(跨线程提交到事件循环)
def step_callback(step_result, _cid=case_id, _cname=case_name):
......@@ -302,10 +306,16 @@ class ExecutionService:
logger.error(f"✗ 步骤回调提交失败: {str(e)}")
result = executor.execute_case(case=case_dict, callback=step_callback)
logger.info(f"[run_all_cases_sync] 用例 {case_name} 执行完成, status={result.status}")
results.append(result)
logger.info(f"[run_all_cases_sync] 所有用例执行完成, 结果数: {len(results)}")
return results
except Exception as e:
logger.error(f"[run_all_cases_sync] 异常: {type(e).__name__}: {e}")
raise
finally:
logger.info("[run_all_cases_sync] 停止执行器")
executor.stop()
try:
......
......@@ -93,6 +93,18 @@ export function getSecuritySummary(executionId: string) {
)
}
/** 获取安全测试报告内容(Markdown) */
export function getSecurityReport(executionId: string) {
return request.get<{ execution_id: string; content: string; format: string }>(
`/api/security/executions/${executionId}/report`
)
}
/** 安全测试报告下载地址 */
export function getSecurityReportDownloadUrl(executionId: string): string {
return `/api/security/executions/${executionId}/report/download`
}
// ==================== 知识库 ====================
/** 获取安全知识库 */
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论