"""Onboarding MVP tests — first-run wizard and provider config persistence. Tests that call /api/onboarding/setup require PyYAML in the test server's Python environment (the agent venv). They are skipped when hermes-agent is not installed, since the server falls back to system Python which typically lacks pyyaml. """ import json import pathlib import sys import urllib.error import urllib.request import pytest BASE = "http://127.0.0.1:8788" # Check if pyyaml is available — onboarding setup tests need it on the server try: import yaml as _yaml _HAS_YAML = True except ImportError: _HAS_YAML = False _needs_yaml = pytest.mark.skipif(not _HAS_YAML, reason="PyYAML not installed — onboarding setup tests require it") def get(path): with urllib.request.urlopen(BASE + path, timeout=10) as r: return json.loads(r.read()), r.status def post(path, body=None): req = urllib.request.Request( BASE + path, data=json.dumps(body or {}).encode(), headers={"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()), r.status except urllib.error.HTTPError as e: return json.loads(e.read()), e.code def _server_hermes_home() -> pathlib.Path: """Get the hermes home path the test server is actually using. Using the server's own /api/onboarding/status response is more robust than reading TEST_STATE_DIR from conftest, which can get the wrong path when conftest is imported multiple times under different HERMES_HOME environments (api.config resets HERMES_HOME at module import time via init_profile_state). """ data, _ = get("/api/onboarding/status") env_path = data.get("system", {}).get("env_path", "") if env_path: return pathlib.Path(env_path).parent # Fallback hermes_home = pathlib.Path.home() / ".hermes" return hermes_home / "webui-mvp-test" @pytest.fixture(autouse=True) def clean_hermes_config_files(): hermes_home = _server_hermes_home() for rel in ("config.yaml", ".env"): (hermes_home / rel).unlink(missing_ok=True) yield for rel in ("config.yaml", ".env"): (hermes_home / rel).unlink(missing_ok=True) def test_onboarding_status_defaults_incomplete(): data, status = get("/api/onboarding/status") assert status == 200 assert data["completed"] is False assert data["settings"]["password_enabled"] is False assert data["system"]["provider_configured"] is False assert data["system"]["chat_ready"] is False assert data["system"]["setup_state"] in {"needs_provider", "agent_unavailable"} assert "provider_note" in data["system"] assert isinstance(data["workspaces"]["items"], list) assert data["setup"]["providers"] @_needs_yaml def test_onboarding_setup_openrouter_writes_real_config_and_env(): data, status = post( "/api/onboarding/setup", { "provider": "openrouter", "model": "anthropic/claude-sonnet-4.6", "api_key": "sk-or-test", }, ) assert status == 200 assert data["system"]["provider_configured"] is True assert data["system"]["provider_ready"] is True if data["system"]["imports_ok"] and data["system"]["hermes_found"]: assert data["system"]["chat_ready"] is True assert data["system"]["setup_state"] == "ready" else: assert data["system"]["chat_ready"] is False assert data["system"]["setup_state"] == "agent_unavailable" cfg_text = (_server_hermes_home() / "config.yaml").read_text(encoding="utf-8") env_text = (_server_hermes_home() / ".env").read_text(encoding="utf-8") assert "provider: openrouter" in cfg_text assert "default: anthropic/claude-sonnet-4.6" in cfg_text assert "OPENROUTER_API_KEY=sk-or-test" in env_text @_needs_yaml def test_onboarding_setup_custom_endpoint_writes_runtime_files(): data, status = post( "/api/onboarding/setup", { "provider": "custom", "model": "google/gemma-3-27b-it", "base_url": "http://localhost:4000/v1", "api_key": "sk-custom-test", }, ) assert status == 200 assert data["system"]["provider_configured"] is True assert data["system"]["provider_ready"] is True if data["system"]["imports_ok"] and data["system"]["hermes_found"]: assert data["system"]["chat_ready"] is True assert data["system"]["setup_state"] == "ready" else: assert data["system"]["chat_ready"] is False assert data["system"]["setup_state"] == "agent_unavailable" assert data["system"]["current_provider"] == "custom" assert data["system"]["current_base_url"] == "http://localhost:4000/v1" cfg_text = (_server_hermes_home() / "config.yaml").read_text(encoding="utf-8") env_text = (_server_hermes_home() / ".env").read_text(encoding="utf-8") assert "provider: custom" in cfg_text assert "default: google/gemma-3-27b-it" in cfg_text assert "base_url: http://localhost:4000/v1" in cfg_text assert "OPENAI_API_KEY=sk-custom-test" in env_text @_needs_yaml def test_onboarding_setup_detects_incomplete_saved_provider(): status, code = post( "/api/onboarding/setup", { "provider": "anthropic", "model": "claude-sonnet-4.6", "api_key": "sk-ant-test", }, ) assert code == 200 (_server_hermes_home() / ".env").unlink(missing_ok=True) data, status_code = get("/api/onboarding/status") assert status_code == 200 assert data["system"]["provider_configured"] is True assert data["system"]["provider_ready"] is False assert data["system"]["chat_ready"] is False assert data["system"]["setup_state"] in {"provider_incomplete", "agent_unavailable"} @_needs_yaml def test_onboarding_setup_rejects_missing_custom_base_url(): data, status = post( "/api/onboarding/setup", { "provider": "custom", "model": "qwen2.5-coder", "api_key": "sk-test", }, ) assert status == 400 assert "base_url is required" in data["error"] def test_onboarding_complete_persists_flag(): data, status = post("/api/onboarding/complete", {}) assert status == 200 assert data["completed"] is True settings = json.loads( (_server_hermes_home() / "settings.json").read_text(encoding="utf-8") ) assert settings["onboarding_completed"] is True data2, status2 = get("/api/onboarding/status") assert status2 == 200 assert data2["completed"] is True def test_onboarding_complete_preserves_other_settings(): """Completing onboarding must not overwrite other user settings.""" # Use send_key (a safe enum setting) to verify settings preservation # without contaminating bot_name or theme checks in other test files. # Use GET /api/settings (not onboarding status) to check preservation # since the onboarding status only returns a subset of settings fields. try: saved, s1 = post("/api/settings", {"send_key": "ctrl+enter"}) assert s1 == 200 assert saved["send_key"] == "ctrl+enter" _, s2 = post("/api/onboarding/complete", {}) assert s2 == 200 # Verify the non-onboarding setting survived the completion call current_settings, s3 = get("/api/settings") assert s3 == 200 assert current_settings["send_key"] == "ctrl+enter" finally: # Always restore default send_key to avoid contaminating other tests post("/api/settings", {"send_key": "enter"}) def test_onboarding_already_completed_status(): """After marking onboarding complete, status must reflect completed=True so the wizard does not re-appear for returning users.""" done, status = post("/api/onboarding/complete", {}) assert status == 200 assert done["completed"] is True data, status2 = get("/api/onboarding/status") assert status2 == 200 assert data["completed"] is True # Reset so test doesn't contaminate others post("/api/settings", {"onboarding_completed": False}) @_needs_yaml def test_onboarding_setup_rejects_api_key_with_newline(): """API keys containing embedded newlines must be rejected to prevent .env injection.""" injected_key = "sk-bad" + chr(10) + "OTHER_KEY=injected" data, status = post( "/api/onboarding/setup", { "provider": "openrouter", "model": "anthropic/claude-sonnet-4.6", "api_key": injected_key, }, ) assert status == 400 assert "newline" in data["error"].lower()