The merges from main kept or added about 45 test files written against the
legacy updater and installer seams that pm-clean replaced. Each failing test
was checked against the current code:
- Deleted, because every test targets code that pm-clean removed or that is
only reachable from dead or frozen old-updater code:
update_orphan/handoff_backend_reap, handoff_desktop_rebuild,
interrupted_recovery (it also launched a real completion against the live
checkout), update_stale_virtualenv, update_venv_health,
verify_core_dependencies, lazy_refresh_venv_repair,
certifi_repair (already dropped in 2f20ceb6f7; the merge resurrected it),
banner_git_state (covered by test_source_check).
- Retargeted where production reads now:
- version identity via version_info.get_code_identity
- update receipts via a ContextVar scope
- pm.extras / pm.installed_package / pm.ensure_import for matrix,
computer_use, fal and tirith
- install hints via pm.install_hint
- the lock pins in pm/lock.json
- probe_root for the critical-module probe
- Fixture repairs:
- git-committed trees for source stamps
- activate's real `--runtime-only` argument
- a semver install stamp for requires_hermes gates
- the Windows gateway restart after a verified update
- snowflake-length ids that cannot collide with the runner uid
- Collection fix: one platforms() marker per test in gateway_proc_fallback.
260 lines
13 KiB
Python
260 lines
13 KiB
Python
"""Catalog pins and custom Git updates publish only a validated dependency set."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401
|
|
|
|
|
|
def _commit(repo, message):
|
|
env = {**os.environ, "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
|
|
"GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid"}
|
|
subprocess.run(["git", "add", "--all"], cwd=repo, env=env, check=True, capture_output=True)
|
|
subprocess.run(["git", "commit", "-qm", message], cwd=repo, env=env, check=True, capture_output=True)
|
|
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
|
|
|
|
|
def _version(repo, version, *, broken=False, minimum="", libraries=("shared-library",)):
|
|
from hermes_cli.plugins_manifest import SUPPORTED_MANIFEST_VERSION
|
|
|
|
(repo / "plugin.yaml").write_text(
|
|
f"name: transactional\nversion: {version}\nmanifest_version: {SUPPORTED_MANIFEST_VERSION}\n"
|
|
f"requires_hermes: '{minimum}'\n", encoding="utf-8")
|
|
(repo / "__init__.py").write_text(f"VERSION = {version!r}\ndef register(ctx):\n pass\n", encoding="utf-8")
|
|
(repo / ".gitignore").write_text("node_modules/\n", encoding="utf-8")
|
|
deps = '["impossible-plugin-dep==1", "impossible-plugin-dep==2"]' if broken else '[]'
|
|
for name in libraries:
|
|
library = repo.parent / name
|
|
library.mkdir(exist_ok=True)
|
|
(library / "pyproject.toml").write_text(
|
|
f'[project]\nname="{name}"\nversion="1"\nrequires-python=">=3.14"\n'
|
|
'[tool.uv]\npackage=false\n', encoding="utf-8")
|
|
if not broken:
|
|
deps = json.dumps(list(libraries))
|
|
sources = "".join(f'{name}={{path="../{name}"}}\n' for name in libraries)
|
|
(repo / "pyproject.toml").write_text(
|
|
f'[project]\nname="transactional"\nversion="{version}"\nrequires-python=">=3.14"\n'
|
|
f'dependencies={deps}\n[tool.uv]\npackage=false\n'
|
|
f'[tool.uv.sources]\n{sources}', encoding="utf-8")
|
|
return _commit(repo, version)
|
|
|
|
|
|
@pytest.fixture
|
|
def installed(admission_env, monkeypatch, request):
|
|
from hermes_cli import plugin_catalog, plugins_cmd, plugins_cmd_catalog
|
|
|
|
root, home = admission_env
|
|
repo = root / "plugin-origin"
|
|
repo.mkdir()
|
|
subprocess.run(["git", "init", "-qb", "main"], cwd=repo, check=True, capture_output=True)
|
|
first = _version(repo, "1.0.0")
|
|
catalog = request.param == "catalog"
|
|
state = {"sha": first}
|
|
|
|
def entry():
|
|
return plugin_catalog.PluginCatalogEntry(
|
|
name="transactional", repo=repo.as_uri(), sha=state["sha"], description="fixture", maintainer="fixture")
|
|
|
|
monkeypatch.setattr(plugin_catalog, "fetch_live_catalog", lambda **kwargs: None)
|
|
monkeypatch.setattr(plugin_catalog, "load_catalog", lambda catalog_dir=None: [entry()])
|
|
monkeypatch.setattr(plugin_catalog, "load_removed_list", lambda catalog_dir=None: [])
|
|
monkeypatch.setattr(plugins_cmd, "_scan_on_install_enabled", lambda: False)
|
|
if catalog:
|
|
target, _, _ = plugins_cmd_catalog.install_catalog_entry(entry(), force=False)
|
|
else:
|
|
target, _, _ = plugins_cmd._install_plugin_core(repo.as_uri(), force=False)
|
|
import shutil
|
|
|
|
shutil.copytree(repo.parent / "shared-library", target.parent / "shared-library")
|
|
plugins_cmd._set_plugin_enabled("transactional", enable=True)
|
|
return root, home, repo, target, state
|
|
|
|
|
|
def _head(repo) -> str:
|
|
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
|
|
|
|
|
|
@pytest.mark.parametrize("installed", ["catalog", "custom"], indirect=True)
|
|
def test_unattended_update_refuses_newly_declared_python_dependencies(installed):
|
|
"""Install gates Python deps behind a prompt; an update that ADDS a dependency must not
|
|
slip them into the shared environment without one. With nobody to ask (dashboard, gateway
|
|
``plugins.auto_apply``) the update is refused and nothing — code, env, metadata — moves."""
|
|
import shutil
|
|
from hermes_cli import plugins_cmd
|
|
from pm.environments import selected_venv
|
|
|
|
root, home, repo, target, state = installed
|
|
old_head, old_venv = _head(target), selected_venv(root / "core")
|
|
state["sha"] = _version(repo, "2.0.0", libraries=("shared-library", "second-library"))
|
|
shutil.copytree(repo.parent / "second-library", target.parent / "second-library", dirs_exist_ok=True)
|
|
|
|
result = plugins_cmd.dashboard_update_user_plugin("transactional")
|
|
|
|
assert result["ok"] is False, result
|
|
assert "second-library" in result["error"]
|
|
assert _head(target) == old_head
|
|
assert selected_venv(root / "core") == old_venv
|
|
|
|
|
|
@pytest.mark.parametrize("installed", ["custom"], indirect=True)
|
|
@pytest.mark.parametrize("answer, published", [("y", True), ("n", False)])
|
|
def test_interactive_update_asks_before_installing_new_python_dependencies(
|
|
installed, monkeypatch, answer, published):
|
|
"""A terminal user is asked the same question install asks; yes publishes code AND the
|
|
dependency together, no leaves both untouched."""
|
|
import shutil
|
|
from types import SimpleNamespace
|
|
from hermes_cli import plugins_cmd
|
|
from hermes_cli.plugins_transaction import update_plugin
|
|
from pm.environments import selected_venv
|
|
|
|
root, home, repo, target, state = installed
|
|
old_head, old_venv = _head(target), selected_venv(root / "core")
|
|
state["sha"] = _version(repo, "2.0.0", libraries=("shared-library", "second-library"))
|
|
shutil.copytree(repo.parent / "second-library", target.parent / "second-library", dirs_exist_ok=True)
|
|
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True))
|
|
monkeypatch.setattr("sys.stdout", SimpleNamespace(isatty=lambda: True, write=lambda *a: None, flush=lambda: None))
|
|
asked: list = []
|
|
monkeypatch.setattr("builtins.input", lambda prompt="": asked.append(prompt) or answer)
|
|
|
|
if published:
|
|
update_plugin(target, interactive=True)
|
|
else:
|
|
with pytest.raises(plugins_cmd.PluginOperationError, match="declined"):
|
|
update_plugin(target, interactive=True)
|
|
|
|
assert asked, "no dependency consent prompt was shown"
|
|
assert (_head(target) == state["sha"]) is published
|
|
assert (selected_venv(root / "core") != old_venv) is published
|
|
assert (_head(target) != old_head) is published
|
|
|
|
|
|
@pytest.mark.parametrize("installed", ["catalog", "custom"], indirect=True)
|
|
def test_update_rebuilds_an_accepted_node_sidecar_when_its_manifest_moves(installed, monkeypatch):
|
|
"""Publication swaps the whole tree, so a node_modules the user accepted at install would
|
|
come back stale (custom pull copies it) or missing (catalog re-clone). When package.json
|
|
changes, the sidecar is rebuilt in the staged copy and published with the code."""
|
|
from hermes_cli import plugins_cmd
|
|
from pm import workspace
|
|
|
|
root, home, repo, target, state = installed
|
|
(repo / "package.json").write_text('{"name": "transactional", "dependencies": {}}', encoding="utf-8")
|
|
_commit(repo, "add node sidecar")
|
|
# the user accepted the sidecar at install time
|
|
(target / "node_modules").mkdir()
|
|
(target / "node_modules" / "stale").write_text("v1", encoding="utf-8")
|
|
(repo / "package.json").write_text('{"name": "transactional", "dependencies": {"left-pad": "*"}}', encoding="utf-8")
|
|
state["sha"] = _commit(repo, "bump node deps")
|
|
rebuilt: list = []
|
|
|
|
def fake_npm(plugin_dir, *, explicit=False):
|
|
rebuilt.append(plugin_dir)
|
|
(plugin_dir / "node_modules").mkdir(exist_ok=True)
|
|
(plugin_dir / "node_modules" / "fresh").write_text("v2", encoding="utf-8")
|
|
return None
|
|
|
|
monkeypatch.setattr(workspace, "install_node_sidecar", fake_npm)
|
|
result = plugins_cmd.dashboard_update_user_plugin("transactional")
|
|
|
|
assert result["ok"] is True, result
|
|
assert len(rebuilt) == 1 and rebuilt[0] != target, "npm must run in the staged copy, not the live tree"
|
|
assert (target / "node_modules" / "fresh").is_file()
|
|
assert _head(target) == state["sha"]
|
|
|
|
|
|
@pytest.mark.parametrize("installed", ["catalog", "custom"], indirect=True)
|
|
@pytest.mark.parametrize("failure", ["dependencies", "version", "publication", "manifest"])
|
|
def test_failed_update_keeps_code_metadata_config_and_environment(installed, monkeypatch, failure):
|
|
from hermes_cli import plugins_cmd
|
|
from pm.environments import selected_venv
|
|
from pm import paths
|
|
from pm.lock import Facts
|
|
|
|
root, home, repo, target, state = installed
|
|
selected = selected_venv(root / "core")
|
|
watched = [target / "plugin.yaml", target / "__init__.py", target / "pyproject.toml",
|
|
home / "config.yaml", home / "plugins/.install-metadata.json", paths.runtime_facts_path()]
|
|
|
|
before = {path: path.read_bytes() for path in watched}
|
|
old_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=target, text=True).strip()
|
|
state["sha"] = _version(repo, "2.0.0", broken=failure == "dependencies",
|
|
minimum=">=999.0.0" if failure == "version" else "")
|
|
if failure == "version":
|
|
# A checkout without a vX.Y.Z tag (CI's depth-1 clone) runs an unparseable version,
|
|
# which the gate deliberately treats as permissive; pin a real one so it can refuse.
|
|
from hermes_cli import plugins_manifest
|
|
monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: "1.0.0")
|
|
if failure == "manifest":
|
|
(repo / "plugin.yaml").write_text("name: [broken", encoding="utf-8")
|
|
state["sha"] = _commit(repo, "invalid manifest")
|
|
if failure == "publication":
|
|
def deny(*args, **kwargs):
|
|
raise OSError("fixture refuses environment publication")
|
|
monkeypatch.setattr(Facts, "record_state", deny)
|
|
result = plugins_cmd.dashboard_update_user_plugin("transactional")
|
|
assert result["ok"] is False, result
|
|
assert result.get("error")
|
|
assert selected_venv(root / "core") == selected
|
|
assert {path: path.read_bytes() for path in watched} == before
|
|
assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=target, text=True).strip() == old_head
|
|
|
|
|
|
@pytest.mark.parametrize("installed", ["catalog", "custom"], indirect=True)
|
|
def test_successful_update_publishes_matching_code_and_durable_workspace(installed):
|
|
import tomllib
|
|
from hermes_cli import plugins_cmd
|
|
from pm.environments import selected_venv
|
|
from pm import paths
|
|
from pm.lock import Facts
|
|
from pm.packages import Venv
|
|
|
|
root, home, repo, target, state = installed
|
|
before = selected_venv(root / "core")
|
|
config = (home / "config.yaml").read_bytes()
|
|
state["sha"] = _version(repo, "2.0.0")
|
|
result = plugins_cmd.dashboard_update_user_plugin("transactional")
|
|
assert result["ok"] is True, result
|
|
assert subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=target, text=True).strip() == state["sha"]
|
|
assert 'VERSION = \'2.0.0\'' in (target / "__init__.py").read_text(encoding="utf-8")
|
|
assert (home / "config.yaml").read_bytes() == config
|
|
fact = Facts(paths.runtime_facts_path()).get("venv")
|
|
assert selected_venv(root / "core") != before
|
|
assert fact["stamp"] == Venv().expected_stamp(fact["extras"])
|
|
workspace = Path(fact["resolved_lock"]).parent
|
|
document = tomllib.loads((workspace / "pyproject.toml").read_text(encoding="utf-8"))
|
|
members = document["tool"]["uv"]["workspace"]["members"]
|
|
assert members
|
|
for member in members:
|
|
path = (workspace / member).resolve()
|
|
assert path.is_relative_to(workspace)
|
|
assert (path / "pyproject.toml").is_file()
|
|
record = json.loads((home / "plugins/.install-metadata.json").read_text(encoding="utf-8"))["transactional"]
|
|
assert record["revision"] == state["sha"]
|
|
|
|
# A packaged member's source changes even when its dependency metadata does not.
|
|
previous_stamp = fact["stamp"]
|
|
(repo / "__init__.py").write_text("VERSION = 'code-only'\ndef register(ctx):\n pass\n", encoding="utf-8")
|
|
state["sha"] = _commit(repo, "code-only update")
|
|
result = plugins_cmd.dashboard_update_user_plugin("transactional")
|
|
assert result["ok"], result
|
|
record = json.loads((home / "plugins/.install-metadata.json").read_text(encoding="utf-8"))["transactional"]
|
|
assert Facts(paths.runtime_facts_path()).get("venv")["stamp"] != previous_stamp
|
|
|
|
from hermes_cli.plugins_updates import run_checks
|
|
|
|
def no_network(*args):
|
|
raise AssertionError("catalog pin must not consult a custom update source")
|
|
|
|
if (record.get("catalog") or {}).get("name"):
|
|
state["sha"] = _version(repo, "3.0.0")
|
|
check = next(row for row in run_checks(home / "plugins", include_pip=False,
|
|
fetch=no_network, ls_remote=no_network)
|
|
if row.name == "transactional")
|
|
assert check.klass == "catalog" and check.update_available is True
|
|
assert check.current == record["revision"] and check.latest == state["sha"]
|