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.
This commit is contained in:
2026-09-17 16:40:02 +08:00
parent a7412824e0
commit ce56c77023
3 changed files with 410 additions and 0 deletions
+74
View File
@@ -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-03QtWebEngine 独立 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}
+259
View File
@@ -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)