test_browser_camofox.py
1 """Tests for the Camofox browser backend.""" 2 3 import json 4 import os 5 from unittest.mock import MagicMock, patch 6 7 import pytest 8 9 from tools.browser_camofox import ( 10 camofox_back, 11 camofox_click, 12 camofox_close, 13 camofox_console, 14 camofox_get_images, 15 camofox_navigate, 16 camofox_press, 17 camofox_scroll, 18 camofox_snapshot, 19 camofox_type, 20 camofox_vision, 21 check_camofox_available, 22 is_camofox_mode, 23 ) 24 25 26 # --------------------------------------------------------------------------- 27 # Configuration detection 28 # --------------------------------------------------------------------------- 29 30 31 class TestCamofoxMode: 32 def test_disabled_by_default(self, monkeypatch): 33 monkeypatch.delenv("CAMOFOX_URL", raising=False) 34 assert is_camofox_mode() is False 35 36 def test_enabled_when_url_set(self, monkeypatch): 37 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 38 assert is_camofox_mode() is True 39 40 def test_cdp_override_takes_priority(self, monkeypatch): 41 """When BROWSER_CDP_URL is set (via /browser connect), CDP takes priority over Camofox.""" 42 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 43 monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222") 44 assert is_camofox_mode() is False 45 46 def test_cdp_override_blank_does_not_disable_camofox(self, monkeypatch): 47 """Empty/whitespace BROWSER_CDP_URL should not suppress Camofox.""" 48 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 49 monkeypatch.setenv("BROWSER_CDP_URL", " ") 50 assert is_camofox_mode() is True 51 52 def test_health_check_unreachable(self, monkeypatch): 53 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") 54 assert check_camofox_available() is False 55 56 57 # --------------------------------------------------------------------------- 58 # Helpers 59 # --------------------------------------------------------------------------- 60 61 62 def _mock_response(status=200, json_data=None): 63 resp = MagicMock() 64 resp.status_code = status 65 resp.json.return_value = json_data or {} 66 resp.content = b"\x89PNG\r\n\x1a\nfake" 67 resp.raise_for_status = MagicMock() 68 return resp 69 70 71 # --------------------------------------------------------------------------- 72 # Navigate 73 # --------------------------------------------------------------------------- 74 75 76 class TestCamofoxNavigate: 77 @patch("tools.browser_camofox.requests.post") 78 def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch): 79 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 80 mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"}) 81 82 result = json.loads(camofox_navigate("https://example.com", task_id="t1")) 83 assert result["success"] is True 84 assert result["url"] == "https://example.com" 85 86 @patch("tools.browser_camofox.requests.post") 87 def test_navigates_existing_tab(self, mock_post, monkeypatch): 88 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 89 # First call creates tab 90 mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"}) 91 camofox_navigate("https://a.com", task_id="t2") 92 93 # Second call navigates 94 mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"}) 95 result = json.loads(camofox_navigate("https://b.com", task_id="t2")) 96 assert result["success"] is True 97 assert result["url"] == "https://b.com" 98 99 def test_connection_error_returns_helpful_message(self, monkeypatch): 100 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999") 101 result = json.loads(camofox_navigate("https://example.com", task_id="t_err")) 102 assert result["success"] is False 103 assert "Cannot connect" in result["error"] 104 105 106 # --------------------------------------------------------------------------- 107 # Snapshot 108 # --------------------------------------------------------------------------- 109 110 111 class TestCamofoxSnapshot: 112 def test_no_session_returns_error(self, monkeypatch): 113 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 114 result = json.loads(camofox_snapshot(task_id="no_such_task")) 115 assert result["success"] is False 116 assert "browser_navigate" in result["error"] 117 118 @patch("tools.browser_camofox.requests.post") 119 @patch("tools.browser_camofox.requests.get") 120 def test_returns_snapshot(self, mock_get, mock_post, monkeypatch): 121 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 122 # Create session 123 mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"}) 124 camofox_navigate("https://x.com", task_id="t3") 125 126 # Return snapshot 127 mock_get.return_value = _mock_response(json_data={ 128 "snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]", 129 "refsCount": 2, 130 }) 131 result = json.loads(camofox_snapshot(task_id="t3")) 132 assert result["success"] is True 133 assert "[e1]" in result["snapshot"] 134 assert result["element_count"] == 2 135 136 137 # --------------------------------------------------------------------------- 138 # Click / Type / Scroll / Back / Press 139 # --------------------------------------------------------------------------- 140 141 142 class TestCamofoxInteractions: 143 @patch("tools.browser_camofox.requests.post") 144 def test_click(self, mock_post, monkeypatch): 145 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 146 mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"}) 147 camofox_navigate("https://x.com", task_id="t4") 148 149 mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"}) 150 result = json.loads(camofox_click("@e5", task_id="t4")) 151 assert result["success"] is True 152 assert result["clicked"] == "e5" 153 154 @patch("tools.browser_camofox.requests.post") 155 def test_type(self, mock_post, monkeypatch): 156 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 157 mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"}) 158 camofox_navigate("https://x.com", task_id="t5") 159 160 mock_post.return_value = _mock_response(json_data={"ok": True}) 161 result = json.loads(camofox_type("@e3", "hello world", task_id="t5")) 162 assert result["success"] is True 163 assert result["typed"] == "hello world" 164 165 @patch("tools.browser_camofox.requests.post") 166 def test_scroll(self, mock_post, monkeypatch): 167 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 168 mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"}) 169 camofox_navigate("https://x.com", task_id="t6") 170 171 mock_post.return_value = _mock_response(json_data={"ok": True}) 172 result = json.loads(camofox_scroll("down", task_id="t6")) 173 assert result["success"] is True 174 assert result["scrolled"] == "down" 175 176 @patch("tools.browser_camofox.requests.post") 177 def test_back(self, mock_post, monkeypatch): 178 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 179 mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"}) 180 camofox_navigate("https://x.com", task_id="t7") 181 182 mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"}) 183 result = json.loads(camofox_back(task_id="t7")) 184 assert result["success"] is True 185 186 @patch("tools.browser_camofox.requests.post") 187 def test_press(self, mock_post, monkeypatch): 188 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 189 mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"}) 190 camofox_navigate("https://x.com", task_id="t8") 191 192 mock_post.return_value = _mock_response(json_data={"ok": True}) 193 result = json.loads(camofox_press("Enter", task_id="t8")) 194 assert result["success"] is True 195 assert result["pressed"] == "Enter" 196 197 198 # --------------------------------------------------------------------------- 199 # Close 200 # --------------------------------------------------------------------------- 201 202 203 class TestCamofoxClose: 204 @patch("tools.browser_camofox.requests.delete") 205 @patch("tools.browser_camofox.requests.post") 206 def test_close_session(self, mock_post, mock_delete, monkeypatch): 207 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 208 mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"}) 209 camofox_navigate("https://x.com", task_id="t9") 210 211 mock_delete.return_value = _mock_response(json_data={"ok": True}) 212 result = json.loads(camofox_close(task_id="t9")) 213 assert result["success"] is True 214 assert result["closed"] is True 215 216 def test_close_nonexistent_session(self, monkeypatch): 217 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 218 result = json.loads(camofox_close(task_id="nonexistent")) 219 assert result["success"] is True 220 221 222 # --------------------------------------------------------------------------- 223 # Console (limited support) 224 # --------------------------------------------------------------------------- 225 226 227 class TestCamofoxConsole: 228 def test_console_returns_empty_with_note(self, monkeypatch): 229 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 230 result = json.loads(camofox_console(task_id="t_console")) 231 assert result["success"] is True 232 assert result["total_messages"] == 0 233 assert "not available" in result["note"] 234 235 236 # --------------------------------------------------------------------------- 237 # Images 238 # --------------------------------------------------------------------------- 239 240 241 class TestCamofoxGetImages: 242 @patch("tools.browser_camofox.requests.post") 243 @patch("tools.browser_camofox.requests.get") 244 def test_get_images(self, mock_get, mock_post, monkeypatch): 245 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 246 mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"}) 247 camofox_navigate("https://x.com", task_id="t10") 248 249 # camofox_get_images parses images from the accessibility tree snapshot 250 snapshot_text = ( 251 '- img "Logo"\n' 252 ' /url: https://x.com/img.png\n' 253 ) 254 mock_get.return_value = _mock_response(json_data={ 255 "snapshot": snapshot_text, 256 }) 257 result = json.loads(camofox_get_images(task_id="t10")) 258 assert result["success"] is True 259 assert result["count"] == 1 260 assert result["images"][0]["src"] == "https://x.com/img.png" 261 262 263 class TestCamofoxVisionConfig: 264 @patch("tools.browser_camofox.requests.post") 265 @patch("tools.browser_camofox._get") 266 @patch("tools.browser_camofox._get_raw") 267 def test_camofox_vision_uses_configured_temperature_and_timeout(self, mock_get_raw, mock_get, mock_post, monkeypatch): 268 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 269 mock_post.return_value = _mock_response(json_data={"tabId": "tab11", "url": "https://x.com"}) 270 camofox_navigate("https://x.com", task_id="t11") 271 272 snapshot_text = '- button "Submit"\n' 273 raw_resp = MagicMock() 274 raw_resp.content = b"fakepng" 275 mock_get_raw.return_value = raw_resp 276 mock_get.return_value = {"snapshot": snapshot_text} 277 278 mock_response = MagicMock() 279 mock_choice = MagicMock() 280 mock_choice.message.content = "Camofox screenshot analysis" 281 mock_response.choices = [mock_choice] 282 283 with ( 284 patch("tools.browser_camofox.open", create=True) as mock_open, 285 patch("agent.auxiliary_client.call_llm", return_value=mock_response) as mock_llm, 286 patch("tools.browser_camofox.load_config", return_value={"auxiliary": {"vision": {"temperature": 1, "timeout": 45}}}), 287 ): 288 mock_open.return_value.__enter__.return_value.read.return_value = b"fakepng" 289 result = json.loads(camofox_vision("what is on the page?", annotate=True, task_id="t11")) 290 291 assert result["success"] is True 292 assert result["analysis"] == "Camofox screenshot analysis" 293 assert mock_llm.call_args.kwargs["temperature"] == 1.0 294 assert mock_llm.call_args.kwargs["timeout"] == 45.0 295 296 @patch("tools.browser_camofox.requests.post") 297 @patch("tools.browser_camofox._get") 298 @patch("tools.browser_camofox._get_raw") 299 def test_camofox_vision_defaults_temperature_when_config_omits_it(self, mock_get_raw, mock_get, mock_post, monkeypatch): 300 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 301 mock_post.return_value = _mock_response(json_data={"tabId": "tab12", "url": "https://x.com"}) 302 camofox_navigate("https://x.com", task_id="t12") 303 304 snapshot_text = '- button "Submit"\n' 305 raw_resp = MagicMock() 306 raw_resp.content = b"fakepng" 307 mock_get_raw.return_value = raw_resp 308 mock_get.return_value = {"snapshot": snapshot_text} 309 310 mock_response = MagicMock() 311 mock_choice = MagicMock() 312 mock_choice.message.content = "Default camofox screenshot analysis" 313 mock_response.choices = [mock_choice] 314 315 with ( 316 patch("tools.browser_camofox.open", create=True) as mock_open, 317 patch("agent.auxiliary_client.call_llm", return_value=mock_response) as mock_llm, 318 patch("tools.browser_camofox.load_config", return_value={"auxiliary": {"vision": {}}}), 319 ): 320 mock_open.return_value.__enter__.return_value.read.return_value = b"fakepng" 321 result = json.loads(camofox_vision("what is on the page?", annotate=True, task_id="t12")) 322 323 assert result["success"] is True 324 assert result["analysis"] == "Default camofox screenshot analysis" 325 assert mock_llm.call_args.kwargs["temperature"] == 0.1 326 assert mock_llm.call_args.kwargs["timeout"] == 120.0 327 328 329 # --------------------------------------------------------------------------- 330 # Routing integration — verify browser_tool routes to camofox 331 # --------------------------------------------------------------------------- 332 333 334 class TestBrowserToolRouting: 335 """Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set.""" 336 337 @patch("tools.browser_camofox.requests.post") 338 def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch): 339 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 340 mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"}) 341 342 from tools.browser_tool import browser_navigate 343 # Bypass SSRF check for test URL 344 with patch("tools.browser_tool._is_safe_url", return_value=True): 345 result = json.loads(browser_navigate("https://example.com", task_id="t_route")) 346 assert result["success"] is True 347 348 def test_check_requirements_passes_with_camofox(self, monkeypatch): 349 monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") 350 from tools.browser_tool import check_browser_requirements 351 assert check_browser_requirements() is True 352 353