diff --git a/tests/conftest.py b/tests/conftest.py index 6dd2e511..feb981e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,6 +72,50 @@ def _redirect_sessions_index(monkeypatch, tmp_path): ) +@pytest.fixture(autouse=True) +def _redirect_the_config_path(monkeypatch, tmp_path): + """Point `config_path()` at this test's scratch tree, in every module that binds it. + + The read half of what `_guard_real_config_files` enforces for writes. The + suite never intends to touch the host's `~/.emrg/config.toml`, and it was + reading it constantly: constructing a real `EmrgServer` resolves the default + path in `ConfigReloader.__init__` (it fingerprints the file as its baseline, + `fingerprint` reads bytes rather than statting). Measured with a spy on + `open`/`io.open` (2026-09-18): **202 reads of the host's real file per + full-suite run** — 201 attributed to that construction (`daemon.py:259` → + `config_reload.py:167` → the `fingerprint` read) and 1 to a poll that let the + reloader tick (`daemon.py:650`). Nothing failed, which is the point — a host + read that no assertion depends on is invisible until a later edit makes an + expectation depend on it, which is how `tests/test_ws_e2e.py`'s upgrade + isolation became dead code and how `tests/test_config_reload.py` inherited + 14 host resolutions. + + Counting `config_path()` calls is not the same instrument: `ConfigReloader` + resolves the path and then reads it, so a fixture that patched the reloader + would hide the read from that spy without ever stopping it. The default path + is only *used* when no explicit `path=` is passed — the upgrade loop's + `load_update_config()` is the other caller, and it is not reached by the + suite (that path's isolation is a separate red line). + + Two modules are re-pointed, because neither name is the other's alias: + `emrg.config` (where `config_path` is defined, and where `load_config` / + `load_update_config` call it) and `emrg.server.config_reload`, which imported + the name by value — patching only the first leaves the reloader on the host. + + The scratch path keeps the shape of the real one — `config.toml` under a + directory named `.emrg` — so the test whose subject *is* the default + resolution (`tests/test_config.py::test_config_path`) still asserts what it + means to. A test that wants a config file of its own patches `config_path` + after this fixture, as `tests/test_upgrade.py` already does. + """ + import emrg.config as cfg_mod + import emrg.server.config_reload as cr_mod + + cfg_path = tmp_path / ".emrg" / "config.toml" + monkeypatch.setattr(cfg_mod, "config_path", lambda: cfg_path) + monkeypatch.setattr(cr_mod, "config_path", lambda: cfg_path) + + @pytest.fixture(autouse=True) def _ensure_git_on_path(monkeypatch): """Make bare ``git`` subprocess calls work on hosts without PATH git. diff --git a/tests/test_config.py b/tests/test_config.py index e844294b..49917c8e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,6 +13,8 @@ load_config, ensure_config, ) +from emrg import config as cfg_mod +from emrg.server import config_reload as cr def test_config_dir(): @@ -28,6 +30,45 @@ def test_config_path(): assert p.parent.name == ".emrg" +def test_the_default_config_path_is_redirected_into_the_scratch_tree(tmp_path): + """`conftest` redirects the default path; this pins that it is in force. + + Read through the *module attribute*, not through this file's own + `from emrg.config import config_path` — a by-value import is a separate name + that a fixture patching `emrg.config` cannot reach, which is the trap this + whole change is about (and the reason this test failed the first time it was + written: it called its own copy and got the host's path back). + + `test_config_path` above asserts the *shape* of the default resolution, and + the redirection keeps the shape (`config.toml` under a `.emrg` directory), so + it cannot tell a redirected run from an unredirected one. Without this pin the + fixture could be deleted and the only symptom would be the suite quietly + reading the host's real `~/.emrg/config.toml` again — 202 reads per run, + measured with a spy on `open`/`io.open` (2026-09-18) and 0 after the + redirection. Attribution of the 202: `daemon.py:259` (the `ConfigReloader` + the server builds) 201, `daemon.py:650` (a poll that ticks) 1. + """ + p = cfg_mod.config_path() + assert p.parent.name == ".emrg" + assert p.parent.parent == tmp_path, ( + "the suite resolved the default config path outside the test's scratch " + "tree — the host's real ~/.emrg/config.toml is being read again" + ) + + +def test_the_reloader_resolves_the_config_path_inside_the_scratch_tree(tmp_path): + """The same redirection for the other module that **binds** the name. + + `config_reload.py` does `from emrg.config import ... config_path ...`, so it + holds its own reference; a fixture that re-pointed only `emrg.config` would + leave `ConfigReloader.__init__` fingerprinting the host's file. Pinned + separately because the two are separate names, and the difference is exactly + the class of defect this suite has hit twice (`tests/test_ws_e2e.py`'s + module-level binding, `tests/test_config_reload.py`'s 14 host reads). + """ + assert cr.config_path() == tmp_path / ".emrg" / "config.toml" + + def test_llm_config_defaults(): cfg = LlmConfig() assert cfg.base_url == "https://api.openai.com/v1"