/ tests / tools / test_browser_camofox.py
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