Files
hermes-agent/tests/hermes_cli/test_plugin_historic_compat.py
ethernet 2eec0d9b64 fix(install): the shared completion tail runs from a source slice
- build_update_products builds only the frontends the checkout carries; a
  python-only slice (the installer's acceptance fixture) publishes commands
  and runs maintenance without asking PM for node.
- stderr_timestamp.py is a launcher boot file copied into published
  commands; it inlines the EX_CONFIG code instead of importing gateway.restart.
- Tests: the stamp-writer slice gains hermes_cli/release_channels.py and
  pm/paths.py (the modules update_channel now imports); the source-launch
  fixture records the source_completion hand-off (--finish-update) instead
  of building products; the stdlib recovery probe blocks PM's engine
  modules, not the pm.environments boot leaf; the memory-provider restart
  test selects a generation the running interpreter has not activated;
  the warm-path installer stage is `products`.
2026-09-19 02:53:58 -04:00

166 lines
7.0 KiB
Python

"""Shipped plugin callers keep their contract without owning PM publication."""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import subprocess
import sys
import pytest
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _plugin(home, name, *, dependencies=True):
path = home / "plugins" / name
path.mkdir(parents=True)
(path / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8")
if dependencies:
(path / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
return path
@pytest.mark.parametrize("active", [None, "default", "profile"])
def test_candidate_member_dirs_preserves_proposed_home_order_and_extras(isolated_home, monkeypatch, active):
from hermes_cli import plugins_admission
home = isolated_home
profile = home / "profiles" / "coder"
profile.mkdir(parents=True)
for directory in (home, profile):
(directory / "config.yaml").write_text("plugins:\n enabled: [old]\n", encoding="utf-8")
_plugin(directory, "old")
_plugin(directory, "new")
_plugin(directory, "blocked")
_plugin(directory, "plain", dependencies=False)
extra = _plugin(home, "extra")
selected_home = profile if active == "profile" else home
expected = [home / "plugins" / "old", profile / "plugins" / "old"]
if active is not None:
expected[active == "profile"] = selected_home / "plugins" / "new"
extra_existing = expected[0]
before = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()}
# Discovery must use PM's independent manifest reader, not application config/UI.
monkeypatch.setitem(sys.modules, "hermes_cli.config", None)
monkeypatch.setitem(sys.modules, "hermes_cli.plugins_cmd", None)
result = plugins_admission.candidate_member_dirs(
iter(["new", "blocked", "plain", "new"]), iter(["blocked"]),
active_plugins_dir=str(selected_home / "plugins") if active else None,
extra_dirs=iter([str(extra_existing), str(extra), str(extra), home / "plugins" / "plain", home / "missing"]),
)
assert isinstance(result, list)
assert result == [*expected, extra]
assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before
@pytest.fixture
def publication(isolated_home, tmp_path):
from pm.environments import install_state_dir
project = tmp_path / "checkout"
project.mkdir()
target = _plugin(isolated_home, "published")
(target / "version").write_bytes(b"new code")
backup = _plugin(isolated_home, ".previous-historic")
(backup / "version").write_bytes(b"old code")
metadata = target.parent / ".install-metadata.json"
metadata.write_bytes(b'{"published": "new"}\n')
# Old callers pass the already-decoded row and exact journal path. The
# adapter must not rediscover/reparse a different current journal.
journal = tmp_path / "historic-publication.json"
journal.write_bytes(b"already decoded by the old caller")
canonical = install_state_dir(project) / "publication.json"
canonical.parent.mkdir(parents=True)
canonical.write_bytes(b"unrelated publication; do not read or remove")
row = {
"kind": "plugin", "target": str(target), "backup": str(backup),
"metadata": str(metadata), "target_existed": True, "facts_before": None,
"metadata_before": base64.b64encode(b'{"published": "old"}\n').decode(),
"metadata_after": base64.b64encode(metadata.read_bytes()).decode(),
}
return project, row, journal, canonical
def _recover_in_stdlib(publication):
project, row, journal, _ = publication
script = """
import json
from pathlib import Path
import sys
sys.path.insert(0, sys.argv[1])
# No selected application environment, no PM engine, and no CLI config/UI reader.
# pm.environments/pm.paths are stdlib boot leaves the recovery owner may use.
for module in ('pm.client', 'pm.install', 'pm.store', 'pm.workspace', 'hermes_cli.config', 'hermes_cli.plugins_cmd'):
sys.modules[module] = None
from hermes_cli import plugins_transaction
row = json.loads(sys.stdin.read())
plugins_transaction.recover_plugin_publication(
project=Path(sys.argv[2]), row=row, journal=Path(sys.argv[3]),
)
"""
return subprocess.run(
[sys.executable, "-I", "-S", "-c", script, str(Path(__file__).resolve().parents[2]), str(project), str(journal)],
input=json.dumps(row), text=True, capture_output=True, check=False,
env={**os.environ, "HERMES_HOME": str(Path(row["metadata"]).parent.parent)},
)
@pytest.mark.parametrize("commit", ["rollback", "explicit", "facts-changed"])
def test_old_publication_recovers_in_stdlib_using_supplied_row_and_journal(publication, commit):
from pm.environments import runtime_facts_path
project, row, journal, canonical = publication
target, backup, metadata = (Path(row[key]) for key in ("target", "backup", "metadata"))
if commit == "explicit":
row["committed"] = True
elif commit == "facts-changed":
runtime_facts_path(project).write_bytes(b"new facts")
result = _recover_in_stdlib(publication)
assert result.returncode == 0, result.stderr
assert (target / "version").read_bytes() == (b"old code" if commit == "rollback" else b"new code")
expected = row["metadata_before"] if commit == "rollback" else row["metadata_after"]
assert metadata.read_bytes() == base64.b64decode(expected)
assert not backup.exists()
assert not journal.exists()
assert canonical.read_bytes() == b"unrelated publication; do not read or remove"
def test_old_publication_rolls_back_a_first_install(publication):
from hermes_cli import plugins_transaction
import shutil
project, row, journal, _ = publication
shutil.rmtree(row["backup"])
row.update(target_existed=False, metadata_before=None)
plugins_transaction.recover_plugin_publication(project, row, journal)
assert not Path(row["target"]).exists()
assert not Path(row["metadata"]).exists()
assert not journal.exists()
@pytest.mark.parametrize("invalid", ["target", "backup", "metadata", "edited-metadata"])
def test_old_publication_refuses_unsafe_or_changed_state_without_writes(publication, tmp_path, invalid):
from hermes_cli import plugins_transaction
project, row, journal, _ = publication
if invalid == "edited-metadata":
Path(row["metadata"]).write_bytes(b"independent user edit")
else:
outside = tmp_path / "outside" / "plugins"
outside.mkdir(parents=True)
row[invalid] = str(outside / Path(row[invalid]).name)
before = {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()}
with pytest.raises(ValueError, match="paths escape|metadata changed"):
plugins_transaction.recover_plugin_publication(project, row, journal)
assert {p: p.read_bytes() for p in tmp_path.rglob("*") if p.is_file()} == before
assert journal.exists()