feat(renderer): platform-gated dual renderer backend with isolated QtWebEngine profiles

Windows prefers WebView2, Linux uses QtWebEngine only; pythonnet deps are win32-marked. Each QtWebEngine instance gets its own profile under data/webengine, and Chromium flags are sanitized before QApplication (--no-sandbox accepted only for explicit root/container use).
This commit is contained in:
2026-09-17 16:40:03 +08:00
parent 342e53df02
commit 554be4f92b
6 changed files with 445 additions and 6 deletions
+242
View File
@@ -0,0 +1,242 @@
# -*- 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)