"""Behavioral tests for Windows-specific compatibility fixes. Complements ``tests/tools/test_windows_compat.py`` (which does source-level pattern linting) with cross-platform-mocked tests that exercise the actual code paths Hermes takes on native Windows. Runs on Linux CI — every test mocks ``sys.platform``, ``subprocess.run``, and ``os.kill`` as needed to simulate Windows behavior without requiring a Windows runner. """ from __future__ import annotations import asyncio import os import signal import subprocess import sys from pathlib import Path from unittest import mock from unittest.mock import MagicMock import pytest # --------------------------------------------------------------------------- # configure_windows_stdio # --------------------------------------------------------------------------- class TestConfigureWindowsStdio: """``hermes_cli.stdio.configure_windows_stdio`` wiring. The function must: - be a no-op on non-Windows - only configure once per process (idempotent) - set PYTHONIOENCODING / PYTHONUTF8 without overriding explicit user settings - reconfigure sys.stdout/stderr/stdin to UTF-8 on Windows - flip the console code page to CP_UTF8 (65001) via ctypes - respect HERMES_DISABLE_WINDOWS_UTF8 opt-out """ @pytest.fixture(autouse=True) def _reset_configured(self, monkeypatch): """Reload the module before each test so the _CONFIGURED flag resets.""" # Remove from sys.modules so import triggers a fresh load sys.modules.pop("hermes_cli.stdio", None) # Fresh import now; tests import from hermes_cli.stdio themselves, # but this guarantees the module they get is a brand-new copy. import hermes_cli.stdio as _s _s._CONFIGURED = False yield sys.modules.pop("hermes_cli.stdio", None) def test_no_op_on_posix(self): from hermes_cli import stdio assert stdio.is_windows() is False result = stdio.configure_windows_stdio() assert result is False def test_idempotent(self): from hermes_cli import stdio stdio.configure_windows_stdio() # Second call returns False because _CONFIGURED is set assert stdio.configure_windows_stdio() is False def test_windows_path_sets_env_and_reconfigures_streams(self, monkeypatch): from hermes_cli import stdio monkeypatch.setattr(stdio, "is_windows", lambda: True) # Pretend the user has no prior setting monkeypatch.delenv("PYTHONIOENCODING", raising=False) monkeypatch.delenv("PYTHONUTF8", raising=False) monkeypatch.delenv("HERMES_DISABLE_WINDOWS_UTF8", raising=False) monkeypatch.delenv("EDITOR", raising=False) monkeypatch.delenv("VISUAL", raising=False) reconfigure_calls = [] def fake_reconfigure(stream, *, encoding="utf-8", errors="replace"): reconfigure_calls.append((stream, encoding, errors)) cp_calls = [] def fake_flip(): cp_calls.append(True) monkeypatch.setattr(stdio, "_reconfigure_stream", fake_reconfigure) monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", fake_flip) # Pretend notepad.exe is on PATH (it always is on real Windows hosts, # but not on the Linux CI runner — mock it so the editor default # survives). monkeypatch.setattr(stdio, "_default_windows_editor", lambda: "notepad") result = stdio.configure_windows_stdio() assert result is True assert os.environ.get("PYTHONIOENCODING") == "utf-8" assert os.environ.get("PYTHONUTF8") == "1" # EDITOR must be set so prompt_toolkit's open_in_editor finds # a working program on Windows (it defaults to /usr/bin/nano). assert os.environ.get("EDITOR") == "notepad" assert len(cp_calls) == 1 # SetConsoleOutputCP path hit assert len(reconfigure_calls) == 3 # stdout, stderr, stdin def test_respects_existing_editor_var(self, monkeypatch): """User's explicit EDITOR wins over our default.""" from hermes_cli import stdio monkeypatch.setattr(stdio, "is_windows", lambda: True) monkeypatch.setenv("EDITOR", "code --wait") monkeypatch.setattr(stdio, "_reconfigure_stream", lambda *a, **kw: None) monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", lambda: None) monkeypatch.setattr(stdio, "_default_windows_editor", lambda: "notepad") stdio.configure_windows_stdio() assert os.environ["EDITOR"] == "code --wait" def test_respects_existing_visual_var(self, monkeypatch): """VISUAL takes precedence over our EDITOR default too.""" from hermes_cli import stdio monkeypatch.setattr(stdio, "is_windows", lambda: True) monkeypatch.delenv("EDITOR", raising=False) monkeypatch.setenv("VISUAL", "nvim") monkeypatch.setattr(stdio, "_reconfigure_stream", lambda *a, **kw: None) monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", lambda: None) monkeypatch.setattr(stdio, "_default_windows_editor", lambda: "notepad") stdio.configure_windows_stdio() # EDITOR should NOT be set when VISUAL already is (prompt_toolkit # checks VISUAL first anyway, but we also shouldn't override it). assert os.environ.get("EDITOR", "") != "notepad" assert os.environ["VISUAL"] == "nvim" def test_respects_existing_env_var(self, monkeypatch): """User's explicit PYTHONIOENCODING wins over our default.""" from hermes_cli import stdio monkeypatch.setattr(stdio, "is_windows", lambda: True) monkeypatch.setenv("PYTHONIOENCODING", "latin-1") monkeypatch.setattr(stdio, "_reconfigure_stream", lambda *a, **kw: None) monkeypatch.setattr(stdio, "_flip_console_code_page_to_utf8", lambda: None) stdio.configure_windows_stdio() assert os.environ["PYTHONIOENCODING"] == "latin-1" @pytest.mark.parametrize("optout", ["1", "true", "True", "yes"]) def test_disable_flag_short_circuits(self, monkeypatch, optout): from hermes_cli import stdio monkeypatch.setattr(stdio, "is_windows", lambda: True) monkeypatch.setenv("HERMES_DISABLE_WINDOWS_UTF8", optout) reconfigure_hit = [] monkeypatch.setattr( stdio, "_reconfigure_stream", lambda *a, **kw: reconfigure_hit.append(True), ) result = stdio.configure_windows_stdio() assert result is False assert reconfigure_hit == [], "opt-out must skip stream reconfiguration" def test_reconfigure_stream_handles_missing_method(self, monkeypatch): """StringIO-like objects without .reconfigure() must not blow up.""" from hermes_cli import stdio import io buf = io.StringIO() # Must not raise stdio._reconfigure_stream(buf) # --------------------------------------------------------------------------- # terminate_pid — the centralized kill primitive # --------------------------------------------------------------------------- class TestTerminatePidRoutingOnWindows: """``gateway.status.terminate_pid`` must use taskkill /T /F on Windows. On Linux we can't reload gateway/status with sys.platform=win32 because the module unconditionally imports ``msvcrt`` in that branch. Instead we patch the module-level ``_IS_WINDOWS`` flag and ``subprocess.run`` on the already-loaded module, which exercises the same branching code. """ def test_force_uses_taskkill_on_windows(self, monkeypatch): from gateway import status captured = {} def fake_run(args, **kwargs): captured["args"] = args result = MagicMock() result.returncode = 0 result.stderr = "" result.stdout = "" return result monkeypatch.setattr(status, "_IS_WINDOWS", True) monkeypatch.setattr(status.subprocess, "run", fake_run) status.terminate_pid(12345, force=True) assert captured["args"][0] == "taskkill" assert "/PID" in captured["args"] assert "12345" in captured["args"] assert "/T" in captured["args"] assert "/F" in captured["args"] def test_force_taskkill_failure_raises_oserror(self, monkeypatch): from gateway import status def fake_run(args, **kwargs): result = MagicMock() result.returncode = 128 result.stderr = "ERROR: The process cannot be terminated." result.stdout = "" return result monkeypatch.setattr(status, "_IS_WINDOWS", True) monkeypatch.setattr(status.subprocess, "run", fake_run) with pytest.raises(OSError, match="cannot be terminated"): status.terminate_pid(12345, force=True) def test_graceful_on_windows_uses_os_kill_sigterm(self, monkeypatch): """Non-force path calls os.kill with SIGTERM (Windows has no SIGKILL). ``terminate_pid(pid)`` with force=False bypasses the taskkill branch and uses ``os.kill`` directly — so platform doesn't actually matter for the signal choice. Verifies the getattr fallback works. """ from gateway import status captured = {} def fake_kill(pid, sig): captured["pid"] = pid captured["sig"] = sig monkeypatch.setattr(status.os, "kill", fake_kill) status.terminate_pid(99, force=False) assert captured["pid"] == 99 assert captured["sig"] == signal.SIGTERM def test_taskkill_not_found_falls_back_to_os_kill(self, monkeypatch): """On Windows without taskkill (WinPE, containers), fall back gracefully.""" from gateway import status captured = {} def fake_run(args, **kwargs): raise FileNotFoundError(2, "taskkill not found") def fake_kill(pid, sig): captured["pid"] = pid captured["sig"] = sig monkeypatch.setattr(status, "_IS_WINDOWS", True) monkeypatch.setattr(status.subprocess, "run", fake_run) monkeypatch.setattr(status.os, "kill", fake_kill) status.terminate_pid(42, force=True) assert captured["pid"] == 42 assert captured["sig"] == signal.SIGTERM # --------------------------------------------------------------------------- # SIGKILL fallback pattern # --------------------------------------------------------------------------- class TestSigkillFallback: """Modules that want SIGKILL must fall back to SIGTERM when absent.""" def test_getattr_fallback_works_when_sigkill_missing(self, monkeypatch): """The `getattr(signal, "SIGKILL", signal.SIGTERM)` pattern.""" # Build a stand-in signal module with no SIGKILL attribute fake_signal = MagicMock() del fake_signal.SIGKILL # ensure it's absent fake_signal.SIGTERM = 15 result = getattr(fake_signal, "SIGKILL", fake_signal.SIGTERM) assert result == 15 def test_getattr_fallback_prefers_sigkill_when_present(self): """On POSIX the fallback is a no-op: real SIGKILL wins.""" result = getattr(signal, "SIGKILL", signal.SIGTERM) assert result == signal.SIGKILL @pytest.mark.parametrize( "module_path, line_pattern", [ ("hermes_cli.kanban_db", 'getattr(signal, "SIGKILL", signal.SIGTERM)'), ], ) def test_module_uses_getattr_fallback(self, module_path, line_pattern): """Source-level check that our modules use the safe fallback.""" rel = module_path.replace(".", "/") + ".py" root = Path(__file__).resolve().parents[2] source = (root / rel).read_text(encoding="utf-8") assert line_pattern in source, ( f"{rel} must use the getattr fallback pattern on its SIGKILL site" ) # --------------------------------------------------------------------------- # OSError widening on liveness probes # # Post-#21561, ``ProcessRegistry._is_host_pid_alive`` delegates to # ``gateway.status._pid_exists``, which is the cross-platform liveness # primitive (psutil-first, ctypes/os.kill fallback). The tests below assert # (a) the delegation is correct and (b) ``_pid_exists`` correctly widens # Windows' ``OSError(WinError 87)`` / ``PermissionError`` behavior on the # POSIX fallback branch. # --------------------------------------------------------------------------- class TestProcessRegistryOSErrorWidening: """_is_host_pid_alive delegates to gateway.status._pid_exists.""" def test_oserror_treated_as_not_alive(self, monkeypatch): """_pid_exists → False propagates as _is_host_pid_alive → False.""" from tools.process_registry import ProcessRegistry monkeypatch.setattr("gateway.status._pid_exists", lambda pid: False) assert ProcessRegistry._is_host_pid_alive(12345) is False def test_permission_error_treated_as_alive(self, monkeypatch): """PermissionError is encoded by _pid_exists as alive=True; propagates as-is. This is a meaningful semantic change from the pre-#21561 version of this test (which asserted PermissionError → not-alive). The old ``os.kill(pid, 0)``-based probe couldn't distinguish "gone" from "owned by another user" on some platforms, so it conservatively returned False. The new psutil-based probe CAN distinguish them via ``OpenProcess + ERROR_ACCESS_DENIED`` on Windows / ``except PermissionError`` on POSIX, so alive=True is correct. """ from tools.process_registry import ProcessRegistry monkeypatch.setattr("gateway.status._pid_exists", lambda pid: True) assert ProcessRegistry._is_host_pid_alive(12345) is True def test_zero_or_none_pid_returns_false_without_probing(self, monkeypatch): """No wasted syscall on falsy pids.""" from tools.process_registry import ProcessRegistry probes = [] monkeypatch.setattr( "gateway.status._pid_exists", lambda pid: probes.append(pid) or True, ) assert ProcessRegistry._is_host_pid_alive(None) is False assert ProcessRegistry._is_host_pid_alive(0) is False assert probes == [] def test_alive_pid_returns_true(self, monkeypatch): from tools.process_registry import ProcessRegistry monkeypatch.setattr("gateway.status._pid_exists", lambda pid: True) assert ProcessRegistry._is_host_pid_alive(os.getpid()) is True class TestPidExistsOSErrorWidening: """gateway.status._pid_exists itself must widen Windows errors correctly. The POSIX fallback branch (reached when psutil isn't importable) is the only path where Python raises ``OSError(WinError 87)`` on Windows for a gone PID instead of ``ProcessLookupError``. The function must catch the wider ``OSError`` to match POSIX semantics. """ def test_oserror_gone_pid_returns_false(self, monkeypatch): """Simulate Windows' OSError(WinError 87) for a gone PID via the POSIX fallback.""" from gateway import status # Force the psutil-first branch to miss so we exercise the fallback. monkeypatch.setitem( __import__("sys").modules, "psutil", type("P", (), {"pid_exists": staticmethod(lambda pid: (_ for _ in ()).throw(ImportError()))})() ) monkeypatch.setattr(status, "_IS_WINDOWS", False) def fake_kill(pid, sig): raise OSError(22, "Invalid argument") monkeypatch.setattr(status.os, "kill", fake_kill) assert status._pid_exists(12345) is False def test_permission_error_returns_true(self, monkeypatch): """POSIX fallback: PermissionError means alive (owned by another user).""" from gateway import status monkeypatch.setitem( __import__("sys").modules, "psutil", type("P", (), {"pid_exists": staticmethod(lambda pid: (_ for _ in ()).throw(ImportError()))})() ) monkeypatch.setattr(status, "_IS_WINDOWS", False) def fake_kill(pid, sig): raise PermissionError(1, "Operation not permitted") monkeypatch.setattr(status.os, "kill", fake_kill) assert status._pid_exists(12345) is True # --------------------------------------------------------------------------- # tzdata dependency # --------------------------------------------------------------------------- class TestTzdataDependencyDeclared: """Windows installs must pull tzdata for zoneinfo to work.""" def test_pyproject_declares_tzdata_for_win32(self): root = Path(__file__).resolve().parents[2] source = (root / "pyproject.toml").read_text(encoding="utf-8") # The dependency line should be conditional on sys_platform == 'win32' # and should NOT be in the core dependencies for Linux/macOS. We do # not care about the exact pinned version (which is bumped over time) # — only that tzdata is declared with a win32 marker. This is an # invariant check, not a snapshot test. import re # Match `"tzdata` … `; sys_platform == 'win32'"` allowing any version # specifier in between (==X.Y.Z, >=X.Y.Z,