hermes-agent/tests/hermes_cli/test_post_update.py

311 lines
11 KiB
Python

"""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.
"""
import json
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"
def test_migrate_config_restores_backup_on_exception(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")
floor = getattr(mig, "SUPPORT_FLOOR_VERSION", 12)
monkeypatch.setattr(cfg, "check_config_version", lambda: (max(20, floor), 34))
monkeypatch.setattr(cfg, "get_config_path", lambda: config_path)
monkeypatch.setattr(cfg, "get_env_path", lambda: tmp_path / ".env")
def exploding_migrate(**kw):
config_path.write_text("half-written garbage", encoding="utf-8")
raise RuntimeError("migration blew up")
monkeypatch.setattr(cfg, "migrate_config", lambda **kw: exploding_migrate(**kw))
with pytest.raises(RuntimeError, match="blew up"):
step_migrate_config()
assert config_path.read_text(encoding="utf-8") == "_config_version: 20\n"
# ── 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}
# ── cua refresh gating ───────────────────────────────────────────────
def test_cua_refresh_skips_when_config_disabled(monkeypatch):
import hermes_cli.config as cfg
monkeypatch.setattr(
cfg, "load_config", lambda: {"updates": {"refresh_cua_driver": False}}
)
result = post_update.step_cua_driver_refresh()
assert result == {"ok": True, "skipped": "config-disabled"}
def test_cua_refresh_skips_when_binary_absent(monkeypatch):
import shutil as _shutil
import hermes_cli.config as cfg
monkeypatch.setattr(cfg, "load_config", lambda: {})
monkeypatch.setattr(_shutil, "which", lambda name: None)
result = post_update.step_cua_driver_refresh()
assert result == {"ok": True, "skipped": "not-installed"}
# ── __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"]
# ── --update-phase runner mode ───────────────────────────────────────
def test_main_update_phase_delegates_with_parsed_flags(monkeypatch):
"""--update-phase routes to update_cmd._run_update_phase_inline with
the CLI flags mapped through and NO windows resume token (the token
is process-local to the parent)."""
import hermes_cli.update_cmd as uc
seen = {}
def fake_phase(**kw):
seen.update(kw)
return 0
monkeypatch.setattr(uc, "_run_update_phase_inline", fake_phase)
rc = post_update.main([
"--update-phase", "--gateway-mode", "--assume-yes",
"--pre-update-snapshot-id", "snap-123",
])
assert rc == 0
assert seen == {
"gateway_mode": True,
"assume_yes": True,
"pre_update_snapshot_id": "snap-123",
"windows_gateway_resume": None,
}
def test_main_update_phase_propagates_exit_code(monkeypatch):
import hermes_cli.update_cmd as uc
monkeypatch.setattr(uc, "_run_update_phase_inline", lambda **kw: 1)
assert post_update.main(["--update-phase"]) == 1
# ── _spawn_post_update_phase ─────────────────────────────────────────
def _spawn(monkeypatch, tmp_path, *, runner_exists=True, run_result=0, run_raises=None, **kw):
"""Drive update_cmd._spawn_post_update_phase with a fake subprocess."""
import hermes_cli.main as hm
import hermes_cli.update_cmd as uc
root = tmp_path / "checkout"
(root / "hermes_cli").mkdir(parents=True)
if runner_exists:
(root / "hermes_cli" / "post_update.py").write_text("# runner\n", encoding="utf-8")
monkeypatch.setattr(hm, "PROJECT_ROOT", root)
captured = {}
def fake_run(cmd, **kwargs):
if run_raises:
raise run_raises
captured["cmd"] = cmd
captured["kwargs"] = kwargs
import subprocess as sp
return sp.CompletedProcess(cmd, run_result)
import subprocess as sp
monkeypatch.setattr(sp, "run", fake_run)
rc = uc._spawn_post_update_phase(
gateway_mode=kw.get("gateway_mode", False),
assume_yes=kw.get("assume_yes", False),
pre_update_snapshot_id=kw.get("pre_update_snapshot_id"),
)
return rc, captured
def test_spawn_command_shape_and_env(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_DESKTOP_CHILD_PID", "424242")
rc, cap = _spawn(
monkeypatch, tmp_path,
gateway_mode=True, assume_yes=True, pre_update_snapshot_id="snap-9",
)
assert rc == 0
cmd = cap["cmd"]
assert cmd[1:4] == ["-m", "hermes_cli.post_update", "--update-phase"]
assert "--gateway-mode" in cmd and "--assume-yes" in cmd
assert cmd[cmd.index("--pre-update-snapshot-id") + 1] == "snap-9"
env = cap["kwargs"]["env"]
# Inherit-and-extend: desktop contracts survive, unbuffered forced on.
assert env["HERMES_DESKTOP_CHILD_PID"] == "424242"
assert env["PYTHONUNBUFFERED"] == "1"
# Inherited stdio: no capture/pipe arguments.
assert "stdout" not in cap["kwargs"] and "capture_output" not in cap["kwargs"]
def test_spawn_returns_child_exit_code(monkeypatch, tmp_path):
rc, _ = _spawn(monkeypatch, tmp_path, run_result=1)
assert rc == 1
def test_spawn_none_when_runner_missing(monkeypatch, tmp_path):
rc, _ = _spawn(monkeypatch, tmp_path, runner_exists=False)
assert rc is None
def test_spawn_none_when_spawn_raises(monkeypatch, tmp_path):
rc, _ = _spawn(monkeypatch, tmp_path, run_raises=OSError("no exec"))
assert rc is None