Прогон на Windows-раннере показал, что причин красного CI больше двух. Четыре падения — не в продукте, а в допущениях тестов, зашитых под Linux. 1. test_a41 читал вывод скрипта в кодировке системы. Скрипт теперь пишет UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на UnicodeDecodeError, оставляя proc.stdout равным None. Кодировка задана явно с обеих сторон трубы. 2. test_p0_3_stop_running_hub знал только про ветку Linux: os.kill по списку от pgrep. На Windows процессы останавливает taskkill по списку от wmic, os.kill не вызывается — тест падал на пустом списке убитых. Инвариант же один для обеих веток: чужой процесс хаба останавливается, собственный PID не трогается. Теперь он проверяется на обеих. 3-4. Оба теста установки подсовывали bash-скрипт hermes-hub-setup.sh. На Windows выбирается HermesHubSetup.exe, и установка честно отвечала «в релизе не найден подходящий файл обновления для текущей платформы». Установщик теперь берётся под ту систему, на которой идёт прогон. Проверка сообщения об ошибке смотрит на то, назван ли код возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3», инвариант один. Тесты: 755 -> 756 passed, 2 skipped, 4 deselected. ruff check . чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""Unit and integration tests for Task A41: Clean First Install & Routing Reset.
|
||
|
||
Verifies:
|
||
1. Clean default configuration (0 profiles, 13 canonical roles with empty preferred_chain).
|
||
2. RoleRegistry CANONICAL_ROLES clean default chains.
|
||
3. Migration idempotence & preservation of user profiles without injecting stubs.
|
||
4. Action reset_router_config creates backup and resets routing without touching credentials.
|
||
5. Clean numbered slot generation upon adding accounts (codex-1, ag-1, claude-1, etc.).
|
||
6. Full compatibility with verify_multi_provider_router script.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
import pytest
|
||
|
||
from antigravity_provider.paths import get_hermes_home, get_profile_dir
|
||
from antigravity_provider.router.action_handler import ActionExecutor, do_reset_router_config
|
||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||
from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
|
||
from antigravity_provider.router.router_config import (
|
||
RolePolicy,
|
||
RouterConfig,
|
||
RouterProfileConfig,
|
||
get_default_router_config,
|
||
load_router_config,
|
||
save_router_config,
|
||
)
|
||
from antigravity_provider.router.settings_service import get_hub_settings, save_hub_settings
|
||
|
||
|
||
@pytest.fixture
|
||
def clean_a41_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||
"""Set up an isolated hermes home directory for testing."""
|
||
hermes_home = tmp_path / "hermes_home"
|
||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||
config_file = hermes_home / "router_profiles.yaml"
|
||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(config_file))
|
||
return hermes_home, config_file
|
||
|
||
|
||
@pytest.mark.unit
|
||
def test_p0_1_clean_default_configuration():
|
||
"""P0-1 & P0-3: Clean configuration on first install has 0 profiles and 14 canonical roles with empty chains."""
|
||
# 1. Check CANONICAL_ROLES registry
|
||
assert len(CANONICAL_ROLES) == 15
|
||
for role_id, role_def in CANONICAL_ROLES.items():
|
||
assert role_def.default_preferred_chain == [], f"Role {role_id} has non-empty default chain"
|
||
|
||
# 2. Check get_default_router_config()
|
||
default_cfg = get_default_router_config()
|
||
assert len(default_cfg.profiles) == 0, f"Expected 0 profiles, got {len(default_cfg.profiles)}"
|
||
assert len(default_cfg.roles) == 15, f"Expected 15 roles, got {len(default_cfg.roles)}"
|
||
assert default_cfg.default_role == "manager"
|
||
|
||
for rname, rpol in default_cfg.roles.items():
|
||
assert rpol.preferred_chain == [], f"Expected empty preferred_chain for role {rname}"
|
||
|
||
|
||
@pytest.mark.unit
|
||
def test_p0_1_dynamic_clean_slot_numbering(clean_a41_env):
|
||
"""P0-1: Accounts added dynamically receive clean numbered slots (ag-1, codex-1, claude-1, etc.)."""
|
||
hermes_home, config_file = clean_a41_env
|
||
|
||
# 1. Antigravity slots
|
||
ag_slot_1 = AutoAssigner.find_free_slot("antigravity")
|
||
assert ag_slot_1 == "ag-1"
|
||
ProfileAuthManager.save_profile_auth("antigravity", ag_slot_1, {"tokens": {"access_token": "token1"}})
|
||
|
||
ag_slot_2 = AutoAssigner.find_free_slot("antigravity")
|
||
assert ag_slot_2 == "ag-2"
|
||
|
||
# 2. Codex slots
|
||
codex_slot_1 = AutoAssigner.find_free_slot("openai-codex")
|
||
assert codex_slot_1 == "codex-1"
|
||
ProfileAuthManager.save_profile_auth("openai-codex", codex_slot_1, {"api_key": "sk-1"})
|
||
|
||
codex_slot_2 = AutoAssigner.find_free_slot("openai-codex")
|
||
assert codex_slot_2 == "codex-2"
|
||
|
||
# 3. Claude slots
|
||
claude_slot_1 = AutoAssigner.find_free_slot("claude")
|
||
assert claude_slot_1 == "claude-1"
|
||
|
||
# 4. Grok slots
|
||
grok_slot_1 = AutoAssigner.find_free_slot("grok")
|
||
assert grok_slot_1 == "grok-1"
|
||
|
||
# 5. OpenCode slots
|
||
opengo_slot_1 = AutoAssigner.find_free_slot("opencode-go")
|
||
assert opengo_slot_1 == "opengo-1"
|
||
|
||
# 6. Local slots
|
||
local_slot_1 = AutoAssigner.find_free_slot("local")
|
||
assert local_slot_1 == "local-1"
|
||
|
||
# 7. OpenRouter, NVIDIA, Ollama slots
|
||
assert AutoAssigner.find_free_slot("openrouter") == "openrouter-1"
|
||
assert AutoAssigner.find_free_slot("nvidia") == "nvidia-1"
|
||
assert AutoAssigner.find_free_slot("ollama") == "ollama-1"
|
||
|
||
|
||
@pytest.mark.unit
|
||
def test_p0_3_migration_preserves_user_config_and_adds_missing_roles_cleanly(clean_a41_env):
|
||
"""P0-3: Migration preserves existing user profiles and chains without injecting fake profile stubs."""
|
||
hermes_home, config_file = clean_a41_env
|
||
|
||
# User configured 2 specific profiles and legacy role names
|
||
user_profiles = {
|
||
"user-primary-ag": RouterProfileConfig(
|
||
profile_id="user-primary-ag",
|
||
provider="antigravity",
|
||
account_id="user-acc-1",
|
||
preferred_models=["gemini-2.5-pro"],
|
||
),
|
||
"user-primary-codex": RouterProfileConfig(
|
||
profile_id="user-primary-codex",
|
||
provider="openai-codex",
|
||
account_id="user-acc-2",
|
||
preferred_models=["o3-mini"],
|
||
),
|
||
}
|
||
|
||
user_roles = {
|
||
"orchestrator": RolePolicy(role_name="orchestrator", preferred_chain=["user-primary-codex"]),
|
||
"coder-primary": RolePolicy(role_name="coder-primary", preferred_chain=["user-primary-ag"]),
|
||
}
|
||
|
||
user_cfg = RouterConfig(
|
||
enabled=True,
|
||
default_role="manager",
|
||
roles=user_roles,
|
||
profiles=user_profiles,
|
||
)
|
||
save_router_config(user_cfg, config_file)
|
||
|
||
# Trigger load and migration
|
||
migrated_cfg = load_router_config(config_file)
|
||
|
||
# 1. User profiles are preserved verbatim
|
||
assert len(migrated_cfg.profiles) == 2
|
||
assert "user-primary-ag" in migrated_cfg.profiles
|
||
assert "user-primary-codex" in migrated_cfg.profiles
|
||
assert migrated_cfg.profiles["user-primary-ag"].preferred_models == ["gemini-2.5-pro"]
|
||
|
||
# 2. Legacy roles migrated to canonical names preserving chains
|
||
assert "manager" in migrated_cfg.roles
|
||
assert migrated_cfg.roles["manager"].preferred_chain == ["user-primary-codex"]
|
||
|
||
assert "developer-1" in migrated_cfg.roles
|
||
assert migrated_cfg.roles["developer-1"].preferred_chain == ["user-primary-ag"]
|
||
|
||
# 3. All canonical roles exist
|
||
assert len(migrated_cfg.roles) == 15
|
||
|
||
# 4. Missing roles added with clean empty chains
|
||
for rname, rpol in migrated_cfg.roles.items():
|
||
if rname not in ("manager", "developer-1"):
|
||
assert rpol.preferred_chain == [], f"Missing role {rname} should have empty chain"
|
||
|
||
|
||
@pytest.mark.unit
|
||
def test_p0_2_p0_4_reset_router_config_and_preserve_credentials(clean_a41_env):
|
||
"""P0-2 & P0-4: Reset action backs up router_profiles.yaml, resets to clean state, and strictly preserves credentials."""
|
||
hermes_home, config_file = clean_a41_env
|
||
|
||
# 1. Seed user configuration
|
||
initial_profiles = {
|
||
"ag-1": RouterProfileConfig(profile_id="ag-1", provider="antigravity"),
|
||
"codex-1": RouterProfileConfig(profile_id="codex-1", provider="openai-codex"),
|
||
}
|
||
initial_roles = {
|
||
"manager": RolePolicy(role_name="manager", preferred_chain=["codex-1"]),
|
||
"developer-1": RolePolicy(role_name="developer-1", preferred_chain=["ag-1"]),
|
||
}
|
||
save_router_config(RouterConfig(profiles=initial_profiles, roles=initial_roles), config_file)
|
||
|
||
# 2. Seed credentials across different providers and hub_settings.json
|
||
ag_auth = ProfileAuthManager.save_profile_auth("antigravity", "ag-1", {"tokens": {"access_token": "secret_ag_token"}})
|
||
codex_auth = ProfileAuthManager.save_profile_auth("openai-codex", "codex-1", {"api_key": "sk-secret-codex-key"})
|
||
claude_auth = ProfileAuthManager.save_profile_auth("claude", "claude-1", {"api_key": "sk-ant-secret-claude-key"})
|
||
grok_auth = ProfileAuthManager.save_profile_auth("grok", "grok-1", {"api_key": "xai-secret-grok-key"})
|
||
opengo_auth = ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "opengo-secret-key"})
|
||
|
||
# Seed hub settings
|
||
save_hub_settings({"email_masking_mode": "full", "quota_threshold_percent": 15.0})
|
||
|
||
# Verify credentials exist on disk before reset
|
||
assert ag_auth.is_file()
|
||
assert codex_auth.is_file()
|
||
assert claude_auth.is_file()
|
||
assert grok_auth.is_file()
|
||
assert opengo_auth.is_file()
|
||
|
||
# 3. Execute Reset Action via ActionExecutor
|
||
res = ActionExecutor.execute("reset_router_config", {})
|
||
assert res["ok"] is True
|
||
assert "сброшена" in res["message"].lower()
|
||
|
||
# 4. Verify backup was created
|
||
backups = list(hermes_home.glob("router_profiles.yaml.bak_*"))
|
||
assert len(backups) >= 1, "Backup file was not created during reset"
|
||
backup_content = backups[0].read_text(encoding="utf-8")
|
||
assert "codex-1" in backup_content
|
||
|
||
# 5. Verify router_profiles.yaml is now in clean state (0 profiles, 15 canonical roles with empty chains)
|
||
reloaded_cfg = load_router_config(config_file)
|
||
assert len(reloaded_cfg.profiles) == 0
|
||
assert len(reloaded_cfg.roles) == 15
|
||
for rname, rpol in reloaded_cfg.roles.items():
|
||
assert rpol.preferred_chain == []
|
||
|
||
# 6. Verify ALL credentials remain 100% intact and untouched
|
||
assert ag_auth.is_file()
|
||
assert codex_auth.is_file()
|
||
assert claude_auth.is_file()
|
||
assert grok_auth.is_file()
|
||
assert opengo_auth.is_file()
|
||
|
||
loaded_ag = ProfileAuthManager.load_profile_auth("antigravity", "ag-1")
|
||
assert loaded_ag["tokens"]["access_token"] == "secret_ag_token"
|
||
|
||
loaded_codex = ProfileAuthManager.load_profile_auth("openai-codex", "codex-1")
|
||
assert loaded_codex["api_key"] == "sk-secret-codex-key"
|
||
|
||
# Verify hub settings untouched
|
||
settings = get_hub_settings()
|
||
assert settings.get("email_masking_mode") == "full"
|
||
assert settings.get("quota_threshold_percent") == 15.0
|
||
|
||
|
||
@pytest.mark.unit
|
||
def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
||
"""P0-5: verify_multi_provider_router.py passes on both clean default and filled user configs."""
|
||
hermes_home, config_file = clean_a41_env
|
||
|
||
# 1. Run on clean default config (file does not exist yet)
|
||
import subprocess
|
||
import sys
|
||
env = os.environ.copy()
|
||
env["HERMES_ROUTER_CONFIG"] = str(config_file)
|
||
env["HERMES_HOME"] = str(hermes_home)
|
||
|
||
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
|
||
|
||
# Кодировка задаётся явно с обеих сторон трубы. Скрипт печатает по-русски и
|
||
# сам переводит вывод на UTF-8; родитель же на Windows по умолчанию читает
|
||
# трубу в кодировке системы (cp1252) и разваливается на UnicodeDecodeError,
|
||
# оставляя proc.stdout равным None — падал разбор вывода, а не проверки.
|
||
run_kwargs = dict(env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
|
||
proc = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
||
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
|
||
assert "10/10 CHECKS PASSED" in proc.stdout
|
||
|
||
# 2. Run on populated user config
|
||
AutoAssigner.find_free_slot("openai-codex")
|
||
AutoAssigner.find_free_slot("antigravity")
|
||
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
|
||
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
|
||
|
||
proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
||
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
|
||
assert "10/10 CHECKS PASSED" in proc_filled.stdout
|