Files
hermes-agent/tests/hermes_cli/test_relaunch.py
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
2026-08-09 22:09:49 -04:00

241 lines
10 KiB
Python

"""Tests for hermes_cli.relaunch — unified self-relaunch utility."""
import sys
import pytest
from hermes_cli import relaunch as relaunch_mod
class TestResolveHermesBin:
def test_prefers_absolute_argv0_when_executable(self, monkeypatch):
fake = "/nix/store/abc/bin/hermes"
monkeypatch.setattr(sys, "argv", [fake])
monkeypatch.setattr(relaunch_mod.os.path, "isfile", lambda p: p == fake)
monkeypatch.setattr(relaunch_mod.os, "access", lambda p, mode: p == fake)
assert relaunch_mod.resolve_hermes_bin() == fake
def test_resolves_relative_argv0(self, monkeypatch, tmp_path):
fake = tmp_path / "hermes"
fake.write_text("#!/bin/sh\n")
fake.chmod(0o755)
monkeypatch.setattr(sys, "argv", [str(fake.name)])
monkeypatch.chdir(tmp_path)
# Ensure we don't accidentally match a real 'hermes' on PATH
monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
assert relaunch_mod.resolve_hermes_bin() == str(fake)
def test_falls_back_to_path_which(self, monkeypatch):
monkeypatch.setattr(sys, "argv", ["-c"]) # not a real path
monkeypatch.setattr(
relaunch_mod.shutil, "which", lambda name: "/usr/bin/hermes" if name == "hermes" else None
)
assert relaunch_mod.resolve_hermes_bin() == "/usr/bin/hermes"
class TestExtractInheritedFlags:
def test_extracts_tui_and_dev(self):
argv = ["--tui", "--dev", "chat"]
assert relaunch_mod._extract_inherited_flags(argv) == ["--tui", "--dev"]
def test_preserves_multiple_skills(self):
argv = ["-s", "foo", "-s", "bar", "--tui"]
assert relaunch_mod._extract_inherited_flags(argv) == ["-s", "foo", "-s", "bar", "--tui"]
class TestInheritedFlagTable:
"""Sanity-check the argparse-introspected table that drives extraction."""
def test_short_and_long_aliases_are_paired(self):
table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
# Each pair declared together in the parser shares takes_value.
for short, long_ in [
("-p", "--profile"),
("-m", "--model"),
("-s", "--skills"),
]:
assert table[short] == table[long_], f"{short}/{long_} disagree"
def test_excluded_flags_are_not_inherited(self):
table = dict(relaunch_mod._INHERITED_FLAGS_TABLE)
# --worktree creates a new worktree per process; inheriting would
# orphan the parent's. Chat-only flags (--quiet/-Q, --verbose/-v,
# --source) can't be in argv at the existing relaunch callsites.
for flag in ["-w", "--worktree", "-Q", "--quiet", "-v", "--verbose", "--source"]:
assert flag not in table, f"{flag} should not be inherited"
class TestBuildRelaunchArgv:
def test_uses_bin_when_available(self, monkeypatch):
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
assert argv[0] == "/usr/bin/hermes"
def test_preserves_inherited_flags(self, monkeypatch):
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
original = ["--tui", "--dev", "--profile", "work", "sessions", "browse"]
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"], original_argv=original)
assert "--tui" in argv
assert "--dev" in argv
assert "--profile" in argv
assert "work" in argv
assert "--resume" in argv
assert "abc" in argv
# The original subcommand should not survive
assert "sessions" not in argv
assert "browse" not in argv
def test_can_disable_preserve(self, monkeypatch):
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
original = ["--tui", "chat"]
argv = relaunch_mod.build_relaunch_argv(
["--resume", "abc"], preserve_inherited=False, original_argv=original
)
assert "--tui" not in argv
assert argv == ["/usr/bin/hermes", "--resume", "abc"]
class TestRelaunch:
def test_calls_execvp(self, monkeypatch):
calls = []
def fake_execvp(path, argv):
calls.append((path, argv))
raise SystemExit(0)
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
with pytest.raises(SystemExit):
relaunch_mod.relaunch(["--resume", "abc"])
assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])]
@pytest.mark.windows_only
def test_windows_uses_subprocess_not_execvp(self, monkeypatch):
"""On Windows, os.execvp raises OSError "Exec format error" when the
target is a .cmd shim or console-script wrapper (both common for
hermes). relaunch() must detect win32 and use subprocess.run +
sys.exit instead.
``windows_only``: the bug is that ``os.execvp`` cannot exec a Windows
console-script shim. On Linux ``execvp`` works fine, so a patched
platform only re-asserted the branch we wrote, never the constraint
that motivated it.
"""
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: r"C:\Users\test\hermes.exe")
# Pin sys.argv: relaunch() preserves inherited flags from the LIVE
# argv, so under pytest it happily inherited the runner's own
# "-m 'windows_only and not integration'" and the assertion below saw
# them in the child argv. Nothing to do with Windows — it only showed
# up here because this is the first lane that actually executes the
# test, and -m is how that lane selects it.
monkeypatch.setattr(relaunch_mod.sys, "argv", [r"C:\Users\test\hermes.exe"])
import subprocess as _subprocess
captured_argv = []
def fake_subprocess_run(argv, **kwargs):
captured_argv.append(list(argv))
class _Result:
returncode = 0
return _Result()
monkeypatch.setattr(_subprocess, "run", fake_subprocess_run)
# execvp MUST NOT be called on Windows — route must go through subprocess
execvp_calls = []
def fake_execvp(*args, **kwargs):
execvp_calls.append(args)
raise AssertionError("os.execvp must not be called on Windows")
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
with pytest.raises(SystemExit) as exc_info:
relaunch_mod.relaunch(["chat"])
assert exc_info.value.code == 0
assert execvp_calls == []
assert captured_argv == [[r"C:\Users\test\hermes.exe", "chat"]]
@pytest.mark.windows_only
def test_windows_propagates_child_exit_code(self, monkeypatch):
"""A non-zero exit from the child should flow through to sys.exit."""
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: r"C:\hermes.exe")
import subprocess as _subprocess
def fake_run(argv, **kwargs):
class _Result:
returncode = 42
return _Result()
monkeypatch.setattr(_subprocess, "run", fake_run)
monkeypatch.setattr(relaunch_mod.os, "execvp", lambda *a, **kw: None)
with pytest.raises(SystemExit) as exc_info:
relaunch_mod.relaunch(["chat"])
assert exc_info.value.code == 42
class TestResolveHermesBinWindowsPyGuard:
"""On Windows, resolve_hermes_bin MUST NOT return a .py path.
os.access(x, os.X_OK) returns True for .py files on Windows because
PATHEXT includes .py when the Python launcher is installed — but
subprocess.run can't actually exec a .py directly, so the relaunch
would fail with the cryptic "%1 is not a valid Win32 application" error.
The Windows cases are ``windows_only``: the PATHEXT-driven ``os.access``
result the guard defends against simply does not occur on POSIX, so a
faked ``sys.platform`` could never reproduce the hazard.
"""
@pytest.mark.windows_only
def test_windows_rejects_py_argv0_falls_through_to_path(self, monkeypatch, tmp_path):
"""On Windows, if sys.argv[0] is a .py file, we must skip the
argv[0] fast-path and fall through to PATH / python -m."""
# Build a fake .py script that "passes" the isfile + X_OK checks.
script = tmp_path / "main.py"
script.write_text("# stub")
monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"])
# Force PATH lookup to return a hermes.exe so the test doesn't
# exercise the None-fallback path (that's a separate test).
monkeypatch.setattr(
relaunch_mod.shutil, "which",
lambda name: r"C:\venv\Scripts\hermes.exe" if name == "hermes" else None,
)
bin_path = relaunch_mod.resolve_hermes_bin()
# Must NOT be the .py — must be the hermes.exe PATH entry.
assert bin_path == r"C:\venv\Scripts\hermes.exe"
@pytest.mark.linux_only
def test_posix_still_accepts_py_argv0(self, monkeypatch, tmp_path):
"""POSIX behaviour unchanged: argv[0] pointing at an executable
script (including .py with a shebang + chmod +x) is fine to return
because POSIX exec can route through the shebang line."""
script = tmp_path / "hermes"
script.write_text("#!/usr/bin/env python3\n")
script.chmod(0o755)
monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"])
assert relaunch_mod.resolve_hermes_bin() == str(script)
@pytest.mark.windows_only
def test_windows_py_argv0_with_no_hermes_on_path_returns_none(self, monkeypatch, tmp_path):
"""Bulletproof fallback: if argv0 is .py on Windows AND hermes.exe
isn't on PATH, return None so the caller falls back to
python -m hermes_cli.main."""
script = tmp_path / "main.py"
script.write_text("# stub")
monkeypatch.setattr(relaunch_mod.sys, "argv", [str(script), "chat"])
monkeypatch.setattr(relaunch_mod.shutil, "which", lambda name: None)
assert relaunch_mod.resolve_hermes_bin() is None