281 lines
15 KiB
Python
281 lines
15 KiB
Python
"""Command → real PM worker → offline wheels → persisted state → fresh imports."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from tests.hermes_cli.plugin_worker_support import (
|
|
plugin_world as plugin_world,
|
|
isolated_python as isolated_python,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("surface,suffix", [
|
|
("pyproject", "yaml"), ("python_dependencies", "yaml"), ("pip_dependencies", "yaml"),
|
|
("python_dependencies", "yml"), ("pip_dependencies", "yml"),
|
|
])
|
|
def test_declaration_consent_admission_and_resync(plugin_world, monkeypatch, capsys, surface, suffix):
|
|
from hermes_cli import plugins_cmd
|
|
from pm import client, receipt
|
|
|
|
world = plugin_world
|
|
origin, revision = world.origin(surface=surface, suffix=suffix)
|
|
before = (world.home / "config.yaml").read_bytes()
|
|
if (surface, suffix) == ("pyproject", "yaml"):
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: False)
|
|
world.command("install", identifier=origin.as_uri(), ref=revision, enable=True, allow_removed=True)
|
|
assert world.enabled() == [], "non-interactive Python admission bypassed consent"
|
|
assert (world.home / "config.yaml").read_bytes() == before
|
|
assert "skipped (non-interactive)" in " ".join(capsys.readouterr().out.split())
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
|
|
monkeypatch.setattr("builtins.input", lambda prompt: "no")
|
|
world.command("install", identifier=origin.as_uri(), force=True, enable=True, allow_removed=True)
|
|
assert world.enabled() == []
|
|
assert (world.home / "config.yaml").read_bytes() == before
|
|
assert "declined" in capsys.readouterr().out
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
|
|
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
|
|
world.command("install", identifier=origin.as_uri(), force=True, enable=True, allow_removed=True)
|
|
assert world.enabled() == ["plugin-worker-proof"]
|
|
assert receipt.latest()["outcome"] == "ok"
|
|
world.imports()
|
|
selected = world.selected()
|
|
client.sync_venv(explicit=True)
|
|
assert world.selected() == selected
|
|
world.imports()
|
|
|
|
declaration = world.home / "plugins/plugin-worker-proof" / ("pyproject.toml" if surface == "pyproject" else f"plugin.{suffix}")
|
|
declaration.write_text(declaration.read_text(encoding="utf-8").replace("==1.0", "==2.0"), encoding="utf-8")
|
|
client.sync_venv(explicit=True)
|
|
assert world.selected() != selected, "changed declaration was not stamped"
|
|
# Code has not changed: inspect the new dependency independently.
|
|
import os
|
|
import subprocess
|
|
python = world.selected() / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
result = subprocess.run([str(python), "-I", "-c", "import plugin_proof_dep; assert plugin_proof_dep.__version__ == '2.0'"],
|
|
capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def test_active_force_reinstall_decline_preserves_entire_selection(plugin_world, monkeypatch):
|
|
from hermes_cli import plugins_cmd
|
|
from pm import paths
|
|
from tests.hermes_cli.plugin_worker_support import git, version
|
|
|
|
world = plugin_world
|
|
origin, first = world.origin()
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
|
|
answers = []
|
|
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "yes")
|
|
world.command("install", identifier=origin.as_uri(), ref=first, enable=True, allow_removed=True)
|
|
world.imports()
|
|
target = world.home / "plugins/plugin-worker-proof"
|
|
watched = [world.home / "config.yaml", target / "__init__.py", target / "plugin.yaml",
|
|
target.parent / ".install-metadata.json", paths.runtime_facts_path()]
|
|
before = {path: path.read_bytes() for path in watched}
|
|
selected = world.selected()
|
|
second = version(origin, pin="2.0")
|
|
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "no")
|
|
try:
|
|
world.command("install", identifier=origin.as_uri(), ref=second, force=True, enable=True, allow_removed=True)
|
|
except SystemExit as exc:
|
|
assert exc.code == 1
|
|
assert {path: path.read_bytes() for path in watched} == before
|
|
assert git(target, "rev-parse", "HEAD") == first
|
|
assert world.selected() == selected
|
|
world.imports()
|
|
|
|
answers.clear()
|
|
monkeypatch.setattr("builtins.input", lambda prompt: answers.append(prompt) or "yes")
|
|
world.command("install", identifier=origin.as_uri(), ref=second, force=True, enable=True, allow_removed=True)
|
|
assert len(answers) == 1, "active replacement asked for the same consent twice"
|
|
assert git(target, "rev-parse", "HEAD") == second
|
|
assert world.selected() != selected
|
|
world.imports(pin="2.0")
|
|
|
|
|
|
@pytest.mark.parametrize("flag", ["no_enable", "enable"])
|
|
def test_active_reinstall_does_not_overwrite_a_later_disable(plugin_world, monkeypatch, flag):
|
|
from hermes_cli import plugins_cmd
|
|
from pm import paths
|
|
from tests.hermes_cli.plugin_worker_support import version
|
|
|
|
world = plugin_world
|
|
origin, first = world.origin()
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
|
|
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
|
|
world.command("install", identifier=origin.as_uri(), ref=first, enable=True, allow_removed=True)
|
|
second = version(origin, pin="2.0")
|
|
watched = [world.home / "config.yaml", paths.runtime_facts_path()]
|
|
acknowledged = {}
|
|
def disable_after_publication(*args):
|
|
world.command("disable", name="plugin-worker-proof")
|
|
acknowledged.update({path: path.read_bytes() for path in watched})
|
|
monkeypatch.setattr(plugins_cmd, "_display_after_install", disable_after_publication)
|
|
world.command("install", identifier=origin.as_uri(), ref=second, force=True, allow_removed=True, **{flag: True})
|
|
assert acknowledged
|
|
assert {path: path.read_bytes() for path in watched} == acknowledged
|
|
assert world.enabled() == []
|
|
|
|
|
|
def test_malformed_portable_member_cannot_join_a_new_generation(plugin_world, monkeypatch):
|
|
import json
|
|
from hermes_cli import plugins_cmd
|
|
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
|
from pm import client, paths
|
|
from tests.hermes_cli.plugin_worker_support import git
|
|
|
|
world = plugin_world
|
|
origin, _ = world.origin(surface="pyproject")
|
|
(origin / "plugin.yaml").unlink()
|
|
(origin / "plugin.json").write_text(json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "plugin-worker-proof"}), encoding="utf-8")
|
|
git(origin, "add", "--all")
|
|
git(origin, "commit", "-qm", "portable declaration")
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
|
|
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
|
|
world.command("install", identifier=origin.as_uri(), enable=True, allow_removed=True)
|
|
world.imports()
|
|
watched = [world.home / "config.yaml", paths.runtime_facts_path()]
|
|
before = {path: path.read_bytes() for path in watched}
|
|
(world.home / "plugins/plugin-worker-proof/plugin.json").write_text("{malformed", encoding="utf-8")
|
|
with pytest.raises((ValueError, RuntimeError), match="plugin.json"):
|
|
client.sync_venv(explicit=True)
|
|
assert {path: path.read_bytes() for path in watched} == before
|
|
|
|
|
|
def test_pack_admits_compatible_members_and_reports_conflict(plugin_world, monkeypatch, capsys):
|
|
import json
|
|
import hermes_yaml as yaml
|
|
from hermes_cli import plugins_cmd
|
|
from tests.hermes_cli.plugin_worker_support import git, version
|
|
|
|
world = plugin_world
|
|
incumbent, first = world.origin()
|
|
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
|
|
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
|
|
monkeypatch.setattr("builtins.input", lambda prompt: "yes")
|
|
world.command("install", identifier=incumbent.as_uri(), ref=first, enable=True, allow_removed=True)
|
|
world.imports()
|
|
good, good_sha = world.origin(name="pack-good", capabilities=["tools.override"])
|
|
bad, bad_sha = world.origin(name="pack-conflict", pin="2.0")
|
|
last, last_sha = world.origin(name="pack-last", capabilities=["tools.override"])
|
|
# Moving HEAD cannot move a pack's pinned selection.
|
|
version(good, name="pack-good", pin="2.0")
|
|
pack = world.root / "pack.yaml"
|
|
pack.write_text(yaml.safe_dump({"name": "proof-pack", "plugins": [
|
|
{"repo": repo.as_uri(), "ref": sha} for repo, sha in [(good, good_sha), (bad, bad_sha), (last, last_sha)]
|
|
], "config": {"pack-good": {"voice": "fixture"}}}), encoding="utf-8")
|
|
answers = iter(["yes", "yes", "no"])
|
|
monkeypatch.setattr("rich.console.Console.input", lambda self, prompt: next(answers))
|
|
with pytest.raises(SystemExit) as exc:
|
|
world.command("pack", pack_action="install", source=str(pack))
|
|
assert exc.value.code == 1
|
|
from rich.text import Text
|
|
assert "2 installed, 1 failed" in " ".join(Text.from_ansi(capsys.readouterr().out).plain.split())
|
|
assert set(world.enabled()) == {"plugin-worker-proof", "pack-good", "pack-last"}
|
|
for name, sha in [("pack-good", good_sha), ("pack-conflict", bad_sha), ("pack-last", last_sha)]:
|
|
assert git(world.home / "plugins" / name, "rev-parse", "HEAD") == sha
|
|
for name in ("plugin-worker-proof", "pack-good", "pack-last"):
|
|
world.imports(name)
|
|
config = yaml.safe_load((world.home / "config.yaml").read_text(encoding="utf-8"))
|
|
entries = config["plugins"]["entries"]
|
|
assert entries["pack-good"]["voice"] == "fixture"
|
|
assert "tools.override" in entries["pack-good"]["granted_capabilities"]
|
|
assert "tools.override" not in entries.get("pack-last", {}).get("granted_capabilities", [])
|
|
receipts = [json.loads(path.read_text()) for path in (world.home / "logs/update_receipts").glob("pm_*.json")]
|
|
assert any(row["outcome"] == "failed" and "plugin-proof-dep" in json.dumps(row) for row in receipts)
|
|
|
|
|
|
@pytest.mark.parametrize("sibling_profiles", [False, True], ids=["same-profile", "sibling-profiles"])
|
|
def test_concurrent_commands_keep_acknowledged_enables(plugin_world, sibling_profiles):
|
|
import queue
|
|
import threading
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import hermes_yaml as yaml
|
|
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
|
from tests.hermes_cli.plugin_worker_support import worker_command
|
|
|
|
world = plugin_world
|
|
homes = [world.home, world.home]
|
|
if sibling_profiles:
|
|
homes = [world.home / "profiles" / name for name in ("left", "right")]
|
|
for home in homes:
|
|
home.mkdir(parents=True)
|
|
(home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8")
|
|
for name, home in zip(("race-left", "race-right"), homes):
|
|
origin, revision = world.origin(name=name, dependency="plugin-proof-dep" if name == "race-left" else "plugin-proof-other")
|
|
token = set_hermes_home_override(home)
|
|
try:
|
|
world.command("install", identifier=origin.as_uri(), ref=revision, no_enable=True, allow_removed=True)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
untouched = (world.home / "config.yaml").read_bytes()
|
|
checkout = Path(__file__).resolve().parents[2]
|
|
command = worker_command(checkout / "pm/worker.py", shutil.which("uv"), sys.executable,
|
|
runtime_python=world.runtime_python)
|
|
processes = []
|
|
try:
|
|
for name, home in zip(("race-left", "race-right"), homes):
|
|
script = (
|
|
"import sys; from pathlib import Path; "
|
|
f"sys.path.insert(0, {str(checkout)!r}); from pm import client, paths; "
|
|
f"paths.repo_root = lambda: Path({str(world.core)!r}); "
|
|
f"paths.lockfile_path = lambda: Path({str(world.root / 'lock.json')!r})\n"
|
|
"def ready(worker, **kwargs):\n"
|
|
" print('READY', flush=True)\n"
|
|
" assert sys.stdin.readline().strip() == 'go'\n"
|
|
f" return {command!r}\n"
|
|
"client.runtime_command = ready\n"
|
|
"from hermes_cli.plugins_cmd import cmd_enable\n"
|
|
"from hermes_cli.plugins_admission import AdmissionRefused\n"
|
|
"try:\n"
|
|
f" cmd_enable({name!r}, allow_tool_override=False)\n"
|
|
"except AdmissionRefused as exc:\n"
|
|
" assert 'configuration changed since this selection was read' in str(exc)\n"
|
|
" raise SystemExit(75)\n"
|
|
)
|
|
processes.append(subprocess.Popen([sys.executable, "-I", "-B", "-c", script],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
env={**os.environ, "HERMES_HOME": str(home)}))
|
|
# Both commands have reached the worker launch (after the old stale
|
|
# read). Release one all the way through publication, then the other.
|
|
ready = queue.Queue()
|
|
for process in processes:
|
|
threading.Thread(target=lambda proc=process: ready.put(proc.stdout.readline().strip()), daemon=True).start()
|
|
assert [ready.get(timeout=30) for _ in processes] == ["READY", "READY"]
|
|
for index, process in enumerate(processes):
|
|
out, err = process.communicate("go\n", timeout=60)
|
|
expected = 75 if index == 1 and not sibling_profiles else 0
|
|
assert process.returncode == expected, out + err
|
|
if not sibling_profiles:
|
|
# The stale second request is refused, not acknowledged. An explicit
|
|
# retry reads the first successful enable and extends it.
|
|
assert world.enabled() == ["race-left"]
|
|
world.command("enable", name="race-right", no_allow_tool_override=True)
|
|
if sibling_profiles:
|
|
assert (world.home / "config.yaml").read_bytes() == untouched
|
|
for name, home in zip(("race-left", "race-right"), homes):
|
|
assert yaml.safe_load((home / "config.yaml").read_text())["plugins"]["enabled"] == [name]
|
|
else:
|
|
assert set(world.enabled()) == {"race-left", "race-right"}, "acknowledged enable was lost"
|
|
for name, home in zip(("race-left", "race-right"), homes):
|
|
original = world.home
|
|
world.home = home
|
|
try:
|
|
world.imports(name)
|
|
finally:
|
|
world.home = original
|
|
finally:
|
|
for process in processes:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.communicate(timeout=10)
|