ba030bc0db
Tests did monkeypatch.setattr(<facade module>, name) where name is now defined in a sibling module and the production path reads the sibling's binding. Where production reads through BOTH bindings the setattr is duplicated onto the defining module (import added next to the existing alias import); where only the sibling reads it the target is repointed. Seams whose production readers go through the facade are left alone.
129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
"""Fleet-wide config migration (#91277 Phase 2 — #20438/#54926/#79048 class).
|
|
|
|
`hermes update` migrated only the active profile's config.yaml; sibling
|
|
profiles silently drifted config versions until their gateway hit a config
|
|
the new code couldn't read. `_migrate_sibling_profile_configs()` runs the
|
|
same non-interactive safe migration for every sibling home, scoped via the
|
|
context-local HERMES_HOME override.
|
|
|
|
These tests use REAL config files on disk and the REAL migration pipeline —
|
|
only the profile-root location is pointed at tmp_path.
|
|
"""
|
|
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
import hermes_cli.update_cmd as update_cmd
|
|
import hermes_cli.update_cmd_config as update_cmd_config
|
|
|
|
|
|
def _write_profile(root: Path, name: str, version: int) -> Path:
|
|
home = root / name
|
|
home.mkdir(parents=True)
|
|
(home / "config.yaml").write_text(
|
|
yaml.safe_dump({"_config_version": version, "model": {"provider": "nous"}}),
|
|
encoding="utf-8",
|
|
)
|
|
return home
|
|
|
|
|
|
def _latest_version() -> int:
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
return int(DEFAULT_CONFIG["_config_version"])
|
|
|
|
|
|
def _setup(monkeypatch, tmp_path, active_home: Path):
|
|
import hermes_cli.profiles as profiles_mod
|
|
|
|
monkeypatch.setattr(profiles_mod, "_get_profiles_root", lambda: tmp_path / "profiles")
|
|
import hermes_constants
|
|
|
|
monkeypatch.setattr(
|
|
hermes_constants, "get_process_hermes_home", lambda: active_home
|
|
)
|
|
monkeypatch.setattr(
|
|
update_cmd, "_reload_config_modules", lambda: None
|
|
) # module reload is orthogonal here; the real one re-imports from disk
|
|
monkeypatch.setattr(
|
|
update_cmd_config, "_reload_config_modules", lambda: None
|
|
) # module reload is orthogonal here; the real one re-imports from disk
|
|
|
|
|
|
def test_sibling_behind_is_migrated_on_disk(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
sibling = _write_profile(tmp_path / "profiles", "research", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
names = [m[0] for m in migrated]
|
|
assert "research" in names
|
|
entry = next(m for m in migrated if m[0] == "research")
|
|
assert entry[1] == 12 and entry[2] == _latest_version()
|
|
# REAL file on disk carries the new version
|
|
on_disk = yaml.safe_load((sibling / "config.yaml").read_text())
|
|
assert on_disk["_config_version"] == _latest_version()
|
|
# and user settings survived
|
|
assert on_disk["model"]["provider"] == "nous"
|
|
|
|
|
|
def test_active_profile_is_skipped(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
assert "active" not in [m[0] for m in migrated]
|
|
# active home untouched (the caller's own migration handles it)
|
|
on_disk = yaml.safe_load((active / "config.yaml").read_text())
|
|
assert on_disk["_config_version"] == 12
|
|
|
|
|
|
def test_current_sibling_untouched(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
sibling = _write_profile(tmp_path / "profiles", "work", _latest_version())
|
|
_setup(monkeypatch, tmp_path, active)
|
|
before = (sibling / "config.yaml").read_bytes()
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
assert migrated == []
|
|
assert (sibling / "config.yaml").read_bytes() == before
|
|
|
|
|
|
def test_unconfigured_profile_skipped(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
bare = tmp_path / "profiles" / "empty"
|
|
bare.mkdir()
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
assert update_cmd._migrate_sibling_profile_configs() == []
|
|
assert not (bare / "config.yaml").exists()
|
|
|
|
|
|
def test_one_broken_profile_does_not_block_others(monkeypatch, tmp_path):
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
broken_home = tmp_path / "profiles" / "broken"
|
|
broken_home.mkdir()
|
|
(broken_home / "config.yaml").write_text(":\nnot yaml: [", encoding="utf-8")
|
|
_write_profile(tmp_path / "profiles", "healthy", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
migrated = update_cmd._migrate_sibling_profile_configs()
|
|
|
|
assert [m[0] for m in migrated] == ["healthy"]
|
|
|
|
|
|
def test_override_is_reset_after_run(monkeypatch, tmp_path):
|
|
"""The ContextVar override must not leak past the sweep."""
|
|
from hermes_constants import get_hermes_home_override
|
|
|
|
active = _write_profile(tmp_path / "profiles", "active", _latest_version())
|
|
_write_profile(tmp_path / "profiles", "research", 12)
|
|
_setup(monkeypatch, tmp_path, active)
|
|
|
|
before = get_hermes_home_override()
|
|
update_cmd._migrate_sibling_profile_configs()
|
|
assert get_hermes_home_override() == before
|