From ce56c7702316d6164256a80eec3dc704e1be42e2 Mon Sep 17 00:00:00 2001 From: sorrow404null Date: Thu, 17 Sep 2026 16:40:02 +0800 Subject: [PATCH] feat(config): isolate config and database via HAOCODE_CONFIG_FILE override core/config_paths.py resolves a single override so tests and multiple instances can run against isolated temp config/db without touching user data. --- core/config_paths.py | 77 ++++++++++ tests/_test_env.py | 74 ++++++++++ tests/test_config_isolation.py | 259 +++++++++++++++++++++++++++++++++ 3 files changed, 410 insertions(+) create mode 100644 core/config_paths.py create mode 100644 tests/_test_env.py create mode 100644 tests/test_config_isolation.py diff --git a/core/config_paths.py b/core/config_paths.py new file mode 100644 index 0000000..f745429 --- /dev/null +++ b/core/config_paths.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +"""core/config_paths.py —— 统一的配置文件路径解析与容错加载(P0-01 新增) + +所有配置读取入口必须经过本模块,不得在别处直接定位 data/config.json: + + 1. 环境变量 HAOCODE_CONFIG_FILE 优先级最高(自动化测试用它指向临时文件); + 2. 未设置环境变量时才回落到项目内 data/config.json(锚定 core/ 目录,不受 CWD 影响)。 + +行为约定(REPAIR_BACKLOG P0-01 硬约束): + · 路径解析与加载过程只在出错时输出可见警告(只含路径与错误类型,不含配置内容); + · 缺失 / 损坏 / 非 JSON 对象 → 返回安全空 dict 继续,不抛异常、不阻断 + 不需要该配置的源码路径; + · import 本模块时不打印、不复制、不迁移任何配置内容。 +""" +import json +import os + +ENV_KEY = "HAOCODE_CONFIG_FILE" + +_DEFAULT_CONFIG_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "data", "config.json") + + +def config_path() -> str: + """返回当前生效的配置文件路径(环境变量优先,调用时解析)。""" + p = os.environ.get(ENV_KEY, "").strip() + return p if p else _DEFAULT_CONFIG_PATH + + +def load_config() -> dict: + """容错加载配置。缺失/损坏/非对象 → 可见警告 + 安全空 dict(绝不抛异常)。""" + p = config_path() + if not os.path.exists(p): + print(f"[config] 配置文件缺失: {p} → 使用安全默认值(providers 为空)继续启动") + return {} + try: + with open(p, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + print(f"[config] 配置文件读取/解析失败: {p}({type(e).__name__})" + f" → 使用安全默认值继续启动") + return {} + if not isinstance(data, dict): + print(f"[config] 配置文件内容不是 JSON 对象: {p}" + f" → 使用安全默认值继续启动") + return {} + return data + + +# ---------------------------------------------------------------------- +# P1-01:渲染窗口配置解析(render_window_mode / render_window_size) +# 规则(与 ui/web/render_window.js 的 JS 侧守卫保持一致): +# size:只接受非布尔整数 10..200,缺失/布尔/字符串/小数/零/负数/越界 → 静默回落 40; +# mode:只接受 "auto"/"manual",否则回落 "auto"。 +# ---------------------------------------------------------------------- +DEFAULT_RENDER_WINDOW_SIZE = 40 +ALLOWED_RENDER_WINDOW_SIZES = (10, 40, 200) + + +def render_window_settings(cfg: dict | None = None) -> dict: + """解析渲染窗口配置。cfg 缺省时读取当前生效配置。永不抛异常。""" + if cfg is None: + cfg = load_config() + if not isinstance(cfg, dict): + cfg = {} + + raw_size = cfg.get("render_window_size", None) + if isinstance(raw_size, bool) or not isinstance(raw_size, int) \ + or raw_size < 10 or raw_size > 200: + size = DEFAULT_RENDER_WINDOW_SIZE + else: + size = raw_size + + raw_mode = cfg.get("render_window_mode", None) + mode = raw_mode if raw_mode in ("auto", "manual") else "auto" + + return {"mode": mode, "size": size} diff --git a/tests/_test_env.py b/tests/_test_env.py new file mode 100644 index 0000000..a9ed604 --- /dev/null +++ b/tests/_test_env.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""tests/_test_env.py —— 自动化测试的统一临时环境(P0-01 新增) + +铁律(VERIFICATION.md §凭据与运行数据隔离): + 1. 每个测试进程独立临时目录 + 最小临时配置; + 2. 在 import 任何可能间接加载 MainWindow 的模块之前设置 HAOCODE_CONFIG_FILE; + 3. 在 import MainWindow 之前把 core.db_manager._DEFAULT_DB 指向临时数据库; + 4. 写入、迁移、附件和截图产物只落到临时目录。 + +用法(放在测试文件顶部、任何 UI import 之前): + + from tests._test_env import isolate + isolate("bashpanel", config={"providers": {}, "mode_switch": True}) + +说明: + · 临时目录按 进程 pid 隔离,测试进程互不干扰; + · 默认临时配置为最小可启动结构(空 providers + 测试 provider); + · 本模块不 import 任何 PyQt 模块,可在无 GUI 环境中安全调用。 +""" +import json +import os +import sys +import tempfile + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_TESTS_DIR) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + + +def default_config() -> dict: + """最小可启动临时配置(不含任何真实凭据)。""" + return { + "providers": { + "testprov": { + "api_key": "test-key-not-real", + "base_url": "http://127.0.0.1:9/v1", + "models": ["test-model"], + "model_contexts": {"test-model": 100000}, + } + }, + "default_provider": "testprov", + "default_model": "test-model", + } + + +def isolate(tag: str = "t", config: dict | None = None) -> dict: + """创建临时配置 + 临时数据库并完成重定向。必须在 import MainWindow 之前调用。 + + 返回 {"base": 临时目录, "config": 临时配置路径, "db": 临时数据库路径}。 + """ + base = os.path.join(tempfile.gettempdir(), + f"haocode_test_{tag}_{os.getpid()}") + os.makedirs(base, exist_ok=True) + + cfg_path = os.path.join(base, "config.json") + with open(cfg_path, "w", encoding="utf-8") as f: + json.dump(config if config is not None else default_config(), + f, ensure_ascii=False, indent=2) + + db_path = os.path.join(base, "chat_history.db") + for p in (db_path, db_path + "-wal", db_path + "-shm"): + if os.path.exists(p): + os.remove(p) + + os.environ["HAOCODE_CONFIG_FILE"] = cfg_path + + # P1-03:QtWebEngine 独立 profile 重定向到临时目录(源码运行才在 data/webengine/) + os.environ.setdefault("HAOCODE_WEBENGINE_PROFILE_DIR", os.path.join(base, "webengine_profile")) + + import core.db_manager as _dbm + _dbm._DEFAULT_DB = db_path + + return {"base": base, "config": cfg_path, "db": db_path} diff --git a/tests/test_config_isolation.py b/tests/test_config_isolation.py new file mode 100644 index 0000000..5d2b62a --- /dev/null +++ b/tests/test_config_isolation.py @@ -0,0 +1,259 @@ +# -*- 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)