311 lines
11 KiB
Python
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
|