The launch-time completion tail now spawns through update_custody.run (checkout custody), but this file's fixture still patched venv_sync.subprocess.call: the owner test ran a real source_completion.py child against a scratch checkout (red on macOS) and the borrowing test's 'no tail ran' assertion was vacuous. Patch the seam production uses, as test_update_launch_completion.py already does.
211 lines
9.7 KiB
Python
211 lines
9.7 KiB
Python
"""A launch under a data root that does not own the checkout leaves the checkout to its owner (#123238).
|
|
|
|
Layout in every test: the user's default root is ``<tmp>/.hermes`` (``Path.home`` is ``<tmp>``) and
|
|
the checkout is ``<tmp>/.hermes/hermes-agent``, the ``install.sh`` layout. A root "has state" for the
|
|
checkout when ``<root>/installs/<install_key>/facts.json`` exists -- the record a committed
|
|
dependency sync leaves.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import venv_sync
|
|
from pm.environments import install_key, install_state_dir, owning_home_root, store_root
|
|
|
|
# Every lane, Windows included: the owner check resolves the platform default root, which differs on Windows, so the
|
|
# marked-OS lanes (scripts/ci/list_os_marked_tests.py) must collect this file too.
|
|
pytestmark = pytest.mark.platforms("any")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_tool_downloads(monkeypatch):
|
|
import pm.client
|
|
|
|
monkeypatch.setattr(pm.client, "ensure_tools_for_sync", lambda: None)
|
|
|
|
|
|
@pytest.fixture
|
|
def completion_tail(monkeypatch):
|
|
"""Record the completion child instead of running it (the tail spawns via update_custody.run)."""
|
|
from hermes_cli import update_custody
|
|
|
|
spawned: list = []
|
|
monkeypatch.setattr(update_custody, "run", lambda command, **kw: spawned.append(command)
|
|
or subprocess.CompletedProcess(command, 0))
|
|
return spawned
|
|
|
|
|
|
def _checkout(tmp_path, monkeypatch, *, parent: Path | None = None) -> Path:
|
|
import hermes_constants
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
# The layout's default root is ``<tmp>/.hermes`` on every OS. On Windows the platform default is
|
|
# ``%LOCALAPPDATA%\hermes`` (``Path.home`` only backs an unset ``LOCALAPPDATA``), and no
|
|
# ``LOCALAPPDATA`` value spells ``<tmp>/.hermes``, so pin the default itself.
|
|
monkeypatch.setattr(hermes_constants, "_get_platform_default_hermes_home", lambda: tmp_path / ".hermes")
|
|
monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False)
|
|
monkeypatch.delenv("HERMES_RUNTIME_DIR", raising=False)
|
|
root = (parent or tmp_path / ".hermes") / "hermes-agent"
|
|
root.mkdir(parents=True)
|
|
(root / ".git").mkdir()
|
|
(root / "pyproject.toml").write_text("[project]\nname='example'\n", encoding="utf-8")
|
|
(root / "install-stamp.json").write_text(json.dumps({"updateMechanism": "self", "commit": "abc"}), encoding="utf-8")
|
|
return root
|
|
|
|
|
|
def _state(data_root: Path, checkout: Path) -> Path:
|
|
facts = data_root / "installs" / install_key(checkout) / "facts.json"
|
|
facts.parent.mkdir(parents=True, exist_ok=True)
|
|
facts.write_text(json.dumps({"packages": {"venv": {"stamp": "complete", "extras": ["all"]}}}), encoding="utf-8")
|
|
return facts
|
|
|
|
|
|
def _home(monkeypatch, path: Path) -> Path:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(path))
|
|
return path
|
|
|
|
|
|
def test_a_fresh_install_and_a_custom_root_keep_their_own_store(tmp_path, monkeypatch):
|
|
# Nobody has state yet: the launching root is about to become the owner.
|
|
root = _checkout(tmp_path, monkeypatch, parent=tmp_path / "data" / "h")
|
|
_home(monkeypatch, tmp_path / "data" / "h")
|
|
assert owning_home_root(root) is None
|
|
assert store_root(root) == tmp_path / "data" / "h" / "tools"
|
|
|
|
# `install.sh --hermes-home /data/h` owns its tree even if the default root once
|
|
# borrowed it and kept state of its own.
|
|
_state(tmp_path / "data" / "h", root)
|
|
_state(tmp_path / ".hermes", root)
|
|
assert owning_home_root(root) is None
|
|
assert store_root(root) == tmp_path / "data" / "h" / "tools"
|
|
|
|
|
|
def test_a_borrowers_own_dependency_state_never_makes_it_the_owner(tmp_path, monkeypatch):
|
|
"""`install.sh --hermes-home <custom>` owns the checkout. The default root launching it borrows,
|
|
and still borrows after its own dependency sync leaves state for the checkout."""
|
|
custom = tmp_path / "data" / "h"
|
|
root = _checkout(tmp_path, monkeypatch, parent=custom)
|
|
_state(custom, root)
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
assert owning_home_root(root) == custom
|
|
|
|
_state(tmp_path / ".hermes", root) # the borrowing launch's own sync
|
|
assert owning_home_root(root) == custom
|
|
assert store_root(root) == custom / "tools"
|
|
|
|
|
|
def test_a_borrower_holding_the_checkout_never_becomes_its_owner(tmp_path, monkeypatch):
|
|
"""`install.sh --dir <dir>` puts the default root's checkout in <dir>. Launching it with
|
|
HERMES_HOME=<dir> borrows, and still borrows after that root's own sync leaves state --
|
|
even when an explicit sync points HERMES_RUNTIME_DIR at <dir>'s own store."""
|
|
import os
|
|
|
|
import pm
|
|
from hermes_cli import _launchers
|
|
|
|
default = tmp_path / ".hermes"
|
|
holder = tmp_path / "data" / "dir"
|
|
root = _checkout(tmp_path, monkeypatch, parent=holder)
|
|
entry = default / "tools" / "python-owner"
|
|
python = entry / ("python.exe" if os.name == "nt" else "bin/python3")
|
|
python.parent.mkdir(parents=True)
|
|
python.touch()
|
|
(default / "tools" / "facts.json").write_text(json.dumps(
|
|
{"schema": 1, "packages": {"python": {"version": "fixture", "entry": entry.name}}}), encoding="utf-8")
|
|
_state(default, root)
|
|
_home(monkeypatch, default)
|
|
local = root / ".hermes" / "bin"
|
|
published = {path: Path(path).read_bytes() for path in _launchers.ensure_install_launchers(root, local)}
|
|
assert published
|
|
|
|
_home(monkeypatch, holder)
|
|
_state(holder, root) # the borrowing launch's own sync
|
|
assert owning_home_root(root) == default
|
|
assert store_root(root) == default / "tools"
|
|
monkeypatch.setattr(pm, "venv_is_current", lambda **kwargs: True)
|
|
assert venv_sync.prepare_launch(root, []) == python
|
|
assert {path: Path(path).read_bytes() for path in published} == published
|
|
|
|
# An explicit sync publishes outside prepare_launch, and <dir>/tools matches the owner layout.
|
|
borrowed = holder / "tools" / "python-holder"
|
|
(borrowed / ("python.exe" if os.name == "nt" else "bin/python3")).parent.mkdir(parents=True)
|
|
(borrowed / ("python.exe" if os.name == "nt" else "bin/python3")).touch()
|
|
(holder / "tools" / "facts.json").write_text(json.dumps(
|
|
{"schema": 1, "packages": {"python": {"version": "fixture", "entry": borrowed.name}}}), encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(holder / "tools"))
|
|
monkeypatch.setattr(_launchers, "expose_cli", lambda *args, **kwargs: {"ok": True})
|
|
assert venv_sync.sync(root)["ok"]
|
|
assert {path: Path(path).read_bytes() for path in published} == published
|
|
monkeypatch.delenv("HERMES_RUNTIME_DIR")
|
|
assert owning_home_root(root) == default
|
|
|
|
|
|
def test_a_borrowing_launch_syncs_its_own_dependencies_and_nothing_of_the_checkout(
|
|
tmp_path, monkeypatch, completion_tail
|
|
):
|
|
"""The first launch under a temporary home: its own dependency generation, but no tail (products,
|
|
maintenance, install stamp), no launcher publication and no owner marker touched."""
|
|
import pm
|
|
from hermes_cli import _launchers
|
|
|
|
root = _checkout(tmp_path, monkeypatch)
|
|
_state(tmp_path / ".hermes", root)
|
|
_home(monkeypatch, tmp_path / "tmp" / "hermes-flash-compat-x" / "a")
|
|
# The owner's store, so no tool store is downloaded into the temporary home.
|
|
assert owning_home_root(root) == tmp_path / ".hermes"
|
|
assert store_root(root) == tmp_path / ".hermes" / "tools"
|
|
stamp = (root / "install-stamp.json").read_bytes()
|
|
owner_marker = root / ".update-incomplete"
|
|
owner_marker.write_text("owner's update\n", encoding="utf-8")
|
|
borrowed_facts = install_state_dir(root) / "facts.json"
|
|
syncs = []
|
|
|
|
def sync(extras=None, **kwargs):
|
|
syncs.append(extras)
|
|
borrowed_facts.parent.mkdir(parents=True, exist_ok=True)
|
|
borrowed_facts.write_text("{}", encoding="utf-8")
|
|
|
|
python = tmp_path / ".hermes" / "tools" / "python" / "bin" / "python3"
|
|
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: borrowed_facts.is_file())
|
|
monkeypatch.setattr(pm, "sync_venv", sync)
|
|
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: python)
|
|
monkeypatch.setattr(venv_sync, "publish_launchers", lambda *a, **kw: pytest.fail("published the owner's launchers"))
|
|
|
|
assert venv_sync.prepare_launch(root, ["kanban", "init"]) == python # relaunch into the synced env
|
|
assert len(syncs) == 1
|
|
assert completion_tail == [], "a borrowing launch ran the checkout's update tail"
|
|
assert not venv_sync.completion_pending_path(root).exists(), "a tail was armed for a borrowing root"
|
|
assert owner_marker.is_file(), "the owner's update marker was removed"
|
|
assert (root / "install-stamp.json").read_bytes() == stamp
|
|
|
|
# Its relaunch finds everything current and does nothing further.
|
|
monkeypatch.setattr(sys, "executable", str(python))
|
|
assert venv_sync.prepare_launch(root, ["kanban", "init"]) is None
|
|
assert len(syncs) == 1 and completion_tail == []
|
|
|
|
|
|
def test_the_owner_still_owes_and_runs_its_tail(tmp_path, monkeypatch, completion_tail):
|
|
"""Unchanged for the owner: a stale environment syncs, arms and runs the tail."""
|
|
import pm
|
|
from hermes_cli import _launchers
|
|
|
|
root = _checkout(tmp_path, monkeypatch)
|
|
facts = _state(tmp_path / ".hermes", root)
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
facts.unlink()
|
|
monkeypatch.setattr(pm, "venv_is_current", lambda **kw: facts.is_file())
|
|
monkeypatch.setattr(pm, "sync_venv", lambda *a, **kw: facts.write_text("{}", encoding="utf-8"))
|
|
monkeypatch.setattr(_launchers, "resolve_store_python", lambda _: Path(sys.executable))
|
|
published = []
|
|
monkeypatch.setattr(venv_sync, "publish_launchers", lambda r, **kw: published.append(r))
|
|
|
|
assert venv_sync.prepare_launch(root, []) == Path(sys.executable)
|
|
assert len(completion_tail) == 1
|
|
assert published == [root]
|