"""post_update step registry: scopes, isolation, and the migrate contract. These assert behavior contracts, not snapshots: the registries' scopes must match what boot_bootstrap gates them with, a failing step must not stop the rest, and step_migrate_config must restore its backups when a migration fails or does not advance the version. """ from pathlib import Path import pytest from hermes_cli import post_update from hermes_cli.post_update import ( HOME_STEPS, MACHINE_STEPS, run_steps, step_migrate_config, step_state_db_guard, ) # ── registry invariants ────────────────────────────────────────────── def test_registries_are_disjoint_and_named(): home_names = {name for name, _ in HOME_STEPS} machine_names = {name for name, _ in MACHINE_STEPS} assert home_names, "home registry must not be empty" assert not (home_names & machine_names) for name, func in (*HOME_STEPS, *MACHINE_STEPS): assert callable(func), name def test_home_steps_cover_the_boot_contract(): # boot_bootstrap gates these with the per-home record; the three # user-state concerns (config, skills, state.db) must all be present. names = {name for name, _ in HOME_STEPS} assert {"migrate_config", "sync_skills", "state_db_guard"} <= names # ── run_steps isolation ────────────────────────────────────────────── def test_run_steps_isolates_failures(): order = [] def ok(): order.append("ok") return {"ok": True} def boom(): order.append("boom") raise RuntimeError("nope") results = run_steps((("first", boom), ("second", ok))) assert order == ["boom", "ok"] # failure did not stop the run assert results["first"]["ok"] is False assert "nope" in results["first"]["error"] assert results["second"] == {"ok": True} # ── step_migrate_config ────────────────────────────────────────────── def test_migrate_config_noop_when_current(monkeypatch): import hermes_cli.config as cfg monkeypatch.setattr(cfg, "check_config_version", lambda: (34, 34)) result = step_migrate_config() assert result == {"ok": True, "skipped": "up-to-date"} def test_migrate_config_restores_backup_when_version_does_not_advance( tmp_path, monkeypatch ): import hermes_cli.config as cfg import hermes_cli.config_migrations as mig config_path = tmp_path / "config.yaml" config_path.write_text("_config_version: 20\n", encoding="utf-8") env_path = tmp_path / ".env" floor = getattr(mig, "SUPPORT_FLOOR_VERSION", 12) versions = iter([(max(20, floor), 34), (max(20, floor), 34)]) monkeypatch.setattr(cfg, "check_config_version", lambda: next(versions)) monkeypatch.setattr(cfg, "get_config_path", lambda: config_path) monkeypatch.setattr(cfg, "get_env_path", lambda: env_path) def fake_migrate(**kw): # Corrupt the file; the version check will then report no advance. config_path.write_text("_config_version: 20\nbroken: true\n", encoding="utf-8") monkeypatch.setattr(cfg, "migrate_config", lambda **kw: fake_migrate(**kw)) with pytest.raises(RuntimeError, match="did not advance"): step_migrate_config() # Original content restored from the backup. assert config_path.read_text(encoding="utf-8") == "_config_version: 20\n" backups = list(tmp_path.glob("config.yaml.bak-*")) assert backups, "backup file must exist" # ── step_state_db_guard ────────────────────────────────────────────── def test_state_db_guard_skips_missing_db(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) assert step_state_db_guard() == {"ok": True, "skipped": "no-state-db"} def test_state_db_guard_flags_corrupt_db(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) (tmp_path / "state.db").write_text("this is not sqlite", encoding="utf-8") result = step_state_db_guard() assert result["ok"] is False assert result.get("error") def test_state_db_guard_passes_valid_db(tmp_path, monkeypatch): import sqlite3 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) conn = sqlite3.connect(tmp_path / "state.db") conn.execute("CREATE TABLE t (x)") conn.commit() conn.close() assert step_state_db_guard() == {"ok": True} # ── step_drop_live_plugin_catalog ──────────────────────────────────── def test_boot_drops_the_live_plugin_catalog_cache(tmp_path, monkeypatch): """Bundled-app updates skip `hermes update`'s tail; the boot step covers them (#119340).""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) cache = tmp_path / "cache" / "plugin-catalog.json" cache.parent.mkdir() cache.write_text("{}", encoding="utf-8") assert ("drop_live_plugin_catalog", post_update.step_drop_live_plugin_catalog) in post_update.BOOT_HOME_STEPS assert post_update.step_drop_live_plugin_catalog() == {"ok": True} assert not cache.exists() assert post_update.step_drop_live_plugin_catalog() == {"ok": True} # absent cache is fine # ── machine-step registry ──────────────────────────────────────────── def test_provisioning_is_the_machine_scope_driver_path(): """cua-driver has no refresh step of its own — provisioning carries it. Pinned managed tools ride pm's lockfile; the provision_runtimes sweep moves a pin bump exactly like node or ripgrep, and a second mechanism would be a second authority on a tool's version. """ names = [name for name, _ in post_update.MACHINE_STEPS] assert "provision_runtimes" in names assert not any("cua" in name for name in names) def test_provisioning_does_not_use_human_diagnostics(tmp_path, monkeypatch): import json import importlib import pm from pm import paths engine = importlib.import_module("pm.install") runtime = tmp_path / "tools" runtime.mkdir() (runtime / "facts.json").write_text(json.dumps({"schema": 1, "packages": {}})) lock = tmp_path / "lock.json" lock.write_text(json.dumps({"schema": 1, "packages": {"node": {"version": "test"}}})) monkeypatch.setenv("HERMES_RUNTIME_DIR", str(runtime)) monkeypatch.setattr(paths, "lockfile_path", lambda: lock) monkeypatch.setattr(engine, "sealed", lambda: False) monkeypatch.setattr(engine, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(pm, "check", lambda: ["translated diagnostic without a package token"]) ensured = [] monkeypatch.setattr(pm, "ensure", lambda name, **kwargs: ensured.append((name, kwargs))) assert post_update.main(["--scope", "machine"]) == 0 assert ensured == [("node", {"explicit": True})] def test_provision_runtimes_is_a_noop_when_pm_is_current(monkeypatch): import pm monkeypatch.setattr(pm, "drift", lambda: {}) assert post_update.step_provision_runtimes() == {"ok": True, "skipped": "current"} def test_provision_runtimes_reensures_only_what_pm_names(monkeypatch): import importlib import pm # pm/__init__ rebinds the name `pm.ensure` to the FUNCTION; the module # object (whose attrs step_provision_runtimes imports at call time) # comes from sys.modules. pm_ensure = importlib.import_module("pm.install") ensured = [] monkeypatch.setattr(pm, "drift", lambda: {"node": "outdated", "venv": "out of sync"}) monkeypatch.setattr(pm_ensure, "sealed", lambda: False) monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: True) monkeypatch.setattr(pm, "ensure", lambda name, explicit=False: ensured.append((name, explicit))) monkeypatch.setattr(pm, "sync_venv", lambda explicit=False, evict_incompatible_plugins=False: ensured.append(("venv", explicit and evict_incompatible_plugins))) result = post_update.step_provision_runtimes() assert result["ok"] is True assert ("node", True) in ensured and ("venv", True) in ensured def test_provision_runtimes_respects_the_lazy_install_policy(monkeypatch): import importlib import pm pm_ensure = importlib.import_module("pm.install") monkeypatch.setattr(pm, "drift", lambda: {"node": "outdated"}) monkeypatch.setattr(pm_ensure, "sealed", lambda: False) monkeypatch.setattr(pm_ensure, "lazy_installs_allowed", lambda: False) result = post_update.step_provision_runtimes() assert result == {"ok": True, "skipped": "lazy-installs-disabled"} # ── __main__ entry ─────────────────────────────────────────────────── def test_main_reports_failure_in_exit_code(monkeypatch): monkeypatch.setattr( post_update, "HOME_STEPS", (("bad", lambda: (_ for _ in ()).throw(RuntimeError("x"))),), ) monkeypatch.setattr(post_update, "MACHINE_STEPS", ()) assert post_update.main(["--scope", "home"]) == 1 def test_main_scope_selects_registries(monkeypatch): ran = [] monkeypatch.setattr( post_update, "HOME_STEPS", (("h", lambda: ran.append("h") or {"ok": True}),) ) monkeypatch.setattr( post_update, "MACHINE_STEPS", (("m", lambda: ran.append("m") or {"ok": True}),) ) assert post_update.main(["--scope", "home"]) == 0 assert ran == ["h"] ran.clear() assert post_update.main(["--scope", "all"]) == 0 assert ran == ["h", "m"] def test_scope_cli_rejects_unrelated_flags_without_running_steps(monkeypatch): ran = [] monkeypatch.setattr(post_update, "HOME_STEPS", (("home", lambda: ran.append("home") or {"ok": True}),)) monkeypatch.setattr(post_update, "MACHINE_STEPS", (("machine", lambda: ran.append("machine") or {"ok": True}),)) for flags in ( ["--gateway-mode"], ["--assume-yes"], ["--pre-update-snapshot-id", "fixture"], ["--pre-update-version", "fixture"], ["--resumed-after-sync"], ["--update-phase", "--resumed-after-sync"], ): with pytest.raises(SystemExit) as exc: post_update.main(flags) assert exc.value.code == 2 assert ran == []