Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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"
Expand Down
Loading