hermes-agent/tests/tools/test_docker_config_migrate.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

173 lines
6.5 KiB
Python

from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from hermes_cli.config import DEFAULT_CONFIG
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "docker_config_migrate.py"
def _load_script_module():
spec = importlib.util.spec_from_file_location("docker_config_migrate_test_module", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _run_migration(hermes_home: Path, **env_overrides: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env.update(
{
"HERMES_HOME": str(hermes_home),
"HERMES_SKIP_CHMOD": "1",
"PYTHONPATH": str(REPO_ROOT),
}
)
env.update(env_overrides)
return subprocess.run(
[sys.executable, str(SCRIPT)],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
)
def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
model_map = {
"local-small": {"context_length": 8192},
"local-large": {"context_length": 32768},
}
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 11,
"custom_providers": [
{
"name": "Local API",
"base_url": "http://localhost:8080/v1",
"api_key": "test-key",
"api_mode": "chat_completions",
"model": "local-small",
"models": model_map,
"context_length": 32768,
"discover_models": False,
}
],
}
),
encoding="utf-8",
)
env_path.write_text("OPENROUTER_API_KEY=test\n", encoding="utf-8")
proc = _run_migration(tmp_path)
assert proc.returncode == 0, proc.stderr
assert "Migrating config schema 11 ->" in proc.stdout
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
assert "custom_providers" not in raw
provider = raw["providers"]["local-api"]
assert provider["api"] == "http://localhost:8080/v1"
assert provider["transport"] == "chat_completions"
assert provider["default_model"] == "local-small"
assert provider["models"] == model_map
assert provider["context_length"] == 32768
assert provider["discover_models"] is False
assert list(tmp_path.glob("config.yaml.bak-*"))
assert list(tmp_path.glob(".env.bak-*"))
def test_docker_config_migrate_restores_backups_when_version_does_not_advance(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
module = _load_script_module()
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
original_config = yaml.safe_dump({"_config_version": 11, "gateway": {"provider": "telegram"}})
original_env = "TELEGRAM_BOT_TOKEN=test-token\n"
config_path.write_text(original_config, encoding="utf-8")
env_path.write_text(original_env, encoding="utf-8")
calls = iter([(11, DEFAULT_CONFIG["_config_version"]), (11, DEFAULT_CONFIG["_config_version"])])
monkeypatch.setattr(module, "check_config_version", lambda: next(calls))
monkeypatch.setattr(module, "get_config_path", lambda: config_path)
monkeypatch.setattr(module, "get_env_path", lambda: env_path)
def _non_advancing_migrate(*, interactive: bool, quiet: bool):
config_path.write_text("gateway: {}\n", encoding="utf-8")
env_path.write_text("", encoding="utf-8")
monkeypatch.setattr(module, "migrate_config", _non_advancing_migrate)
with pytest.raises(RuntimeError, match="did not advance config version"):
module.main()
assert config_path.read_text(encoding="utf-8") == original_config
assert env_path.read_text(encoding="utf-8") == original_env
def test_docker_config_migrate_second_boot_preserves_env_byte_for_byte(tmp_path: Path) -> None:
"""Regression for #51579: booting ``gateway run`` twice (i.e. a host
reboot under ``--restart unless-stopped``) must not strip or rewrite
``$HERMES_HOME/.env``. The first boot migrates the stale config and bumps
``_config_version``; the second boot must be a no-op that leaves ``.env``
byte-identical to what the user supplied.
This exercises the real script + real ``migrate_config`` + real file I/O
via subprocess — not mocks — so it covers the actual Docker boot path,
not just the failure-rollback shapes above.
"""
config_path = tmp_path / "config.yaml"
env_path = tmp_path / ".env"
config_path.write_text(
yaml.safe_dump(
{
"_config_version": 11,
"gateway": {"provider": "telegram"},
}
),
encoding="utf-8",
)
original_env = (
"TELEGRAM_BOT_TOKEN=secret-bot-token\n"
"TELEGRAM_ALLOWED_USERS=123456789\n"
"OPENROUTER_API_KEY=sk-test-provider-key\n"
)
env_path.write_text(original_env, encoding="utf-8")
env_bytes_before = env_path.read_bytes()
# ── First boot: stale config migrates, version advances. ──
first = _run_migration(tmp_path)
assert first.returncode == 0, first.stderr
assert "Migrating config schema 11 ->" in first.stdout
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
# The token (and every other credential) must survive the migration.
assert env_path.exists(), ".env must never be deleted by the boot migration"
assert env_path.read_bytes() == env_bytes_before
config_after_first = config_path.read_bytes()
first_boot_backups = sorted(tmp_path.glob("config.yaml.bak-*"))
# ── Second boot (host reboot): version is current, must be a no-op. ──
second = _run_migration(tmp_path)
assert second.returncode == 0, second.stderr
assert "Migrating config schema" not in second.stdout
# .env is still present and byte-for-byte identical to the original.
assert env_path.exists()
assert env_path.read_bytes() == env_bytes_before
# config.yaml is untouched by the second boot, and no new backup is made.
assert config_path.read_bytes() == config_after_first
assert sorted(tmp_path.glob("config.yaml.bak-*")) == first_boot_backups