test(#3664): Windows pytest-harness compatibility (batch: #4254/4255/4256/4257/4259) (#4261)

* stage-3664: Windows pytest harness hardening (test-only batch #4254/4255/4256/4257/4259)

* test(#3664): Windows pytest-harness compatibility batch

Combines 5 rodboev test-only PRs (#4254/#4255/#4256/#4257/#4259), all under tests/.
Linux assertion paths preserved at full strictness; relaxations + new markers are
Windows/no-fork gated. Codex SAFE + Opus SHIP + changed tests green (cascade-only
full-suite noise is the box artifact). No version stamp (tests-only, Nathan rule).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-15 12:42:33 -07:00
1 parent b01f3d5930
commit 5a316b7ac4
10 files changed
+187 -28

No files matched your search

+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Internal
- **Windows pytest-harness compatibility (#3664).** Hardened the test suite to run on Windows: profile-home fallback paths are path-normalized, strict POSIX file-mode (`0o600`) assertions are gated behind `os.name != "nt"` (Linux still asserts them at full strictness), the conftest cleanup handles Windows process-tree/port teardown and the Py3.12+ `shutil.rmtree` `onexc` shim, and tests that require `fork`/`fcntl` carry `@requires_fork` / `@requires_fcntl` markers (which never skip on Linux). Test-only — no runtime or app behavior change, no Linux CI behavior change. (#4254, #4255, #4256, #4257, #4259)
## [v0.51.438] — 2026-06-15 — Release OY (model cost/health comparison table on Insights)
### Added
+134 -18
View File
@@ -14,6 +14,8 @@ PATH DISCOVERY:
4. System python3 as a last resort
"""
import json
import inspect
import multiprocessing
import os
import pathlib
import shutil
@@ -32,6 +34,16 @@ if not (3, 11) <= sys.version_info[:2] <= (3, 13):
returncode=3,
)
WINDOWS = sys.platform == "win32"
requires_fcntl = pytest.mark.skipif(
WINDOWS,
reason="requires fcntl-backed nonblocking pipe reads",
)
requires_fork = pytest.mark.skipif(
"fork" not in multiprocessing.get_all_start_methods(),
reason="requires multiprocessing fork",
)
# ── Repo root discovery ────────────────────────────────────────────────────
# conftest.py lives at <repo>/tests/conftest.py
TESTS_DIR = pathlib.Path(__file__).parent.resolve()
@@ -244,6 +256,8 @@ requires_agent_modules = pytest.mark.skipif(
def pytest_configure(config):
config.addinivalue_line("markers", "requires_agent: skip when hermes-agent dir is not found")
config.addinivalue_line("markers", "requires_agent_modules: skip when hermes-agent Python modules are not importable")
config.addinivalue_line("markers", "requires_fcntl: skip when fcntl-backed file-descriptor operations are unavailable")
config.addinivalue_line("markers", "requires_fork: skip when the platform lacks multiprocessing fork support")
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
@@ -601,6 +615,120 @@ def _server_boot_diagnostic(headline, log_path):
return "\n".join(parts)
def _kill_process_tree(pid):
"""Best-effort kill for a known fixture-owned or port-owning PID."""
if not pid or pid <= 0:
return
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
**({"creationflags": subprocess.CREATE_NO_WINDOW}),
)
return
import signal
os.kill(pid, signal.SIGKILL)
except Exception:
pass
def _kill_port_owner(port):
"""Best-effort free of TEST_PORT, using a Windows-native owner lookup."""
try:
if sys.platform != "win32":
subprocess.run(["fuser", "-k", f"{port}/tcp"], capture_output=True, timeout=5)
return
creationflags = {"creationflags": subprocess.CREATE_NO_WINDOW}
ps_cmd = (
"$port = " + str(port) + "; "
"$conn = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue | "
"Where-Object { $_.LocalAddress -in @('127.0.0.1', '::1', '0.0.0.0', '::', '::ffff:127.0.0.1') } | "
"Select-Object -First 1 -ExpandProperty OwningProcess; "
"if ($conn) { $conn }"
)
proc = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd],
capture_output=True,
text=True,
timeout=5,
**creationflags,
)
pid_text = proc.stdout.strip()
if not pid_text:
proc = subprocess.run(
["netstat", "-ano", "-p", "tcp"],
capture_output=True,
text=True,
timeout=5,
**creationflags,
)
port_suffixes = (f":{port}", f"[::]:{port}", f"127.0.0.1:{port}", f"[::1]:{port}")
for line in proc.stdout.splitlines():
parts = line.split()
if len(parts) < 5 or parts[0].upper() != "TCP":
continue
local_addr = parts[1]
state = parts[3].upper()
owner_pid = parts[4]
if state == "LISTENING" and any(local_addr.endswith(suffix) for suffix in port_suffixes):
pid_text = owner_pid
break
if pid_text:
_kill_process_tree(int(pid_text))
except Exception:
pass
def _rmtree_retry(path):
"""Remove a tree, retrying on Windows after clearing read-only bits."""
target = pathlib.Path(path)
if not target.exists():
return
if sys.platform != "win32":
shutil.rmtree(target)
return
def _clear_readonly(_func, entry, _exc):
try:
os.chmod(entry, 0o666)
_func(entry)
except Exception:
raise
rmtree_kwargs = (
{"onexc": _clear_readonly}
if "onexc" in inspect.signature(shutil.rmtree).parameters
else {"onerror": _clear_readonly}
)
attempts = 5
last_exc = None
for attempt in range(1, attempts + 1):
try:
shutil.rmtree(target, **rmtree_kwargs)
return
except FileNotFoundError:
return
except Exception as exc:
last_exc = exc
if attempt == attempts:
break
time.sleep(0.3)
leftovers = []
try:
leftovers = [child.name for child in list(target.iterdir())[:5]]
except Exception:
pass
leftover_note = f" leftovers={leftovers}" if leftovers else ""
raise RuntimeError(f"Could not remove {target} after {attempts} attempts.{leftover_note}") from last_exc
# ── Session-scoped test server ────────────────────────────────────────────────
@pytest.fixture(scope="session", autouse=True)
@@ -612,18 +740,13 @@ def test_server():
# Kill any leftover process on the test port before starting.
# Stale servers from QA harness runs or prior test sessions cause
# conftest to think the server is already up, producing false failures.
try:
import subprocess as _sp
_sp.run(['fuser', '-k', f'{TEST_PORT}/tcp'],
capture_output=True, timeout=5)
except Exception:
pass
_kill_port_owner(TEST_PORT)
import time as _time
_time.sleep(0.5) # brief pause to let the port release
# Clean slate
if TEST_STATE_DIR.exists():
shutil.rmtree(TEST_STATE_DIR)
_rmtree_retry(TEST_STATE_DIR)
TEST_STATE_DIR.mkdir(parents=True)
TEST_WORKSPACE.mkdir(parents=True)
@@ -759,16 +882,12 @@ def test_server():
last_reason = reason
# Tear down the failed attempt and free the port before retrying.
try:
proc.kill()
_kill_process_tree(proc.pid)
proc.wait(timeout=5)
except Exception:
pass
if _attempt < boot_attempts:
try:
import subprocess as _sp2
_sp2.run(['fuser', '-k', f'{TEST_PORT}/tcp'], capture_output=True, timeout=5)
except Exception:
pass
_kill_port_owner(TEST_PORT)
time.sleep(1.0)
else:
pytest.fail(
@@ -787,12 +906,9 @@ def test_server():
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
_kill_process_tree(proc.pid)
try:
shutil.rmtree(TEST_STATE_DIR)
except Exception:
pass
_rmtree_retry(TEST_STATE_DIR)
# ── Test base URL ─────────────────────────────────────────────────────────────
+4 -1
View File
@@ -79,7 +79,7 @@ class TestSessionPersistence(unittest.TestCase):
self.assertIn("valid_token", auth._sessions)
def test_sessions_file_permissions(self) -> None:
"""Sessions file must be owner-read-only (0600)."""
"""Sessions file must stay strict on POSIX and still be created on Windows."""
auth.create_session()
# Check the path auth actually writes to (auth._SESSIONS_FILE is computed
# from api.config.STATE_DIR at import time). Asserting against a local
@@ -87,6 +87,9 @@ class TestSessionPersistence(unittest.TestCase):
# this module may import before api.config.STATE_DIR resolves to _TEST_STATE.
sessions_file = auth._SESSIONS_FILE
self.assertTrue(sessions_file.exists(), ".sessions.json was not created")
if os.name == "nt":
self.assertTrue(sessions_file.is_file(), ".sessions.json was not written as a file")
return
mode = oct(sessions_file.stat().st_mode & 0o777)
self.assertEqual(mode, oct(0o600),
f".sessions.json permissions {mode} — expected 0o600")
+15 -4
View File
@@ -231,10 +231,11 @@ class TestMainForegroundRouting:
def stub_main_dependencies(self, monkeypatch, tmp_path):
"""Stub out everything main() calls except the routing decision."""
import bootstrap as bs
python_exe = sys.executable
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
@@ -265,6 +266,7 @@ class TestMainForegroundRouting:
def test_foreground_flag_uses_execv(self, stub_main_dependencies, clean_env, monkeypatch):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
monkeypatch.setattr(sys, "platform", "linux")
execv_calls = []
popen_calls = []
@@ -289,9 +291,10 @@ class TestMainForegroundRouting:
assert len(popen_calls) == 0, "--foreground must NOT call subprocess.Popen"
path, argv = execv_calls[0]
assert path == "/usr/bin/python3"
python_exe = sys.executable
assert path == python_exe
# argv[0] is the program name (convention), argv[1] is the script
assert argv[0] == "/usr/bin/python3"
assert argv[0] == python_exe
assert argv[1].endswith("server.py")
@pytest.mark.parametrize("var", [
@@ -304,6 +307,7 @@ class TestMainForegroundRouting:
def test_supervisor_env_var_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch, var):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv(var, "deadbeef")
execv_calls = []
@@ -327,6 +331,7 @@ class TestMainForegroundRouting:
def test_explicit_opt_in_env_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground flag
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
execv_calls = []
@@ -348,12 +353,13 @@ class TestForegroundEnvAndCwd:
@pytest.fixture
def setup(self, monkeypatch, tmp_path):
import bootstrap as bs
python_exe = sys.executable
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
agent_dir = tmp_path / "agent"
agent_dir.mkdir()
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: python_exe)
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda *a, **kw: a[0])
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
@@ -364,6 +370,7 @@ class TestForegroundEnvAndCwd:
def test_foreground_chdirs_to_agent_dir_before_exec(self, setup, monkeypatch, clean_env):
bs, agent_dir = setup
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--host", "127.0.0.1", "9999"])
monkeypatch.setattr(sys, "platform", "linux")
chdir_calls = []
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
@@ -382,6 +389,7 @@ class TestForegroundEnvAndCwd:
monkeypatch.setattr(sys, "argv", [
"bootstrap.py", "--foreground", "--host", "0.0.0.0", "9119"
])
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(os, "chdir", lambda p: None)
def fake_execv(*a):
@@ -402,6 +410,7 @@ class TestForegroundEnvAndCwd:
def test_foreground_does_not_call_wait_for_health(self, setup, monkeypatch, clean_env):
bs, _ = setup
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(os, "chdir", lambda p: None)
wait_calls = []
@@ -444,9 +453,11 @@ class TestForegroundExecutabilityGuard:
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
bs = setup_with_bad_python
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
monkeypatch.setattr(sys, "platform", "linux")
execv_calls = []
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
monkeypatch.setattr(os, "access", lambda path, mode: False)
monkeypatch.setattr(os, "chdir", lambda p: None)
with pytest.raises(RuntimeError, match="not executable"):
@@ -5,6 +5,9 @@ import threading
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from conftest import requires_fork
def _install_fake_cron(monkeypatch, run_job, events):
cron_pkg = types.ModuleType("cron")
@@ -193,6 +196,7 @@ def _run_lock_probe_with_context(context_name, target, result_queue):
return process.exitcode, acquired
@requires_fork
def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
"""Spawn starts a fresh interpreter where fork would clone a held lock."""
helper_dir = tmp_path / "spawn_helper"
@@ -244,6 +248,7 @@ def test_spawn_context_does_not_inherit_parent_thread_locks(tmp_path):
assert spawn_acquired is True
@requires_fork
def test_manual_cron_subprocess_drains_large_result_before_join(tmp_path):
"""A >100 KB result must not deadlock the parent before it can persist output."""
if _real_hermes_agent_editable_install_present():
@@ -349,6 +354,7 @@ def test_manual_cron_run_does_not_hold_profile_lock_for_job_duration(tmp_path, m
assert routes._is_cron_running("job1574") == (False, 0.0)
@requires_fork
def test_cron_job_subprocess_executes_under_selected_profile_home(tmp_path, monkeypatch):
if _real_hermes_agent_editable_install_present():
import pytest as _pytest
@@ -1,4 +1,5 @@
import json
import os
import stat
import time
@@ -15,7 +16,9 @@ def test_login_attempts_persist_failed_attempts(tmp_path, monkeypatch):
data = json.loads(attempts_file.read_text(encoding="utf-8"))
assert "203.0.113.10" in data
assert len(data["203.0.113.10"]) == 1
assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600
assert attempts_file.exists()
if os.name != "nt":
assert stat.S_IMODE(attempts_file.stat().st_mode) == 0o600
def test_login_attempts_load_prunes_expired_entries(tmp_path, monkeypatch):
+12 -3
View File
@@ -495,12 +495,19 @@ def test_fix_credential_permissions_corrects_loose_files(tmp_path, monkeypatch):
fix_credential_permissions()
import stat
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600, ".env not fixed to 600"
assert stat.S_IMODE(google_file.stat().st_mode) == 0o600, "google_token.json not fixed to 600"
assert env_file.exists(), ".env missing after permission repair"
assert google_file.exists(), "google_token.json missing after permission repair"
if os.name != "nt":
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600, ".env not fixed to 600"
assert stat.S_IMODE(google_file.stat().st_mode) == 0o600, "google_token.json not fixed to 600"
# Windows CI cannot assert a POSIX mode-bit repair here; keeping the files
# present after the repair pass is the strongest portable contract for now.
def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
"""fix_credential_permissions() does not alter already-strict files."""
import os
env_file = tmp_path / ".env"
env_file.write_text("SECRET=abc")
env_file.chmod(0o600)
@@ -511,4 +518,6 @@ def test_fix_credential_permissions_skips_correct_files(tmp_path, monkeypatch):
fix_credential_permissions()
import stat
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
assert env_file.exists(), ".env missing after strict-permission check"
if os.name != "nt":
assert stat.S_IMODE(env_file.stat().st_mode) == 0o600
+1 -1
View File
@@ -440,7 +440,7 @@ class TestGetProfileHome:
monkeypatch.setitem(sys.modules, "api.profiles", None)
monkeypatch.setenv("HERMES_HOME", "/custom/hermes")
result = _get_profile_home(None)
assert str(result) == "/custom/hermes"
assert result == Path(os.environ["HERMES_HOME"]).expanduser()
def test_expands_tilde_in_hermes_home(self, monkeypatch):
"""If HERMES_HOME contains ~, it gets expanded."""
+4
View File
@@ -16,6 +16,9 @@ import unittest
from contextlib import suppress
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from conftest import requires_fcntl
ROOT = Path(__file__).parent.parent
@@ -190,6 +193,7 @@ class TestTLSEndToEnd(unittest.TestCase):
self.assertEqual(data.get("status"), "ok")
conn.close()
@requires_fcntl
def test_tls_startup_failure_fallback_to_http(self):
"""Bad cert paths should print a warning and start HTTP anyway."""
port = _find_free_port()
+3
View File
@@ -64,6 +64,9 @@ def test_oauth_write_auth_json_uses_chmod_0600_before_rename(monkeypatch, tmp_pa
os.umask(old_umask)
assert fake_path.exists(), "auth.json was not written"
if os.name == "nt":
assert fake_path.is_file(), "auth.json was not written as a file"
return
mode = stat.S_IMODE(fake_path.stat().st_mode)
# The file must be chmod 0600 — owner read/write only.
assert mode == 0o600, (