Files
hermes-agent/tests/scripts/test_desktop_cli_wrapper.py
ethernet 3d12e86ef1 feat(pm): unified package manager — pm store foundation
Introduce the pm store: a unified, hash-verified package store that
replaces lazy_deps and the old installer's ad-hoc tool downloads.
Store tools are provisioned on PATH (ffmpeg, node/npm via pinned uv),
with a resumable 8-way downloader, verify() returning failure reasons,
and adopt() made EPERM-safe. chromium ships in the payload for every
target. The 3600-line install.sh is replaced by a staged bootstrapper
(heavy deps are pm's job after this); setup-hermes.sh, Dockerfile and
nix pin tables are rewired onto the store. Old install-script tests,
lazy_deps/managed_uv/build_info, and the ps1/bash installer test
batteries are removed with the machinery they tested.

Rebuilt from ethie/pm onto upstream/main (ac6c8028e0) after the
utf-8-sig sweep. 16 hot files (main also churned them) hand-merged:
platform adapters, main.py, electron/main.ts, tui_gateway/server.py,
cua_backend, installer-tests workflow, install.sh (full rewrite),
setup-hermes.sh, plugins doc.
2026-08-31 18:00:48 -04:00

192 lines
7.4 KiB
Python

"""Unit tests for the bundled payload's win32 launcher wrapper.
scripts/desktop-cli/launcher-wrapper.py is the zip overlay a distlib
ScriptMaker packs into every minted CLI launcher exe (the rust shim's
replacement). The wrapper is import-safe on purpose, so these tests drive
its real logic — path resolution, sys.path order, the pycache_prefix
default — plus a real subprocess dispatch through a rendered copy.
The mechanism itself (distlib minting a PE whose shebang carries the
<launcher_dir> placeholder) is exercised end-to-end by
test_mint_launchers.py.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[2]
_WRAPPER = _REPO / "scripts" / "desktop-cli" / "launcher-wrapper.py"
def _load(env=None):
"""Import the wrapper with its placeholders substituted, like the build
script does, and return its module namespace."""
text = _WRAPPER.read_text(encoding="utf-8")
for placeholder, value in {
"__HERMES_ENTRY_MODULE__": "stubmod.entry",
"__HERMES_ENTRY_FUNC__": "main",
"__HERMES_REPO_REL__": "../repo",
"__HERMES_SITE_REL__": "../venv/Lib/site-packages",
}.items():
text = text.replace(placeholder, value)
namespace: dict = {"__name__": "launcher_wrapper_under_test"}
exec(compile(text, str(_WRAPPER), "exec"), namespace) # noqa: S102 - test fixture
return namespace
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def test_launcher_dir_resolves_from_argv0(tmp_path):
ns = _load()
argv0 = tmp_path / "bin" / "hermes.exe"
assert ns["launcher_dir"](str(argv0)) == str(tmp_path / "bin")
def test_payload_sys_paths_order_repo_first(tmp_path):
ns = _load()
here = str(tmp_path / "bin")
entries = ns["payload_sys_paths"](here)
# Repo first — its hermes_cli wins over anything stale in the venv
# (the sealed payload has no working editable install). Same order the
# old rust shim composed PYTHONPATH in.
assert entries == [
os.path.join(here, "..", "repo"),
os.path.join(here, "..", "venv", "Lib", "site-packages"),
]
def test_payload_sys_paths_accepts_forward_slash_rels_on_any_platform(tmp_path):
"""The layout facts ride in the wrapper as forward-slash strings (the
payload manifest convention); splitting must not depend on os.sep."""
ns = _load()
here = str(tmp_path / "bin")
for entry in ns["payload_sys_paths"](here):
assert os.path.isabs(os.path.normpath(entry))
# ---------------------------------------------------------------------------
# configure(): env hygiene, sys.path, pycache_prefix default
# ---------------------------------------------------------------------------
def test_configure_prepends_repo_then_site_packages(tmp_path):
ns = _load()
here = str(tmp_path / "bin")
original = list(sys.path)
try:
entries = ns["configure"](here, environ={})
assert sys.path[:2] == entries
assert sys.path[0] == os.path.join(here, "..", "repo")
assert sys.path[1] == os.path.join(here, "..", "venv", "Lib", "site-packages")
finally:
sys.path[:] = original
def test_configure_drops_inherited_pythonpath_and_pythonhome(tmp_path):
ns = _load()
original = list(sys.path)
environ = {"PYTHONPATH": "/foreign/install", "PYTHONHOME": "/foreign/python"}
try:
ns["configure"](str(tmp_path), environ=environ)
assert "PYTHONPATH" not in environ
assert "PYTHONHOME" not in environ
finally:
sys.path[:] = original
def test_configure_defaults_pycache_prefix_to_localappdata(tmp_path, monkeypatch):
ns = _load()
environ = {"LOCALAPPDATA": str(tmp_path / "lad")}
original = sys.pycache_prefix
try:
ns["configure"](str(tmp_path), environ=environ)
expected = os.path.join(str(tmp_path / "lad"), "hermes", "pycache")
assert environ["PYTHONPYCACHEPREFIX"] == expected
# The env var alone cannot retro-activate; sys.pycache_prefix is
# the live switch (the rust shim set it on the CHILD's environment).
assert str(sys.pycache_prefix) == expected
finally:
sys.pycache_prefix = original
def test_configure_keeps_a_user_set_pycache_prefix(tmp_path, monkeypatch):
ns = _load()
environ = {"LOCALAPPDATA": str(tmp_path / "lad"), "PYTHONPYCACHEPREFIX": str(tmp_path / "mine")}
original = sys.pycache_prefix
try:
ns["configure"](str(tmp_path), environ=environ)
assert environ["PYTHONPYCACHEPREFIX"] == str(tmp_path / "mine")
assert sys.pycache_prefix == original # untouched
finally:
sys.pycache_prefix = original
def test_configure_without_localappdata_leaves_pycache_alone(tmp_path):
ns = _load()
environ: dict = {}
original = sys.pycache_prefix
try:
ns["configure"](str(tmp_path), environ=environ)
assert "PYTHONPYCACHEPREFIX" not in environ
assert sys.pycache_prefix == original
finally:
sys.pycache_prefix = original
# ---------------------------------------------------------------------------
# Real dispatch: run a rendered copy as a subprocess against a stub module
# ---------------------------------------------------------------------------
def _render(tmp_path: Path) -> Path:
text = _WRAPPER.read_text(encoding="utf-8")
for placeholder, value in {
"__HERMES_ENTRY_MODULE__": "hermes_cli.main",
"__HERMES_ENTRY_FUNC__": "main",
"__HERMES_REPO_REL__": "../repo",
"__HERMES_SITE_REL__": "../venv/Lib/site-packages",
}.items():
text = text.replace(placeholder, value)
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
out = bin_dir / "hermes.exe"
out.write_text(text, encoding="utf-8")
return out
@pytest.mark.skipif(sys.platform != "win32", reason="minted launchers only exist on win32 payloads")
def test_rendered_wrapper_dispatches_to_the_entry_module(tmp_path):
"""The full wrapper contract without distlib: a rendered copy placed in
bin/ next to a stub repo + venv resolves its own dir from sys.argv[0],
imports hermes_cli.main off the payload paths, and returns main()'s
exit code."""
bin_dir = tmp_path / "bin"
(bin_dir / ".." / "repo" / "hermes_cli").mkdir(parents=True, exist_ok=True)
(bin_dir / ".." / "venv" / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True)
(bin_dir / ".." / "repo" / "hermes_cli" / "__init__.py").write_text("")
(bin_dir / ".." / "repo" / "hermes_cli" / "main.py").write_text(
"import sys\n"
"def main():\n"
" assert sys.argv[0].endswith('hermes.exe'), sys.argv[0]\n"
" assert sys.argv[1:] == ['--version']\n"
" import stubdep # importable only via the venv site-packages entry\n"
" return 7\n"
)
(bin_dir / ".." / "venv" / "Lib" / "site-packages" / "stubdep.py").write_text("X = 1\n")
wrapper = _render(tmp_path)
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME", "PYTHONPYCACHEPREFIX")}
env["LOCALAPPDATA"] = str(tmp_path / "lad")
python = Path(sys.base_prefix) / "python.exe" if sys.platform == "win32" else sys.executable
proc = subprocess.run([str(python), str(wrapper), "--version"], capture_output=True, text=True, env=env)
assert proc.returncode == 7, proc.stderr