Files
hermes-agent/tests/hermes_cli/test_doctor_command_install.py
2026-10-10 15:20:13 +02:00

195 lines
7.9 KiB
Python

"""Command diagnostics use the same selection and launch contract as setup."""
import json
from argparse import Namespace
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
from hermes_cli import _launchers, doctor, doctor_platform
from pm.environments import install_state_dir, site_packages
def _tree(tmp_path, monkeypatch):
home = tmp_path / "data"
home.mkdir()
project = tmp_path / "source"
project.mkdir()
(project / ".install_method").write_text("git", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
monkeypatch.delenv("PREFIX", raising=False)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(doctor, "PROJECT_ROOT", project)
monkeypatch.setattr(doctor, "HERMES_HOME", home)
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", ((None, doctor_platform._check_command_installation),))
command = tmp_path / ".local" / "bin" / "hermes"
command.parent.mkdir(parents=True)
monkeypatch.setenv("PATH", str(command.parent))
return project, home, command
def _generation(project):
selected = install_state_dir(project) / "environments" / "selected" / "venv"
site_packages(selected).mkdir(parents=True)
(selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
(install_state_dir(project) / "facts.json").write_text(
json.dumps({"packages": {"venv": {"environment": str(selected)}}}), encoding="utf-8"
)
return selected
def _pm_source(project, home):
root = Path(__file__).resolve().parents[2]
for relative in (
"hermes", "hermes_bootstrap.py", "hermes_constants.py", "hermes_cli/__init__.py",
"pm/environments.py", "pm/filesystem.py", "hermes_cli/runtime_state.py",
"hermes_cli/_early_recovery.py", "hermes_cli/_parser.py",
"hermes_cli/venv_sync.py", "hermes_cli/steward.py", "hermes_cli/stderr_timestamp.py",
):
target = project / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / relative, target)
(project / "hermes_cli/main.py").write_text(
"def main():\n import selected_probe\n print(selected_probe.VALUE)\n return 0\n",
encoding="utf-8",
)
interpreter = home / "tools" / "python-fixture" / "bin" / "python3"
interpreter.parent.mkdir(parents=True)
interpreter.symlink_to(Path(sys._base_executable).resolve())
(home / "tools" / "facts.json").write_text(
json.dumps({"packages": {"python": {"entry": "python-fixture"}}}), encoding="utf-8"
)
return interpreter
@pytest.mark.platforms("posix")
def test_pm_generation_does_not_require_a_legacy_console_script(tmp_path, monkeypatch, capsys):
project, home, command = _tree(tmp_path, monkeypatch)
selected = _generation(project)
_pm_source(project, home)
assert _launchers.stage_launcher("hermes", project, command.parent) == command
doctor.run_doctor(Namespace(fix=True))
out = capsys.readouterr().out
assert "All checks passed" in out
assert not (project / "venv").exists()
assert not (selected / "bin" / "hermes").exists()
assert command.is_file() and not command.is_symlink()
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("prior", ["missing", "legacy"])
def test_pm_fix_publishes_a_generation_aware_launcher(tmp_path, monkeypatch, capsys, prior):
project, home, command = _tree(tmp_path, monkeypatch)
_pm_source(project, home)
selected = _generation(project)
(site_packages(selected) / "selected_probe.py").write_text("VALUE = 'selected'\n", encoding="utf-8")
stale = project / "venv" / "bin" / "hermes"
stale.parent.mkdir(parents=True)
stale.write_text("#!/bin/sh\nexit 99\n", encoding="utf-8")
stale.chmod(0o755)
if prior == "legacy":
command.symlink_to(stale)
doctor.run_doctor(Namespace(fix=True))
assert "Fixed 1 issue" in capsys.readouterr().out
assert not command.is_symlink()
result = subprocess.run([str(command)], cwd=tmp_path, capture_output=True, text=True, timeout=30, check=False)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stdout.strip() == "selected"
assert stale.is_file()
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("kind", ["wrapper", "symlink"])
def test_fix_preserves_user_managed_commands(tmp_path, monkeypatch, capsys, kind):
project, home, command = _tree(tmp_path, monkeypatch)
_pm_source(project, home)
_generation(project)
wrapper = tmp_path / "custom-wrapper"
body = "#!/bin/sh\nexit 42\n"
wrapper.write_text(body, encoding="utf-8")
wrapper.chmod(0o755)
if kind == "symlink":
command.symlink_to(wrapper)
else:
shutil.copy2(wrapper, command)
doctor.run_doctor(Namespace(fix=True))
out = capsys.readouterr().out
assert "Fixed 1 issue" not in out
assert command.read_text(encoding="utf-8") == body
if kind == "symlink":
assert command.is_symlink() and command.resolve() == wrapper
assert "manual" in out.lower()
@pytest.mark.parametrize("layout", ["legacy", "generation"])
@pytest.mark.parametrize("active", [True, False])
def test_doctor_reports_selected_import_tree_not_interpreter_prefix(tmp_path, monkeypatch, capsys, layout, active):
import importlib
import pm.paths
project, _home, _command = _tree(tmp_path, monkeypatch)
if layout == "generation":
selected = _generation(project)
else:
selected = project / "venv"
site_packages(selected).mkdir(parents=True)
(selected / "pyvenv.cfg").write_text("home = fixture\n", encoding="utf-8")
monkeypatch.setattr(pm.paths, "repo_root", lambda: project)
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", ((None, doctor_platform._check_python_environment),))
# A venv prefix with no selected imports is not evidence of activation;
# conversely PM's base interpreter can import the selected tree directly.
monkeypatch.setattr(sys, "prefix", sys.base_prefix if active else str(selected))
if active:
module = site_packages(selected) / "doctor_selected_probe.py"
module.write_text("VALUE = 'selected'\n", encoding="utf-8")
monkeypatch.syspath_prepend(str(site_packages(selected)))
monkeypatch.delitem(sys.modules, "doctor_selected_probe", raising=False)
loaded = importlib.import_module("doctor_selected_probe")
assert Path(loaded.__file__) == module
monkeypatch.delitem(sys.modules, "doctor_selected_probe")
doctor.run_doctor(Namespace(fix=False))
out = capsys.readouterr().out
assert str(selected) in out
assert ("active in this process" in out) is active
assert ("runs outside it" in out) is not active
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("method, remedy", [
("git", "hermes pm repair"), ("nix", "Nix"), ("docker", "docker pull"), ("apt", "pkg upgrade"),
])
def test_remedies_and_launcher_repairs_respect_install_owner(tmp_path, monkeypatch, capsys, method, remedy):
project, _home, command = _tree(tmp_path, monkeypatch)
(project / ".install_method").write_text(method, encoding="utf-8")
entry = project / "venv" / "bin" / "hermes"
entry.parent.mkdir(parents=True)
entry.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
entry.chmod(0o755)
monkeypatch.setattr(doctor_platform, "_PACKAGES", (("doctor_missing_dependency_probe", "Required dependency", False),))
monkeypatch.setattr(doctor, "DOCTOR_CHECKS", (
(None, doctor_platform._check_required_packages), (None, doctor_platform._check_command_installation),
))
doctor.run_doctor(Namespace(fix=True))
out = capsys.readouterr().out
assert remedy in out
assert "pip install" not in out
if method == "git":
assert command.is_symlink() and command.resolve() == entry
else:
assert not command.exists()
assert "hermes pm repair" not in out