# -*- 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"ok {{pid}}") 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)