Files
hermes-agent/tests/hermes_cli/test_migrate_xai.py
ethernet 59624b7ace tests: replace every bare host skipif with platforms()
The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.

Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.

Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
  its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
  skip-on-Windows moves onto the 11 methods that had no host mark; the
  11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
  platforms("linux") in favour of the module's "posix" (mkfifo exists on
  macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
  or wider marks removed.
2026-09-21 19:18:15 -04:00

298 lines
11 KiB
Python

"""Tests for ``hermes migrate xai`` — apply path with ruamel round-trip."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from hermes_cli.xai_retirement import (
RetirementIssue,
apply_migration,
find_retired_xai_refs,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def trap_config(tmp_path: Path) -> Path:
"""A config.yaml with retired models AND comments to verify round-trip."""
p = tmp_path / "config.yaml"
p.write_text(
"# Hermes config (sample)\n"
"principal:\n"
" provider: xai # the main model\n"
" model: grok-4-1-fast-non-reasoning # retiring May 15\n"
" temperature: 0.5\n"
"auxiliary:\n"
" vision:\n"
" provider: xai\n"
" model: grok-4-fast-reasoning # retiring\n"
" compression:\n"
" provider: openai # not affected\n"
" model: gpt-4o-mini\n"
"delegation:\n"
" model: grok-code-fast-1 # retiring\n"
"plugins:\n"
" image_gen:\n"
" xai:\n"
" model: grok-imagine-image-pro # retiring\n",
encoding="utf-8",
)
return p
@pytest.fixture
def clean_config(tmp_path: Path) -> Path:
p = tmp_path / "config.yaml"
p.write_text(
"principal:\n"
" provider: xai\n"
" model: grok-4.3\n",
encoding="utf-8",
)
return p
def _parse(path: Path) -> dict:
"""Load with ruamel for assertion convenience."""
from ruamel.yaml import YAML
yaml = YAML(typ="rt")
with path.open("r", encoding="utf-8") as fh:
return yaml.load(fh)
# ---------------------------------------------------------------------------
# Dry-run / no-op
# ---------------------------------------------------------------------------
class TestNoOpPaths:
def test_clean_config_returns_unchanged_result(self, clean_config: Path):
issues = find_retired_xai_refs(_parse(clean_config))
assert issues == []
result = apply_migration(clean_config, issues)
assert result.config_changed is False
assert result.backup_path is None
# File untouched
assert "grok-4.3" in clean_config.read_text(encoding="utf-8")
def test_empty_issues_list_is_noop(self, trap_config: Path):
original = trap_config.read_text(encoding="utf-8")
result = apply_migration(trap_config, issues=[])
assert result.config_changed is False
assert trap_config.read_text(encoding="utf-8") == original
def test_missing_file_raises(self, tmp_path: Path):
with pytest.raises(FileNotFoundError):
apply_migration(tmp_path / "absent.yaml", issues=[
RetirementIssue(
config_path="principal.model",
current_model="grok-3",
replacement="grok-4.3",
)
])
# ---------------------------------------------------------------------------
# Apply: surgical replacement
# ---------------------------------------------------------------------------
class TestApplyReplacement:
def test_replaces_principal_model(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
result = apply_migration(trap_config, issues)
assert result.config_changed is True
cfg = _parse(trap_config)
assert cfg["principal"]["model"] == "grok-4.3"
def test_does_not_touch_unrelated_slots(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
cfg = _parse(trap_config)
# auxiliary.compression was never xAI, must remain untouched
assert cfg["auxiliary"]["compression"]["model"] == "gpt-4o-mini"
assert cfg["auxiliary"]["compression"]["provider"] == "openai"
# principal.temperature must survive
assert cfg["principal"]["temperature"] == 0.5
# ---------------------------------------------------------------------------
# Round-trip preservation (the hard part)
# ---------------------------------------------------------------------------
class TestRoundTripPreservation:
def test_preserves_top_level_key_order(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
text = trap_config.read_text(encoding="utf-8")
order = [
text.index("principal:"),
text.index("auxiliary:"),
text.index("delegation:"),
text.index("plugins:"),
]
assert order == sorted(order)
# ---------------------------------------------------------------------------
# Backup behaviour
# ---------------------------------------------------------------------------
class TestBackup:
def test_backup_is_written_by_default(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
original = trap_config.read_text(encoding="utf-8")
result = apply_migration(trap_config, issues)
assert result.backup_path is not None
assert result.backup_path.exists()
assert result.backup_path.read_text(encoding="utf-8") == original
def test_no_backup_when_disabled(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
result = apply_migration(trap_config, issues, backup=False)
assert result.backup_path is None
# No bak file in the directory
assert not list(trap_config.parent.rglob("config.yaml.pre-migrate-xai.*"))
# ---------------------------------------------------------------------------
# Idempotence
# ---------------------------------------------------------------------------
class TestIdempotence:
def test_apply_twice_is_safe(self, trap_config: Path):
# First pass: replace
issues_1 = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues_1)
# Second pass: nothing to do
issues_2 = find_retired_xai_refs(_parse(trap_config))
assert issues_2 == []
result_2 = apply_migration(trap_config, issues_2)
assert result_2.config_changed is False
# ---------------------------------------------------------------------------
# Fail-closed on unreadable existing config
# ---------------------------------------------------------------------------
class TestUnreadableExistingConfig:
def test_apply_refuses_to_overwrite_unreadable_config(self, trap_config: Path):
"""apply_migration must not clobber an existing config.yaml it can't
read. It reads the file first (which raises on an unreadable file), and
the require_readable_config_before_write guard before the write is a
belt-and-suspenders backstop for the read-then-write window. Either way
the original bytes must survive."""
import os
issues = find_retired_xai_refs(_parse(trap_config))
assert issues # sanity: trap_config has retired refs
original = trap_config.read_bytes()
os.chmod(trap_config, 0o000)
try:
with pytest.raises((PermissionError, RuntimeError, OSError)):
apply_migration(trap_config, issues, backup=False)
finally:
os.chmod(trap_config, 0o644)
assert trap_config.read_bytes() == original
# ---------------------------------------------------------------------------
# Crash durability — the rewrite must be atomic
# ---------------------------------------------------------------------------
class TestCrashDurability:
"""apply_migration() rewrites the whole config.yaml in place.
A bare ``open(path, "w")`` truncates the file *before* the dump runs, so an
interruption (crash, SIGINT, ENOSPC) leaves config.yaml empty or
half-written. Routing through ``utils.atomic_write_text`` means the target
is only ever swapped in via an atomic rename after the temp file is fully
written and fsynced.
"""
def test_config_survives_an_interrupted_write(self, trap_config: Path):
"""A failure mid-write must leave the original config.yaml untouched.
``--no-backup`` is a documented flag, so on that path the file being
rewritten is the only copy in existence.
"""
import os
issues = find_retired_xai_refs(_parse(trap_config))
assert issues # sanity: trap_config has retired refs
original = trap_config.read_bytes()
def boom(fd):
raise OSError("simulated crash mid-write")
with pytest.MonkeyPatch.context() as mp:
mp.setattr(os, "fsync", boom)
with pytest.raises(OSError):
apply_migration(trap_config, issues, backup=False)
# The original bytes must survive verbatim...
assert trap_config.read_bytes() == original
# ...and the aborted write must not leave a temp file behind.
assert list(trap_config.parent.glob("*.tmp")) == []
@pytest.mark.require_symlinks
def test_symlinked_config_is_replaced_in_place(self, tmp_path: Path):
"""A config.yaml symlinked into a dotfiles repo must stay a symlink."""
real_dir = tmp_path / "dotfiles"
real_dir.mkdir()
real = real_dir / "hermes-config.yaml"
real.write_text(
"principal:\n"
" provider: xai\n"
" model: grok-3\n",
encoding="utf-8",
)
link = tmp_path / "config.yaml"
link.symlink_to(real)
issues = find_retired_xai_refs(_parse(link))
assert issues
apply_migration(link, issues, backup=False)
assert link.is_symlink(), "atomic replace detached the symlink"
assert real.read_text(encoding="utf-8") == link.read_text(encoding="utf-8")
assert "grok-4.3" in real.read_text(encoding="utf-8")
@pytest.mark.platforms("posix") # POSIX permission bits
def test_existing_file_mode_is_preserved(self, trap_config: Path):
"""Managed (NixOS 0640) and container installs widen config.yaml
deliberately; the migration must not silently tighten it to 0600."""
import os
import stat
os.chmod(trap_config, 0o640)
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues, backup=False)
mode = stat.S_IMODE(trap_config.stat().st_mode)
assert mode == 0o640, f"mode changed to {oct(mode)}"
def test_comments_survive_the_atomic_write(self, trap_config: Path):
"""The ruamel round-trip must still run — serializing via a string
buffer instead of the file handle must not drop comments."""
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues, backup=False)
text = trap_config.read_text(encoding="utf-8")
assert "# Hermes config (sample)" in text
assert "# the main model" in text
assert "# not affected" in text