Files
Haocode/tests/test_renderer_matrix.py
T
2026-09-17 16:30:02 +08:00

243 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
P1-03 渲染器矩阵单测(平台模拟 + 真实进程级,无 UI 依赖,不启动真实 LLM)
覆盖:
R1 resolve_backend 平台矩阵:Windows/Linux × auto/webview2/qtwebengine/非法值
(非法值 → 可见警告 + 平台默认;Linux 请求 webview2 → 警告 + qtwebengine
R3 Linux 模拟导入链(子进程 sys.platform='linux' 导入 ui.views.main_window):
_wv2mod 为 None,且 core.webview2 / clr / clr_loader 未进入 sys.modules
R4 两个 QtWebEngine 实例并行(2 个 offscreen 子进程同时跑):
各自独立 profile 载入本地页面并关闭,无 profile 锁争用、无互相清理
R5 Chromium sandbox 标志契约:普通桌面剥离 --no-sandbox + 告警;
root/容器 + 显式 --no-sandbox → 保留 + 高可见警告;root/容器未设 → 提示
运行: PYTHONIOENCODING=utf-8 python tests/test_renderer_matrix.py
"""
import contextlib
import io
import os
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = sys.executable
IS_WIN = os.name == "nt"
from core import renderer_backend as rb # noqa: E402
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
# ======================================================================
# R1. resolve_backend 平台矩阵(mock 平台,纯逻辑)
# ======================================================================
_orig_is_win = rb.is_windows
def _mock(is_win: bool):
rb.is_windows = lambda: is_win
try:
_mock(True)
b, w = rb.resolve_backend("auto")
check("R1.1 Win auto → webview2 无警告", b == "webview2" and w is None, f"{b} {w!r}")
b, w = rb.resolve_backend("webview2")
check("R1.2 Win webview2 → webview2", b == "webview2" and w is None, f"{b} {w!r}")
b, w = rb.resolve_backend("qtwebengine")
check("R1.3 Win qtwebengine → qtwebengine(强制回落)", b == "qtwebengine" and w is None,
f"{b} {w!r}")
b, w = rb.resolve_backend("gecko")
check("R1.4 Win 非法值 → webview2 + 可见警告",
b == "webview2" and w and "非法" in w, f"{b} {w!r}")
b, w = rb.resolve_backend(123)
check("R1.5 Win 非字符串 → webview2 + 警告", b == "webview2" and w is not None,
f"{b} {w!r}")
b, w = rb.resolve_backend(" QTWebEngine ")
check("R1.6 大小写/空白容差 → qtwebengine", b == "qtwebengine" and w is None,
f"{b} {w!r}")
_mock(False)
b, w = rb.resolve_backend("auto")
check("R1.7 Linux auto → qtwebengine 无警告", b == "qtwebengine" and w is None,
f"{b} {w!r}")
b, w = rb.resolve_backend("webview2")
check("R1.8 Linux webview2 → qtwebengine + 警告",
b == "qtwebengine" and w and "webview2" in w, f"{b} {w!r}")
b, w = rb.resolve_backend("qtwebengine")
check("R1.9 Linux qtwebengine → qtwebengine", b == "qtwebengine" and w is None,
f"{b} {w!r}")
b, w = rb.resolve_backend("gecko")
check("R1.10 Linux 非法值 → qtwebengine + 警告",
b == "qtwebengine" and w and "非法" in w, f"{b} {w!r}")
finally:
rb.is_windows = _orig_is_win
# webview2_module 门控(同进程 mock
try:
_mock(False)
check("R1.11 非 Windowswebview2_module() 返回 None 且不导入",
rb.webview2_module() is None)
finally:
rb.is_windows = _orig_is_win
# ======================================================================
# R5. Chromium sandbox 标志契约(mock root/容器探测)
# ======================================================================
_orig_rootc = rb.is_root_or_container
def _run_sanitize(flags, rootc):
rb.is_root_or_container = lambda: rootc
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
out = rb.sanitize_chromium_flags(flags, verbose=True)
return out, buf.getvalue()
try:
out, log = _run_sanitize("--disable-gpu", False)
check("R5.1 普通桌面:无 --no-sandbox 原样返回、无告警",
out == "--disable-gpu" and "no-sandbox" not in log, f"{out!r} {log!r}")
out, log = _run_sanitize("--disable-gpu --no-sandbox", False)
check("R5.2 普通桌面:剥离 --no-sandbox + 告警",
out == "--disable-gpu" and "--no-sandbox" in log, f"{out!r} {log!r}")
out, log = _run_sanitize("--disable-gpu --no-sandbox", True)
check("R5.3 root/容器 + 显式:保留 --no-sandbox + 高可见警告",
out == "--disable-gpu --no-sandbox" and "高可见警告" in log, f"{out!r} {log!r}")
out, log = _run_sanitize("--disable-gpu", True)
check("R5.4 root/容器未设:原样返回 + 提示显式设置",
out == "--disable-gpu" and "--no-sandbox" in log and "高可见警告" not in log,
f"{out!r} {log!r}")
finally:
rb.is_root_or_container = _orig_rootc
# ======================================================================
# R3. Linux 模拟导入链(子进程:sys.platform='linux' 后 import main_window
# ======================================================================
r3_code = (
"import os, sys;"
f"sys.path.insert(0, {ROOT!r});"
"os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen');"
"os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-gpu';"
"sys.platform = 'linux';" # 模拟非 Windows(导入门控按 sys.platform 判断)
"import ui.views.main_window as m;"
"bad = sorted(k for k in ('core.webview2', 'clr', 'clr_loader') if k in sys.modules);"
"ok = m._wv2mod is None and not bad;"
"print('R3_OK' if ok else f'R3_FAIL {bad!r}')"
)
r3 = subprocess.run([PY, "-c", r3_code], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=180)
check("R3.1 Linux 模拟:main_window 导入成功且 _wv2mod 为 None、未导入 webview2/clr",
"R3_OK" in (r3.stdout or ""),
f"rc={r3.returncode} out={(r3.stdout or '')[-200:]!r} err={(r3.stderr or '')[-300:]!r}")
# ======================================================================
# R4. 两个 QtWebEngine 实例并行(独立 profileoffscreen
# ======================================================================
_r4_base = tempfile.mkdtemp(prefix="haocode_p103_profiles_")
_worker = os.path.join(_r4_base, "qtwe_worker.py")
_worker_code = f'''# -*- coding: utf-8 -*-
import os, sys
sys.path.insert(0, {ROOT!r})
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QTimer, QUrl
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWebEngineWidgets import QWebEngineView
from ui.views.custom_web_page import CustomWebPage
from core import renderer_backend as rbr
app = QApplication(sys.argv)
pid = os.getpid()
name = f"parallel_{{pid}}"
pdir = rbr.webengine_profile_dir(name)
prof = QWebEngineProfile(name)
prof.setPersistentStoragePath(os.path.join(pdir, "storage"))
prof.setCachePath(os.path.join(pdir, "cache"))
html = os.path.join(os.path.dirname(pdir), f"page_{{pid}}.html")
with open(html, "w", encoding="utf-8") as f:
f.write(f"<html><body>ok {{pid}}</body></html>")
view = QWebEngineView()
view.setPage(CustomWebPage(prof, view))
view.resize(800, 600)
view.show()
st = {{"ok": False}}
fired = {{"v": False}}
def finish():
if fired["v"]: return
fired["v"] = True
print(f"WORKER_OK pid={{pid}} loaded={{st['ok']}} profile={{pdir}}", flush=True)
app.quit()
view.page().loadFinished.connect(lambda o: (st.__setitem__("ok", bool(o)), finish()))
view.load(QUrl.fromLocalFile(html))
QTimer.singleShot(30000, finish) # 兜底显式时间预算(30s)
app.exec()
sys.exit(0 if st["ok"] else 1)
'''
with open(_worker, "w", encoding="utf-8") as f:
f.write(_worker_code)
_r4_env = {**os.environ,
"QT_QPA_PLATFORM": "offscreen",
"QTWEBENGINE_CHROMIUM_FLAGS": "--disable-gpu",
"HAOCODE_WEBENGINE_PROFILE_DIR": _r4_base,
"PYTHONIOENCODING": "utf-8"}
procs = []
try:
for _ in range(2):
procs.append(subprocess.Popen([PY, _worker], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
encoding="utf-8", errors="replace", env=_r4_env))
outs = []
for p in procs:
try:
out, err = p.communicate(timeout=90)
outs.append((p.returncode, out, err))
except subprocess.TimeoutExpired:
p.kill()
outs.append((None, "", "TIMEOUT"))
oks = [rc == 0 and "WORKER_OK" in out for rc, out, err in outs]
check("R4.1 两个 QtWebEngine 实例并行:都成功载入本地页面",
all(oks) and len({out for _, out, _ in outs if out}) == 2,
repr([(rc, (out or '')[-120:], (err or '')[-200:]) for rc, out, err in outs]))
# 两个不同 profile 目录、关闭后目录仍在(无互相清理)
prof_dirs = []
for rc, out, err in outs:
for line in (out or "").splitlines():
if line.startswith("WORKER_OK"):
prof_dirs.append(line.rsplit("profile=", 1)[1].strip())
check("R4.2 两个实例 profile 目录不同(独立,不争用)",
len(prof_dirs) == 2 and prof_dirs[0] != prof_dirs[1], repr(prof_dirs))
check("R4.3 关闭后 profile 目录仍存在(无互相清理)",
all(os.path.isdir(d) for d in prof_dirs), repr(prof_dirs))
finally:
for p in procs:
try:
if p.poll() is None:
p.kill()
except Exception:
pass
# ======================================================================
print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True)
sys.exit(0 if all(ok for _, ok in RESULTS) else 1)