fix: 第二轮深度调试修复 - useSpeechSynthesis守卫+NPE防护+E2E测试完善

- P0: useSpeechSynthesis.ts cancel()增加isSupported守卫
- P0: iot_provider.go 添加loader nil检查防止NPE panic
- 新增CDP E2E v4测试脚本 14项全绿通过
- 生成第二轮修复报告 docs/debug/2026-05-21-round2-fixes.md
This commit is contained in:
2026-05-22 00:10:37 +08:00
parent a058b0ab8e
commit b15e1c9541
7 changed files with 1236 additions and 8 deletions
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""CDP E2E 综合测试 v3 — 完整端到端验证(登录后测试所有功能)"""
import json, time, urllib.request, sys
from websocket import create_connection
# 配置
BASE_URL = "http://localhost:8080/api/v1"
FRONTEND_URL = "http://localhost:5199"
CDP_URL = "http://127.0.0.1:9225"
CREDENTIALS = {"username": "yeij0942", "password": "Jiang1143218570"}
passed = 0
failed = 0
def check(name, condition, detail=""):
global passed, failed
if condition:
passed += 1
print(f"{name}")
else:
failed += 1
print(f"{name} {detail}")
# ============================================================
# Part A: Backend API Tests
# ============================================================
print("=" * 60)
print("Part A: Backend API 测试")
print("=" * 60)
# A1: Health checks
print("\n--- A1: 微服务健康检查 ---")
for svc, port in [("gateway", 8080), ("ai-core", 8081), ("memory-service", 8091), ("tool-engine", 8092), ("iot-debug", 8083)]:
try:
req = urllib.request.Request(f"http://localhost:{port}/api/v1/health", method="GET")
resp = urllib.request.urlopen(req, timeout=5)
check(f"{svc}:{port} 健康检查", resp.status == 200, f"status={resp.status}")
except Exception as e:
check(f"{svc}:{port} 健康检查", False, str(e)[:60])
# A2: Login
print("\n--- A2: 用户登录 ---")
try:
req = urllib.request.Request(
f"{BASE_URL}/auth/login",
data=json.dumps(CREDENTIALS).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
resp = urllib.request.urlopen(req, timeout=10)
login_data = json.loads(resp.read())
access_token = login_data.get("data", {}).get("token", "")
refresh_token_val = login_data.get("data", {}).get("refresh_token", "")
user_id = login_data.get("data", {}).get("user_id", "")
check("登录成功", bool(access_token), f"token={access_token[:20]}...")
check("获取 refresh_token", bool(refresh_token_val))
check("获取 user_id", bool(user_id), f"user_id={user_id}")
except Exception as e:
check("登录", False, str(e)[:80])
access_token = ""
refresh_token_val = ""
AUTH_HEADER = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
# A3: Token Refresh
if refresh_token_val:
print("\n--- A3: Token 刷新 ---")
try:
req = urllib.request.Request(
f"{BASE_URL}/auth/refresh",
data=json.dumps({"refresh_token": refresh_token_val}).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
resp = urllib.request.urlopen(req, timeout=10)
refresh_data = json.loads(resp.read())
new_token = refresh_data.get("data", {}).get("token", "")
check("Token 刷新成功", bool(new_token), f"new_token={new_token[:20]}...")
if new_token:
access_token = new_token
AUTH_HEADER = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
except Exception as e:
check("Token 刷新", False, str(e)[:80])
# A4: Session CRUD
print("\n--- A4: 会话管理 ---")
session_id = ""
try:
# Create session
req = urllib.request.Request(
f"{BASE_URL}/sessions",
data=json.dumps({"user_id": user_id, "title": "CDP E2E Test"}).encode(),
headers=AUTH_HEADER,
method="POST"
)
resp = urllib.request.urlopen(req, timeout=10)
session_data = json.loads(resp.read())
session_id = session_data.get("data", {}).get("id", "")
check("创建会话", bool(session_id), f"session_id={session_id}")
# List sessions
req = urllib.request.Request(f"{BASE_URL}/sessions?user_id={user_id}", headers=AUTH_HEADER)
resp = urllib.request.urlopen(req, timeout=10)
sessions = json.loads(resp.read())
check("列出会话", isinstance(sessions.get("data"), list), f"count={len(sessions.get('data', []))}")
except Exception as e:
check("会话管理", False, str(e)[:80])
# A5: Memory Search
print("\n--- A5: 记忆搜索 ---")
try:
req = urllib.request.Request(
f"{BASE_URL}/memory/search",
data=json.dumps({"user_id": user_id, "query": "test"}).encode(),
headers=AUTH_HEADER,
method="POST"
)
resp = urllib.request.urlopen(req, timeout=10)
mem_data = json.loads(resp.read())
check("记忆搜索API正常", resp.status in [200, 404], f"status={resp.status}")
except Exception as e:
check("记忆搜索", False, str(e)[:60])
# A6: IoT Devices
print("\n--- A6: IoT 设备 ---")
try:
req = urllib.request.Request(f"{BASE_URL}/iot/devices", headers=AUTH_HEADER)
resp = urllib.request.urlopen(req, timeout=10)
iot_data = json.loads(resp.read())
devices = iot_data.get("data", {}).get("devices", iot_data.get("data", []))
check("IoT设备列表API正常", resp.status in [200, 404], f"status={resp.status}, devices={len(devices) if isinstance(devices, list) else 'N/A'}")
except Exception as e:
check("IoT设备列表", False, str(e)[:60])
print(f"\nBackend 测试结果: {passed} 通过, {failed} 失败")
# ============================================================
# Part B: Frontend CDP E2E Tests
# ============================================================
print("\n" + "=" * 60)
print("Part B: Frontend CDP E2E 测试")
print("=" * 60)
# B1: 创建新的浏览器页面 (about:blank 确保没有 Service Worker 缓存)
print("\n--- B1: 创建新页面 ---")
try:
req = urllib.request.Request(f"{CDP_URL}/json/new?url=about:blank", method="PUT")
resp = urllib.request.urlopen(req, timeout=10)
page = json.loads(resp.read())
ws_url = page["webSocketDebuggerUrl"]
page_id = page.get("id", "")
print(f" Page ID: {page_id}")
check("创建空白页面", bool(ws_url))
except Exception as e:
print(f" ❌ 创建页面失败: {e}")
sys.exit(1)
ws = create_connection(ws_url, timeout=15)
def cdp(method, params=None, msg_id=1):
ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
def recv_all(timeout=3):
ws.settimeout(timeout)
msgs = []
try:
while True:
msgs.append(ws.recv())
except:
pass
return msgs
# Enable domains
cdp("Page.enable", {}, 1)
cdp("Network.enable", {}, 2)
cdp("Runtime.enable", {}, 3)
cdp("Log.enable", {}, 4)
time.sleep(0.5)
recv_all(0.5)
# B2: 导航到前端页面
print("\n--- B2: 导航到前端 ---")
cdp("Page.navigate", {"url": FRONTEND_URL + "/"}, 10)
time.sleep(4)
msgs = recv_all(3)
# 检查 console 日志和错误
has_js_error = False
console_logs = []
for m in msgs:
d = json.loads(m)
method = d.get("method", "")
params = d.get("params", {})
if method == "Runtime.exceptionThrown":
has_js_error = True
exc = params.get("exceptionDetails", {})
print(f" [JS ERROR] {exc.get('text', '')}")
elif method == "Runtime.consoleAPICalled":
args = params.get("args", [])
texts = [str(a.get("value", "") or a.get("description", ""))[:120] for a in args]
console_logs.append(" ".join(texts))
elif method == "Log.entryAdded":
entry = params.get("entry", {})
console_logs.append(f"[{entry.get('level','log')}] {entry.get('text','')[:120]}")
check("页面加载无 JS 异常", not has_js_error)
# B3: 检查 localStorage(应该为空或没有 test-token-cyrene
print("\n--- B3: localStorage 检查 ---")
cdp("Runtime.evaluate", {
"expression": "JSON.stringify({token: localStorage.getItem('token'), user_id: localStorage.getItem('user_id'), cyrene_store_version: localStorage.getItem('cyrene_store_version'), allKeys: Object.keys(localStorage)})"
}, 20)
time.sleep(0.5)
msgs = recv_all(1)
ls_data = {}
for m in msgs:
d = json.loads(m)
if d.get("id") == 20:
val = d.get("result", {}).get("result", {}).get("value", "")
ls_data = json.loads(val) if val else {}
print(f" localStorage: {ls_data}")
has_test_token = ls_data.get("token") == "test-token-cyrene"
check("无 test-token-cyrene", not has_test_token, "发现硬编码token!" if has_test_token else "")
# B4: 检查 DOM 渲染(登录页面应出现)
print("\n--- B4: DOM 检查 ---")
cdp("Runtime.evaluate", {
"expression": "document.querySelector('input') ? 'has_input' : 'no_input'"
}, 30)
time.sleep(0.5)
msgs = recv_all(1)
for m in msgs:
d = json.loads(m)
if d.get("id") == 30:
val = d.get("result", {}).get("result", {}).get("value", "")
check("登录表单已渲染", val == "'has_input'", f"got: {val}")
# 截图
cdp("Page.captureScreenshot", {"format": "png"}, 31)
time.sleep(1)
msgs = recv_all(2)
for m in msgs:
d = json.loads(m)
if d.get("id") == 31:
img_data = d.get("result", {}).get("data", "")
if img_data:
import base64
with open("/tmp/cdp_screenshot_before_login.png", "wb") as f:
f.write(base64.b64decode(img_data))
print(" 截图已保存到 /tmp/cdp_screenshot_before_login.png")
# B5: 通过 API 直接在页面上设置 token(绕过登录UI)
print("\n--- B5: 注入 token 并模拟已登录状态 ---")
if access_token:
cdp("Runtime.evaluate", {
"expression": f"""
localStorage.setItem('token', '{access_token}');
localStorage.setItem('user_id', '{user_id}');
localStorage.setItem('cyrene_store_version', '1');
'token_injected'
"""
}, 40)
time.sleep(0.5)
recv_all(0.5)
# 重新加载页面
cdp("Page.reload", {}, 41)
time.sleep(4)
msgs = recv_all(3)
# 检查网络请求是否使用正确的 token
api_calls = []
console_errors = []
for m in msgs:
d = json.loads(m)
method = d.get("method", "")
params = d.get("params", {})
if method == "Network.requestWillBeSent":
req = params.get("request", {})
url = req.get("url", "")
headers = req.get("headers", {})
auth = headers.get("Authorization", "") or headers.get("authorization", "")
if "/api/" in url:
api_calls.append(f"{req.get('method','?')} {url[:100]} Auth={auth[:30]}...")
elif method == "Runtime.consoleAPICalled":
args = params.get("args", [])
if params.get("type") == "error":
texts = [str(a.get("value", "") or a.get("description", ""))[:150] for a in args]
console_errors.append(" ".join(texts))
elif method == "Runtime.exceptionThrown":
exc = params.get("exceptionDetails", {})
console_errors.append(f"JS Exception: {exc.get('text', '')}")
print(f" API 调用数: {len(api_calls)}")
for c in api_calls[:5]:
print(f" {c}")
check("API 调用使用正确 token", all("test-token-cyrene" not in c for c in api_calls), "仍使用旧token!" if any("test-token-cyrene" in c for c in api_calls) else "")
# 检查 WebSocket 连接状态
ws_connected = any("ws_connect" in c.lower() or "WebSocket" in c for c in console_logs)
has_auth_error = any("401" in e or "认证" in e or "无效" in e for e in console_errors)
check("无认证错误", not has_auth_error, str(console_errors[:2]) if console_errors else "")
# 截图 after login
cdp("Page.captureScreenshot", {"format": "png"}, 42)
time.sleep(1)
msgs = recv_all(2)
for m in msgs:
d = json.loads(m)
if d.get("id") == 42:
img_data = d.get("result", {}).get("data", "")
if img_data:
import base64
with open("/tmp/cdp_screenshot_after_login.png", "wb") as f:
f.write(base64.b64decode(img_data))
print(" 截图已保存到 /tmp/cdp_screenshot_after_login.png")
# B6: DOM 状态检查 - 应该看到侧边栏和聊天界面
print("\n--- B6: 登录后 UI 状态 ---")
checks_js = """
(() => {
const hasSidebar = !!document.querySelector('aside, [class*="sidebar"], [class*="Sidebar"]');
const hasChatInput = !!document.querySelector('textarea, [contenteditable="true"], input[type="text"]');
const hasHeader = !!document.querySelector('header, [class*="header"], [class*="Header"]');
const bodyText = document.body.innerText.substring(0, 200);
return JSON.stringify({hasSidebar, hasChatInput, hasHeader, bodyText});
})()
"""
cdp("Runtime.evaluate", {"expression": checks_js}, 50)
time.sleep(0.5)
msgs = recv_all(1)
for m in msgs:
d = json.loads(m)
if d.get("id") == 50:
val = d.get("result", {}).get("result", {}).get("value", "")
try:
ui = json.loads(val)
print(f" UI状态: sidebar={ui.get('hasSidebar')}, chatInput={ui.get('hasChatInput')}, header={ui.get('hasHeader')}")
print(f" Body文本: {ui.get('bodyText', '')[:100]}")
check("侧边栏已渲染", ui.get("hasSidebar", False))
check("聊天输入框存在", ui.get("hasChatInput", False))
except:
pass
ws.close()
# ============================================================
# Summary
# ============================================================
print("\n" + "=" * 60)
print(f"测试完成: {passed} 通过, {failed} 失败, 总计 {passed+failed}")
print("=" * 60)