mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Fix variable name breakage (run_agent, hermes_constants, etc.) where import rewriter changed 'import X' to 'import hermes_agent.Y' but test code still referenced 'X' as a variable name. Fix package-vs-module confusion (cli.auth, cli.models, cli.ui) where single files became directories. Fix hardcoded file paths in tests pointing to old locations. Fix tool registry to discover tools in subpackage directories. Fix stale import in hermes_agent/tools/__init__.py. Part of #14182, #14183
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""Persistence tests for the Camofox browser backend.
|
|
|
|
Tests that managed persistence uses stable identity while default mode
|
|
uses random identity. Camofox automatically maps each userId to a
|
|
dedicated persistent Firefox profile on the server side.
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from hermes_agent.tools.browser.camofox import (
|
|
_drop_session,
|
|
_get_session,
|
|
_managed_persistence_enabled,
|
|
camofox_close,
|
|
camofox_navigate,
|
|
camofox_soft_cleanup,
|
|
check_camofox_available,
|
|
get_vnc_url,
|
|
)
|
|
from hermes_agent.tools.browser.camofox_state import get_camofox_identity
|
|
|
|
|
|
def _mock_response(status=200, json_data=None):
|
|
resp = MagicMock()
|
|
resp.status_code = status
|
|
resp.json.return_value = json_data or {}
|
|
resp.raise_for_status = MagicMock()
|
|
return resp
|
|
|
|
|
|
def _enable_persistence():
|
|
"""Return a patch context that enables managed persistence via config."""
|
|
config = {"browser": {"camofox": {"managed_persistence": True}}}
|
|
return patch("hermes_agent.tools.browser.camofox.load_config", return_value=config)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_session_state():
|
|
import hermes_agent.tools.browser.camofox as mod
|
|
yield
|
|
with mod._sessions_lock:
|
|
mod._sessions.clear()
|
|
mod._vnc_url = None
|
|
mod._vnc_url_checked = False
|
|
|
|
|
|
class TestManagedPersistenceToggle:
|
|
def test_disabled_by_default(self):
|
|
config = {"browser": {"camofox": {"managed_persistence": False}}}
|
|
with patch("hermes_agent.tools.browser.camofox.load_config", return_value=config):
|
|
assert _managed_persistence_enabled() is False
|
|
|
|
def test_enabled_via_config_yaml(self):
|
|
config = {"browser": {"camofox": {"managed_persistence": True}}}
|
|
with patch("hermes_agent.tools.browser.camofox.load_config", return_value=config):
|
|
assert _managed_persistence_enabled() is True
|
|
|
|
def test_disabled_when_key_missing(self):
|
|
config = {"browser": {}}
|
|
with patch("hermes_agent.tools.browser.camofox.load_config", return_value=config):
|
|
assert _managed_persistence_enabled() is False
|
|
|
|
def test_disabled_on_config_load_error(self):
|
|
with patch("hermes_agent.tools.browser.camofox.load_config", side_effect=Exception("fail")):
|
|
assert _managed_persistence_enabled() is False
|
|
|
|
|
|
class TestEphemeralMode:
|
|
"""Default behavior: random userId, no persistence."""
|
|
|
|
def test_session_gets_random_user_id(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
session = _get_session("task-1")
|
|
assert session["user_id"].startswith("hermes_")
|
|
assert session["managed"] is False
|
|
|
|
def test_different_tasks_get_different_user_ids(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
s1 = _get_session("task-1")
|
|
s2 = _get_session("task-2")
|
|
assert s1["user_id"] != s2["user_id"]
|
|
|
|
def test_session_reuse_within_same_task(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
s1 = _get_session("task-1")
|
|
s2 = _get_session("task-1")
|
|
assert s1 is s2
|
|
|
|
|
|
class TestManagedPersistenceMode:
|
|
"""With managed_persistence: stable userId derived from Hermes profile."""
|
|
|
|
def test_session_gets_stable_user_id(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
with _enable_persistence():
|
|
session = _get_session("task-1")
|
|
expected = get_camofox_identity("task-1")
|
|
assert session["user_id"] == expected["user_id"]
|
|
assert session["session_key"] == expected["session_key"]
|
|
assert session["managed"] is True
|
|
|
|
def test_same_user_id_after_session_drop(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
with _enable_persistence():
|
|
s1 = _get_session("task-1")
|
|
uid1 = s1["user_id"]
|
|
_drop_session("task-1")
|
|
s2 = _get_session("task-1")
|
|
assert s2["user_id"] == uid1
|
|
|
|
def test_same_user_id_across_tasks(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
with _enable_persistence():
|
|
s1 = _get_session("task-a")
|
|
s2 = _get_session("task-b")
|
|
# Same profile = same userId, different session keys
|
|
assert s1["user_id"] == s2["user_id"]
|
|
assert s1["session_key"] != s2["session_key"]
|
|
|
|
def test_different_profiles_get_different_user_ids(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
with _enable_persistence():
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile-a"))
|
|
s1 = _get_session("task-1")
|
|
uid_a = s1["user_id"]
|
|
_drop_session("task-1")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile-b"))
|
|
s2 = _get_session("task-1")
|
|
assert s2["user_id"] != uid_a
|
|
|
|
def test_navigate_uses_stable_identity(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
requests_seen = []
|
|
|
|
def _capture_post(url, json=None, timeout=None):
|
|
requests_seen.append(json)
|
|
return _mock_response(
|
|
json_data={"tabId": "tab-1", "url": "https://example.com"}
|
|
)
|
|
|
|
with _enable_persistence(), \
|
|
patch("hermes_agent.tools.browser.camofox.requests.post", side_effect=_capture_post):
|
|
result = json.loads(camofox_navigate("https://example.com", task_id="task-1"))
|
|
|
|
assert result["success"] is True
|
|
expected = get_camofox_identity("task-1")
|
|
assert requests_seen[0]["userId"] == expected["user_id"]
|
|
|
|
def test_navigate_reuses_identity_after_close(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
requests_seen = []
|
|
|
|
def _capture_post(url, json=None, timeout=None):
|
|
requests_seen.append(json)
|
|
return _mock_response(
|
|
json_data={"tabId": f"tab-{len(requests_seen)}", "url": "https://example.com"}
|
|
)
|
|
|
|
with (
|
|
_enable_persistence(),
|
|
patch("hermes_agent.tools.browser.camofox.requests.post", side_effect=_capture_post),
|
|
patch("hermes_agent.tools.browser.camofox.requests.delete", return_value=_mock_response()),
|
|
):
|
|
first = json.loads(camofox_navigate("https://example.com", task_id="task-1"))
|
|
camofox_close("task-1")
|
|
second = json.loads(camofox_navigate("https://example.com", task_id="task-1"))
|
|
|
|
assert first["success"] is True
|
|
assert second["success"] is True
|
|
tab_requests = [req for req in requests_seen if "userId" in req]
|
|
assert len(tab_requests) == 2
|
|
assert tab_requests[0]["userId"] == tab_requests[1]["userId"]
|
|
|
|
|
|
class TestVncUrlDiscovery:
|
|
"""VNC URL is derived from the Camofox health endpoint."""
|
|
|
|
def test_vnc_url_from_health_port(self, monkeypatch):
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://myhost:9377")
|
|
health_resp = _mock_response(json_data={"ok": True, "vncPort": 6080})
|
|
with patch("hermes_agent.tools.browser.camofox.requests.get", return_value=health_resp):
|
|
assert check_camofox_available() is True
|
|
assert get_vnc_url() == "http://myhost:6080"
|
|
|
|
def test_vnc_url_none_when_headless(self, monkeypatch):
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
health_resp = _mock_response(json_data={"ok": True})
|
|
with patch("hermes_agent.tools.browser.camofox.requests.get", return_value=health_resp):
|
|
check_camofox_available()
|
|
assert get_vnc_url() is None
|
|
|
|
def test_vnc_url_rejects_invalid_port(self, monkeypatch):
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
health_resp = _mock_response(json_data={"ok": True, "vncPort": "bad"})
|
|
with patch("hermes_agent.tools.browser.camofox.requests.get", return_value=health_resp):
|
|
check_camofox_available()
|
|
assert get_vnc_url() is None
|
|
|
|
def test_vnc_url_only_probed_once(self, monkeypatch):
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
health_resp = _mock_response(json_data={"ok": True, "vncPort": 6080})
|
|
with patch("hermes_agent.tools.browser.camofox.requests.get", return_value=health_resp) as mock_get:
|
|
check_camofox_available()
|
|
check_camofox_available()
|
|
# Second call still hits /health for availability but doesn't re-parse vncPort
|
|
assert get_vnc_url() == "http://localhost:6080"
|
|
|
|
def test_navigate_includes_vnc_hint(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
import hermes_agent.tools.browser.camofox as mod
|
|
mod._vnc_url = "http://localhost:6080"
|
|
mod._vnc_url_checked = True
|
|
|
|
with patch("hermes_agent.tools.browser.camofox.requests.post", return_value=_mock_response(
|
|
json_data={"tabId": "t1", "url": "https://example.com"}
|
|
)):
|
|
result = json.loads(camofox_navigate("https://example.com", task_id="vnc-test"))
|
|
|
|
assert result["vnc_url"] == "http://localhost:6080"
|
|
assert "vnc_hint" in result
|
|
|
|
|
|
class TestCamofoxSoftCleanup:
|
|
"""camofox_soft_cleanup drops local state only when managed persistence is on."""
|
|
|
|
def test_returns_true_and_drops_session_when_enabled(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
with _enable_persistence():
|
|
_get_session("task-1")
|
|
result = camofox_soft_cleanup("task-1")
|
|
|
|
assert result is True
|
|
# Session should have been dropped from in-memory store
|
|
import hermes_agent.tools.browser.camofox as mod
|
|
with mod._sessions_lock:
|
|
assert "task-1" not in mod._sessions
|
|
|
|
def test_returns_false_when_disabled(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
_get_session("task-1")
|
|
config = {"browser": {"camofox": {"managed_persistence": False}}}
|
|
with patch("hermes_agent.tools.browser.camofox.load_config", return_value=config):
|
|
result = camofox_soft_cleanup("task-1")
|
|
|
|
assert result is False
|
|
# Session should still be present — not dropped
|
|
import hermes_agent.tools.browser.camofox as mod
|
|
with mod._sessions_lock:
|
|
assert "task-1" in mod._sessions
|
|
|
|
def test_does_not_call_server_delete(self, tmp_path, monkeypatch):
|
|
"""Soft cleanup must never hit the Camofox /sessions DELETE endpoint."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
|
|
|
with (
|
|
_enable_persistence(),
|
|
patch("hermes_agent.tools.browser.camofox.requests.delete") as mock_delete,
|
|
):
|
|
_get_session("task-1")
|
|
camofox_soft_cleanup("task-1")
|
|
|
|
mock_delete.assert_not_called()
|