243 lines
9.7 KiB
Python
243 lines
9.7 KiB
Python
# -*- 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 非 Windows:webview2_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 实例并行(独立 profile,offscreen)
|
||
# ======================================================================
|
||
_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)
|