Four origin/main merges brought back `linux_only` / `macos_only` / `windows_only` marks in 41 test files, along with the pre-platforms() versions of scripts/ci/list_os_marked_tests.py and check_os_marker_fakes.py. Because the legacy names are no longer registered, pytest treated them as unknown marks — a warning — so every Windows- or macOS-only test RAN on Linux (test_local_runtime_recovery.py tripped the live-system kill guard). Rewrite the marks, restore the platforms()-aware CI scripts (keeping main's os.walk fix for vanishing __pycache__ dirs), drop the stale _BASELINE entries, and make the conftest reject the retired marks outright so the next merge cannot resurrect them silently.
225 lines
9.6 KiB
Python
225 lines
9.6 KiB
Python
"""Windows essentials and native Winsock/legacy-encoding controls.
|
|
|
|
Actual production UTF-8 env and Unicode RPC are exercised in
|
|
`test_code_execution_modes.py`; never reproduce the production scrubber here.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from tools.code_execution_env import _scrub_child_env
|
|
from tools import code_execution_env
|
|
|
|
|
|
def _no_passthrough(_):
|
|
return False
|
|
|
|
|
|
@pytest.mark.parametrize("windows", [False, True])
|
|
@pytest.mark.parametrize("passthrough", [False, True])
|
|
def test_native_essentials_and_passthrough_priority(windows, passthrough):
|
|
# Platform is explicit input to this pure policy helper, not a fake host.
|
|
essentials = {
|
|
"SYSTEMROOT": r"C:\Windows", "SystemRoot": r"C:\Windows",
|
|
"SystemDrive": "C:", "WINDIR": r"C:\Windows",
|
|
"ComSpec": r"C:\Windows\System32\cmd.exe", "comspec": r"C:\Windows\System32\cmd.exe",
|
|
"APPDATA": r"C:\Users\alice\AppData\Roaming",
|
|
"LOCALAPPDATA": r"C:\Users\alice\AppData\Local",
|
|
}
|
|
safe = {"PATH": r"C:\Windows\System32;C:\Python", "HOME": r"C:\Users\alice",
|
|
"PATHEXT": ".COM;.EXE;.BAT;.CMD;.PY",
|
|
"USERPROFILE": r"C:\Users\alice", "TEMP": r"C:\Users\alice\Temp"}
|
|
secret = {"OPENAI_API_KEY": "fake-provider", "GITHUB_TOKEN": "fake-github",
|
|
"MY_PASSWORD": "fake-password", "TENOR_API_KEY": "fake-third-party",
|
|
"RANDOM_UNKNOWN_VAR": "unknown"}
|
|
result = _scrub_child_env({**essentials, **safe, **secret}, is_windows=windows,
|
|
is_passthrough=lambda k: passthrough and k == "TENOR_API_KEY")
|
|
assert result == {**safe, **(essentials if windows else {}),
|
|
**({"TENOR_API_KEY": "fake-third-party"} if passthrough else {})}
|
|
|
|
|
|
# ``platforms("windows")`` rather than ``skipif(sys.platform != "win32")``: the
|
|
# dedicated Windows CI job selects its files by grepping for the marker, so a
|
|
# bare skipif is invisible to it — the file is never imported there and these
|
|
# tests run on no host at all.
|
|
@pytest.mark.platforms("windows")
|
|
class TestWindowsSocketSmokeTest:
|
|
"""Integration-ish smoke test: spawn a child Python with a scrubbed
|
|
env and confirm it can create an AF_INET socket. This is the
|
|
regression that motivated the fix — without SYSTEMROOT the child
|
|
hits WinError 10106 before any RPC is attempted."""
|
|
|
|
def test_child_can_create_socket_with_scrubbed_env(self):
|
|
scrubbed = _scrub_child_env(os.environ, is_passthrough=_no_passthrough)
|
|
|
|
# Build a tiny child script that simply opens an AF_INET socket.
|
|
script = textwrap.dedent("""
|
|
import socket, sys
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.close()
|
|
print("OK")
|
|
sys.exit(0)
|
|
except OSError as exc:
|
|
print(f"FAIL: {exc}")
|
|
sys.exit(1)
|
|
""").strip()
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", script],
|
|
env=scrubbed,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Child failed to create socket with scrubbed env:\n"
|
|
f" stdout={result.stdout!r}\n"
|
|
f" stderr={result.stderr!r}\n"
|
|
f" scrubbed keys={sorted(scrubbed.keys())}"
|
|
)
|
|
assert "OK" in result.stdout
|
|
|
|
|
|
class TestNativeLegacyEncodingControls:
|
|
@pytest.mark.platforms("windows")
|
|
def test_windows_default_encoding_would_have_failed(self):
|
|
"""Negative control: prove that on Windows, writing the stub
|
|
*without* ``encoding="utf-8"`` would corrupt the file. If this
|
|
test ever starts failing (i.e. default write succeeds), it means
|
|
Python's default encoding has changed and the explicit UTF-8
|
|
requirement may be obsolete — reconsider the fix."""
|
|
from tools.code_execution_tool import generate_hermes_tools_module
|
|
import tempfile
|
|
|
|
stub = generate_hermes_tools_module(["terminal"], transport="uds")
|
|
# Find a non-ASCII character we can use to prove the corruption.
|
|
non_ascii = [c for c in stub if ord(c) > 127]
|
|
if not non_ascii:
|
|
pytest.skip("stub has no non-ASCII chars — nothing to corrupt")
|
|
|
|
# Write with default encoding (simulating the old buggy code).
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".py", delete=False
|
|
) as f:
|
|
try:
|
|
f.write(stub)
|
|
tmp_path = f.name
|
|
wrote_successfully = True
|
|
except UnicodeEncodeError:
|
|
# Default encoding can't even encode it — that's the bug
|
|
# in a different form. Still proves the point.
|
|
tmp_path = f.name
|
|
wrote_successfully = False
|
|
|
|
try:
|
|
if not wrote_successfully:
|
|
# Default-encoding write raised outright. The bug is real.
|
|
return
|
|
|
|
# Read back as UTF-8 (what Python does on import).
|
|
with open(tmp_path, encoding="utf-8") as fh:
|
|
try:
|
|
fh.read()
|
|
# If this succeeds on Windows, the platform default is
|
|
# already UTF-8 (e.g. Python 3.15 with UTF-8 mode on).
|
|
# In that case the explicit encoding= is belt-and-
|
|
# suspenders but no longer strictly required. Skip.
|
|
pytest.skip(
|
|
"Default text-file encoding is UTF-8-compatible on "
|
|
"this Windows build — explicit encoding= is no "
|
|
"longer load-bearing, but keep it for belt-and-"
|
|
"suspenders."
|
|
)
|
|
except UnicodeDecodeError:
|
|
# Exactly the failure mode that motivated the fix.
|
|
pass
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
@pytest.mark.platforms("windows")
|
|
def test_windows_child_without_utf8_env_would_fail(self):
|
|
"""Negative control: spawn a Python child *without* our env
|
|
overrides and prove that on Windows, printing non-ASCII fails.
|
|
If this ever starts passing, Python has changed its default
|
|
stdio encoding on Windows and the fix may be obsolete — but
|
|
keep the env vars anyway for belt-and-suspenders."""
|
|
script = textwrap.dedent("""
|
|
import sys
|
|
print("em-dash \\u2014 arrow \\u2192")
|
|
sys.exit(0)
|
|
""").strip()
|
|
|
|
# Scrubbed env WITHOUT the PYTHONIOENCODING / PYTHONUTF8 overrides.
|
|
# Also scrub PYTHONUTF8 and PYTHONIOENCODING from the inherited
|
|
# env so we reproduce the buggy state even if the parent test
|
|
# runner has them set.
|
|
scrubbed = _scrub_child_env(os.environ, is_passthrough=_no_passthrough)
|
|
for k in ("PYTHONIOENCODING", "PYTHONUTF8", "PYTHONLEGACYWINDOWSSTDIO"):
|
|
scrubbed.pop(k, None)
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", script],
|
|
env=scrubbed,
|
|
capture_output=True,
|
|
text=False,
|
|
timeout=15,
|
|
)
|
|
# Either the child crashed (expected), or modern Python handled
|
|
# it anyway — in which case the fix is still defensive but no
|
|
# longer strictly required. Skip with a note if so.
|
|
if result.returncode == 0 and b"\xe2\x80\x94" in result.stdout:
|
|
pytest.skip(
|
|
"This Python/Windows build handles non-ASCII stdout even "
|
|
"without PYTHONIOENCODING/PYTHONUTF8 — fix is defensive "
|
|
"but no longer strictly load-bearing. Keep the env vars "
|
|
"for older Python builds and C.ASCII-locale containers."
|
|
)
|
|
# Otherwise: crash OR garbled output — both count as proving the
|
|
# bug is real on this system.
|
|
|
|
|
|
def _configured_timezone_child_env():
|
|
return code_execution_env._build_child_env(
|
|
rpc_endpoint="socket",
|
|
rpc_token="token",
|
|
tmpdir="/tmp/hermes-code-execution-test",
|
|
child_python=sys.executable,
|
|
)
|
|
|
|
|
|
def test_windows_child_keeps_os_local_timezone_when_timezone_is_configured(monkeypatch):
|
|
"""Windows CPython cannot interpret an IANA zone name in ``TZ``."""
|
|
monkeypatch.setattr(code_execution_env, "_IS_WINDOWS", True)
|
|
monkeypatch.setattr("hermes_time.get_timezone_name", lambda: "America/Los_Angeles")
|
|
|
|
assert "TZ" not in _configured_timezone_child_env()
|
|
|
|
|
|
@pytest.mark.platforms("windows")
|
|
def test_windows_live_child_offset_matches_os_zone_when_timezone_is_configured(monkeypatch):
|
|
"""The user-visible contract of #112233: with ``timezone:`` configured, a real Windows child
|
|
must report the OS zone's UTC offset — an IANA name in ``TZ`` made the MSVC runtime derive
|
|
``time.timezone == 0`` (+01:00 instead of -07:00) while ``time.tzname`` still read correctly."""
|
|
import datetime
|
|
import json
|
|
|
|
monkeypatch.setattr("hermes_time.get_timezone_name", lambda: "America/Los_Angeles")
|
|
child_env = _configured_timezone_child_env()
|
|
result = subprocess.run(
|
|
[sys.executable, "-c",
|
|
"import json, time, datetime; print(json.dumps([time.timezone, "
|
|
"datetime.datetime.now().astimezone().utcoffset().total_seconds()]))"],
|
|
env=child_env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
child_timezone, child_offset = json.loads(result.stdout.strip())
|
|
# The test process itself has no TZ override, so its view IS the OS zone.
|
|
assert child_offset == datetime.datetime.now().astimezone().utcoffset().total_seconds()
|
|
assert child_timezone == time.timezone
|