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.
298 lines
11 KiB
Python
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
|