Files
hermes-webui/tests/test_ctl_script.py
T
2026-06-01 04:42:05 +00:00

366 lines
12 KiB
Python

import os
import shutil
import subprocess
import sys
import textwrap
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CTL = REPO_ROOT / "ctl.sh"
def run_ctl(
home: Path,
*args: str,
env: dict[str, str] | None = None,
timeout: float = 5.0,
repo_root: Path = REPO_ROOT,
load_dotenv: bool = False,
):
merged = os.environ.copy()
for key in (
"HERMES_WEBUI_HOST",
"HERMES_WEBUI_PORT",
"HERMES_WEBUI_PYTHON",
"HERMES_WEBUI_STATE_DIR",
"HERMES_WEBUI_PID_FILE",
"HERMES_WEBUI_LOG_FILE",
"HERMES_WEBUI_CTL_STATE_FILE",
"HERMES_WEBUI_NO_DOTENV",
):
merged.pop(key, None)
merged.update(
{
"HOME": str(home),
"HERMES_HOME": str(home / ".hermes"),
"PATH": os.environ.get("PATH", ""),
"HERMES_WEBUI_NO_DOTENV": "0" if load_dotenv else "1",
}
)
if env:
merged.update(env)
return subprocess.run(
["bash", str(repo_root / "ctl.sh"), *args],
cwd=repo_root,
env=merged,
text=True,
capture_output=True,
timeout=timeout,
)
def write_fake_python(path: Path) -> None:
path.write_text(
textwrap.dedent(
"""
#!/usr/bin/env bash
printf 'fake-python args:%s\n' "$*" >> "${FAKE_PYTHON_LOG}"
printf 'host=%s port=%s state=%s\n' "${HERMES_WEBUI_HOST:-}" "${HERMES_WEBUI_PORT:-}" "${HERMES_WEBUI_STATE_DIR:-}" >> "${FAKE_PYTHON_LOG}"
trap 'printf "terminated\n" >> "${FAKE_PYTHON_LOG}"; exit 0' TERM INT
while true; do sleep 0.1; done
"""
).lstrip(),
encoding="utf-8",
)
path.chmod(0o755)
def wait_for_pid_file(pid_file: Path, timeout: float = 3.0) -> int:
deadline = time.time() + timeout
while time.time() < deadline:
if pid_file.exists():
raw = pid_file.read_text(encoding="utf-8").strip()
if raw:
return int(raw)
time.sleep(0.05)
raise AssertionError(f"PID file was not written: {pid_file}")
def wait_for_file_text(path: Path, timeout: float = 3.0) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
if path.exists():
text = path.read_text(encoding="utf-8")
if text:
return text
time.sleep(0.05)
raise AssertionError(f"File was not written: {path}")
def assert_process_exits(pid: int, timeout: float = 3.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
return
time.sleep(0.05)
raise AssertionError(f"process {pid} did not exit")
def test_start_writes_pid_under_hermes_home_runs_foreground_no_browser_and_logs(tmp_path):
fake_python = tmp_path / "fake-python"
fake_log = tmp_path / "fake-python.log"
write_fake_python(fake_python)
result = run_ctl(
tmp_path,
"start",
env={
"HERMES_WEBUI_PYTHON": str(fake_python),
"FAKE_PYTHON_LOG": str(fake_log),
"HERMES_WEBUI_HOST": "0.0.0.0",
"HERMES_WEBUI_PORT": "18991",
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
},
)
assert result.returncode == 0, result.stderr + result.stdout
hermes_home = tmp_path / ".hermes"
pid_file = hermes_home / "webui.pid"
log_file = hermes_home / "webui.log"
pid = wait_for_pid_file(pid_file)
try:
assert pid > 1
assert log_file.exists()
fake_output = wait_for_file_text(fake_log)
assert "bootstrap.py --no-browser --foreground" in fake_output
assert "host=0.0.0.0 port=18991" in fake_output
assert str(hermes_home / "webui") in fake_output
status = run_ctl(tmp_path, "status")
assert status.returncode == 0
assert "running" in status.stdout
assert f"PID: {pid}" in status.stdout
assert "Bound: 0.0.0.0:18991" in status.stdout
assert f"Log: {log_file}" in status.stdout
finally:
stop = run_ctl(tmp_path, "stop")
assert stop.returncode == 0, stop.stderr + stop.stdout
assert_process_exits(pid)
assert not pid_file.exists()
def test_start_uses_nohup_so_daemon_survives_launcher_exit():
ctl_text = CTL.read_text(encoding="utf-8")
assert "trap '' HUP" in ctl_text
assert 'exec nohup "${python_exe}"' in ctl_text
def test_start_can_ignore_repo_dotenv_for_authoritative_test_env(tmp_path):
repo_root = tmp_path / "repo"
repo_root.mkdir()
shutil.copy2(CTL, repo_root / "ctl.sh")
(repo_root / "bootstrap.py").write_text("# fake bootstrap target\n", encoding="utf-8")
(repo_root / ".env").write_text(
f"HERMES_WEBUI_STATE_DIR={tmp_path / 'host-specific-webui'}\n",
encoding="utf-8",
)
fake_python = tmp_path / "fake-python"
fake_log = tmp_path / "fake-python.log"
write_fake_python(fake_python)
result = run_ctl(
tmp_path,
"start",
env={
"HERMES_WEBUI_PYTHON": str(fake_python),
"FAKE_PYTHON_LOG": str(fake_log),
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
},
repo_root=repo_root,
)
assert result.returncode == 0, result.stderr + result.stdout
pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
try:
fake_output = wait_for_file_text(fake_log)
assert str(tmp_path / ".hermes" / "webui") in fake_output
assert "host-specific-webui" not in fake_output
finally:
stop = run_ctl(tmp_path, "stop", repo_root=repo_root)
assert stop.returncode == 0, stop.stderr + stop.stdout
assert_process_exits(pid)
def test_start_loads_dotenv_but_inline_overrides_win(tmp_path):
repo_root = tmp_path / "repo"
repo_root.mkdir()
shutil.copy2(CTL, repo_root / "ctl.sh")
(repo_root / "bootstrap.py").write_text("# fake bootstrap target\n", encoding="utf-8")
fake_python = tmp_path / "fake-python"
fake_log = tmp_path / "fake-python.log"
write_fake_python(fake_python)
(repo_root / ".env").write_text(
"HERMES_WEBUI_HOST=127.9.9.9\nHERMES_WEBUI_PORT=18888\n",
encoding="utf-8",
)
result = run_ctl(
tmp_path,
"start",
env={
"HERMES_WEBUI_PYTHON": str(fake_python),
"FAKE_PYTHON_LOG": str(fake_log),
"HERMES_WEBUI_HOST": "0.0.0.0",
"HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1",
},
repo_root=repo_root,
load_dotenv=True,
)
assert result.returncode == 0, result.stderr + result.stdout
pid = wait_for_pid_file(tmp_path / ".hermes" / "webui.pid")
try:
fake_output = wait_for_file_text(fake_log)
assert "fake-python args:" in fake_output
assert "host=0.0.0.0 port=18888" in fake_output
finally:
stop = run_ctl(tmp_path, "stop", repo_root=repo_root)
assert stop.returncode == 0, stop.stderr + stop.stdout
assert_process_exits(pid)
def test_stale_pid_file_is_removed_without_killing_unrelated_process(tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
pid_file = hermes_home / "webui.pid"
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
try:
pid_file.write_text(str(sleeper.pid), encoding="utf-8")
result = run_ctl(tmp_path, "stop")
assert result.returncode == 0
assert "stale" in (result.stdout + result.stderr).lower()
assert sleeper.poll() is None, "ctl.sh must not kill unrelated PIDs"
assert not pid_file.exists()
finally:
sleeper.terminate()
try:
sleeper.wait(timeout=3)
except subprocess.TimeoutExpired:
sleeper.kill()
def _write_fake_launchctl(fake_bin, pid):
launchctl = fake_bin / "launchctl"
launchctl.write_text(
textwrap.dedent(
f"""
#!/usr/bin/env bash
if [[ "$1" == "print" ]]; then
printf '\\tpid = {pid}\\n'
exit 0
fi
exit 1
"""
).lstrip(),
encoding="utf-8",
)
launchctl.chmod(0o755)
def _write_fake_lsof(fake_bin, listening):
"""Fake lsof: exit 0 (PID listens on the port) iff `listening` is True."""
lsof = fake_bin / "lsof"
lsof.write_text(
"#!/usr/bin/env bash\n" + ("exit 0\n" if listening else "exit 1\n"),
encoding="utf-8",
)
lsof.chmod(0o755)
def test_start_refuses_second_instance_when_launchd_job_owns_the_port(tmp_path):
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
_write_fake_launchctl(fake_bin, sleeper.pid)
# launchd-owned PID IS listening on the requested (default) port → real conflict.
_write_fake_lsof(fake_bin, listening=True)
try:
result = run_ctl(
tmp_path,
"start",
env={
"PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
"HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
},
)
assert result.returncode == 2
combined = result.stdout + result.stderr
assert "Refusing to start a second Hermes WebUI" in combined
assert "launchctl kickstart -k" in combined
assert not (tmp_path / ".hermes" / "webui.pid").exists()
finally:
sleeper.terminate()
try:
sleeper.wait(timeout=3)
except subprocess.TimeoutExpired:
sleeper.kill()
def test_start_allows_alternate_port_while_launchd_job_runs_on_default(tmp_path):
"""A second instance on a DIFFERENT port must not be blocked by the launchd
guard, even while the launchd-managed default instance is alive (#3291 fix /
Codex regression-gate finding for v0.51.191)."""
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
sleeper = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])
_write_fake_launchctl(fake_bin, sleeper.pid)
# launchd-owned PID is alive but NOT listening on our (alternate) port → no conflict.
_write_fake_lsof(fake_bin, listening=False)
# A fake python so `start` "launches" without needing the real server.
fake_python = tmp_path / "fake-python"
fake_python.write_text(
"#!/usr/bin/env bash\ntrap 'exit 0' TERM INT\nwhile true; do sleep 1; done\n",
encoding="utf-8",
)
fake_python.chmod(0o755)
started_pid = None
try:
result = run_ctl(
tmp_path,
"start",
env={
"PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
"HERMES_WEBUI_LAUNCHD_LABEL": "com.parantoux.hermes-webui",
"HERMES_WEBUI_PORT": "18992",
"HERMES_WEBUI_PYTHON": str(fake_python),
},
)
combined = result.stdout + result.stderr
assert "Refusing to start a second Hermes WebUI" not in combined, combined
assert result.returncode == 0, combined
pid_file = tmp_path / ".hermes" / "webui.pid"
if pid_file.exists():
started_pid = int(pid_file.read_text().strip())
finally:
if started_pid:
try:
os.kill(started_pid, 15)
except ProcessLookupError:
pass
sleeper.terminate()
try:
sleeper.wait(timeout=3)
except subprocess.TimeoutExpired:
sleeper.kill()
def test_logs_supports_non_following_line_count(tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
log_file = hermes_home / "webui.log"
log_file.write_text("one\ntwo\nthree\n", encoding="utf-8")
result = run_ctl(tmp_path, "logs", "--lines", "2", "--no-follow")
assert result.returncode == 0
assert result.stdout == "two\nthree\n"