/ tests / tools / test_browser_cloud_fallback.py
test_browser_cloud_fallback.py
  1  """Tests for cloud browser provider runtime fallback to local Chromium.
  2  
  3  Covers the fallback logic in _get_session_info() when a cloud provider
  4  is configured but fails at runtime (issue #10883).
  5  """
  6  import logging
  7  from unittest.mock import Mock, patch
  8  
  9  import pytest
 10  
 11  import tools.browser_tool as browser_tool
 12  
 13  
 14  def _reset_session_state(monkeypatch):
 15      """Clear caches so each test starts fresh."""
 16      monkeypatch.setattr(browser_tool, "_active_sessions", {})
 17      monkeypatch.setattr(browser_tool, "_cached_cloud_provider", None)
 18      monkeypatch.setattr(browser_tool, "_cloud_provider_resolved", False)
 19      monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None)
 20      monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None)
 21  
 22  
 23  class TestCloudProviderRuntimeFallback:
 24      """Tests for _get_session_info cloud → local fallback."""
 25  
 26      def test_cloud_failure_falls_back_to_local(self, monkeypatch):
 27          """When cloud provider.create_session raises, fall back to local."""
 28          _reset_session_state(monkeypatch)
 29  
 30          provider = Mock()
 31          provider.create_session.side_effect = RuntimeError("401 Unauthorized")
 32          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
 33          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
 34  
 35          session = browser_tool._get_session_info("task-1")
 36  
 37          assert session["fallback_from_cloud"] is True
 38          assert "401 Unauthorized" in session["fallback_reason"]
 39          assert session["fallback_provider"] == "Mock"
 40          assert session["features"]["local"] is True
 41          assert session["cdp_url"] is None
 42  
 43      def test_cloud_success_no_fallback(self, monkeypatch):
 44          """When cloud succeeds, no fallback markers are present."""
 45          _reset_session_state(monkeypatch)
 46  
 47          provider = Mock()
 48          provider.create_session.return_value = {
 49              "session_name": "cloud-sess",
 50              "bb_session_id": "bb_123",
 51              "cdp_url": None,
 52              "features": {"browser_use": True},
 53          }
 54          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
 55          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
 56  
 57          session = browser_tool._get_session_info("task-2")
 58  
 59          assert session["session_name"] == "cloud-sess"
 60          assert "fallback_from_cloud" not in session
 61          assert "fallback_reason" not in session
 62  
 63      def test_cloud_and_local_both_fail(self, monkeypatch):
 64          """When both cloud and local fail, raise RuntimeError with both contexts."""
 65          _reset_session_state(monkeypatch)
 66  
 67          provider = Mock()
 68          provider.create_session.side_effect = RuntimeError("cloud boom")
 69          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
 70          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
 71          monkeypatch.setattr(
 72              browser_tool, "_create_local_session",
 73              Mock(side_effect=OSError("no chromium")),
 74          )
 75  
 76          with pytest.raises(RuntimeError, match="cloud boom.*local.*no chromium"):
 77              browser_tool._get_session_info("task-3")
 78  
 79      def test_no_provider_uses_local_directly(self, monkeypatch):
 80          """When no cloud provider is configured, local mode is used with no fallback markers."""
 81          _reset_session_state(monkeypatch)
 82  
 83          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None)
 84          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
 85  
 86          session = browser_tool._get_session_info("task-4")
 87  
 88          assert session["features"]["local"] is True
 89          assert "fallback_from_cloud" not in session
 90  
 91      def test_cdp_override_bypasses_provider(self, monkeypatch):
 92          """CDP override takes priority — cloud provider is never consulted."""
 93          _reset_session_state(monkeypatch)
 94  
 95          provider = Mock()
 96          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
 97          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://host:9222/devtools/browser/abc")
 98  
 99          session = browser_tool._get_session_info("task-5")
100  
101          provider.create_session.assert_not_called()
102          assert session["cdp_url"] == "ws://host:9222/devtools/browser/abc"
103  
104      def test_fallback_logs_warning_with_provider_name(self, monkeypatch, caplog):
105          """Fallback emits a warning log with the provider class name and error."""
106          _reset_session_state(monkeypatch)
107  
108          BrowserUseProviderFake = type("BrowserUseProvider", (), {
109              "create_session": Mock(side_effect=ConnectionError("timeout")),
110          })
111          provider = BrowserUseProviderFake()
112          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
113          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
114  
115          with caplog.at_level(logging.WARNING, logger="tools.browser_tool"):
116              session = browser_tool._get_session_info("task-6")
117  
118          assert session["fallback_from_cloud"] is True
119          assert any("BrowserUseProvider" in r.message and "timeout" in r.message
120                      for r in caplog.records)
121  
122      def test_cloud_failure_does_not_poison_next_task(self, monkeypatch):
123          """A fallback for one task_id doesn't affect a new task_id when cloud recovers."""
124          _reset_session_state(monkeypatch)
125  
126          call_count = 0
127  
128          def create_session_flaky(task_id):
129              nonlocal call_count
130              call_count += 1
131              if call_count == 1:
132                  raise RuntimeError("transient failure")
133              return {
134                  "session_name": "cloud-ok",
135                  "bb_session_id": "bb_999",
136                  "cdp_url": None,
137                  "features": {"browser_use": True},
138              }
139  
140          provider = Mock()
141          provider.create_session.side_effect = create_session_flaky
142          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
143          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
144  
145          # First call fails → fallback
146          s1 = browser_tool._get_session_info("task-a")
147          assert s1["fallback_from_cloud"] is True
148  
149          # Second call (different task) → cloud succeeds
150          s2 = browser_tool._get_session_info("task-b")
151          assert "fallback_from_cloud" not in s2
152          assert s2["session_name"] == "cloud-ok"
153  
154      def test_cloud_returns_invalid_session_triggers_fallback(self, monkeypatch):
155          """Cloud provider returning None or empty dict triggers fallback."""
156          _reset_session_state(monkeypatch)
157  
158          provider = Mock()
159          provider.create_session.return_value = None
160          monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
161          monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
162  
163          session = browser_tool._get_session_info("task-7")
164  
165          assert session["fallback_from_cloud"] is True
166          assert "invalid session" in session["fallback_reason"]