Files
hermes-agent/tests/scripts/install/test_install_sh_acp_launcher.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

215 lines
7.8 KiB
Python

"""`setup_path()` must also install a `hermes-acp` launcher.
ACP hosts (Zed, JetBrains, Buzz Desktop) spawn the agent by resolving a command
name against the login-shell PATH. The `hermes-acp` console script generated by
the packaging metadata lives inside the install's venv, which is not on that
PATH, so those hosts report Hermes as not installed. `setup_path()` therefore
writes a `hermes-acp` launcher next to the `hermes` one.
These tests drive the real block out of `scripts/install.sh` rather than
asserting on a copy of it, so the shim cannot drift away from the test.
"""
import re
import stat
import subprocess
from pathlib import Path
from unittest.mock import patch
INSTALL_SH = Path(__file__).resolve().parent.parent.parent.parent / "scripts" / "install.sh"
ACP_BLOCK = re.compile(
r'( rm -f "\$command_link_dir/hermes-acp".*?'
r'log_success "Installed hermes-acp launcher[^\n]*\n)',
re.S,
)
def _extract_acp_shim_block() -> str:
match = ACP_BLOCK.search(INSTALL_SH.read_text(encoding="utf-8"))
assert match, (
"could not locate the hermes-acp launcher block in scripts/install.sh — "
"if it was renamed, update this test with it"
)
return match.group(1)
def _run_block(tmp_path: Path, use_venv: str) -> Path:
"""Execute the extracted block with the env vars setup_path() sets."""
command_link_dir = tmp_path / "local_bin"
command_link_dir.mkdir()
hermes_bin = tmp_path / "venv" / "bin" / "python"
hermes_bin.parent.mkdir(parents=True)
hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8")
entrypoint = tmp_path / "hermes"
entrypoint.write_text("#!/bin/sh\n", encoding="utf-8")
script = (
"set -e\n"
f"HERMES_BIN={hermes_bin}\n"
f"HERMES_ENTRYPOINT={entrypoint}\n"
f"command_link_dir={command_link_dir}\n"
f"command_link_display_dir={command_link_dir}\n"
f"USE_VENV={use_venv}\n"
"log_success(){ :; }\n" + _extract_acp_shim_block()
)
result = subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
cwd=tmp_path,
)
assert result.returncode == 0, (
f"acp shim block failed:\nstdout={result.stdout}\nstderr={result.stderr}"
)
return command_link_dir / "hermes-acp"
def test_venv_install_writes_executable_acp_launcher(tmp_path):
shim = _run_block(tmp_path, "true")
assert shim.is_file()
assert shim.stat().st_mode & stat.S_IXUSR, "launcher must be user-executable"
text = shim.read_text(encoding="utf-8")
assert "unset PYTHONPATH" in text
assert "unset PYTHONHOME" in text
# The launcher must dispatch to the ACP subcommand, otherwise the host gets
# an interactive CLI on stdio and the handshake never completes.
assert re.search(r'exec .*\bacp\b', text), text
def test_non_venv_install_writes_acp_launcher(tmp_path):
shim = _run_block(tmp_path, "false")
text = shim.read_text(encoding="utf-8")
assert re.search(r'exec .*\bacp\b', text), text
def test_acp_launcher_does_not_follow_a_symlink_into_the_venv(tmp_path):
"""Guards the #21454 failure mode for the new launcher.
An older install could leave `hermes-acp` as a symlink to the venv console
script. Without the `rm -f`, `cat >` follows it and overwrites that script
with a shim that then execs itself.
"""
command_link_dir = tmp_path / "local_bin"
command_link_dir.mkdir()
console_script = tmp_path / "venv" / "bin" / "hermes-acp"
console_script.parent.mkdir(parents=True)
marker = "#!/usr/bin/env python\n# real console script\n"
console_script.write_text(marker, encoding="utf-8")
shim_path = command_link_dir / "hermes-acp"
shim_path.symlink_to(console_script)
assert shim_path.is_symlink()
hermes_bin = tmp_path / "venv" / "bin" / "python"
hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8")
entrypoint = tmp_path / "hermes"
entrypoint.write_text("#!/bin/sh\n", encoding="utf-8")
script = (
"set -e\n"
f"HERMES_BIN={hermes_bin}\n"
f"HERMES_ENTRYPOINT={entrypoint}\n"
f"command_link_dir={command_link_dir}\n"
f"command_link_display_dir={command_link_dir}\n"
"USE_VENV=true\n"
"log_success(){ :; }\n" + _extract_acp_shim_block()
)
result = subprocess.run(
["bash", "-c", script], capture_output=True, text=True, cwd=tmp_path
)
assert result.returncode == 0, result.stderr
assert console_script.read_text(encoding="utf-8") == marker, (
"venv/bin/hermes-acp was overwritten — symlink-stomp regression (#21454)"
)
assert not shim_path.is_symlink(), (
"command_link_dir/hermes-acp must be replaced with a regular file"
)
# ---------------------------------------------------------------------------
# hermes-agent launcher regression (#74819)
# ---------------------------------------------------------------------------
HERMES_AGENT_BLOCK = re.compile(
r'(rm -f "\$command_link_dir/hermes-agent".*?'
r'log_success "Installed hermes-agent launcher[^\n]*\n)',
re.S,
)
def _extract_hermes_agent_shim_block() -> str:
match = HERMES_AGENT_BLOCK.search(INSTALL_SH.read_text(encoding="utf-8"))
assert match, (
"could not locate the hermes-agent launcher block in scripts/install.sh — "
"if it was renamed, update this test with it"
)
return match.group(1)
def _run_hermes_agent_block(tmp_path: Path, use_venv: str) -> Path | None:
"""Execute the extracted hermes-agent block with the env vars setup_path() sets."""
if use_venv == "false":
# --no-venv: hermes-agent is NOT installed by this block (handled
# elsewhere), so there's nothing to test here.
return None
command_link_dir = tmp_path / "local_bin"
command_link_dir.mkdir()
hermes_bin = tmp_path / "venv" / "bin" / "python"
hermes_bin.parent.mkdir(parents=True)
hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8")
install_dir = tmp_path / "install"
install_dir.mkdir()
script = (
"set -e\n"
f"HERMES_BIN={hermes_bin}\n"
f"INSTALL_DIR={install_dir}\n"
f"command_link_dir={command_link_dir}\n"
f"command_link_display_dir={command_link_dir}\n"
f"USE_VENV={use_venv}\n"
"log_success(){ :; }\n" + _extract_hermes_agent_shim_block()
)
result = subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
cwd=tmp_path,
)
assert result.returncode == 0, (
f"hermes-agent shim block failed:\nstdout={result.stdout}\nstderr={result.stderr}"
)
return command_link_dir / "hermes-agent"
def test_venv_install_writes_executable_hermes_agent_launcher(tmp_path):
"""venv install must write a user-executable hermes-agent launcher."""
shim = _run_hermes_agent_block(tmp_path, "true")
assert shim is not None
assert shim.is_file()
assert shim.stat().st_mode & stat.S_IXUSR, "launcher must be user-executable"
text = shim.read_text(encoding="utf-8")
assert "unset PYTHONPATH" in text
assert "unset PYTHONHOME" in text
assert "run_agent.py" in text, "hermes-agent must dispatch to run_agent.py"
def test_hermes_agent_launcher_cleanup_on_uninstall(tmp_path):
"""uninstall.remove_wrapper_script() must remove hermes-agent alongside
hermes and hermes-acp."""
from hermes_cli.uninstall import remove_wrapper_script
# Simulate a hermes-agent wrapper in the user-local location
local_shim = tmp_path / ".local" / "bin" / "hermes-agent"
local_shim.parent.mkdir(parents=True)
local_shim.write_text("#!/usr/bin/env bash\nexec hermes-agent\n", encoding="utf-8")
with patch.object(Path, "home", return_value=tmp_path):
removed = remove_wrapper_script()
assert local_shim in removed, "local hermes-agent wrapper must be removed"