"""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 from tests._pytest_port import BASE # 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()