|
| 1 | +"""Tests for hybrid browser-backend routing (LAN/localhost auto-local). |
| 2 | +
|
| 3 | +When a cloud browser provider (Browserbase / Browser-Use / Firecrawl) is |
| 4 | +configured globally, ``browser.auto_local_for_private_urls`` (default True) |
| 5 | +causes ``browser_navigate`` to transparently spawn a local Chromium sidecar |
| 6 | +for URLs whose host resolves to a private/loopback/LAN address, while |
| 7 | +public URLs continue to hit the cloud session in the same conversation. |
| 8 | +
|
| 9 | +These tests cover the routing decision layer — session_key selection, |
| 10 | +sidecar detection, last-active-session tracking, and the config toggle. |
| 11 | +The downstream session creation is covered by test_browser_cloud_fallback.py. |
| 12 | +""" |
| 13 | +from unittest.mock import Mock |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
| 17 | +import tools.browser_tool as browser_tool |
| 18 | + |
| 19 | + |
| 20 | +@pytest.fixture(autouse=True) |
| 21 | +def _reset_routing_state(monkeypatch): |
| 22 | + """Clear module-level caches so each test starts clean.""" |
| 23 | + monkeypatch.setattr(browser_tool, "_active_sessions", {}) |
| 24 | + monkeypatch.setattr(browser_tool, "_last_active_session_key", {}) |
| 25 | + monkeypatch.setattr(browser_tool, "_cached_cloud_provider", None) |
| 26 | + monkeypatch.setattr(browser_tool, "_cloud_provider_resolved", False) |
| 27 | + monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls_resolved", False) |
| 28 | + monkeypatch.setattr(browser_tool, "_cached_auto_local_for_private_urls", True) |
| 29 | + monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None) |
| 30 | + monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None) |
| 31 | + # Default: no CDP override, no Camofox |
| 32 | + monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None) |
| 33 | + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False) |
| 34 | + |
| 35 | + |
| 36 | +class TestNavigationSessionKey: |
| 37 | + """Tests for _navigation_session_key URL-based routing decisions.""" |
| 38 | + |
| 39 | + def test_public_url_uses_bare_task_id(self, monkeypatch): |
| 40 | + """Public URL with cloud provider configured → bare task_id (cloud).""" |
| 41 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 42 | + key = browser_tool._navigation_session_key("default", "https://github.com/x/y") |
| 43 | + assert key == "default" |
| 44 | + |
| 45 | + def test_localhost_routes_to_local_sidecar(self, monkeypatch): |
| 46 | + """``localhost`` URL → ``::local`` suffix when cloud configured + flag on.""" |
| 47 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 48 | + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") |
| 49 | + assert key == "default::local" |
| 50 | + |
| 51 | + def test_loopback_ipv4_routes_to_local_sidecar(self, monkeypatch): |
| 52 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 53 | + key = browser_tool._navigation_session_key("default", "http://127.0.0.1:8080/") |
| 54 | + assert key == "default::local" |
| 55 | + |
| 56 | + def test_rfc1918_lan_routes_to_local_sidecar(self, monkeypatch): |
| 57 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 58 | + key = browser_tool._navigation_session_key("default", "http://192.168.1.50:8000/") |
| 59 | + assert key == "default::local" |
| 60 | + |
| 61 | + def test_ipv6_loopback_routes_to_local_sidecar(self, monkeypatch): |
| 62 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 63 | + key = browser_tool._navigation_session_key("default", "http://[::1]:3000/") |
| 64 | + assert key == "default::local" |
| 65 | + |
| 66 | + def test_public_ip_literal_uses_bare_task_id(self, monkeypatch): |
| 67 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 68 | + key = browser_tool._navigation_session_key("default", "https://8.8.8.8/") |
| 69 | + assert key == "default" |
| 70 | + |
| 71 | + def test_mdns_local_hostname_routes_to_sidecar(self, monkeypatch): |
| 72 | + """``*.local`` mDNS / ``*.lan`` / ``*.internal`` hostnames route to sidecar.""" |
| 73 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 74 | + for host in ("raspberrypi.local", "printer.lan", "db.internal"): |
| 75 | + key = browser_tool._navigation_session_key("default", f"http://{host}/") |
| 76 | + assert key == "default::local", f"host {host!r} did not route to sidecar" |
| 77 | + |
| 78 | + def test_no_cloud_provider_stays_on_bare_task_id(self, monkeypatch): |
| 79 | + """When cloud provider is not configured, no hybrid routing happens.""" |
| 80 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None) |
| 81 | + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") |
| 82 | + assert key == "default" |
| 83 | + |
| 84 | + def test_camofox_mode_stays_on_bare_task_id(self, monkeypatch): |
| 85 | + """Camofox is already local — no hybrid routing needed.""" |
| 86 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 87 | + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True) |
| 88 | + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") |
| 89 | + assert key == "default" |
| 90 | + |
| 91 | + def test_cdp_override_stays_on_bare_task_id(self, monkeypatch): |
| 92 | + """A user-supplied CDP endpoint owns the whole session — no hybrid.""" |
| 93 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 94 | + monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://localhost:9222") |
| 95 | + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") |
| 96 | + assert key == "default" |
| 97 | + |
| 98 | + def test_feature_flag_off_disables_hybrid_routing(self, monkeypatch): |
| 99 | + """``auto_local_for_private_urls: false`` keeps private URLs on cloud.""" |
| 100 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 101 | + monkeypatch.setattr(browser_tool, "_auto_local_for_private_urls", lambda: False) |
| 102 | + key = browser_tool._navigation_session_key("default", "http://localhost:3000/") |
| 103 | + assert key == "default" |
| 104 | + |
| 105 | + def test_none_task_id_defaults(self, monkeypatch): |
| 106 | + """``None`` task_id resolves to 'default'.""" |
| 107 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: Mock()) |
| 108 | + key = browser_tool._navigation_session_key(None, "http://localhost:3000/") |
| 109 | + assert key == "default::local" |
| 110 | + |
| 111 | + |
| 112 | +class TestSessionKeyHelpers: |
| 113 | + def test_is_local_sidecar_key(self): |
| 114 | + assert browser_tool._is_local_sidecar_key("default::local") |
| 115 | + assert browser_tool._is_local_sidecar_key("my_task::local") |
| 116 | + assert not browser_tool._is_local_sidecar_key("default") |
| 117 | + assert not browser_tool._is_local_sidecar_key("my_task") |
| 118 | + |
| 119 | + def test_last_session_key_falls_back_to_task_id(self, monkeypatch): |
| 120 | + """Without a recorded last-active key, returns the bare task_id.""" |
| 121 | + monkeypatch.setattr(browser_tool, "_last_active_session_key", {}) |
| 122 | + assert browser_tool._last_session_key("default") == "default" |
| 123 | + assert browser_tool._last_session_key("task-42") == "task-42" |
| 124 | + assert browser_tool._last_session_key(None) == "default" |
| 125 | + |
| 126 | + def test_last_session_key_returns_recorded_key(self, monkeypatch): |
| 127 | + monkeypatch.setattr( |
| 128 | + browser_tool, |
| 129 | + "_last_active_session_key", |
| 130 | + {"default": "default::local", "task-42": "task-42"}, |
| 131 | + ) |
| 132 | + assert browser_tool._last_session_key("default") == "default::local" |
| 133 | + assert browser_tool._last_session_key("task-42") == "task-42" |
| 134 | + # Unknown task_id still falls back |
| 135 | + assert browser_tool._last_session_key("other") == "other" |
| 136 | + |
| 137 | + |
| 138 | +class TestHybridRoutingSessionCreation: |
| 139 | + """_get_session_info must force a local session when the key carries ``::local``.""" |
| 140 | + |
| 141 | + def test_local_sidecar_key_skips_cloud_provider(self, monkeypatch): |
| 142 | + """A ``::local``-suffixed key creates a local session even when cloud is set.""" |
| 143 | + provider = Mock() |
| 144 | + provider.create_session.return_value = { |
| 145 | + "session_name": "should_not_be_used", |
| 146 | + "bb_session_id": "bb_xxx", |
| 147 | + "cdp_url": "wss://fake.browserbase.com/ws", |
| 148 | + } |
| 149 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider) |
| 150 | + monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None) |
| 151 | + |
| 152 | + session = browser_tool._get_session_info("default::local") |
| 153 | + |
| 154 | + assert provider.create_session.call_count == 0 |
| 155 | + assert session["bb_session_id"] is None |
| 156 | + assert session["cdp_url"] is None |
| 157 | + assert session["features"]["local"] is True |
| 158 | + |
| 159 | + def test_bare_task_id_with_cloud_provider_uses_cloud(self, monkeypatch): |
| 160 | + """A bare task_id with cloud provider configured hits the cloud path.""" |
| 161 | + provider = Mock() |
| 162 | + provider.create_session.return_value = { |
| 163 | + "session_name": "cloud-sess", |
| 164 | + "bb_session_id": "bb_123", |
| 165 | + "cdp_url": "wss://real.browserbase.com/ws", |
| 166 | + } |
| 167 | + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider) |
| 168 | + monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda t: None) |
| 169 | + monkeypatch.setattr(browser_tool, "_resolve_cdp_override", lambda u: u) |
| 170 | + |
| 171 | + session = browser_tool._get_session_info("default") |
| 172 | + |
| 173 | + assert provider.create_session.call_count == 1 |
| 174 | + assert session["bb_session_id"] == "bb_123" |
| 175 | + |
| 176 | + |
| 177 | +class TestCleanupHybridSessions: |
| 178 | + """cleanup_browser(bare_task_id) must reap both cloud + local sidecar sessions.""" |
| 179 | + |
| 180 | + def test_cleanup_reaps_both_primary_and_sidecar(self, monkeypatch): |
| 181 | + """Given a bare task_id with both sessions alive, both get cleaned.""" |
| 182 | + reaped = [] |
| 183 | + |
| 184 | + def _fake_cleanup_one(key): |
| 185 | + reaped.append(key) |
| 186 | + |
| 187 | + monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one) |
| 188 | + monkeypatch.setattr( |
| 189 | + browser_tool, |
| 190 | + "_active_sessions", |
| 191 | + { |
| 192 | + "default": {"session_name": "cloud_sess"}, |
| 193 | + "default::local": {"session_name": "local_sess"}, |
| 194 | + }, |
| 195 | + ) |
| 196 | + monkeypatch.setattr( |
| 197 | + browser_tool, "_last_active_session_key", {"default": "default::local"} |
| 198 | + ) |
| 199 | + |
| 200 | + browser_tool.cleanup_browser("default") |
| 201 | + |
| 202 | + assert set(reaped) == {"default", "default::local"} |
| 203 | + # last-active pointer dropped |
| 204 | + assert "default" not in browser_tool._last_active_session_key |
| 205 | + |
| 206 | + def test_cleanup_reaps_only_primary_when_no_sidecar(self, monkeypatch): |
| 207 | + """When no sidecar exists, only the primary is reaped.""" |
| 208 | + reaped = [] |
| 209 | + |
| 210 | + def _fake_cleanup_one(key): |
| 211 | + reaped.append(key) |
| 212 | + |
| 213 | + monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one) |
| 214 | + monkeypatch.setattr( |
| 215 | + browser_tool, |
| 216 | + "_active_sessions", |
| 217 | + {"default": {"session_name": "cloud_sess"}}, |
| 218 | + ) |
| 219 | + |
| 220 | + browser_tool.cleanup_browser("default") |
| 221 | + |
| 222 | + assert reaped == ["default"] |
| 223 | + |
| 224 | + def test_cleanup_sidecar_directly_keeps_primary(self, monkeypatch): |
| 225 | + """Calling cleanup with a ``::local`` key reaps only the sidecar.""" |
| 226 | + reaped = [] |
| 227 | + |
| 228 | + def _fake_cleanup_one(key): |
| 229 | + reaped.append(key) |
| 230 | + |
| 231 | + monkeypatch.setattr(browser_tool, "_cleanup_single_browser_session", _fake_cleanup_one) |
| 232 | + monkeypatch.setattr( |
| 233 | + browser_tool, |
| 234 | + "_active_sessions", |
| 235 | + { |
| 236 | + "default": {"session_name": "cloud_sess"}, |
| 237 | + "default::local": {"session_name": "local_sess"}, |
| 238 | + }, |
| 239 | + ) |
| 240 | + monkeypatch.setattr( |
| 241 | + browser_tool, "_last_active_session_key", {"default": "default::local"} |
| 242 | + ) |
| 243 | + |
| 244 | + browser_tool.cleanup_browser("default::local") |
| 245 | + |
| 246 | + assert reaped == ["default::local"] |
| 247 | + # Last-active pointer NOT dropped (primary task is still alive) |
| 248 | + assert browser_tool._last_active_session_key.get("default") == "default::local" |
0 commit comments