test: update smoke and regression suites for dual-renderer paths

Offscreen harnesses now resize+show before load, use per-instance webengine profile dirs and explicit exit codes; compaction and bash stream suites follow the renamed internals.
This commit is contained in:
2026-09-17 16:40:06 +08:00
parent 0a62877cde
commit acbf73f2c3
10 changed files with 916 additions and 213 deletions
+315 -69
View File
@@ -1,90 +1,336 @@
# -*- coding: utf-8 -*-
"""问题 1 取证:长会话渲染的真实 DOM 规模与耗时(离屏,临时库)"""
"""P1-01 渲染窗口 400 条消息规模诊断(offscreen;临时 DB + 临时配置;不启动真实 LLM)
运行: QT_QPA_PLATFORM=offscreen python tests/diag_render_scale.py [N]
N 默认 400(位置参数可覆盖)
验证项(manual 模式保证确定性;末尾追加 auto 模式抽查):
1. 初始窗口:.message-wrapper == min(N, size) 且 ≤ size(默认 40);
窗口 = 最新 size 条;load-older 可见、load-newer 隐藏;
2. 向上分页至头部:每页锚点误差 ≤ 2px(绝对顶部例外:scrollTop==0 且露出新页);
全程窗口 ≤ size;到达头部后 hasMoreOlder=false
全部 N 条消息可达(各步渲染 id 并集 == 链 id 集),无重复 id;
3. 自头部向下回翻 2 页:锚点稳定(≤2px);
4. 规模报告:DOM 节点总数、页面高度、每页耗时采样(ms);
5. auto 模式抽查:切 auto + 滚到顶部 → 自动补页发生。
调试铁律:本脚本自带总超时(QTimer 300s),bash 侧以 timeout=360 运行。
"""
import json
import os
import sys
import time
import json
import tempfile
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"
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
# 铁律:临时 DB + 临时配置(含 render_window_mode/size),先于任何 UI import
from tests._test_env import isolate, default_config # noqa: E402
N = int(sys.argv[1]) if len(sys.argv) > 1 else 400
_SIZE = 40
_PAGE = _SIZE // 2 # 页大小 = 半窗(与 main_window._rw_page_size 一致)
_cfg = dict(default_config())
_cfg["render_window_mode"] = "manual" # 确定性诊断;auto 行为末尾单独抽查
_cfg["render_window_size"] = _SIZE
_env = isolate("render_scale", config=_cfg)
import core.db_manager as _dbm # noqa: E402
_dbm._DEFAULT_DB = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.db")
_cfg = os.path.join(tempfile.gettempdir(), f"haocode_q1_{os.getpid()}.json")
open(_cfg, "w", encoding="utf-8").write('{"providers": {}}')
os.environ["HAOCODE_CONFIG_FILE"] = _cfg
from core.db_manager import DBManager # noqa: E402
# ---------- 造数:N 条链(含附件用户消息 / assistant 时间线 / 一个分支兄弟) ----------
db = DBManager()
sess = db.create_session(title=f"P1-01 scale {N}")
sid = sess["id"]
parent = None
chain_ids = []
for i in range(N):
role = "user" if i % 2 == 0 else "assistant"
content = f"消息 {i} —— " + "这是一段用于撑起 DOM 高度的填充段落。" * 6
atts = None
if role == "user" and i % 50 == 0:
atts = json.dumps({"user_text": content, "attachments": [
{"type": "pdf", "mode": "text", "name": f"spec_{i}.pdf",
"size_kb": 12, "pages": 3, "lines": 40, "content": "文本附件内容"}
]}, ensure_ascii=False)
timeline = None
if role == "assistant" and i % 75 == 3:
timeline = json.dumps([
{"t": "think", "text": f"思考片段 {i}"},
{"t": "text", "text": f"时间线正文 {i}"},
], ensure_ascii=False)
msg_id = f"scale-{i}"
db.add_message(session_id=sid, role=role, content=content, parent_id=parent,
msg_id=msg_id, attachment_metadata=atts, timeline=timeline)
parent = msg_id
chain_ids.append(msg_id)
# 分支兄弟:必须插在链中间(add_message 会把新消息设为叶子,
# 若放在主链之后会抢走叶子、截断可见链)
if i == 298 and N > 300:
db.add_message(session_id=sid, role="assistant", content="分支兄弟回复",
parent_id=parent, msg_id="scale-branch-sib")
db.mark_session_has_messages(sid)
from PyQt6.QtWidgets import QApplication # noqa: E402
from PyQt6.QtTest import QTest # noqa: E402
from PyQt6.QtCore import QTimer # noqa: E402
from ui.views.main_window import MainWindow # noqa: E402
N = int(sys.argv[1]) if len(sys.argv) > 1 else 200
app = QApplication(sys.argv)
w = MainWindow()
w.resize(1200, 850)
w.show()
for _ in range(30):
app.processEvents()
QTest.qWait(20)
sid = w.db.create_session(f"长会话渲染取证 {N}")["id"]
parent = None
# 造 N 轮(每轮 user + assistantassistant 带代码块 + 工具时间线,贴近真实负载)
for i in range(N // 2):
parent = w.db.add_message(session_id=sid, role="user",
content=f"{i} 个问题:帮我看看这段代码\n```python\nprint({i})\n```",
parent_id=parent)["id"]
tl = json.dumps([
{"t": "think", "text": "分析中…" * 20},
{"t": "text", "text": f"### 回答 {i}\n\n要点如下:\n\n- 第一条\n- 第二条\n\n```python\nfor j in range(10):\n print(j)\n```\n"},
{"t": "tool", "id": f"c{i}", "name": "bash", "args": '{"command":"echo hi"}',
"ok": True, "result": "hi\n" * 30},
], ensure_ascii=False)
parent = w.db.add_message(session_id=sid, role="assistant",
content=f"回答 {i}:见代码块与工具结果。",
parent_id=parent, timeline=tl)["id"]
chain = w.db.get_message_chain(sid)
print(f"库内消息条数 = {len(chain)}")
t0 = time.time()
w.load_messages_to_web(sid)
for _ in range(80):
app.processEvents()
QTest.qWait(25)
dt = time.time() - t0
holder = {}
done = []
results = []
def got(res):
holder["dom"] = res
done.append(1)
def check(name, fn):
try:
fn()
print(f" PASS {name}")
results.append(True)
except Exception as e:
import traceback
traceback.print_exc()
print(f" FAIL {name}: {e}")
results.append(False)
w.browser.page().runJavaScript(
"JSON.stringify({wrappers: document.querySelectorAll('.message-wrapper').length,"
" nodes: document.getElementsByTagName('*').length,"
" height: document.scrollingElement.scrollHeight,"
" codeBlocks: document.querySelectorAll('.code-block-wrapper').length,"
" katex: document.querySelectorAll('.katex').length})", got)
for _ in range(60):
app.processEvents()
QTest.qWait(25)
if done:
break
window = None
js = {"done": False}
print(f"渲染耗时 ≈ {dt:.2f}s")
print("DOM 统计 =", holder.get("dom"))
if holder.get("dom"):
d = json.loads(holder["dom"])
print(f" → 消息节点 {d['wrappers']} 个 / 全 DOM 节点 {d['nodes']} 个 / "
f"页面总高 {d['height']}px / 代码块 {d['codeBlocks']}")
print(f" → 平均每条消息 {d['nodes'] // max(1, d['wrappers'])} 个 DOM 节点")
os.remove(_cfg)
def _run_js(code, timeout_s=15):
result = {"val": None, "done": False}
def on_ret(val):
if isinstance(val, str):
try:
val = json.loads(val)
except Exception:
pass
result["val"] = val
result["done"] = True
if hasattr(window.browser, "execute_js_async"):
window.browser.execute_js_async(code, on_ret)
else:
window.browser.page().runJavaScript(code, on_ret)
t0 = time.time()
while not result["done"] and time.time() - t0 < timeout_s:
app.processEvents()
time.sleep(0.02)
assert result["done"], f"JS 执行超时: {code[:60]}"
return result["val"]
def wait_until(cond_js, timeout_s=30, desc=""):
t0 = time.time()
while time.time() - t0 < timeout_s:
if _run_js(cond_js, timeout_s=5):
return True
time.sleep(0.1)
raise AssertionError(f"等待超时: {desc}")
page_metrics = {"pages": [], "heights": [], "dom_nodes": []}
def try_load():
global window
window = MainWindow()
# offscreen 零视口(innerHeight=0)→ 给真实尺寸,滚动/锚点几何才有效
window.resize(1400, 950)
window.show()
for _ in range(10):
app.processEvents()
time.sleep(0.05)
# 等真实视口
wait_until("window.innerHeight > 0 && window.innerWidth > 0", 30, "视口尺寸")
# 等 JS 就绪 + 首轮(可能零视口)渲染完成
wait_until("window.jsReady === true", 30, "jsReady")
wait_until(f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
60, "首轮渲染")
# 真实视口下走完整 load_messages_to_web 路径重新窗口化加载
window.load_messages_to_web(window.current_session_id, show_loading=False)
wait_until(f"rwState !== null && rwState.generation >= 2 && "
f"document.querySelectorAll('#chat-container .message-wrapper').length >= {_SIZE}",
60, "视口重载")
wait_until("(window.__rwPageRendering === false && document.getElementById('load-older') !== null) ? 1 : 0",
10, "初始窗口收尾")
run_checks()
def run_checks():
sel = "#chat-container .message-wrapper"
# ---- 1) 初始窗口 ----
def initial():
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
assert cnt == _SIZE, f"初始窗口 {cnt} != {_SIZE}"
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer, "
"mode: rwState.mode, size: rwState.size})")
assert st["mode"] == "manual" and st["size"] == _SIZE, st
assert st["len"] == _SIZE and st["hidO"] == N - _SIZE, st
assert st["newer"] is False and st["older"] is True, st
assert _run_js("document.getElementById('load-older').hidden") is False
assert _run_js("document.getElementById('load-newer').hidden") is True
# 窗口 = 最新 size 条
first = _run_js(f"document.querySelector('{sel}').id")
assert first == chain_ids[N - _SIZE], first
# 初始贴底
assert _run_js("isNearBottom()"), "初始窗口未对齐底部"
check(f"初始窗口 = 最新 {_SIZE} 条(链长 {N}", initial)
# ---- 2a) 中部锚点保持(真实"窗口中部换页"路径,误差 ≤2px) ----
seen = set(chain_ids[-_SIZE:])
anchor_errors = []
def page_older_once():
"""执行一次向上换页(调用方保证视口位置);断言窗口上限与锚点。"""
before = _run_js("JSON.stringify(rwCaptureAnchor() || {})")
before = json.loads(before) if isinstance(before, str) else (before or {})
scroll_before = _run_js("window.scrollY")
t0 = time.time()
_run_js("rwRequestPage('older')")
# 等待 pending 消费 + 批次渲染收尾
wait_until("rwState.pending === null && window.__rwPageRendering === false",
30, "换页完成")
dt = (time.time() - t0) * 1000
page_metrics["pages"].append(dt)
cnt = _run_js(f"document.querySelectorAll('{sel}').length")
assert cnt <= _SIZE, f"窗口超限 {cnt}"
if before.get("msgId"):
el = _run_js(f"document.getElementById({json.dumps(before['msgId'])}) ? "
f"document.getElementById({json.dumps(before['msgId'])}).getBoundingClientRect().top + window.scrollY : null")
assert el is not None, f"锚点消息被裁剪: {before['msgId']}"
if scroll_before <= 1:
# 绝对顶部:停在 0 露出新页(需求定义的例外:不做锚点断言)
assert _run_js("window.scrollY") <= 1, "绝对顶部未保持"
anchor_errors.append(0.0)
else:
expect = scroll_before + (el - before["docTop"])
actual = _run_js("window.scrollY")
err = abs(expect - actual)
anchor_errors.append(err)
assert err <= 2.0, f"锚点误差 {err}px (expect={expect} actual={actual})"
else:
anchor_errors.append(0.0)
ids_now = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
for i in ids_now:
seen.add(i)
page_metrics["dom_nodes"].append(_run_js("document.querySelectorAll('body *').length"))
page_metrics["heights"].append(_run_js("document.body.scrollHeight"))
def anchor_middle():
# 滚到窗口中部(35% 处):既非绝对顶部也非贴底 → 走完整锚点数学
_run_js("window.scrollTo(0, Math.max(1, document.body.scrollHeight * 0.15)); 1")
time.sleep(0.2)
app.processEvents()
scroll_before = _run_js("window.scrollY")
assert scroll_before > 10, f"未到中部: {scroll_before}"
assert not _run_js("isNearBottom()"), "中部位置不应贴底(防振荡前置条件)"
page_older_once()
st = _run_js("JSON.stringify({hidO: rwState.hiddenOlder, hidN: rwState.hiddenNewer})")
assert st["hidN"] > 0, f"向上换页后应裁出较新隐藏区(无振荡): {st}"
assert st["hidO"] == N - _SIZE - _PAGE, f"窗口应上移 _PAGE 条: {st}"
check("中部锚点保持(误差≤2px,无 newer 振荡)", anchor_middle)
# ---- 2b) 自顶部循环向上分页至头部(真实"load older"点击路径) ----
def page_up_to_head():
_run_js("window.scrollTo(0, 0); 1")
time.sleep(0.2)
app.processEvents()
steps = 0
while True:
if not _run_js("rwState.hasMoreOlder"):
break
page_older_once()
steps += 1
assert steps <= N // _SIZE + 10, "换页次数异常(疑似死循环)"
first = _run_js(f"document.querySelector('{sel}').id")
assert first == chain_ids[0], f"未到达头部: {first}"
check("自顶部向上分页至头部(绝对顶部例外)", page_up_to_head)
def head_state():
st = _run_js("JSON.stringify({len: rwState.order.length, hidO: rwState.hiddenOlder, "
"hidN: rwState.hiddenNewer, older: rwState.hasMoreOlder, newer: rwState.hasMoreNewer})")
assert st["hidO"] == 0 and st["older"] is False, st
assert st["newer"] is True and st["hidN"] == N - _SIZE, st
# 全链可达 + 无重复
all_ids = _run_js(f"Array.from(document.querySelectorAll('{sel}')).map(e => e.id)")
assert len(all_ids) == len(set(all_ids)), "DOM 出现重复消息 id"
assert len(seen) == N, f"可达 {len(seen)}/{N}"
check("头部状态 + 全链可达无重复", head_state)
# ---- 3) 自顶部向下回翻 2 页(窗口下移,视口留在顶部) ----
def page_down_two():
for k in range(2):
_run_js("window.scrollTo(0, 0); 1")
time.sleep(0.15)
app.processEvents()
_run_js("rwRequestPage('newer')")
wait_until("rwState.pending === null && window.__rwPageRendering === false",
30, f"向下换页{k}完成")
# 顶部回翻:锚点(最旧一条)随 trimHead 移除 → 视口留在顶部露出新页
assert _run_js("window.scrollY") <= 1, "顶部回翻应留在顶部"
first = _run_js(f"document.querySelector('{sel}').id")
assert first == chain_ids[_PAGE * (k + 1)], f"窗口未下移: {first}"
st = _run_js("rwState.hasMoreOlder")
assert st is True, "回翻后应仍有更旧消息"
check("自顶部向下回翻 2 页(窗口下移)", page_down_two)
# ---- 5) auto 模式抽查:顶部自动补页 ----
def auto_probe():
_run_js("window.scrollTo(0, 0); 1")
time.sleep(0.15)
app.processEvents()
_run_js("rwState.mode = 'auto'; 1")
hid_before = _run_js("rwState.hiddenOlder")
_run_js("rwAutoCheck(); 1") # 直接触发自动补页判定(事件接线另行覆盖)
# 给自动补页 12s(每页渲染约 0.5-2s)
t0 = time.time()
moved = False
while time.time() - t0 < 12:
app.processEvents()
time.sleep(0.2)
if _run_js("rwState.hiddenOlder") < hid_before:
moved = True
break
assert moved, "auto 模式顶部未自动补页"
check("auto 模式:顶部自动补页", auto_probe)
# ---- 4) 规模报告 ----
nodes = page_metrics["dom_nodes"]
heights = page_metrics["heights"]
pages_ms = page_metrics["pages"]
print("\n================ 规模报告 ================")
print(f"链长 N={N} | 窗口 size={_SIZE}")
print(f"换页数={len(pages_ms)} | 每页耗时 min/avg/max = "
f"{min(pages_ms):.0f}/{(sum(pages_ms) / len(pages_ms)):.0f}/{max(pages_ms):.0f} ms")
print(f"DOM 节点总数 min/max = {min(nodes)}/{max(nodes)}")
print(f"页面高度 min/max = {min(heights)}/{max(heights)} px")
if anchor_errors:
print(f"锚点误差 min/max = {min(anchor_errors):.2f}/{max(anchor_errors):.2f} px")
print("==========================================")
def finish(ok=None):
app.quit()
QTimer.singleShot(500, try_load)
QTimer.singleShot(300000, lambda: (print("[超时] 300s 总超时"), finish(False)))
app.exec()
try:
window.close()
except Exception:
pass
print(f"\n===== {'ALL PASS' if all(results) else 'HAS FAILURES'}: {sum(results)}/{len(results)} =====")
# QtWebEngine 退出段错误规避:汇总已打印,直接退出
os._exit(0 if all(results) else 1)