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:
+315
-69
@@ -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 + assistant,assistant 带代码块 + 工具时间线,贴近真实负载)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user