diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index 3cdc32e..9128fc5 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -19,7 +19,7 @@ Usage: from collections.abc import Callable, Iterable from dataclasses import dataclass -from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command +from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command from browser_cli.models import Group, Tab __all__ = ["BrowserCLI", "BrowserCounts", "BrowserNotConnected", "Tab", "Group"] @@ -40,7 +40,8 @@ class BrowserCLI: instances are active. Equivalent to ``--browser`` on the CLI. remote: Connect to a remote browser exposed via ``browser-cli serve``. Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``). - When set, ``browser`` is ignored. + Can be combined with ``browser`` to route to a specific + remote profile. token: Auth token for the remote serve instance. """ self._browser = browser @@ -53,7 +54,10 @@ class BrowserCLI: def _multi_browser_targets(self): if self._browser is not None: return [] - targets = active_browser_targets() + if self._remote: + targets = remote_browser_targets(self._remote, self._token) + else: + targets = active_browser_targets() if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets @@ -81,7 +85,15 @@ class BrowserCLI: # ── Internal factories ──────────────────────────────────────────────── - def _make_tab(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Tab: + def _make_tab( + self, + data: dict, + *, + browser_profile: str | None = None, + browser_name: str | None = None, + browser_remote: str | None = None, + browser_token: str | None = None, + ) -> Tab: tab = Tab( id=data["id"], window_id=data.get("windowId", 0), @@ -92,10 +104,22 @@ class BrowserCLI: group_id=data.get("groupId") or None, browser=browser_name, ) - tab._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile) + tab._browser = self if browser_profile is None else BrowserCLI( + browser=browser_profile, + remote=browser_remote, + token=browser_token, + ) return tab - def _make_group(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None) -> Group: + def _make_group( + self, + data: dict, + *, + browser_profile: str | None = None, + browser_name: str | None = None, + browser_remote: str | None = None, + browser_token: str | None = None, + ) -> Group: group = Group( id=data["id"], title=data.get("title") or "", @@ -104,7 +128,11 @@ class BrowserCLI: tab_count=data.get("tabCount", 0), browser=browser_name, ) - group._browser = self if browser_profile is None else BrowserCLI(browser=browser_profile) + group._browser = self if browser_profile is None else BrowserCLI( + browser=browser_profile, + remote=browser_remote, + token=browser_token, + ) return group # ── Navigation ──────────────────────────────────────────────────────── @@ -196,7 +224,13 @@ class BrowserCLI: multi_results = self._collect_multi_browser("tabs.list", {}) if multi_results: return [ - self._make_tab(tab, browser_profile=target.profile, browser_name=target.display_name) + self._make_tab( + tab, + browser_profile=target.profile, + browser_name=target.display_name, + browser_remote=target.remote, + browser_token=target.token, + ) for target, tabs in multi_results for tab in (tabs or []) ] @@ -343,7 +377,13 @@ class BrowserCLI: multi_results = self._collect_multi_browser("group.list", {}) if multi_results: return [ - self._make_group(group, browser_profile=target.profile, browser_name=target.display_name) + self._make_group( + group, + browser_profile=target.profile, + browser_name=target.display_name, + browser_remote=target.remote, + browser_token=target.token, + ) for target, groups in multi_results for group in (groups or []) ] diff --git a/tests/test_api.py b/tests/test_api.py index 30760be..dbd80c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,6 +5,7 @@ These tests mock `send_command` so no live browser connection is required. import pytest from unittest.mock import MagicMock, patch, call +import browser_cli from browser_cli import BrowserCLI, BrowserCounts, Tab, Group from browser_cli.client import BrowserNotConnected, BrowserTarget @@ -63,6 +64,12 @@ class TestBrowserCLIInit: b = BrowserCLI(browser="chrome") assert b._browser == "chrome" + def test_remote_options_stored(self): + b = BrowserCLI(browser="work", remote="host:8765", token="secret") + assert b._browser == "work" + assert b._remote == "host:8765" + assert b._token == "secret" + # ── Internal factories ──────────────────────────────────────────────────────── @@ -164,6 +171,11 @@ class TestNavigation: b_profile.reload() mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None) + def test_remote_forwarded(self, mock_send): + b = BrowserCLI(browser="work", remote="host:8765", token="secret") + b.reload() + mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret") + # ── Search ──────────────────────────────────────────────────────────────────── @@ -311,6 +323,25 @@ class TestTabs: call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=None), ] + def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send): + b = BrowserCLI(remote="host:8765", token="secret") + with patch( + "browser_cli.active_browser_targets", + side_effect=AssertionError("local targets should not be used for explicit remote"), + ), patch( + "browser_cli.remote_browser_targets", + return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")], + ): + mock_send.side_effect = [[TAB_DATA], None] + tabs = b.tabs_list() + tabs[0].close() + + assert [tab.browser for tab in tabs] == ["host:work"] + assert mock_send.call_args_list == [ + call("tabs.list", {}, profile="work", remote="host:8765", token="secret"), + call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret"), + ] + def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send): with patch( "browser_cli.active_browser_targets", @@ -415,6 +446,25 @@ class TestGroups: call("group.close", {"groupId": 99}, profile="work", remote=None, token=None), ] + def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send): + b = BrowserCLI(remote="host:8765", token="secret") + with patch( + "browser_cli.active_browser_targets", + side_effect=AssertionError("local targets should not be used for explicit remote"), + ), patch( + "browser_cli.remote_browser_targets", + return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")], + ): + mock_send.side_effect = [[GROUP_DATA], None] + groups = b.group_list() + groups[0].close() + + assert [group.browser for group in groups] == ["host:work"] + assert mock_send.call_args_list == [ + call("group.list", {}, profile="work", remote="host:8765", token="secret"), + call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret"), + ] + def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send): with patch( "browser_cli.active_browser_targets",