Completes the test-sharding half of #3197 (Docker-cache half shipped v0.51.177). Adds pytest-shard 3-way split to tests.yml (3 shards x 3 Python = 9 jobs, fail-fast: false). pytest-shard is 0-indexed so the matrix uses [0,1,2] — the original #3197 used [1,2,3] which would have crashed the out-of-range job and silently skipped shard 0's tests. Made the suite shard-safe by fixing 4 cross-test state-pollution bugs that passed sequentially but failed when sharded: - test_onboarding_mvp: reset onboarding_completed flag (settings.json) in the autouse fixture; the config-cleanup only cleared config.yaml/.env. - test_issue693_system_health_panel: invalidate the process-wide password-hash cache before/after so a prior test's "no password" cache doesn't defeat the auth-gate assertion. - test_auth_session_persistence: assert against auth._SESSIONS_FILE (where auth actually writes) instead of a local _TEST_STATE path that only matched under a lucky import order. - test_profile_env_isolation (root cause of the worst leak): stop deleting + re-importing api.profiles under a temp HERMES_BASE_HOME — that swapped the module object and poisoned the cached _DEFAULT_HERMES_HOME for every later test (broke test_title_aux_routing's load_config). Now points the cached path via monkeypatch.setattr (auto-restored, no module swap). - conftest: autouse fixture restores HERMES_HOME/HERMES_BASE_HOME after each test as defense-in-depth against future switch_profile leaks. Verified: all 3 shards green (6912 passed, 0 failed); full sequential run still green (6957 passed, 0 failed). Slowest shard ~70s vs ~180s sequential.
252 lines
9.0 KiB
Python
252 lines
9.0 KiB
Python
"""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)
|
|
# onboarding_completed lives in settings.json (not config.yaml/.env), so the
|
|
# unlinks above don't reset it. A prior test that completes onboarding would
|
|
# otherwise leak the flag and make tests asserting the pristine "incomplete"
|
|
# default fail under sharded/reordered runs. Reset it via the settings API
|
|
# (the source of truth the server reads) before AND after each test.
|
|
post("/api/settings", {"onboarding_completed": False})
|
|
yield
|
|
for rel in ("config.yaml", ".env"):
|
|
(hermes_home / rel).unlink(missing_ok=True)
|
|
post("/api/settings", {"onboarding_completed": False})
|
|
|
|
|
|
|
|
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()
|