Files
hermes-agent/tests/pm/_fixtures.py
ethernet 925c08ceca fix: CI python-tests backlog — no import-time dependency syncs, CI-shaped test fixtures
Production:
- agent/bedrock_adapter.py, agent/vertex_adapter.py: pm.ensure_import ran at
  module import. In any process that imports these modules without a committed
  PM selection (CI's build_environment test venv, a fresh checkout) that sync
  rebuilt the dependency environment mid-process and replaced sys.path with a
  generation missing the caller's own packages (anthropic, aiohttp vanished).
  The extra is now ensured at first client build / credential request.
- plugins/platforms/matrix/adapter.py: a complete install needs no
  ensure_and_bind round trip; only a partial one syncs.
- tools/browser_tool.py: drop the facade's duplicate warm_agent_browser_npx_cache
  shim; the compat pointer already resolves to browser_tool_install.

Test harness:
- tests/home_io_guard.py: PATH-entry probes (shutil.which) and the running
  interpreter's own installation (stdlib reads, realpath ancestry, fixture
  symlinks into it) are not Hermes state; a patched Path.expanduser must not
  crash the guard. run_tests.sh no longer filters PATH — the guard owns it.
- tests/tui_gateway/conftest.py: import hermes_bootstrap before any file opens
  a MagicMock hermes_constants window (6 files exited the process at boot).
- tests/hermes_cli/conftest.py probe_root: scratch checkouts the import guard
  probes need hermes_bootstrap.py (the launcher imports it).
- tests/pm/_fixtures.py stage_host_python: a copied relocatable python needs
  its stdlib beside it (No module named 'encodings' on CI).
- tests/install/e2e-assets/smoke-env.mjs: dependency-free env shaping so the
  source-build-env probe runs under bare node (main deleted the Playwright
  entry it was imported through).
- adapt main's new tests to branch seams (model_metadata_http, launch
  completion tail, CI toolchain exports uv after python, source_launch
  hermes_cli stub, systemd_notify single marker).
2026-09-20 11:47:06 -04:00

171 lines
6.3 KiB
Python

"""Real artifacts and isolated PM workers shared by lifecycle tests."""
from __future__ import annotations
from functools import partial
from contextlib import contextmanager
import hashlib
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import importlib
import io
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import threading
import zipfile
import pytest
def _wheel(directory: Path, name: str, version: str = "1.0", requirements=()) -> Path:
metadata = f"{name}-{version}.dist-info"
entries = {
f"{name}/__init__.py": f"__version__ = {version!r}\n",
f"{metadata}/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
+ "".join(f"Requires-Dist: {requirement}\n" for requirement in requirements),
f"{metadata}/WHEEL": "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
}
entries[f"{metadata}/RECORD"] = "".join(f"{path},,\n" for path in entries)
wheel = directory / f"{name}-{version}-py3-none-any.whl"
with zipfile.ZipFile(wheel, "w") as archive:
for path, body in entries.items():
archive.writestr(path, body)
return wheel
def stage_host_python(python: Path) -> Path:
"""Place a runnable copy of the test host's interpreter at ``python`` (``<root>/bin/python…``).
CI's test interpreter is a relocatable python-build-standalone whose prefix is wherever the
binary sits, so a bare copy cannot find its stdlib (``No module named 'encodings'``). Copy the
host's stdlib beside it (a real copy, not a link: payload guards refuse links that escape the
tree); site-packages and the test suite stay out. Production's package stage ships the whole
distribution.
"""
python.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(sys._base_executable).resolve(), python)
host_lib = Path(sys.base_prefix) / "lib"
stdlib = next((d for d in host_lib.glob("python3.*") if (d / "os.py").is_file()), None)
if stdlib is not None and not (python.parent.parent / "lib" / stdlib.name).exists():
shutil.copytree(stdlib, python.parent.parent / "lib" / stdlib.name,
ignore=shutil.ignore_patterns("site-packages", "test", "__pycache__", "idlelib", "tkinter"),
symlinks=True)
return python
def _run(command, *, cwd: Path, env: dict) -> str:
result = subprocess.run(command, cwd=cwd, env=env, capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
def _ar_member(name: str, data: bytes) -> bytes:
hdr = (
name.ljust(16).encode()
+ b"0".ljust(12)
+ b"0".ljust(6)
+ b"0".ljust(6)
+ b"100644".ljust(8)
+ str(len(data)).encode().ljust(10)
+ b"`\n"
)
pad = b"\n" if len(data) % 2 else b""
return hdr + data + pad
def make_tar(docroot: Path, name: str, files: dict[str, str]) -> tuple[str, str]:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for rel, content in files.items():
data = content.encode()
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mode = 0o755
tf.addfile(info, io.BytesIO(data))
payload = buf.getvalue()
(docroot / name).write_bytes(payload)
return name, hashlib.sha256(payload).hexdigest()
@contextmanager
def threaded_server(handler):
with ThreadingHTTPServer(("127.0.0.1", 0), handler) as server:
server.daemon_threads = True
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
thread.join(timeout=5)
@pytest.fixture
def served(tmp_path):
docroot = tmp_path / "www"
docroot.mkdir()
handler = partial(SimpleHTTPRequestHandler, directory=str(docroot))
with threaded_server(handler) as server:
yield docroot, f"http://127.0.0.1:{server.server_port}"
@pytest.fixture(scope="module")
def isolated_python(tmp_path_factory):
from pm.runtime_stage import stage_runtime
root = tmp_path_factory.mktemp("pm-python")
uv = shutil.which("uv")
assert uv, "the worker contract requires real uv"
python = stage_runtime(Path(uv), Path(sys.executable), root)
_run([str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"],
cwd=root, env=dict(os.environ))
return python
@pytest.fixture
def client(tmp_path, monkeypatch, isolated_python):
from pm import paths
client = importlib.import_module("pm.client")
monkeypatch.setattr("pm.runtime.runtime_python", lambda **kwargs: isolated_python)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "store"))
monkeypatch.setattr(paths, "lockfile_path", lambda: tmp_path / "lock.json")
return client
def worker_toolchain(client, monkeypatch, isolated_python, injection=""):
uv = shutil.which("uv")
assert uv
worker = Path(client.__file__).with_name("worker.py")
script = (
"import runpy, sys, os; from pathlib import Path; "
f"sys.path.insert(0, {str(worker.parent.parent)!r}); "
"import pm._uv; "
f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r}));\n"
+ injection + f"\nrunpy.run_path({str(worker)!r}, run_name='__main__')"
)
monkeypatch.setattr(client, "runtime_command", lambda path, **kwargs: [str(isolated_python), "-I", "-B", "-c", script])
@pytest.fixture
def build_worker(client, isolated_python, monkeypatch):
"""Inject prepared real tools only inside the independent worker process."""
worker_toolchain(client, monkeypatch, isolated_python)
monkeypatch.setattr(client, "is_runtime", lambda: False)
def caller_engine(*args, **kwargs):
pytest.fail("dependency engine ran in the caller, not the PM worker")
for module, names in {
"pm.operations": ("build_environment", "lock_project"),
"pm.build_operations": ("build_requirements_environment", "export_requirements", "check_project_lock"),
}.items():
for name in names:
monkeypatch.setattr(importlib.import_module(module), name, caller_engine)
return client