"""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 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 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("tools.browser_camofox.load_config", return_value=config) @pytest.fixture(autouse=True) def _clear_session_state(): import 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("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("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("tools.browser_camofox.load_config", return_value=config): assert _managed_persistence_enabled() is False def test_disabled_on_config_load_error(self): with patch("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("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("tools.browser_camofox.requests.post", side_effect=_capture_post), patch("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("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("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("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("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 tools.browser_camofox as mod mod._vnc_url = "http://localhost:6080" mod._vnc_url_checked = True with patch("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 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("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 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("tools.browser_camofox.requests.delete") as mock_delete, ): _get_session("task-1") camofox_soft_cleanup("task-1") mock_delete.assert_not_called()