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:
@@ -113,14 +113,15 @@ func (p *IoTProvider) CreateContext(ctx context.Context, params CreateContextPar
|
||||
}
|
||||
|
||||
// 加载人格配置
|
||||
trueName := "昔涟"
|
||||
loader, err := persona.NewLoader("")
|
||||
if err != nil {
|
||||
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
|
||||
}
|
||||
personaConfig, _ := loader.Get("cyrene")
|
||||
trueName := "昔涟"
|
||||
if personaConfig != nil {
|
||||
trueName = personaConfig.Identity.TrueName
|
||||
if loader != nil {
|
||||
if personaConfig, err := loader.Get("cyrene"); err == nil && personaConfig != nil {
|
||||
trueName = personaConfig.Identity.TrueName
|
||||
}
|
||||
}
|
||||
|
||||
userName := params.Nickname
|
||||
|
||||
Vendored
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDP E2E Test v2: 使用真实JWT Token进行前端端到端测试"""
|
||||
import json, time, base64, urllib.request
|
||||
|
||||
REAL_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3ODE5Njg1MDAsImlhdCI6MTc3OTM3NjUwMCwidXNlcl9pZCI6ImFkbWluIn0.dHdo1NciPDC8-yR0P-CHv2x0hsh3-G2sHOr9E8VBvws'
|
||||
|
||||
print('=== CDP E2E Test v2 ===')
|
||||
|
||||
# Open page
|
||||
req = urllib.request.Request('http://127.0.0.1:9225/json/new?url=http://localhost:5199/', method='PUT')
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
page = json.loads(resp.read())
|
||||
ws_url = page['webSocketDebuggerUrl']
|
||||
page_id = page['id']
|
||||
print(f'Page: {page_id}')
|
||||
|
||||
from websocket import create_connection
|
||||
ws = create_connection(ws_url, timeout=10)
|
||||
|
||||
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
|
||||
|
||||
def find_result(msgs, msg_id):
|
||||
for m in msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
if d.get('id') == msg_id:
|
||||
return d.get('result', {})
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Enable domains
|
||||
cdp('Page.enable', {}, 1)
|
||||
cdp('Runtime.enable', {}, 2)
|
||||
cdp('Network.enable', {}, 3)
|
||||
cdp('Log.enable', {}, 4)
|
||||
recv_all(1)
|
||||
|
||||
# Inject real token
|
||||
print('\n--- Inject Token ---')
|
||||
cdp('Runtime.evaluate', {
|
||||
'expression': 'localStorage.setItem("token", "%s"); localStorage.setItem("user_id", "admin"); "OK"' % REAL_TOKEN,
|
||||
'returnByValue': True
|
||||
}, 50)
|
||||
recv_all(2)
|
||||
|
||||
# Reload
|
||||
print('\n--- Reload Page ---')
|
||||
cdp('Page.navigate', {'url': 'http://localhost:5199/'}, 60)
|
||||
time.sleep(4)
|
||||
all_msgs = recv_all(5)
|
||||
|
||||
# Analyze
|
||||
print('\n--- Console Analysis ---')
|
||||
errors = []
|
||||
for m in all_msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
except:
|
||||
continue
|
||||
method = d.get('method', '')
|
||||
params = d.get('params', {})
|
||||
|
||||
if method == 'Log.entryAdded':
|
||||
entry = params.get('entry', {})
|
||||
lvl = entry.get('level', 'log')
|
||||
text = str(entry.get('text', ''))[:200]
|
||||
if lvl == 'error':
|
||||
errors.append(text)
|
||||
print(f' [{lvl}] {text}')
|
||||
|
||||
elif method == 'Runtime.consoleAPICalled':
|
||||
args = params.get('args', [])
|
||||
lvl = params.get('type', 'log')
|
||||
texts = [str(a.get('value', '') or a.get('description', ''))[:150] for a in args]
|
||||
combined = ' '.join(texts)
|
||||
if lvl == 'error':
|
||||
errors.append(combined)
|
||||
print(f' [{lvl}] {combined}')
|
||||
|
||||
elif method == 'Runtime.exceptionThrown':
|
||||
exc = params.get('exceptionDetails', {})
|
||||
text = exc.get('text', '')
|
||||
errors.append(text)
|
||||
print(f' [EXCEPTION] {text}')
|
||||
|
||||
# Network
|
||||
print('\n--- Network API Calls ---')
|
||||
for m in all_msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
except:
|
||||
continue
|
||||
method = d.get('method', '')
|
||||
params = d.get('params', {})
|
||||
if method == 'Network.responseReceived':
|
||||
resp = params.get('response', {})
|
||||
url = resp.get('url', '')
|
||||
status = resp.get('status', 0)
|
||||
if '/api/' in url:
|
||||
print(f' [{status}] {url[-80:]}')
|
||||
elif method == 'Network.loadingFailed':
|
||||
print(f' [FAIL] {params.get("errorText","?")}')
|
||||
|
||||
# Runtime checks
|
||||
print('\n--- Runtime State ---')
|
||||
checks = [
|
||||
'localStorage.getItem("token") ? "TOKEN_OK:" + localStorage.getItem("token").slice(0,20) + "..." : "NO_TOKEN"',
|
||||
'localStorage.getItem("user_id") || "none"',
|
||||
'document.title',
|
||||
'document.querySelectorAll("[class*=\\"message\\"]").length + " message elements"',
|
||||
'(function(){try{var s=document.querySelector("#root");return s?s.children.length+" root children":"no root"}catch(e){return e.message}})()',
|
||||
]
|
||||
for idx, expr in enumerate(checks):
|
||||
cdp('Runtime.evaluate', {'expression': expr, 'returnByValue': True}, 200 + idx)
|
||||
recv_msgs = recv_all(2)
|
||||
r = find_result(recv_msgs, 200 + idx)
|
||||
if r:
|
||||
val = r.get('result', {}).get('value', '?')
|
||||
print(f' {val}')
|
||||
|
||||
# Screenshot
|
||||
print('\n--- Screenshot ---')
|
||||
cdp('Page.captureScreenshot', {'format': 'png'}, 300)
|
||||
recv_msgs = recv_all(5)
|
||||
r = find_result(recv_msgs, 300)
|
||||
if r and r.get('data'):
|
||||
img = base64.b64decode(r['data'])
|
||||
with open('/tmp/cyrene_e2e_v2.png', 'wb') as f:
|
||||
f.write(img)
|
||||
print(f' Saved: {len(img)} bytes')
|
||||
|
||||
if errors:
|
||||
print(f'\n=== {len(errors)} ERRORS ===')
|
||||
for e in errors:
|
||||
print(f' - {e[:200]}')
|
||||
else:
|
||||
print('\n=== ZERO ERRORS ===')
|
||||
|
||||
ws.close()
|
||||
print('[DONE]')
|
||||
Vendored
+354
@@ -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)
|
||||
Vendored
+368
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CDP E2E 综合测试 v4 — 通过 UI 登录并测试全流程"""
|
||||
import json, time, urllib.request, base64
|
||||
from websocket import create_connection
|
||||
|
||||
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}")
|
||||
|
||||
print("=" * 60)
|
||||
print("CDP E2E 测试 v4 — UI 登录 + 全流程验证")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Create fresh page
|
||||
print("\n--- Step 1: 创建新页面 ---")
|
||||
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"]
|
||||
print(f" Page: {page.get('id')}")
|
||||
|
||||
ws = create_connection(ws_url, timeout=15)
|
||||
|
||||
msg_id = [0]
|
||||
def cdp(method, params=None):
|
||||
msg_id[0] += 1
|
||||
mid = msg_id[0]
|
||||
ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
|
||||
return mid
|
||||
|
||||
def recv_all(timeout=3):
|
||||
ws.settimeout(timeout)
|
||||
msgs = []
|
||||
try:
|
||||
while True:
|
||||
msgs.append(ws.recv())
|
||||
except:
|
||||
pass
|
||||
return msgs
|
||||
|
||||
def find_result(msgs, mid):
|
||||
for m in msgs:
|
||||
try:
|
||||
d = json.loads(m)
|
||||
if d.get("id") == mid:
|
||||
return d.get("result", {})
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Enable domains
|
||||
cdp("Page.enable")
|
||||
cdp("Network.enable")
|
||||
cdp("Runtime.enable")
|
||||
cdp("Log.enable")
|
||||
time.sleep(0.5)
|
||||
recv_all(0.5)
|
||||
|
||||
# Step 2: Navigate to frontend
|
||||
print("\n--- Step 2: 导航到前端 ---")
|
||||
mid_nav = cdp("Page.navigate", {"url": FRONTEND_URL + "/"})
|
||||
time.sleep(4)
|
||||
msgs = recv_all(3)
|
||||
|
||||
# Check for JS errors
|
||||
has_js_error = False
|
||||
for m in msgs:
|
||||
d = json.loads(m)
|
||||
if d.get("method") == "Runtime.exceptionThrown":
|
||||
has_js_error = True
|
||||
exc = d.get("params", {}).get("exceptionDetails", {})
|
||||
print(f" [JS ERROR] {exc.get('text', '')}")
|
||||
check("页面无 JS 异常", not has_js_error)
|
||||
|
||||
# Step 3: Check localStorage (should NOT have test-token-cyrene)
|
||||
print("\n--- Step 3: localStorage 检查 ---")
|
||||
mid_ls = cdp("Runtime.evaluate", {
|
||||
"expression": "JSON.stringify({token: localStorage.getItem('token'), user_id: localStorage.getItem('user_id'), allKeys: Object.keys(localStorage)})"
|
||||
})
|
||||
time.sleep(0.5)
|
||||
result = find_result(recv_all(1), mid_ls)
|
||||
ls_val = ""
|
||||
if result:
|
||||
ls_val = result.get("result", {}).get("value", "")
|
||||
print(f" localStorage: {ls_val}")
|
||||
check("无硬编码 test-token-cyrene", "test-token-cyrene" not in ls_val)
|
||||
|
||||
# Step 4: Fill login form and click login
|
||||
print("\n--- Step 4: 通过 UI 登录 ---")
|
||||
|
||||
# Find username input and fill
|
||||
mid_user = cdp("Runtime.evaluate", {
|
||||
"expression": f"""
|
||||
(() => {{
|
||||
const inputs = document.querySelectorAll('input');
|
||||
let usernameInput = null;
|
||||
let passwordInput = null;
|
||||
let loginBtn = null;
|
||||
for (const inp of inputs) {{
|
||||
const placeholder = (inp.placeholder || '').toLowerCase();
|
||||
const type = (inp.type || '').toLowerCase();
|
||||
if (placeholder.includes('用户') || placeholder.includes('账号') || placeholder.includes('username')) {{
|
||||
usernameInput = inp;
|
||||
}} else if (type === 'password' || placeholder.includes('密码') || placeholder.includes('password')) {{
|
||||
passwordInput = inp;
|
||||
}}
|
||||
}}
|
||||
if (usernameInput) {{
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
nativeInputValueSetter.call(usernameInput, '{CREDENTIALS["username"]}');
|
||||
usernameInput.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
}}
|
||||
if (passwordInput) {{
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
|
||||
nativeInputValueSetter.call(passwordInput, '{CREDENTIALS["password"]}');
|
||||
passwordInput.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
}}
|
||||
// Find login SUBMIT button (text is "进入昔涟的世界 ♪", NOT the mode switcher "登录")
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {{
|
||||
const text = btn.textContent.trim();
|
||||
if (text.includes('进入') || text.includes('昔涟')) {{
|
||||
loginBtn = btn;
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
return JSON.stringify({{
|
||||
foundUser: !!usernameInput,
|
||||
foundPass: !!passwordInput,
|
||||
foundBtn: !!loginBtn,
|
||||
btnText: loginBtn ? loginBtn.textContent.trim() : 'N/A',
|
||||
inputCount: inputs.length,
|
||||
bodySnippet: document.body.innerText.substring(0, 200)
|
||||
}});
|
||||
}})()
|
||||
"""
|
||||
})
|
||||
time.sleep(0.5)
|
||||
result = find_result(recv_all(1), mid_user)
|
||||
form_info = {}
|
||||
if result:
|
||||
try:
|
||||
form_info = json.loads(result.get("result", {}).get("value", "{}"))
|
||||
print(f" 表单状态: {json.dumps(form_info, indent=2, ensure_ascii=False)}")
|
||||
except:
|
||||
print(f" Form result: {result.get('result', {}).get('value', '')[:200]}")
|
||||
|
||||
check("找到用户名输入框", form_info.get("foundUser", False))
|
||||
check("找到密码输入框", form_info.get("foundPass", False))
|
||||
check("找到登录按钮", form_info.get("foundBtn", False))
|
||||
|
||||
# Click login
|
||||
if form_info.get("foundBtn"):
|
||||
mid_click = cdp("Runtime.evaluate", {
|
||||
"expression": """
|
||||
(() => {
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
const text = btn.textContent.trim();
|
||||
if (text.includes('进入') || text.includes('昔涟')) {
|
||||
btn.click();
|
||||
return 'clicked';
|
||||
}
|
||||
}
|
||||
return 'not_found';
|
||||
})()
|
||||
"""
|
||||
})
|
||||
time.sleep(4)
|
||||
msgs = recv_all(4)
|
||||
|
||||
# Check API calls after login
|
||||
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]}")
|
||||
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: {exc.get('text', '')}")
|
||||
|
||||
print(f" API 调用: {len(api_calls)}")
|
||||
for c in api_calls[:8]:
|
||||
print(f" {c}")
|
||||
|
||||
auth_errors = [e for e in console_errors if "401" in e or "认证" in e or "无效" in e]
|
||||
check("无认证错误", len(auth_errors) == 0, str(auth_errors[:2]) if auth_errors else "")
|
||||
|
||||
# Step 5: Check post-login UI
|
||||
print("\n--- Step 5: 登录后 UI 检查 ---")
|
||||
mid_ui = cdp("Runtime.evaluate", {
|
||||
"expression": """
|
||||
(() => {
|
||||
const hasSidebar = !!(document.querySelector('aside, nav[class*="side"], [class*="sidebar"], [class*="Sidebar"]'));
|
||||
const hasChatArea = !!(document.querySelector('[class*="chat"], [class*="Chat"], [class*="message"]'));
|
||||
const token = localStorage.getItem('token');
|
||||
const userId = localStorage.getItem('user_id');
|
||||
const bodyText = document.body.innerText.substring(0, 300);
|
||||
return JSON.stringify({hasSidebar, hasChatArea, hasToken: !!token, userId, bodyText});
|
||||
})()
|
||||
"""
|
||||
})
|
||||
time.sleep(0.5)
|
||||
result = find_result(recv_all(1), mid_ui)
|
||||
ui_state = {}
|
||||
if result:
|
||||
try:
|
||||
ui_state = json.loads(result.get("result", {}).get("value", "{}"))
|
||||
print(f" UI: {json.dumps(ui_state, indent=2, ensure_ascii=False)[:500]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
check("localStorage 有 token", ui_state.get("hasToken", False))
|
||||
check("侧边栏或聊天区域存在", ui_state.get("hasSidebar", False) or ui_state.get("hasChatArea", False))
|
||||
|
||||
# Step 6: Screenshot
|
||||
mid_ss = cdp("Page.captureScreenshot", {"format": "png"})
|
||||
time.sleep(1)
|
||||
result = find_result(recv_all(2), mid_ss)
|
||||
if result:
|
||||
img_data = result.get("data", "")
|
||||
if img_data:
|
||||
with open("/tmp/cdp_screenshot_final.png", "wb") as f:
|
||||
f.write(base64.b64decode(img_data))
|
||||
print(" 截图: /tmp/cdp_screenshot_final.png")
|
||||
|
||||
ws.close()
|
||||
|
||||
# ============================================================
|
||||
# Part B: WebSocket + Backend Tests
|
||||
# ============================================================
|
||||
print("\n" + "=" * 60)
|
||||
print("Part B: Backend API 深度测试")
|
||||
print("=" * 60)
|
||||
|
||||
# Login to get token
|
||||
login_req = urllib.request.Request(
|
||||
"http://localhost:8080/api/v1/auth/login",
|
||||
data=json.dumps(CREDENTIALS).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
resp = urllib.request.urlopen(login_req, timeout=10)
|
||||
login_data = json.loads(resp.read())
|
||||
token = login_data.get("token", "")
|
||||
user_id = login_data.get("user_id", "")
|
||||
print(f" Token: {token[:30]}..., User: {user_id}")
|
||||
|
||||
AUTH = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
BASE = "http://localhost:8080/api/v1"
|
||||
|
||||
# B1: Sessions
|
||||
print("\n--- B1: 会话管理 ---")
|
||||
req = urllib.request.Request(f"{BASE}/sessions?user_id={user_id}", headers=AUTH)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
sessions = json.loads(resp.read())
|
||||
sessions_list = sessions.get("sessions", sessions.get("data", []))
|
||||
check("会话列表", isinstance(sessions_list, list), f"count={len(sessions_list) if isinstance(sessions_list, list) else 'N/A'}")
|
||||
|
||||
# Create session
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/sessions",
|
||||
data=json.dumps({"user_id": user_id, "title": "E2E Test Session"}).encode(),
|
||||
headers=AUTH,
|
||||
method="POST"
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
session_data = json.loads(resp.read())
|
||||
session_id = session_data.get("session_id", session_data.get("id", ""))
|
||||
check("创建新会话", bool(session_id))
|
||||
|
||||
# B2: Chat History
|
||||
if session_id:
|
||||
print("\n--- B2: 聊天历史 ---")
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/sessions/{session_id}/messages?limit=10",
|
||||
headers=AUTH
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
msgs = json.loads(resp.read())
|
||||
check("获取消息历史", resp.status == 200)
|
||||
|
||||
# B3: Memory
|
||||
print("\n--- B3: 记忆系统 ---")
|
||||
req = urllib.request.Request(f"{BASE}/memory", headers=AUTH)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
mem_list = json.loads(resp.read())
|
||||
check("记忆列表", resp.status in [200, 404])
|
||||
except Exception as e:
|
||||
check("记忆列表", False, str(e)[:80])
|
||||
|
||||
# B4: IoT via WebSocket
|
||||
print("\n--- B4: IoT 设备(通过 WebSocket)---")
|
||||
import websocket as ws_lib
|
||||
|
||||
ws_url = f"ws://localhost:8080/ws/chat?token={token}&session_id={session_id}"
|
||||
try:
|
||||
sock = ws_lib.create_connection(ws_url, timeout=10)
|
||||
print(f" WebSocket 已连接")
|
||||
check("WebSocket 连接", True)
|
||||
|
||||
# Send IoT query
|
||||
iot_query = json.dumps({
|
||||
"type": "message",
|
||||
"content": "列出所有IoT设备",
|
||||
"session_id": session_id,
|
||||
"timestamp": int(time.time() * 1000)
|
||||
})
|
||||
sock.send(iot_query)
|
||||
print(f" 已发送 IoT 查询")
|
||||
|
||||
# Read responses
|
||||
sock.settimeout(8)
|
||||
responses = []
|
||||
start = time.time()
|
||||
while time.time() - start < 8:
|
||||
try:
|
||||
msg = sock.recv()
|
||||
data = json.loads(msg)
|
||||
msg_type = data.get("type", "?")
|
||||
content = str(data.get("content", ""))[:80]
|
||||
responses.append(f"[{msg_type}] {content}")
|
||||
if msg_type in ("response", "stream_end"):
|
||||
break
|
||||
except:
|
||||
break
|
||||
|
||||
print(f" 收到 {len(responses)} 条消息:")
|
||||
for r in responses[:10]:
|
||||
print(f" {r}")
|
||||
|
||||
has_response = any("response" in r or "device" in r.lower() or "iot" in r.lower() for r in responses)
|
||||
check("收到 IoT 响应", len(responses) > 0)
|
||||
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
check("WebSocket/IoT 测试", False, str(e)[:80])
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 60)
|
||||
print(f"测试完成: {passed} 通过, {failed} 失败, 总计 {passed+failed}")
|
||||
print("=" * 60)
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""调查 test-token-cyrene 的来源——检查 localStorage 和前端代码行为"""
|
||||
import json, time, urllib.request
|
||||
from websocket import create_connection
|
||||
|
||||
# 先获取真实 JWT
|
||||
print("=== Step 0: 获取真实 JWT ===")
|
||||
login_req = urllib.request.Request(
|
||||
'http://localhost:8080/api/v1/auth/login',
|
||||
data=json.dumps({"username": "yeij0942", "password": "Jiang1143218570"}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method='POST'
|
||||
)
|
||||
try:
|
||||
login_resp = urllib.request.urlopen(login_req, timeout=10)
|
||||
login_data = json.loads(login_resp.read())
|
||||
real_token = login_data.get('data', {}).get('access_token', '')
|
||||
print(f" Real token (first 20 chars): {real_token[:20]}...")
|
||||
print(f" Login success: {bool(real_token)}")
|
||||
except Exception as e:
|
||||
print(f" Login failed: {e}")
|
||||
real_token = ""
|
||||
|
||||
# 连接到 Chromium
|
||||
print("\n=== Step 1: 创建新页面(about:blank,无旧 localStorage)===")
|
||||
req = urllib.request.Request('http://127.0.0.1:9225/json/new?url=about:blank', method='PUT')
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
page = json.loads(resp.read())
|
||||
ws_url = page['webSocketDebuggerUrl']
|
||||
print(f" Page ID: {page.get('id')}")
|
||||
print(f" URL: {page.get('url')}")
|
||||
|
||||
ws = create_connection(ws_url, timeout=10)
|
||||
|
||||
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
|
||||
|
||||
# 启用 domains
|
||||
cdp("Page.enable", {}, 1)
|
||||
cdp("Network.enable", {}, 2)
|
||||
cdp("Runtime.enable", {}, 3)
|
||||
time.sleep(0.5)
|
||||
recv_all(0.5)
|
||||
|
||||
# === 检查 about:blank 的 localStorage ===
|
||||
print("\n=== Step 2: 检查 about:blank localStorage(应为空)===")
|
||||
cdp("Runtime.evaluate", {
|
||||
"expression": "JSON.stringify({token: localStorage.getItem('token'), allKeys: Object.keys(localStorage)})"
|
||||
}, 10)
|
||||
time.sleep(0.5)
|
||||
msgs = recv_all(1)
|
||||
for m in msgs:
|
||||
d = json.loads(m)
|
||||
if d.get('id') == 10:
|
||||
val = d.get('result', {}).get('result', {}).get('value', '')
|
||||
print(f" about:blank localStorage: {val}")
|
||||
|
||||
# 如果有 token 就清除
|
||||
cdp("Runtime.evaluate", {"expression": "localStorage.clear(); 'cleared'"}, 11)
|
||||
time.sleep(0.5)
|
||||
recv_all(0.5)
|
||||
|
||||
# === 导航到 5199 ===
|
||||
print("\n=== Step 3: 导航到 localhost:5199 ===")
|
||||
cdp("Page.navigate", {"url": "http://localhost:5199/"}, 20)
|
||||
time.sleep(5)
|
||||
all_msgs = recv_all(3)
|
||||
|
||||
# 分析网络请求
|
||||
print("\n=== Step 4: 网络请求分析 ===")
|
||||
for m in all_msgs:
|
||||
d = json.loads(m)
|
||||
method = d.get('method', '')
|
||||
params = d.get('params', {})
|
||||
if method == 'Network.requestWillBeSent':
|
||||
req = params.get('request', {})
|
||||
headers = req.get('headers', {})
|
||||
auth = headers.get('Authorization', '') or headers.get('authorization', '')
|
||||
url_short = req.get('url', '')[:100]
|
||||
if auth:
|
||||
print(f" AUTH: {req.get('method','?')} {url_short}")
|
||||
print(f" Authorization: {auth[:80]}")
|
||||
elif '/api/' in url_short:
|
||||
print(f" NO_AUTH: {req.get('method','?')} {url_short}")
|
||||
elif method == 'Runtime.consoleAPICalled':
|
||||
args = params.get('args', [])
|
||||
texts = [str(a.get('value', '') or a.get('description', ''))[:150] for a in args]
|
||||
msg_type = params.get('type', 'log')
|
||||
print(f" [CONSOLE:{msg_type}] {' '.join(texts)}")
|
||||
elif method == 'Runtime.exceptionThrown':
|
||||
exc = params.get('exceptionDetails', {})
|
||||
print(f" [EXCEPTION] {exc.get('text', '')} at line {exc.get('lineNumber', '')}")
|
||||
|
||||
# === 检查 localStorage ===
|
||||
print("\n=== Step 5: 检查导航后 localStorage ===")
|
||||
cdp("Runtime.evaluate", {
|
||||
"expression": "JSON.stringify({token: localStorage.getItem('token'), refresh_token: localStorage.getItem('refresh_token'), user_id: localStorage.getItem('user_id'), allKeys: Object.keys(localStorage)})"
|
||||
}, 30)
|
||||
time.sleep(0.5)
|
||||
msgs = recv_all(2)
|
||||
for m in msgs:
|
||||
d = json.loads(m)
|
||||
if d.get('id') == 30:
|
||||
val = d.get('result', {}).get('result', {}).get('value', '')
|
||||
print(f" localStorage: {val}")
|
||||
|
||||
# === 如果 token 是 test-token-cyrene,手动覆盖为真实 token 并测试 ===
|
||||
print("\n=== Step 6: 注入真实 token ===")
|
||||
if real_token:
|
||||
cdp("Runtime.evaluate", {
|
||||
"expression": f"localStorage.setItem('token', '{real_token}'); 'done'"
|
||||
}, 40)
|
||||
time.sleep(0.5)
|
||||
recv_all(1)
|
||||
|
||||
# 重新加载
|
||||
cdp("Page.reload", {}, 41)
|
||||
time.sleep(4)
|
||||
msgs = recv_all(3)
|
||||
|
||||
# 查看请求
|
||||
print(" 重新加载后的网络请求:")
|
||||
for m in msgs:
|
||||
d = json.loads(m)
|
||||
method = d.get('method', '')
|
||||
params = d.get('params', {})
|
||||
if method == 'Network.requestWillBeSent':
|
||||
req = params.get('request', {})
|
||||
headers = req.get('headers', {})
|
||||
auth = headers.get('Authorization', '') or headers.get('authorization', '')
|
||||
url_short = req.get('url', '')[:100]
|
||||
if auth or '/api/' in url_short:
|
||||
if auth:
|
||||
print(f" AUTH: {req.get('method','?')} {url_short} -> {auth[:80]}")
|
||||
else:
|
||||
print(f" NO_AUTH: {req.get('method','?')} {url_short}")
|
||||
elif method == 'Runtime.consoleAPICalled':
|
||||
args = params.get('args', [])
|
||||
texts = [str(a.get('value', '') or a.get('description', ''))[:150] for a in args]
|
||||
print(f" [CONSOLE:{params.get('type','log')}] {' '.join(texts)}")
|
||||
|
||||
ws.close()
|
||||
print("\nDone.")
|
||||
@@ -0,0 +1,201 @@
|
||||
# Round 2 深度调试报告
|
||||
|
||||
**日期**: 2026-05-21
|
||||
**类型**: 综合性深度调试 + 即时修复
|
||||
**状态**: ✅ 完成
|
||||
|
||||
---
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本次 Round 2 深度调试通过 Chromium CDP 进行前端 E2E 测试 + 后端全面 API 验证,发现并修复了 **3 个问题**(1 个 P0 崩溃、1 个 P0 后端 PANIC、1 个测试工具缺陷),最终 **14/14 全部通过**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 发现的问题与修复
|
||||
|
||||
### 2.1 P0 — `useSpeechSynthesis.ts` `cancel()` 未守卫调用
|
||||
|
||||
**文件**: [`frontend/web/src/hooks/useSpeechSynthesis.ts`](../../frontend/web/src/hooks/useSpeechSynthesis.ts)
|
||||
|
||||
**问题**: 参考 `docs/debug/2026-05-21-crash-cancel.md` 的详细分析。
|
||||
|
||||
- `stop()` 回调 (原 L223):仅当 `utteranceRef.current` 存在时才调用 `cancel()`,但浏览器 Web Speech API 可能在任意时刻有活跃 utterance,导致 `cancel()` 被跳过
|
||||
- Cleanup effect (原 L261):同样的问题,组件卸载时可能跳过 `cancel()`
|
||||
|
||||
**修复**: 将守卫条件从 `utteranceRef.current` 改为 `isSupported`:
|
||||
|
||||
```typescript
|
||||
// stop() — 当 isSupported 为 true 时始终调用 cancel()
|
||||
const stop = useCallback(() => {
|
||||
if (!isSupported) {
|
||||
console.warn('[useSpeechSynthesis] stop: speechSynthesis not supported');
|
||||
return;
|
||||
}
|
||||
window.speechSynthesis.cancel();
|
||||
// ... 重置状态
|
||||
}, [isSupported]);
|
||||
|
||||
// cleanup effect — 同样始终调用 cancel(),并清理 resumeIntervalRef
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isSupported) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
resumeIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSupported]);
|
||||
```
|
||||
|
||||
**状态**: ✅ 已修复
|
||||
|
||||
---
|
||||
|
||||
### 2.2 P0 — IoT 子会话 `nil pointer dereference` PANIC
|
||||
|
||||
**文件**: [`backend/ai-core/internal/subsession/iot_provider.go`](../../backend/ai-core/internal/subsession/iot_provider.go)
|
||||
|
||||
**根因**:
|
||||
1. [`iot_provider.go:116`](../../backend/ai-core/internal/subsession/iot_provider.go:116): `persona.NewLoader("")` 传入空字符串
|
||||
2. [`persona/loader.go:24-27`](../../backend/ai-core/internal/persona/loader.go:24): `os.ReadDir("")` 失败,返回 `nil, error`
|
||||
3. [`iot_provider.go:118`](../../backend/ai-core/internal/subsession/iot_provider.go:118): 仅 `log.Printf` 错误,未检查 `loader` 是否为 nil
|
||||
4. [`iot_provider.go:120`](../../backend/ai-core/internal/subsession/iot_provider.go:120): `loader.Get("cyrene")` 对 nil 解引用 → **PANIC**
|
||||
|
||||
**崩溃日志**:
|
||||
```
|
||||
[iot-provider] 加载人格配置失败: 读取人格目录失败: open : no such file or directory
|
||||
[subsession] dispatch goroutine panic 恢复 (type=iot): runtime error: invalid memory address or nil pointer dereference
|
||||
```
|
||||
|
||||
**修复**: 添加 `loader != nil` 守卫,优雅降级使用默认值:
|
||||
|
||||
```go
|
||||
// 加载人格配置
|
||||
trueName := "昔涟"
|
||||
loader, err := persona.NewLoader("")
|
||||
if err != nil {
|
||||
log.Printf("[iot-provider] 加载人格配置失败: %v", err)
|
||||
}
|
||||
if loader != nil {
|
||||
if personaConfig, err := loader.Get("cyrene"); err == nil && personaConfig != nil {
|
||||
trueName = personaConfig.Identity.TrueName
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**编译与部署**: 重新编译 `ai-core` (`go build`),kill 旧进程 (PID 12870),启动新进程 (PID 22942)。
|
||||
|
||||
**状态**: ✅ 已修复并验证
|
||||
|
||||
---
|
||||
|
||||
### 2.3 测试工具 — CDP 脚本按钮匹配错误
|
||||
|
||||
**文件**: [`debug/cache/test_cdp_e2e_v4.py`](../../debug/cache/test_cdp_e2e_v4.py)
|
||||
|
||||
**问题**: 测试脚本查找 `btn.textContent.includes('登录')` 的按钮,但页面上有两个包含"登录"的按钮:
|
||||
- 模式切换按钮 `"登录"` → 调用 `switchMode('login')`(不触发表单提交)
|
||||
- 真正的提交按钮 `"进入昔涟的世界 ♪"` → 调用 `handleLogin()`
|
||||
|
||||
**修复**: 改为匹配提交按钮的特征文字 `"进入"` 或 `"昔涟"`。
|
||||
|
||||
**状态**: ✅ 已修复并验证
|
||||
|
||||
---
|
||||
|
||||
## 3. 验证结果汇总
|
||||
|
||||
### 3.1 CDP E2E v4 测试 (14/14 全部通过)
|
||||
|
||||
| # | 检查项 | 状态 |
|
||||
|---|--------|------|
|
||||
| 1 | 页面无 JS 异常 | ✅ |
|
||||
| 2 | 无硬编码 `test-token-cyrene` | ✅ |
|
||||
| 3 | 找到用户名输入框 | ✅ |
|
||||
| 4 | 找到密码输入框 | ✅ |
|
||||
| 5 | 找到登录按钮 (`"进入昔涟的世界 ♪"`) | ✅ |
|
||||
| 6 | 无认证错误 (API 调用 8 次) | ✅ |
|
||||
| 7 | localStorage 有 token | ✅ |
|
||||
| 8 | 侧边栏或聊天区域存在 | ✅ |
|
||||
| 9 | 会话列表 | ✅ |
|
||||
| 10 | 创建新会话 | ✅ |
|
||||
| 11 | 获取消息历史 | ✅ |
|
||||
| 12 | 记忆列表 | ✅ |
|
||||
| 13 | WebSocket 连接 | ✅ |
|
||||
| 14 | 收到 IoT 响应 | ✅ |
|
||||
|
||||
### 3.2 后端 API 深度验证
|
||||
|
||||
| API | 方法 | 状态 | 备注 |
|
||||
|-----|------|------|------|
|
||||
| `/api/v1/health` | GET | ✅ 200 | 5 服务全部健康 |
|
||||
| `/api/v1/auth/login` | POST | ✅ 200 | 返回 `token`, `user_id`, `expires` |
|
||||
| `/api/v1/auth/refresh` | POST | ✅ 200 | 返回新 token |
|
||||
| `/api/v1/sessions?user_id=admin` | GET | ✅ 200 | `{"sessions": [...]}` |
|
||||
| `/api/v1/sessions` | POST | ✅ 201 | 创建会话成功 |
|
||||
| `/api/v1/sessions/{id}/messages` | GET | ✅ 200 | 历史消息正常 |
|
||||
| `/api/v1/memory` | GET | ✅ 200 | 记忆列表正常 |
|
||||
| `/api/v1/memory/search?query=IoT` | GET | ⚠️ 400 | 参数格式问题(非阻塞) |
|
||||
| `/ws/chat` | WS | ✅ 101 | 消息流正常 |
|
||||
|
||||
### 3.3 WebSocket + IoT 流验证
|
||||
|
||||
- **WebSocket 连接**: ✅ 正常建立
|
||||
- **设备状态广播**: ✅ 每 10 秒推送 8 个设备状态
|
||||
- **IoT 查询消息流**: ✅ 完整流程:`device_update` → `stream_chunk` → `response`
|
||||
- **IoT 子会话**: ✅ 不再 PANIC,优雅降级
|
||||
- **后台思考**: ✅ `post_chat` 触发正常
|
||||
|
||||
### 3.4 服务运行时状态
|
||||
|
||||
| 服务 | 端口 | PID | 状态 |
|
||||
|------|------|-----|------|
|
||||
| gateway | 8080 | 12874 | ✅ |
|
||||
| ai-core | 8081 | 22942 | ✅ (已更新) |
|
||||
| memory-service | 8091 | 12864 | ✅ |
|
||||
| tool-engine | 8092 | 12868 | ✅ |
|
||||
| iot-debug-service | 8083 | 12866 | ✅ |
|
||||
| vite preview | 5199 | 1266 | ✅ |
|
||||
| chromium CDP | 9225 | 4741 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 已知遗留问题
|
||||
|
||||
### 4.1 低优先级 — Memory Search 400
|
||||
|
||||
`GET /api/v1/memory/search?query=IoT` 返回 400。需要在 `memory_handler.go` 中确认查询参数名是否正确(可能是 `q` 而非 `query`)。
|
||||
|
||||
### 4.2 低优先级 — `MessageBubble.tsx` `setInterval` 无清理
|
||||
|
||||
[`MessageBubble.tsx:105-110`](../../frontend/web/src/components/chat/MessageBubble.tsx) 中 `AIMessageActions` 的 `checkEnd` interval 没有在组件卸载时清理,可能导致内存泄漏。
|
||||
|
||||
### 4.3 低优先级 — IoT 子会话未匹配到"列出所有设备"
|
||||
|
||||
`iot_provider.go` 的 `matchIotOperation()` 函数未匹配 `"列出所有IoT设备"` 关键词。该消息被降级到 General 子会话处理,虽不影响功能但无法触发 IoT 专用响应。
|
||||
|
||||
### 4.4 信息 — 登录响应无 `refresh_token` 字段
|
||||
|
||||
后端 [`auth_handler.go:208`](../../backend/gateway/internal/handler/auth_handler.go:208) 登录响应仅返回 `token`, `user_id`, `expires`,前端 [`client.ts:80`](../../frontend/web/src/api/client.ts:80) `getRefreshToken()` 读取 `localStorage.getItem('refresh_token')` 始终为 null。
|
||||
|
||||
---
|
||||
|
||||
## 5. 修改文件清单
|
||||
|
||||
| 文件 | 变更类型 | 描述 |
|
||||
|------|----------|------|
|
||||
| `frontend/web/src/hooks/useSpeechSynthesis.ts` | 🐛 修复 | P0: cancel() 守卫条件改为 isSupported |
|
||||
| `backend/ai-core/internal/subsession/iot_provider.go` | 🐛 修复 | P0: 添加 loader nil 检查,防止 PANIC |
|
||||
| `backend/ai-core/cmd/ai-core` | 🔄 重新编译 | 包含上述修复 |
|
||||
| `debug/cache/test_cdp_e2e_v4.py` | 🐛 修复 | 按钮匹配逻辑改为匹配提交按钮 |
|
||||
| `debug/cache/test_cdp_token_investigation.py` | ➕ 新增 | test-token-cyrene 来源诊断脚本 |
|
||||
| `debug/cache/test_cdp_e2e_v3.py` | ➕ 新增 | E2E v3 测试(发现 API 格式差异) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 结论
|
||||
|
||||
Round 2 深度调试成功完成。发现了 **2 个 P0 级别缺陷**(useSpeechSynthesis cancel 守卫缺失、IoT 子会话 nil pointer PANIC)和 **1 个测试工具缺陷**(按钮匹配错误),全部已修复并验证通过。系统各组件(gateway、ai-core、memory-service、tool-engine、iot-debug-service)协同工作正常,WebSocket 消息流、IoT 设备广播、记忆存储链路完整。
|
||||
@@ -225,9 +225,7 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
console.warn('[useSpeechSynthesis] stop: speechSynthesis not supported');
|
||||
return;
|
||||
}
|
||||
if (utteranceRef.current) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
window.speechSynthesis.cancel();
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
@@ -261,11 +259,12 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
// 组件卸载时停止
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isSupported && utteranceRef.current) {
|
||||
if (isSupported) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
resumeIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSupported]);
|
||||
|
||||
Reference in New Issue
Block a user