# -*- coding: utf-8 -*- """tests/test_config_isolation.py —— P0-01 配置路径与测试隔离回归 运行: python tests/test_config_isolation.py (仓库惯例:无 pytest 依赖,独立可跑;GUI 部分自动走 offscreen) 完成证据(REPAIR_BACKLOG.md P0-01): 1. 打开路径拦截器记录到的配置打开路径全部位于临时目录,真实配置路径从未被打开; 该断言只用路径字符串比较,不读取、不散列真实配置文件; 2. 临时配置读写用例通过,临时数据库之外没有数据库写入; 3. 缺失配置与损坏配置各有一个回归用例:日志含明确警告、返回安全默认值、无异常; 4. 静态扫描:源码中不存在绕过统一路径解析(core/config_paths)的运行时配置读取。 """ import ast import contextlib import io import json import os import sqlite3 import sys _TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) _ROOT = os.path.dirname(_TESTS_DIR) sys.path.insert(0, _ROOT) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("HAOCODE_RENDER", "software") os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu") os.environ.setdefault("PYTHONIOENCODING", "utf-8") from tests._test_env import isolate, default_config # noqa: E402 _TMP = isolate("cfgiso") # 必须在 import MainWindow 之前 _REAL_CONFIG = os.path.join(_ROOT, "data", "config.json") # 仅作路径字符串,永不打开 ok = True def check(name, cond, extra=""): global ok print((" PASS " if cond else " FAIL ") + name + ("" if cond else f" {extra}"), flush=True) if not cond: ok = False # ====================================================================== # 拦截器:记录 builtins.open 与 sqlite3.connect 触碰的路径(不读取真实配置内容) # ====================================================================== class OpenInterceptor: def __init__(self): self.ops = [] # (path, mode) self._real_open = None def __enter__(self): self._real_open = __builtins__["open"] if isinstance( __builtins__, dict) else __builtins__.open import builtins builtins.open = self._open return self def _open(self, file, mode="r", *args, **kwargs): try: p = os.fspath(file) except TypeError: p = None if p is not None: self.ops.append((os.path.abspath(str(p)), str(mode))) return self._real_open(file, mode, *args, **kwargs) def __exit__(self, *exc): import builtins builtins.open = self._real_open return False # ---- 断言辅助 ---- def config_ops(self): # 含原子写的临时文件 config.json.tmp return [(p, m) for p, m in self.ops if os.path.basename(p).startswith("config.json")] def real_config_opened(self): return [op for op in self.ops if os.path.normcase(op[0]) == os.path.normcase(_REAL_CONFIG)] class SqliteInterceptor: def __init__(self): self.paths = [] self._real_connect = None def __enter__(self): self._real_connect = sqlite3.connect sqlite3.connect = self._connect return self def _connect(self, database, *args, **kwargs): self.paths.append(str(database)) return self._real_connect(database, *args, **kwargs) def __exit__(self, *exc): sqlite3.connect = self._real_connect return False # ====================================================================== # 1) 静态扫描:不得存在绕过统一路径解析的配置读取 # (源码中不允许出现 "config.json" 字符串字面量,core/config_paths.py 除外; # 文档字符串与注释不计) # ====================================================================== def _docstring_values(tree) -> set: vals = set() def _first_expr_body(body): if body and isinstance(body[0], ast.Expr) and \ isinstance(body[0].value, ast.Constant) and \ isinstance(body[0].value.value, str): vals.add(body[0].value.value) _first_expr_body(tree.body) for node in ast.walk(tree): if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): _first_expr_body(node.body) return vals def static_scan(): allowed = os.path.normcase(os.path.join(_ROOT, "core", "config_paths.py")) bad = [] scan_dirs = [os.path.join(_ROOT, d) for d in ("core", "ui", "tools")] scan_files = [os.path.join(_ROOT, "main.py")] targets = list(scan_files) for d in scan_dirs: for dirpath, _dirnames, filenames in os.walk(d): if "__pycache__" in dirpath: continue for fn in filenames: if fn.endswith(".py"): targets.append(os.path.join(dirpath, fn)) for path in targets: with open(path, "r", encoding="utf-8") as f: src = f.read() tree = ast.parse(src, filename=path) docstrings = _docstring_values(tree) for node in ast.walk(tree): if isinstance(node, ast.Constant) and isinstance(node.value, str) \ and "config.json" in node.value \ and node.value not in docstrings: if os.path.normcase(path) != allowed: bad.append(f"{os.path.relpath(path, _ROOT)}:{node.lineno}") return bad bad_literal = static_scan() check("静态:源码无绕过统一解析的 config.json 字面量(仅 core/config_paths.py 允许)", not bad_literal, f"发现: {bad_literal}") # ====================================================================== # 2) 缺失配置 / 损坏配置:可见警告 + 安全默认值 + 无异常 # ====================================================================== from core.config_paths import config_path, load_config # noqa: E402 import core.llm_engine as le # noqa: E402 saved_env = os.environ.get("HAOCODE_CONFIG_FILE") _missing = os.path.join(_TMP["base"], "no_such_config.json") os.environ["HAOCODE_CONFIG_FILE"] = _missing _buf = io.StringIO() with contextlib.redirect_stdout(_buf): _res = load_config() _log = _buf.getvalue() check("缺失配置:返回安全空 dict", _res == {}) check("缺失配置:日志含明确警告", "缺失" in _log and "config" in _log, _log) _corrupt = os.path.join(_TMP["base"], "corrupt.json") with open(_corrupt, "w", encoding="utf-8") as f: f.write("{ this is not valid json") os.environ["HAOCODE_CONFIG_FILE"] = _corrupt _buf = io.StringIO() with contextlib.redirect_stdout(_buf): _res = load_config() _log = _buf.getvalue() check("损坏配置:返回安全空 dict", _res == {}) check("损坏配置:日志含明确警告", "解析失败" in _log or "读取/解析失败" in _log, _log) _notdict = os.path.join(_TMP["base"], "notdict.json") with open(_notdict, "w", encoding="utf-8") as f: f.write("[1, 2, 3]") os.environ["HAOCODE_CONFIG_FILE"] = _notdict _buf = io.StringIO() with contextlib.redirect_stdout(_buf): _res = load_config() _log = _buf.getvalue() check("非对象配置:返回安全空 dict 且有警告", _res == {} and "不是 JSON 对象" in _log, _log) os.environ["HAOCODE_CONFIG_FILE"] = saved_env # 恢复临时配置 check("环境变量恢复:config_path 回到临时目录", os.path.normcase(config_path()) == os.path.normcase(_TMP["config"])) # ====================================================================== # 3) 临时配置读取(llm_engine 统一入口) # ====================================================================== _cfg_now = le._load_config() check("llm_engine._load_config 读取临时配置(含测试 provider)", "testprov" in _cfg_now.get("providers", {})) # ====================================================================== # 4) MainWindow 全链路:构造主窗口期间,配置打开/数据库连接全部落在临时目录 # ====================================================================== from PyQt6.QtWidgets import QApplication # noqa: E402 from ui.views.main_window import MainWindow # noqa: E402 import ui.views.bash_panel as bp # noqa: E402 app = QApplication.instance() or QApplication(sys.argv) oi = OpenInterceptor() si = SqliteInterceptor() with oi, si: win = MainWindow() # 宽度记录写回(config.json 写路径) bp.save_panel_width(340) win.close() win.deleteLater() app.processEvents() check("MainWindow 构造期间真实配置从未被打开", not oi.real_config_opened(), f"打开记录: {oi.real_config_opened()}") _cfg_ops = oi.config_ops() check("MainWindow 链路至少发生一次配置读取(路径被验证而非空转)", len(_cfg_ops) >= 1, f"记录: {_cfg_ops}") _check_base = os.path.normcase(os.path.abspath(_TMP["base"])) _outside = [op for op in _cfg_ops if not os.path.normcase(op[0]).startswith(_check_base)] check("全部 config.json 打开路径均位于临时目录", not _outside, f"越界: {_outside}") _write_ops = [op for op in _cfg_ops if "w" in op[1]] check("配置写路径(bash_panel 宽度)落在临时目录", bool(_write_ops) and not [op for op in _write_ops if not os.path.normcase(op[0]).startswith(_check_base)], f"写记录: {_write_ops}") check("save_panel_width 写回可被读回", bp.load_panel_width() == 340) _db_outside = [p for p in si.paths if not (os.path.normcase(os.path.abspath(p)).startswith(_check_base) or p in (":memory:", ""))] check("临时数据库之外没有数据库连接/写入", not _db_outside, f"越界: {_db_outside}") check("MainWindow.config_data 来自临时配置(providers 无真实凭据)", set(win.config_data.get("providers", {}).keys()) == {"testprov"}) # ====================================================================== # 汇总 # 注:QtWebEngine 在 offscreen 下的 C++ 静态析构可能在正常 sys.exit 后段错误, # 测试进程用 os._exit 直接退出(退出码已在上方断言中确定),不改变测试结果。 # ====================================================================== print(f"\n===== test_config_isolation: {'ALL PASS' if ok else 'HAS FAILURES'} =====", flush=True) os._exit(0 if ok else 1)