Files
hermes-agent/tests/pm/test_activation_setup.py

212 lines
9.1 KiB
Python

"""Bash activation crosses real setup, PM install, and uv dependency publication.
Only the downloaded tool payloads are fixtures: bootstrap uv locates the test
interpreter, then delegates every dependency operation to real, offline uv.
PM/activation/setup code is copied unmodified; all state is disposable.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shlex
import shutil
import stat
import subprocess
import sys
import pytest
from pm.lock import Lockfile
from pm.store import current_target
from tests.pm.test_pm_core import make_tar, served # noqa: F401 -- shared HTTP fixture
from tests.pm.test_workspace_build_inputs import _wheel
pytestmark = pytest.mark.platforms("posix")
REPO = Path(__file__).resolve().parents[2]
def _snapshot(paths):
"""Notice creation, rewriting, chmod, and new children in protected locations."""
result = {}
for root in paths:
for path in [root, *sorted(root.rglob("*"))]:
if path.exists():
info = path.stat()
result[str(path)] = (
stat.S_IMODE(info.st_mode), info.st_mtime_ns,
path.read_bytes() if path.is_file() else None,
)
return result
def test_activation_real_setup_pm_lifecycle(tmp_path, served):
bash, uv = shutil.which("bash"), shutil.which("uv")
assert bash and uv, "this integration contract requires Bash and real uv"
interpreter = str(Path(sys._base_executable).resolve())
core = tmp_path / "checkout with spaces"
core.mkdir()
home = tmp_path / "home"
home.mkdir()
hermes_home = home / ".hermes"
runtime = tmp_path / "runtime"
scratch = tmp_path / "tmp"
scratch.mkdir()
env = {
"PATH": os.environ["PATH"], "HOME": str(home), "HERMES_HOME": str(hermes_home),
"HERMES_RUNTIME_DIR": str(runtime), "SHELL": bash, "TMPDIR": str(scratch),
"XDG_CONFIG_HOME": str(home / ".config"), "XDG_CACHE_HOME": str(home / ".cache"),
"LANG": "C.UTF-8", "PYTHONNOUSERSITE": "1", "UV_OFFLINE": "1",
"UV_CACHE_DIR": str(home / ".cache" / "uv"), "UV_PYTHON_DOWNLOADS": "never",
}
for name in ("activate", "setup-hermes.sh", "hermes_constants.py", "utils.py"):
shutil.copy2(REPO / name, core / name)
for name in ("pm", "hermes_cli"):
shutil.copytree(REPO / name, core / name, ignore=shutil.ignore_patterns("__pycache__"))
# Plugin selection imports the real CLI config reader even with no plugins.
# Supply its installed YAML dependency, not a stub parser or config module.
import yaml
shutil.copytree(Path(yaml.__file__).parent, core / "yaml", ignore=shutil.ignore_patterns("__pycache__"))
# Never copy real user files: these are deliberately public fixture sentinels.
protected = [core / ".env", home / ".local" / "bin", hermes_home / "skills",
home / ".bashrc", home / ".bash_profile", home / ".zshrc"]
for path in (home / ".local" / "bin" / "hermes", hermes_home / "skills" / "keep.md",
home / ".bashrc", home / ".bash_profile", home / ".zshrc"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("user-owned fixture\n", encoding="utf-8")
(core / ".env.example").write_text("FIXTURE_ONLY=example\n", encoding="utf-8")
(core / "skills").mkdir()
(core / "skills" / "bundled.md").write_text("must not be seeded\n", encoding="utf-8")
untouched = _snapshot(protected)
wheels = tmp_path / "wheels"
wheels.mkdir()
_wheel(wheels, "activation_dep", "1.0")
(core / "pyproject.toml").write_text(
'[project]\nname="activation-proof"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=["activation-dep==1.0"]\n[project.optional-dependencies]\nall=[]\n'
'[tool.uv]\npackage=false\nno-index=true\n'
f'find-links=[{json.dumps(wheels.as_posix())}]\n', encoding="utf-8",
)
locked = subprocess.run(
[uv, "lock", "--offline", "--python", interpreter], cwd=core, env=env,
capture_output=True, text=True, timeout=60,
)
assert locked.returncode == 0, locked.stdout + locked.stderr
dependency_lock = (core / "uv.lock").read_bytes()
docroot, base_url = served
# Both the shell bootstrap and PM's fallback downloader stay on loopback.
(core / "pm" / "artifact-mirror.json").write_text(
json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=2) + "\n",
encoding="utf-8",
)
calls = tmp_path / "uv-calls"
uv_script = (
f"#!{bash}\n"
f"printf '%s\\n' \"$*\" >> {shlex.quote(str(calls))}\n"
'if [ "$1 $2" = "python install" ]; then exit 0; fi\n'
f'if [ "$1 $2" = "python find" ]; then printf \'%s\\n\' {shlex.quote(interpreter)}; exit 0; fi\n'
f"exec {shlex.quote(uv)} --offline \"$@\"\n"
)
_, uv_digest = make_tar(docroot, "uv.tar.gz", {"uv-fixture/uv": uv_script})
python_script = f'#!{bash}\nexec {shlex.quote(interpreter)} "$@"\n'
lock = Lockfile(core / "pm" / "lock.json")
# Replace shipped pins, not PM implementations or its package registry.
lock.path.unlink()
lock = Lockfile(lock.path)
target = current_target()
version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}+fixture"
def pin_python(revision):
filename = f"python-{revision}.tar.gz"
_, digest = make_tar(docroot, filename, {
"python/bin/python3": python_script,
"python/revision": revision,
})
lock.set_pin("python", version, {target: {"url": f"{base_url}/{filename}", "sha256": digest}})
lock.save()
return digest
lock.set_pin("uv", "fixture", {target: {"url": f"{base_url}/uv.tar.gz", "sha256": uv_digest}})
first_digest = pin_python("first")
def activate(*, succeeds=True):
# No `set -e`: inspect source's status and prove the caller survives.
script = '''
prior_path=$PATH
prior_pythonpath=${PYTHONPATH-}
source "$1/activate"
status=$?
if [ "$status" != 0 ]; then
test "$PATH" = "$prior_path" || exit 91
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 92
test -z "${__HERMES_ACTIVATED-}" || exit 93
printf 'CALLER_SURVIVED:%s\\n' "$status"
exit "$status"
fi
python3 -c 'import activation_dep, json, os; print(json.dumps({"version": activation_dep.__version__, "module": activation_dep.__file__, "pythonpath": os.environ["PYTHONPATH"]}))' || exit 94
deactivate
test "$PATH" = "$prior_path" || exit 95
test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
'''
result = subprocess.run(
[bash, "--noprofile", "--norc", "-c", script, "activation-test", str(core)],
cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90,
)
assert _snapshot(protected) == untouched
assert (result.returncode == 0) is succeeds, result.stdout + result.stderr
return result
def selection():
records = list(hermes_home.glob("installs/*/facts.json"))
assert len(records) == 1
return json.loads(records[0].read_text())["packages"]["venv"]
def operations(name):
return [line for line in calls.read_text().splitlines() if line.split()[0] == name]
cold = activate()
first = selection()
probe = json.loads(cold.stdout)
assert probe["version"] == "1.0"
assert Path(probe["module"]).is_relative_to(Path(first["environment"]))
assert probe["pythonpath"].split(os.pathsep)[0] == str(core)
assert len(operations("venv")) == len(operations("sync")) == 1
facts = json.loads((runtime / "facts.json").read_text())["packages"]
assert facts["python"]["artifacts"] == [first_digest]
assert facts["uv"]["artifacts"] == [uv_digest]
# Also catch chmod of an existing .env, not just unwanted first creation.
(core / ".env").write_text("FIXTURE_ONLY=existing\n", encoding="utf-8")
(core / ".env").chmod(0o644)
untouched = _snapshot(protected)
activate()
assert selection() == first
assert len(operations("venv")) == len(operations("sync")) == 1
assert len([line for line in operations("python") if line.startswith("python install ")]) == 2
second_digest = pin_python("second")
activate()
second = selection()
assert second["stamp"] != first["stamp"]
assert second["environment"] != first["environment"]
assert Path(first["environment"]).is_dir()
assert len(operations("venv")) == len(operations("sync")) == 2
facts = json.loads((runtime / "facts.json").read_text())["packages"]
assert facts["python"]["artifacts"] == [second_digest]
assert (core / "uv.lock").read_bytes() == dependency_lock
# A real uv failure must cross PM -> setup -> source without selection or
# caller mutation. An invalid local lock fails deterministically offline.
(core / "uv.lock").write_text("not valid TOML [\n", encoding="utf-8")
failed = activate(succeeds=False)
assert "pm install failed" in failed.stderr
assert "setup failed" in failed.stderr
assert "CALLER_SURVIVED:" in failed.stdout
assert selection() == second
assert len(operations("sync")) == 3
assert len([line for line in operations("python") if line.startswith("python install ")]) == 4