281 lines
12 KiB
Python
281 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""四项 UI 增强回归测试(真实 WebEngine 页面):
|
||
1. rAF 卡死模拟(AMD 核显/合成器不出帧)→ 40ms 兜底仍实时渲染流式正文
|
||
2. 深度思考默认收起 + .streaming-think 蓝色动画类;收尾后标签还原
|
||
3. bash chip 耗时徽章([exit 0] (1.2s))+ 超时徽章(命令超时(>120s))
|
||
4. 长结果:默认尾部 4000 字 + 展开按钮;点击展开全文/再点收起;
|
||
收起状态下 finish 后结果仍可见(修复「结束后展开为空」)
|
||
运行: python tests/smoke_live_guard.py
|
||
"""
|
||
import json
|
||
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
|
||
|
||
app = QApplication(sys.argv)
|
||
window = None
|
||
results = []
|
||
|
||
|
||
def check(name, ok, detail=""):
|
||
print(f" {'PASS' if ok else 'FAIL'} {name}"
|
||
+ (f" [{detail}]" if detail and not ok else ""))
|
||
results.append(ok)
|
||
|
||
|
||
# Phase 0: rAF + 所有页面定时器全灭(极端节流)→
|
||
# 内容必须上屏:同步通道(token 内)+ Qt 看门狗(200ms,forceRenderNow)
|
||
JS_PHASE_0A = r"""
|
||
(function() {
|
||
var out = {};
|
||
window.__realRAF = window.requestAnimationFrame;
|
||
window.__realCAF = window.cancelAnimationFrame;
|
||
window.__realTO = window.setTimeout;
|
||
window.__realCTO = window.clearTimeout;
|
||
window.__realSI = window.setInterval;
|
||
window.__realCSI = window.clearInterval;
|
||
window.__fakeRafId = 0;
|
||
window.__fakeTimerId = 1000;
|
||
window.requestAnimationFrame = function() { return ++window.__fakeRafId; };
|
||
window.cancelAnimationFrame = function(){};
|
||
window.setTimeout = function() { return ++window.__fakeTimerId; };
|
||
window.clearTimeout = function(){};
|
||
window.setInterval = function() { return ++window.__fakeTimerId; };
|
||
window.clearInterval = function(){};
|
||
|
||
var m0 = 'lg-sync-' + Date.now();
|
||
window.__lgM0 = m0;
|
||
createMessage(m0, 'assistant', '', 'LG');
|
||
appendReasoning(m0, 'sync think ');
|
||
appendToken(m0, 'sync text one ');
|
||
appendToken(m0, 'two');
|
||
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
|
||
var t = '';
|
||
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
|
||
out.textAtBurst = t;
|
||
return JSON.stringify(out);
|
||
})()
|
||
"""
|
||
|
||
JS_PHASE_0B = r"""
|
||
(function() {
|
||
var out = {};
|
||
var m0 = window.__lgM0;
|
||
var segs = document.querySelectorAll('#' + m0 + ' .md-segment');
|
||
var t = '';
|
||
for (var i = 0; i < segs.length; i++) t += segs[i].textContent || '';
|
||
var tcs = document.querySelectorAll('#' + m0 + ' .think-content');
|
||
var th = '';
|
||
for (var i = 0; i < tcs.length; i++) th += tcs[i].textContent || '';
|
||
out.text = t;
|
||
out.think = th;
|
||
// 恢复页面定时器
|
||
window.requestAnimationFrame = window.__realRAF;
|
||
window.cancelAnimationFrame = window.__realCAF;
|
||
window.setTimeout = window.__realTO;
|
||
window.clearTimeout = window.__realCTO;
|
||
window.setInterval = window.__realSI;
|
||
window.clearInterval = window.__realCSI;
|
||
finishMessage(m0);
|
||
return JSON.stringify(out);
|
||
})()
|
||
"""
|
||
|
||
# Phase A: 模拟 rAF 永不触发 + 流式 token;断言此刻 DOM 尚空(卡死生效)
|
||
JS_PHASE_A = r"""
|
||
(function() {
|
||
var out = {};
|
||
window.__realRAF = window.requestAnimationFrame;
|
||
window.__realCAF = window.cancelAnimationFrame;
|
||
// 模拟真实卡死:rAF 返回真实挂起 ID(truthy)但回调永不被调用
|
||
window.__fakeRafId = 0;
|
||
window.requestAnimationFrame = function(cb) { window.__rafCalls = (window.__rafCalls||0)+1; return ++window.__fakeRafId; };
|
||
window.cancelAnimationFrame = function(){};
|
||
|
||
var mid = 'lg-' + Date.now();
|
||
window.__lgMid = mid;
|
||
createMessage(mid, 'assistant', '', 'LG');
|
||
appendReasoning(mid, 'deep ');
|
||
appendReasoning(mid, 'thinking...');
|
||
appendToken(mid, 'live text one ');
|
||
appendToken(mid, 'two ');
|
||
appendToken(mid, 'three');
|
||
|
||
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
|
||
var domLen = 0;
|
||
for (var i = 0; i < segs.length; i++)
|
||
domLen += (segs[i].textContent || '').length;
|
||
out.rafCalls = window.__rafCalls || 0;
|
||
out.domLenBeforeTimers = domLen;
|
||
|
||
// 思考块状态(默认收起 + 蓝色动画类)
|
||
var block = document.querySelector('#' + mid + ' .think-block');
|
||
out.thinkOpen = block ? block.open : null;
|
||
out.thinkStreaming = block ? block.classList.contains('streaming-think') : false;
|
||
out.thinkLabel = block ? (block.querySelector('.think-label')||{}).textContent : null;
|
||
return JSON.stringify(out);
|
||
})()
|
||
"""
|
||
|
||
# Phase B(timers 跑过之后): 兜底应已渲染正文 + 收尾 + 其余特性
|
||
JS_PHASE_B = r"""
|
||
(function() {
|
||
var out = {};
|
||
var mid = window.__lgMid;
|
||
var segs = document.querySelectorAll('#' + mid + ' .md-segment');
|
||
var domText = '';
|
||
for (var i = 0; i < segs.length; i++) domText += segs[i].textContent || '';
|
||
out.liveTextRendered = domText.indexOf('live text one two three') !== -1;
|
||
|
||
// 收尾:思考块标签还原 + 动画类移除
|
||
finishMessage(mid);
|
||
var block = document.querySelector('#' + mid + ' .think-block');
|
||
out.thinkLabelAfter = block ? (block.querySelector('.think-label')||{}).textContent : null;
|
||
out.thinkStreamingAfter = block ? block.classList.contains('streaming-think') : false;
|
||
// 恢复 rAF
|
||
window.requestAnimationFrame = window.__realRAF;
|
||
window.cancelAnimationFrame = window.__realCAF;
|
||
|
||
// ---- 耗时 / 超时徽章 ----
|
||
var mid2 = 'lg-timing-' + Date.now();
|
||
createMessage(mid2, 'assistant', '', 'LG');
|
||
toolExecutionStarted(mid2, 'call-ok', 'bash', JSON.stringify({command:'echo hi'}));
|
||
toolExecutionFinished(mid2, 'call-ok', 'bash', true, '$ echo hi\nhi\n[exit 0] (1.2s)');
|
||
toolExecutionStarted(mid2, 'call-tmo', 'bash', JSON.stringify({command:'sleep 999'}));
|
||
toolExecutionFinished(mid2, 'call-tmo', 'bash', false, '命令超时(>120s)已终止');
|
||
var w2 = document.getElementById(mid2);
|
||
var chipOk = w2.querySelector('[data-call-id="call-ok"]');
|
||
var chipTmo = w2.querySelector('[data-call-id="call-tmo"]');
|
||
out.durBadge = chipOk ? (chipOk.querySelector('.tool-chip-time')||{}).textContent : null;
|
||
out.tmoBadge = chipTmo ? (chipTmo.querySelector('.tool-chip-timeout')||{}).textContent : null;
|
||
|
||
// ---- 收起状态下 finish:结果必须已写入(展开可见) ----
|
||
var livePre = chipOk ? chipOk.querySelector('.tool-chip-live') : null;
|
||
out.resultWhileCollapsed = livePre ? (livePre.textContent || '').indexOf('hi') !== -1 : false;
|
||
|
||
// ---- 长结果:尾部预览 + 展开/收起 ----
|
||
var longRes = 'L'.repeat(1000) + ' MIDDLE-MARKER ' + 'R'.repeat(5000); // 6015 字,标记在头部(预览窗外)
|
||
toolExecutionStarted(mid2, 'call-long', 'read', JSON.stringify({path:'/big.txt'}));
|
||
toolExecutionFinished(mid2, 'call-long', 'read', true, longRes);
|
||
var chipLong = w2.querySelector('[data-call-id="call-long"]');
|
||
var pre = chipLong ? chipLong.querySelector('.tool-chip-live') : null;
|
||
var btn = chipLong ? chipLong.querySelector('.tool-expand-btn') : null;
|
||
out.longHasBtn = !!btn;
|
||
out.longPreviewLen = pre ? (pre.textContent || '').length : 0;
|
||
out.longPreviewIsTail = pre ? (pre.textContent || '').lastIndexOf('R') > (pre.textContent||'').lastIndexOf('MIDDLE-MARKER') : false;
|
||
out.longPreviewHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : true;
|
||
if (btn) {
|
||
btn.click();
|
||
out.expandedHasMarker = pre ? (pre.textContent || '').indexOf('MIDDLE-MARKER') !== -1 : false;
|
||
out.expandedLen = pre ? (pre.textContent || '').length : 0;
|
||
out.fullClass = pre ? pre.classList.contains('tool-chip-full') : false;
|
||
btn.click();
|
||
out.collapseBackLen = pre ? (pre.textContent || '').length : 0;
|
||
}
|
||
return JSON.stringify(out);
|
||
})()
|
||
"""
|
||
|
||
|
||
def step1():
|
||
global window
|
||
print("[step1] creating MainWindow...", flush=True)
|
||
window = MainWindow()
|
||
window.show()
|
||
print("[step1] shown, scheduling step2", flush=True)
|
||
QTimer.singleShot(3000, step2)
|
||
|
||
|
||
def step2():
|
||
print("[step2] phase 0: kill all page timers + burst tokens", flush=True)
|
||
# 注册为当前会话的活跃流 → Qt 看门狗(200ms)会对它 forceRenderNow
|
||
window.current_session_id = "fake-sync-sid"
|
||
window._active_streams["fake-sync-sid"] = {
|
||
"msg_id": None, "content": "pending", "reasoning": "",
|
||
"timeline": [], "tl_kind": None, "parent_id": None,
|
||
"branch_info": None, "worker": None, "previous_leaf_id": None,
|
||
}
|
||
window.browser.page().runJavaScript(JS_PHASE_0A, on_p0a)
|
||
|
||
|
||
def on_p0a(res):
|
||
a = json.loads(str(res))
|
||
print(f" phase0 burst 瞬间: text={a['textAtBurst']!r}")
|
||
# 把 JS 里生成的 mid 告诉看门狗(msg_id 需一致才会 forceRenderNow)
|
||
# —— 简化:直接改 JS 端记录的 mid 对应的 buffer 即可,这里用 runJavaScript 对齐
|
||
window.browser.page().runJavaScript(
|
||
"window.__lgM0", lambda r: _align_and_wait(str(r)))
|
||
|
||
|
||
def _align_and_wait(mid):
|
||
mid = mid.strip().strip('"')
|
||
window._active_streams["fake-sync-sid"]["msg_id"] = mid
|
||
print("[phase0] 等待 Qt 看门狗渲染 (400ms) ...", flush=True)
|
||
QTimer.singleShot(400, on_p0b)
|
||
|
||
|
||
def on_p0b():
|
||
window.browser.page().runJavaScript(JS_PHASE_0B, on_p0)
|
||
|
||
|
||
def on_p0(res):
|
||
p = json.loads(str(res))
|
||
print(f" phase0 400ms 后: text={p['text']!r} think={p['think']!r}")
|
||
check("定时器全灭: burst 内思考同步上屏", "sync think" in p["think"], str(p))
|
||
check("定时器全灭: 正文经同步通道/看门狗上屏",
|
||
"sync text one two" in p["text"], str(p))
|
||
window._active_streams.pop("fake-sync-sid", None)
|
||
window.browser.page().runJavaScript(JS_PHASE_A, on_a)
|
||
|
||
|
||
def on_a(res):
|
||
print("[on_a] got result:", str(res)[:120], flush=True)
|
||
a = json.loads(str(res))
|
||
print(f" phaseA: rafCalls={a['rafCalls']} domLenBefore={a['domLenBeforeTimers']}")
|
||
# v7 起:首 token 插入前已同步渲染(:empty 布局失效修复的副作用——首字必现),
|
||
# 故此刻 DOM 可能已有首 token 内容;本断言只验证 rAF 被调用但从未触发。
|
||
check("rAF 被卡死(0 帧触发)", a["rafCalls"] > 0, str(a))
|
||
check("思考块默认收起", a["thinkOpen"] is False)
|
||
check("思考块带蓝色动画类 streaming-think", a["thinkStreaming"])
|
||
QTimer.singleShot(300, step3)
|
||
|
||
|
||
def step3():
|
||
window.browser.page().runJavaScript(JS_PHASE_B, on_b)
|
||
|
||
|
||
def on_b(res):
|
||
b = json.loads(str(res))
|
||
check("rAF 卡死时兜底仍实时渲染正文", b["liveTextRendered"], str(b))
|
||
check("收尾后思考标签还原为『已完成深度思考』",
|
||
b["thinkLabelAfter"] == "已完成深度思考", str(b["thinkLabelAfter"]))
|
||
check("收尾后移除 streaming-think 动画类", b["thinkStreamingAfter"] is False)
|
||
check("耗时徽章 ⏱ 1.2s", b["durBadge"] == "⏱ 1.2s", str(b["durBadge"]))
|
||
check("超时徽章 ⏱ 超时 120s", b["tmoBadge"] == "⏱ 超时 120s", str(b["tmoBadge"]))
|
||
check("收起状态下 finish 后结果仍可见(修复展开为空)", b["resultWhileCollapsed"])
|
||
check("长结果有展开按钮", b["longHasBtn"])
|
||
check("长结果默认只显示尾部 4002 字", b["longPreviewLen"] == 4002,
|
||
str(b["longPreviewLen"]))
|
||
check("尾部预览不含中段标记(标记在头部)", b["longPreviewIsTail"]
|
||
and b["longPreviewHasMarker"] is False)
|
||
check("点击展开全文(含中段标记 + full 类)",
|
||
b["expandedHasMarker"] and b["fullClass"]
|
||
and b["expandedLen"] > 5000, str(b))
|
||
check("再点收回到尾部预览", b["collapseBackLen"] == 4002,
|
||
str(b["collapseBackLen"]))
|
||
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: "
|
||
f"{sum(results)}/{len(results)} =====")
|
||
app.quit()
|
||
|
||
|
||
QTimer.singleShot(500, step1)
|
||
app.exec()
|