# -*- coding: utf-8 -*- """时间线 DOM 冒烟测试(真实 WebEngine 页面内执行): 1. 流式事件序列 → 思考/正文/工具按事件顺序穿插 2. 工具 chip 按 call_id 对号入座 + ok 状态(修复反转 bug) 3. SVG 箭头存在、流式光标移除 4. 历史消息静态路径不受影响 运行: QT_QPA_PLATFORM=offscreen python tests/smoke_timeline.py """ import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("HAOCODE_RENDER", "software") os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" # 绕过 AMD 核显 context lost from PyQt6.QtWidgets import QApplication # noqa: E402 from PyQt6.QtCore import QTimer # noqa: E402 from ui.views.main_window import MainWindow # noqa: E402 (QtWebEngine 已先导入) app = QApplication(sys.argv) results = [] window = None done = {"ok": False} def check(name, ok, detail=""): print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" [{detail}]" if detail and not ok else "")) results.append(ok) def js(s): return s JS_SIM = js(r""" (function() { var out = {}; try { // ---- 1. 流式时间线 ---- var mid = 'tl-test-' + Date.now(); createMessage(mid, 'assistant', '', 'TL'); var wrapper = document.getElementById(mid); appendReasoning(mid, 'think part 1 '); appendReasoning(mid, 'more thinking'); appendToken(mid, 'before tool text '); toolExecutionStarted(mid, 'call-1', 'bash', JSON.stringify({command: 'echo hi'})); toolExecutionUpdated(mid, 'call-1', 'hi\n'); toolExecutionFinished(mid, 'call-1', 'bash', true, '$ echo hi\nhi\n[exit 0]'); appendToken(mid, ' after tool'); appendReasoning(mid, ' second thinking'); appendToken(mid, ' final answer'); finishMessage(mid); var tl = wrapper.querySelector('.reply-content'); out.blocks = Array.prototype.map.call(tl.children, function(el) { return el.className.split(' ')[0]; }); var chip = wrapper.querySelector('.tool-chip'); var status = chip ? chip.querySelector('.tool-chip-status') : null; out.chipStatusText = status ? status.textContent : null; out.chipOk = status ? status.classList.contains('ok') : false; out.chipBrief = chip ? (chip.querySelector('.tool-chip-brief') || {}).textContent : null; out.chipCallId = chip ? chip.getAttribute('data-call-id') : null; out.thinkChevron = !!wrapper.querySelector('.think-block .chev'); out.chipChevron = !!wrapper.querySelector('.tool-chip .chev'); out.typingRemoved = !wrapper.querySelector('.streaming-typing'); out.streamClassOff = !wrapper.classList.contains('streaming'); out.thinkBlocks = wrapper.querySelectorAll('.think-block').length; out.mdSegs = wrapper.querySelectorAll('.md-segment').length; // ---- 2. 历史消息静态路径 ---- var mid2 = 'tl-hist-' + Date.now(); createMessage(mid2, 'assistant', 'hello **world**', 'TL'); insertThinkBlock(mid2, 'history thinking'); finishMessage(mid2); var w2 = document.getElementById(mid2); var content2 = w2.querySelector('.message-content'); out.histOrder = Array.prototype.map.call(content2.children, function(el) { return el.className.split(' ')[0]; }); out.histBold = w2.querySelector('.reply-content strong') !== null; out.histThinkChevron = !!w2.querySelector('.think-block .chev'); out.histThinkLabel = (w2.querySelector('.think-label') || {}).textContent; // ---- 3. 失败工具 chip(反转修复验证)---- var mid3 = 'tl-fail-' + Date.now(); createMessage(mid3, 'assistant', '', 'TL'); toolExecutionStarted(mid3, 'call-x', 'bash', '{"command":"false"}'); toolExecutionFinished(mid3, 'call-x', 'bash', false, 'boom'); var w3 = document.getElementById(mid3); var st3 = w3.querySelector('.tool-chip-status'); out.failChipFail = st3 ? st3.classList.contains('fail') : false; out.failChipText = st3 ? st3.textContent : null; finishMessage(mid3); // 清理 [mid, mid2, mid3].forEach(function(id) { var el = document.getElementById(id); if (el) el.parentNode.removeChild(el); }); } catch (e) { out.error = String(e) + ' | ' + (e.stack || '').split('\n')[1]; } return JSON.stringify(out); })() """) def run_js_check(): page = window.browser.page() if hasattr(window, "browser") else None if page is None: # 找 QWebEnginePage from PyQt6.QtWebEngineWidgets import QWebEngineView views = window.findChildren(QWebEngineView) page = views[0].page() if views else None if page is None: check("页面句柄", False, "no page") finish() return def on_result(res): try: import json data = json.loads(str(res)) except Exception as e: check("JS 执行", False, f"bad json: {res} ({e})") finish() return if "error" in data: check("JS 执行", False, data["error"]) finish() return # 期望时间线: think → text → chip → text → think → text expected = ["think-block", "md-segment", "tool-chip", "md-segment", "think-block", "md-segment"] check("时间线块顺序(思考/正文/工具穿插)", data["blocks"] == expected, str(data["blocks"])) check("两个思考段 + 三个正文段", data["thinkBlocks"] == 2 and data["mdSegs"] == 3, f"think={data['thinkBlocks']} segs={data['mdSegs']}") check("工具 chip 成功状态(非反转)", data["chipOk"] and data["chipStatusText"] == "✓ 完成", f"{data['chipStatusText']} ok={data['chipOk']}") check("chip 摘要=结果首行", data["chipBrief"] == "$ echo hi", str(data["chipBrief"])) check("chip call_id 贯通", data["chipCallId"] == "call-1", str(data["chipCallId"])) check("SVG 箭头(思考+工具)", data["thinkChevron"] and data["chipChevron"]) check("流式光标已移除 + streaming 类移除", data["typingRemoved"] and data["streamClassOff"]) check("失败 chip 标 ✗", data["failChipFail"] and data["failChipText"] == "✗ 失败", f"{data['failChipText']}") check("历史消息: 思考在正文前", data["histOrder"].index("think-block") < data["histOrder"].index("reply-content"), str(data["histOrder"])) check("历史消息: markdown 正常解析", data["histBold"]) check("历史消息: 思考块带 SVG 箭头+标签", data["histThinkChevron"] and data["histThinkLabel"] == "已完成深度思考", str(data["histThinkLabel"])) finish() page.runJavaScript(JS_SIM, on_result) def finish(): done["ok"] = True print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: " f"{sum(results)}/{len(results)} =====") app.quit() def main(): global window try: window = MainWindow() except Exception: import traceback traceback.print_exc() app.quit() return QTimer.singleShot(6000, run_js_check) QTimer.singleShot(25000, lambda: (check("超时", False, "25s 未完成"), finish()) if not done["ok"] else None) app.exec() if __name__ == "__main__": main()