Files
daniel156161 8dece7800f
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
feat: group multi-browser output by source
- Add browser source grouping metadata to SDK-created tabs, groups,
  list results, and aggregate count results.
- Render grouped local/remote browser tables consistently for clients,
  tabs, groups, windows, sessions, and remote status output.
- Document remote control, auth, HTTP gateway usage, and the refreshed
  project structure in the README.
- Add coverage for grouped output and BrowserCounts browser_groups.
- Bump the Python package, extension manifest, and lockfile to 0.15.6.
- Add a just publish helper for building and publishing release artifacts.
2026-06-18 00:52:04 +02:00

1295 lines
48 KiB
Python

"""Unit tests for the BrowserCLI Python SDK (BrowserCLI, namespaces, Tab, Group).
These tests mock `send_command` so no live browser connection is required.
Commands live on namespace accessors: b.tabs.list(), b.dom.click(), b.groups.create(), ...
"""
import asyncio
import pytest
from unittest.mock import MagicMock, patch, call
import browser_cli
from browser_cli import AsyncBrowserCLI, BrowserCLI, BrowserCounts, Tab, Group
from browser_cli.client import BrowserNotConnected, BrowserTarget
# ── Helpers ───────────────────────────────────────────────────────────────────
TAB_DATA = {
"id": 10,
"windowId": 1,
"active": True,
"index": 3,
"title": "Example",
"url": "https://example.com",
"groupId": None,
}
GROUP_DATA = {
"id": 42,
"title": "Work",
"color": "blue",
"collapsed": False,
"tabCount": 3,
}
@pytest.fixture()
def mock_send():
"""Patch send_command for the duration of one test.
BrowserCLI._cmd calls the `send_command` name that was imported into
browser_cli/__init__.py, so we must patch it there, not in the client module.
"""
with patch("browser_cli.send_command") as m, patch("browser_cli.active_browser_targets", return_value=[]):
yield m
@pytest.fixture()
def b(mock_send):
"""A BrowserCLI instance with send_command mocked."""
return BrowserCLI()
@pytest.fixture()
def b_profile(mock_send):
"""A BrowserCLI instance targeting a specific profile."""
return BrowserCLI(browser="brave")
# ── BrowserCLI construction ───────────────────────────────────────────────────
class TestBrowserCLIInit:
def test_default_browser_is_none(self):
b = BrowserCLI()
assert b._browser is None
def test_named_browser_stored(self):
b = BrowserCLI(browser="chrome")
assert b._browser == "chrome"
def test_remote_options_stored(self):
b = BrowserCLI(browser="work", remote="host:8765", key=None)
assert b._browser == "work"
assert b._remote == "host:8765"
def test_public_target_properties(self):
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
assert b.browser == "work"
assert b.remote == "browser-host.example:443"
assert b.key == "agent"
def test_tab_factory_preserves_index(self):
tab = BrowserCLI().tab_from(TAB_DATA)
assert tab.index == 3
def test_tab_factory_defaults_missing_index_to_zero(self):
data = {key: value for key, value in TAB_DATA.items() if key != "index"}
tab = BrowserCLI().tab_from(data)
assert tab.index == 0
def test_namespaces_present_and_bound(self):
b = BrowserCLI()
for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
"page", "storage", "session", "perf", "extension", "decorators"):
ns = getattr(b, name)
assert ns is not None
assert ns._c is b
def test_raw_command_escape_hatch(self, mock_send):
mock_send.return_value = {"ok": True}
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
result = b.command("custom.command", {"value": 1})
assert result == {"ok": True}
mock_send.assert_called_once_with(
"custom.command",
{"value": 1},
profile="work",
remote="browser-host.example:443",
key="agent",
)
def test_raw_command_defaults_args_to_empty_dict(self, b, mock_send):
b.command("custom.command")
mock_send.assert_called_once_with("custom.command", {}, profile=None, remote=None, key=None)
def test_internal_sdk_command_decorator_keeps_method_metadata(self, b):
assert b.nav.reload.__name__ == "reload"
assert b.nav.reload.__doc__
assert getattr(b.nav.reload, "_browser_cli_command") == "navigate.reload"
# ── Internal factories ────────────────────────────────────────────────────────
class TestMakeTab:
def test_all_fields(self, b):
tab = b.tab_from(TAB_DATA)
assert tab.id == 10
assert tab.window_id == 1
assert tab.active is True
assert tab.title == "Example"
assert tab.url == "https://example.com"
assert tab.group_id is None
def test_bound_to_browser(self, b):
tab = b.tab_from(TAB_DATA)
assert tab._browser is b
def test_missing_optional_fields_use_defaults(self, b):
tab = b.tab_from({"id": 1})
assert tab.window_id == 0
assert tab.active is False
assert tab.title == ""
assert tab.url == ""
assert tab.group_id is None
def test_group_id_set(self, b):
tab = b.tab_from({**TAB_DATA, "groupId": 99})
assert tab.group_id == 99
class TestMakeGroup:
def test_all_fields(self, b):
group = b.group_from(GROUP_DATA)
assert group.id == 42
assert group.title == "Work"
assert group.color == "blue"
assert group.collapsed is False
assert group.tab_count == 3
def test_bound_to_browser(self, b):
group = b.group_from(GROUP_DATA)
assert group._browser is b
def test_missing_optional_fields_use_defaults(self, b):
group = b.group_from({"id": 5})
assert group.title == ""
assert group.color == ""
assert group.collapsed is False
assert group.tab_count == 0
# ── Navigation ────────────────────────────────────────────────────────────────
class TestNavigation:
def test_open(self, b, mock_send):
b.nav.open("https://example.com")
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None},
profile=None, remote=None, key=None,
)
def test_open_background(self, b, mock_send):
b.nav.open("https://example.com", background=True)
args = mock_send.call_args[0]
assert args[1]["background"] is True
def test_open_with_group(self, b, mock_send):
b.nav.open("https://x.com", group="Work")
assert mock_send.call_args[0][1]["group"] == "Work"
def test_open_focus_is_explicit(self, b, mock_send):
b.nav.open("https://example.com", focus=True)
assert mock_send.call_args[0][1]["focus"] is True
def test_tabs_open_returns_bound_tab(self, b, mock_send):
mock_send.return_value = {"id": 123, "url": "https://example.com"}
tab = b.tabs.open("https://example.com", background=True)
assert tab.id == 123
assert tab.url == "https://example.com"
assert tab._browser is b
mock_send.assert_called_once_with(
"navigate.open",
{"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None},
profile=None,
remote=None,
key=None,
)
def test_tabs_open_wait_uses_open_wait(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.tabs.open("https://example.com", wait=True, timeout=1.5)
assert tab.id == 10
mock_send.assert_called_once_with(
"navigate.open_wait",
{"url": "https://example.com", "timeout": 1500, "background": True, "focus": False, "window": None, "group": None},
profile=None,
remote=None,
key=None,
)
def test_tabs_open_unexpected_response_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="navigate.open returned unexpected data"):
b.tabs.open("https://example.com")
def test_nav_open_wait_unexpected_response_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="navigate.open_wait returned unexpected data"):
b.nav.open_wait("https://example.com")
def test_reload(self, b, mock_send):
b.nav.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
def test_hard_reload(self, b, mock_send):
b.nav.hard_reload(tab_id=7)
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
def test_back(self, b, mock_send):
b.nav.back(tab_id=3)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
def test_forward(self, b, mock_send):
b.nav.forward(tab_id=3)
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
def test_focus(self, b, mock_send):
b.nav.focus("github.com")
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
def test_nav_to(self, b, mock_send):
b.nav.to(5, "https://example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.nav.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
def test_remote_forwarded(self, mock_send):
b = BrowserCLI(browser="work", remote="host:8765", key=None)
b.nav.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
# ── Search ────────────────────────────────────────────────────────────────────
class TestSearch:
def test_known_engine_builds_url(self, b, mock_send):
b.nav.search("google", "python asyncio")
call_args = mock_send.call_args[0]
assert call_args[0] == "navigate.open"
assert "python+asyncio" in call_args[1]["url"] or "python%20asyncio" in call_args[1]["url"]
def test_unknown_engine_raises(self, b):
with pytest.raises(ValueError, match="Unknown search engine"):
b.nav.search("nonexistent", "query")
def test_search_alias_ddg(self, b, mock_send):
b.nav.search("ddg", "hello")
url = mock_send.call_args[0][1]["url"]
assert "duckduckgo.com" in url
def test_search_background(self, b, mock_send):
b.nav.search("bing", "test", background=True)
assert mock_send.call_args[0][1]["background"] is True
# ── Extract ───────────────────────────────────────────────────────────────────
class TestExtract:
def test_extract_markdown_default(self, b, mock_send):
mock_send.return_value = "# Title"
result = b.extract.markdown()
assert result == "# Title"
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
def test_extract_markdown_selector(self, b, mock_send):
mock_send.return_value = "## Post"
assert b.extract.markdown("article") == "## Post"
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
def test_extract_links(self, b, mock_send):
mock_send.return_value = [{"href": "https://x"}]
assert b.extract.links() == [{"href": "https://x"}]
mock_send.assert_called_once_with("extract.links", {}, profile=None, remote=None, key=None)
def test_extract_text_none(self, b, mock_send):
mock_send.return_value = None
assert b.extract.text() == ""
# ── Tabs ──────────────────────────────────────────────────────────────────────
class TestTabs:
def test_tabs_list_returns_tab_objects(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.tabs.list()
assert len(tabs) == 1
assert isinstance(tabs[0], Tab)
assert tabs[0].id == 10
def test_tabs_list_empty(self, b, mock_send):
mock_send.return_value = []
assert b.tabs.list() == []
def test_tabs_list_none_response(self, b, mock_send):
mock_send.return_value = None
assert b.tabs.list() == []
def test_tabs_close_returns_count(self, b, mock_send):
mock_send.return_value = {"closed": 3}
assert b.tabs.close() == 3
def test_tabs_close_non_dict_response(self, b, mock_send):
mock_send.return_value = None
assert b.tabs.close() == 1
def test_tabs_close_by_id(self, b, mock_send):
mock_send.return_value = {"closed": 1}
b.tabs.close(tab_id=10)
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"},
profile=None, remote=None, key=None,
)
def test_tabs_close_by_ids(self, b, mock_send):
mock_send.return_value = {"closed": 3}
assert b.tabs.close(tab_ids=[10, 20, 30]) == 3
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": None, "tabIds": [10, 20, 30], "inactive": False, "duplicates": False, "gentleMode": "auto"},
profile=None, remote=None, key=None,
)
def test_tabs_close_by_ids_accepts_tab_objects(self, b, mock_send):
tabs = [b.tab_from({**TAB_DATA, "id": 10}), b.tab_from({**TAB_DATA, "id": 20})]
mock_send.return_value = {"closed": 2}
assert b.tabs.close(tab_ids=tabs) == 2
mock_send.assert_called_once_with(
"tabs.close",
{"tabId": None, "tabIds": [10, 20], "inactive": False, "duplicates": False, "gentleMode": "auto"},
profile=None, remote=None, key=None,
)
def test_tabs_close_by_ids_chunks_large_batches(self, b, mock_send):
mock_send.side_effect = [{"closed": 50}, {"closed": 50}, {"closed": 20}]
assert b.tabs.close(tab_ids=range(120), gentle_mode="normal") == 120
assert mock_send.call_args_list == [
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(0, 50)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(50, 100)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
call(
"tabs.close",
{"tabId": None, "tabIds": list(range(100, 120)), "inactive": False, "duplicates": False, "gentleMode": "normal"},
profile=None, remote=None, key=None,
),
]
def test_tabs_move(self, b, mock_send):
b.tabs.move(10, forward=True)
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, key=None,
)
def test_tabs_activate(self, b, mock_send):
b.tabs.activate(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_tabs_active_in_window(self, b, mock_send):
mock_send.return_value = TAB_DATA
tab = b.tabs.active_in_window(1)
assert isinstance(tab, Tab)
assert tab.id == 10
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, key=None)
def test_tabs_active_in_window_missing_raises(self, b, mock_send):
mock_send.return_value = None
with pytest.raises(RuntimeError, match="No active tab found for window 1"):
b.tabs.active_in_window(1)
def test_tabs_filter(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.tabs.filter("example")
assert isinstance(tabs[0], Tab)
def test_tabs_filter_none(self, b, mock_send):
mock_send.return_value = None
assert b.tabs.filter("x") == []
def test_tabs_filter_predicate(self, b, mock_send):
mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://youtube.com"}]
tabs = b.tabs.filter(lambda tab: "youtube" in tab.url)
assert [tab.id for tab in tabs] == [11]
def test_tabs_filter_list_transformer(self, b, mock_send):
mock_send.return_value = [TAB_DATA, {**TAB_DATA, "id": 11, "url": "https://example.com"}]
tabs = b.tabs.filter(lambda tabs: tabs[:1])
assert [tab.id for tab in tabs] == [10]
def test_tabs_count(self, b, mock_send):
mock_send.return_value = 5
assert b.tabs.count() == 5
def test_tabs_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[TAB_DATA],
[{**TAB_DATA, "id": 11}],
None,
]
tabs = b.tabs.list()
tabs[1].close()
assert [tab.browser for tab in tabs] == ["uuid-1", "work"]
assert [tab.id for tab in tabs] == [10, 11]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="default"),
call("tabs.list", {}, profile="work"),
call("tabs.close", {"tabId": 11}, profile="work", remote=None, key=None),
]
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", key=None)
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")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs.list()
tabs[0].close()
assert [tab.browser for tab in tabs] == ["work"]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="host:8765", key=None),
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
]
def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
b = BrowserCLI(remote="browser-host.example", key="agent")
with patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
):
mock_send.side_effect = [[TAB_DATA], None]
tabs = b.tabs.list()
tabs[0].close()
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"),
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
]
def test_tabs_list_browser_host_alias_fans_out_to_remote_targets(self, mock_send):
b = BrowserCLI(browser="browser-host.example", key="agent")
with patch(
"browser_cli.remote_targets_for_alias",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", browser_name="Chrome"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", browser_name="Firefox"),
],
):
mock_send.side_effect = [[TAB_DATA], [{**TAB_DATA, "id": 11}], None]
tabs = b.tabs.list()
tabs[1].close()
assert [tab.browser for tab in tabs] == ["main", "work"]
assert [tab.browser_name for tab in tabs] == ["Chrome", "Firefox"]
assert [tab.browser_group for tab in tabs] == [None, None]
assert mock_send.call_args_list == [
call("tabs.list", {}, profile="main", remote="browser-host.example:8765", key="agent"),
call("tabs.list", {}, profile="work", remote="browser-host.example:8765", key="agent"),
call("tabs.close", {"tabId": 11}, profile="work", remote="browser-host.example:8765", key="agent"),
]
def test_tabs_active_returns_active_tab(self, b, mock_send):
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
tabs = b.tabs.list()
active = b.tabs.active()
assert tabs[0].id == 10
assert active.id == 10
assert mock_send.call_args_list == [
call("tabs.list", {}, profile=None, remote=None, key=None),
call("tabs.status", {"tabId": None}, profile=None, remote=None, key=None),
]
def test_tabs_get_first_and_close_helpers(self, b, mock_send):
mock_send.side_effect = [TAB_DATA, [TAB_DATA], [], {"closed": 1}, {"closed": 1}]
tab = b.tabs.get(10)
found = b.tabs.first("Example")
missing = b.tabs.first("Missing")
closed_by_object = b.tabs.close(tab.id)
closed_by_id = b.tabs.close(10)
assert tab.id == 10
assert found and found.id == 10
assert missing is None
assert closed_by_object == 1
assert closed_by_id == 1
assert mock_send.call_args_list == [
call("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None),
call("tabs.query", {"search": "Missing"}, profile=None, remote=None, key=None),
call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}, profile=None, remote=None, key=None),
call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}, profile=None, remote=None, key=None),
]
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [3, 4]
result = b.tabs.count("github")
assert result == BrowserCounts(
total=7,
by_browser={"uuid-1": 3, "work": 4},
browser_groups={"uuid-1": "local", "work": "local"},
)
assert mock_send.call_args_list == [
call("tabs.count", {"pattern": "github"}, profile="default"),
call("tabs.count", {"pattern": "github"}, profile="work"),
]
def test_tabs_count_multi_browser_keeps_remote_display_groups(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", display_group="browser-host.example"),
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", display_group="browser-host.example"),
],
):
mock_send.side_effect = [1, 2]
result = b.tabs.count()
assert result == BrowserCounts(
total=3,
by_browser={"browser-host.example:main": 1, "browser-host.example:work": 2},
browser_groups={"browser-host.example:main": "browser-host.example", "browser-host.example:work": "browser-host.example"},
)
assert mock_send.call_args_list == [
call("tabs.count", {"pattern": None}, profile="main", remote="browser-host.example:8765", key=None),
call("tabs.count", {"pattern": None}, profile="work", remote="browser-host.example:8765", key=None),
]
def test_tabs_query(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
result = b.tabs.query("example")
assert isinstance(result[0], Tab)
mock_send.assert_called_once_with("tabs.query", {"search": "example"}, profile=None, remote=None, key=None)
def test_tabs_html(self, b, mock_send):
mock_send.return_value = "<html></html>"
assert b.tabs.html(10) == "<html></html>"
def test_tabs_html_none(self, b, mock_send):
mock_send.return_value = None
assert b.tabs.html() == ""
def test_tabs_dedupe(self, b, mock_send):
mock_send.return_value = {"closed": 2}
assert b.tabs.dedupe() == 2
def test_tabs_dedupe_non_dict(self, b, mock_send):
mock_send.return_value = None
assert b.tabs.dedupe() == 0
def test_tabs_sort(self, b, mock_send):
b.tabs.sort(by="title")
mock_send.assert_called_once_with("tabs.sort", {"by": "title", "gentleMode": "auto"}, profile=None, remote=None, key=None)
def test_tabs_merge_windows(self, b, mock_send):
mock_send.return_value = {"moved": 4}
assert b.tabs.merge_windows() == 4
def test_tabs_close_inactive(self, b, mock_send):
mock_send.return_value = {"closed": 2}
assert b.tabs.close_inactive() == 2
def test_tabs_close_duplicates(self, b, mock_send):
mock_send.return_value = {"closed": 1}
assert b.tabs.close_duplicates() == 1
def test_tabs_mute_pin_return_tab_id(self, b, mock_send):
mock_send.side_effect = [{"tabId": 10}, None]
assert b.tabs.mute(10) == 10
assert b.tabs.pin() == 0
assert mock_send.call_args_list == [
call("tabs.mute", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.pin", {"tabId": None}, profile=None, remote=None, key=None),
]
def test_tabs_screenshot(self, b, mock_send):
mock_send.return_value = {"dataUrl": "data:image/png;base64,abc"}
assert b.tabs.screenshot(10) == "data:image/png;base64,abc"
mock_send.assert_called_once_with(
"tabs.screenshot", {"tabId": 10, "format": "png", "quality": None}, profile=None, remote=None, key=None
)
# ── Groups ────────────────────────────────────────────────────────────────────
class TestGroups:
def test_group_list_returns_group_objects(self, b, mock_send):
mock_send.return_value = [GROUP_DATA]
groups = b.groups.list()
assert len(groups) == 1
assert isinstance(groups[0], Group)
assert groups[0].id == 42
def test_group_list_empty(self, b, mock_send):
mock_send.return_value = None
assert b.groups.list() == []
def test_group_tabs(self, b, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = b.groups.tabs(42)
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_group_count(self, b, mock_send):
mock_send.return_value = 7
assert b.groups.count() == 7
def test_group_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[GROUP_DATA],
[{**GROUP_DATA, "id": 99, "title": "Later"}],
None,
]
groups = b.groups.list()
groups[1].close()
assert [group.browser for group in groups] == ["uuid-1", "work"]
assert [group.id for group in groups] == [42, 99]
assert mock_send.call_args_list == [
call("group.list", {}, profile="default"),
call("group.list", {}, profile="work"),
call("group.close", {"groupId": 99}, profile="work", remote=None, key=None),
]
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
b = BrowserCLI(remote="host:8765", key=None)
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")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.groups.list()
groups[0].close()
assert [group.browser for group in groups] == ["work"]
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="host:8765", key=None),
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
]
def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
b = BrowserCLI(remote="browser-host.example", key="agent")
with patch(
"browser_cli.remote_browser_targets",
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
):
mock_send.side_effect = [[GROUP_DATA], None]
groups = b.groups.list()
groups[0].close()
assert mock_send.call_args_list == [
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"),
call("group.close", {"groupId": 42}, profile="work", remote="browser-host.example", key="agent"),
]
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [2, 5]
result = b.groups.count()
assert result == BrowserCounts(
total=7,
by_browser={"uuid-1": 2, "work": 5},
browser_groups={"uuid-1": "local", "work": "local"},
)
def test_group_query(self, b, mock_send):
mock_send.return_value = [GROUP_DATA]
groups = b.groups.query("Work")
assert isinstance(groups[0], Group)
mock_send.assert_called_once_with("group.query", {"search": "Work"}, profile=None, remote=None, key=None)
def test_group_close(self, b, mock_send):
b.groups.close(42)
mock_send.assert_called_once_with("group.close", {"groupId": 42, "gentleMode": "auto"}, profile=None, remote=None, key=None)
def test_group_create_dict_response(self, b, mock_send):
mock_send.return_value = GROUP_DATA
group = b.groups.create("Work")
assert isinstance(group, Group)
assert group.id == 42
assert group.title == "Work"
def test_group_create_int_response(self, b, mock_send):
mock_send.return_value = 99
group = b.groups.create("Misc")
assert group.id == 99
assert group.title == "Misc"
def test_group_add_tab(self, b, mock_send):
mock_send.return_value = {"tabId": 55}
tab_id = b.groups.add_tab(42, "https://example.com")
assert tab_id == 55
mock_send.assert_called_once_with(
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, key=None
)
def test_group_add_tab_non_dict_response(self, b, mock_send):
mock_send.return_value = 55
assert b.groups.add_tab(42) == 55
def test_group_move_forward(self, b, mock_send):
b.groups.move(42, forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
class TestWindows:
def test_windows_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[{"id": 1, "tabCount": 2, "state": "normal"}],
[{"id": 2, "tabCount": 3, "state": "maximized"}],
]
result = b.windows.list()
assert result == [
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1", "browserGroup": "local"},
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work", "browserGroup": "local"},
]
def test_windows_open_without_url(self, b, mock_send):
mock_send.return_value = {"id": 5}
result = b.windows.open()
assert result == {"id": 5}
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
def test_windows_open_with_url(self, b, mock_send):
mock_send.return_value = {"id": 9}
result = b.windows.open("https://example.com")
assert result == {"id": 9}
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
class TestDom:
def test_dom_click(self, b, mock_send):
b.dom.click("#go")
mock_send.assert_called_once_with("dom.click", {"selector": "#go"}, profile=None, remote=None, key=None)
def test_dom_type(self, b, mock_send):
b.dom.type("#in", "hello")
mock_send.assert_called_once_with("dom.type", {"selector": "#in", "text": "hello"}, profile=None, remote=None, key=None)
def test_dom_query_none(self, b, mock_send):
mock_send.return_value = None
assert b.dom.query("a") == []
def test_dom_exists_falsy(self, b, mock_send):
mock_send.return_value = None
assert b.dom.exists("a") is False
def test_dom_wait_for(self, b, mock_send):
mock_send.return_value = {"selector": "#done", "found": True}
result = b.dom.wait_for("#done", timeout=2.5, visible=True, tab_id=10)
assert result == {"selector": "#done", "found": True}
mock_send.assert_called_once_with(
"dom.wait_for",
{"selector": "#done", "timeout": 2500, "visible": True, "hidden": False, "tabId": 10},
profile=None,
remote=None,
key=None,
)
def test_dom_eval(self, b, mock_send):
mock_send.return_value = 42
assert b.dom.eval("1+41", tab_id=3) == 42
mock_send.assert_called_once_with("dom.eval", {"code": "1+41", "tabId": 3}, profile=None, remote=None, key=None)
class TestPageStorageCookies:
def test_page_info(self, b, mock_send):
mock_send.return_value = {"title": "X"}
assert b.page.info() == {"title": "X"}
mock_send.assert_called_once_with("page.info", {}, profile=None, remote=None, key=None)
def test_storage_get(self, b, mock_send):
mock_send.return_value = "v"
assert b.storage.get("k") == "v"
mock_send.assert_called_once_with("storage.get", {"key": "k", "type": "local", "tabId": None}, profile=None, remote=None, key=None)
def test_storage_set(self, b, mock_send):
b.storage.set("k", "v", type="session")
mock_send.assert_called_once_with(
"storage.set", {"key": "k", "value": "v", "type": "session", "tabId": None}, profile=None, remote=None, key=None
)
class TestPerf:
def test_perf_status(self, b, mock_send):
mock_send.return_value = {"profile": "auto"}
assert b.perf.status() == {"profile": "auto"}
mock_send.assert_called_once_with("perf.status", {}, profile=None, remote=None, key=None)
def test_perf_set_profile(self, b, mock_send):
mock_send.return_value = {"profile": "gentle"}
assert b.perf.set_profile("gentle") == {"profile": "gentle"}
mock_send.assert_called_once_with("perf.set_profile", {"profile": "gentle"}, profile=None, remote=None, key=None)
def test_perf_job_status(self, b, mock_send):
mock_send.return_value = {"phase": "done"}
assert b.perf.job_status("j1") == {"phase": "done"}
mock_send.assert_called_once_with("jobs.status", {"jobId": "j1"}, profile=None, remote=None, key=None)
class TestExtension:
def test_extension_reload(self, b, mock_send):
b.extension.reload()
mock_send.assert_called_once_with("extension.reload", {}, profile=None, remote=None, key=None)
class TestSession:
def test_session_save(self, b, mock_send):
b.session.save("work")
mock_send.assert_called_once_with("session.save", {"name": "work"}, profile=None, remote=None, key=None)
def test_session_load(self, b, mock_send):
b.session.load("work")
mock_send.assert_called_once_with(
"session.load",
{"name": "work", "gentleMode": "auto", "discardBackgroundTabs": False, "lazy": False, "eagerTabs": 10},
profile=None, remote=None, key=None,
)
def test_session_load_background(self, b, mock_send):
mock_send.return_value = {"jobId": "j1"}
assert b.session.load_background("work") == {"jobId": "j1"}
assert mock_send.call_args[0][1]["__background"] is True
def test_session_list(self, b, mock_send):
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
result = b.session.list()
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
with patch(
"browser_cli.active_browser_targets",
return_value=[
BrowserTarget("default", "uuid-1", "/tmp/uuid-1.sock"),
BrowserTarget("work", "work", "/tmp/work.sock"),
],
):
mock_send.side_effect = [
[{"name": "first", "tabs": 2, "savedAt": 1712707200000}],
[{"name": "second", "tabs": 5, "savedAt": 1712707300000}],
]
result = b.session.list()
assert result == [
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1", "browserGroup": "local"},
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work", "browserGroup": "local"},
]
assert mock_send.call_args_list == [
call("session.list", {}, profile="default"),
call("session.list", {}, profile="work"),
]
# ── Tab model ─────────────────────────────────────────────────────────────────
class TestTabModel:
@pytest.fixture()
def tab(self, b, mock_send):
return b.tab_from(TAB_DATA)
def test_close(self, tab, mock_send):
tab.close()
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, key=None)
def test_activate(self, tab, mock_send):
tab.activate()
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
def test_reload(self, tab, mock_send):
tab.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_hard_reload(self, tab, mock_send):
tab.hard_reload()
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, key=None)
def test_move_forward(self, tab, mock_send):
tab.move(forward=True)
mock_send.assert_called_once_with(
"tabs.move",
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
profile=None, remote=None, key=None,
)
def test_move_to_group(self, tab, mock_send):
tab.move(group_id=5)
assert mock_send.call_args[0][1]["groupId"] == 5
def test_html(self, tab, mock_send):
mock_send.return_value = "<html/>"
assert tab.html() == "<html/>"
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, key=None)
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
)
def test_screenshot(self, tab, mock_send):
mock_send.return_value = {"dataUrl": "data:image/png;base64,abc"}
assert tab.screenshot() == "data:image/png;base64,abc"
mock_send.assert_called_once_with(
"tabs.screenshot", {"tabId": 10, "format": "png", "quality": None}, profile=None, remote=None, key=None
)
def test_pin_unpin(self, tab, mock_send):
tab.pin()
tab.unpin()
assert mock_send.call_args_list == [
call("tabs.pin", {"tabId": 10}, profile=None, remote=None, key=None),
call("tabs.unpin", {"tabId": 10}, profile=None, remote=None, key=None),
]
def test_refresh(self, tab, mock_send):
mock_send.return_value = {**TAB_DATA, "title": "Fresh"}
fresh = tab.refresh()
assert fresh.title == "Fresh"
mock_send.assert_called_once_with("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None)
def test_wait_for_load_and_watch_url(self, tab, mock_send):
mock_send.side_effect = [TAB_DATA, TAB_DATA]
tab.wait_for_load(timeout=1.5, ready_state="interactive")
tab.watch_url("example", timeout=2)
assert mock_send.call_args_list == [
call("navigate.wait", {"tabId": 10, "timeout": 1500, "readyState": "interactive"}, profile=None, remote=None, key=None),
call("tabs.watch_url", {"pattern": "example", "tabId": 10, "timeout": 2000}, profile=None, remote=None, key=None),
]
def test_open_background_changes_same_tab(self, tab, mock_send):
tab.open("https://new.example.com", background=True)
mock_send.assert_called_once_with(
"navigate.to",
{"tabId": 10, "url": "https://new.example.com"},
profile=None, remote=None, key=None,
)
def test_unbound_raises(self):
tab = Tab(id=1, window_id=0, active=False, title="", url="")
with pytest.raises(RuntimeError, match="not bound"):
tab.close()
# ── Group model ───────────────────────────────────────────────────────────────
class TestGroupModel:
@pytest.fixture()
def group(self, b, mock_send):
return b.group_from(GROUP_DATA)
def test_close(self, group, mock_send):
group.close()
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
def test_tabs(self, group, mock_send):
mock_send.return_value = [TAB_DATA]
tabs = group.tabs()
assert isinstance(tabs[0], Tab)
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
def test_move_forward(self, group, mock_send):
group.move(forward=True)
mock_send.assert_called_once_with(
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
)
def test_move_backward(self, group, mock_send):
group.move(backward=True)
assert mock_send.call_args[0][1]["backward"] is True
def test_add_tab(self, group, mock_send):
mock_send.return_value = {"tabId": 77}
result = group.add_tab("https://example.com")
assert result == 77
def test_add_tab_no_url(self, group, mock_send):
mock_send.return_value = {"tabId": 78}
group.add_tab()
assert mock_send.call_args[0][1]["url"] is None
def test_unbound_raises(self):
group = Group(id=1, title="x", color="", collapsed=False, tab_count=0)
with pytest.raises(RuntimeError, match="not bound"):
group.close()
# ── SDK decorators ───────────────────────────────────────────────────────────
class TestSDKDecorators:
def test_active_tab_injects_keyword_and_preserves_name(self, b, mock_send):
mock_send.return_value = TAB_DATA
@b.decorators.active_tab
def current(*, tab):
return tab.id
assert current() == 10
assert current.__name__ == "current"
mock_send.assert_called_once_with("tabs.status", {"tabId": None}, profile=None, remote=None, key=None)
def test_active_tab_can_inject_positional(self, b, mock_send):
mock_send.return_value = TAB_DATA
@b.decorators.active_tab(keyword=None)
def current(tab):
return tab.url
assert current() == "https://example.com"
def test_new_tab_injects_and_optionally_closes(self, b, mock_send):
mock_send.return_value = {"id": 123, "url": "https://example.com"}
@b.decorators.new_tab("https://example.com", wait=True, timeout=1.5, close=True)
def work(*, tab):
return tab.id
assert work() == 123
assert mock_send.mock_calls == [
call(
"navigate.open_wait",
{"url": "https://example.com", "timeout": 1500, "background": True, "focus": False, "window": None, "group": None},
profile=None,
remote=None,
key=None,
),
call("tabs.close", {"tabId": 123}, profile=None, remote=None, key=None),
]
def test_wait_for_selector_runs_before_function_and_can_inject_result(self, b, mock_send):
mock_send.return_value = {"selector": "#ready", "ok": True}
seen = []
@b.decorators.wait_for_selector("#ready", visible=True, timeout=2.0, keyword="wait_result")
def work(*, wait_result):
seen.append(wait_result)
return "done"
assert work() == "done"
assert seen == [{"selector": "#ready", "ok": True}]
mock_send.assert_called_once_with(
"dom.wait_for",
{"selector": "#ready", "timeout": 2000, "visible": True, "hidden": False, "tabId": None},
profile=None,
remote=None,
key=None,
)
def test_performance_profile_restores_previous_profile(self, b, mock_send):
mock_send.side_effect = [
{"performanceProfile": "normal"},
{"performanceProfile": "ultra"},
{"performanceProfile": "normal"},
]
@b.decorators.performance_profile("ultra")
def work():
return "ok"
assert work() == "ok"
assert mock_send.mock_calls == [
call("perf.status", {}, profile=None, remote=None, key=None),
call("perf.set_profile", {"profile": "ultra"}, profile=None, remote=None, key=None),
call("perf.set_profile", {"profile": "normal"}, profile=None, remote=None, key=None),
]
class TestMoreSDKDecorators:
def test_wait_for_url_injects_tab(self, b, mock_send):
mock_send.return_value = TAB_DATA
@b.decorators.wait_for_url(r"/done$", tab_id=10, timeout=1.25)
def work(*, tab):
return tab.id
assert work() == 10
mock_send.assert_called_once_with(
"tabs.watch_url",
{"pattern": r"/done$", "tabId": 10, "timeout": 1250},
profile=None,
remote=None,
key=None,
)
def test_save_session_before_runs_before_function(self, b, mock_send):
calls = []
@b.decorators.save_session_before("backup")
def work():
calls.append("function")
work()
assert calls == ["function"]
mock_send.assert_called_once_with("session.save", {"name": "backup"}, profile=None, remote=None, key=None)
def test_retry_retries_sync_function(self, b):
calls = []
@b.decorators.retry(times=3)
def flaky():
calls.append("try")
if len(calls) < 3:
raise RuntimeError("not yet")
return "ok"
assert flaky() == "ok"
assert calls == ["try", "try", "try"]
def test_async_decorated_function_uses_nonblocking_browser_calls(self, b, mock_send):
mock_send.return_value = TAB_DATA
@b.decorators.active_tab
async def current(*, tab):
await asyncio.sleep(0)
return tab.id
assert asyncio.run(current()) == 10
mock_send.assert_called_once_with("tabs.status", {"tabId": None}, profile=None, remote=None, key=None)
def test_retry_retries_async_function(self, b):
calls = []
@b.decorators.retry(times=2)
async def flaky():
calls.append("try")
if len(calls) == 1:
raise RuntimeError("not yet")
return "ok"
assert asyncio.run(flaky()) == "ok"
assert calls == ["try", "try"]
class TestAsyncBrowserCLI:
def test_async_namespace_method_uses_async_transport(self):
with patch("browser_cli.client.send_command_async") as mock_send_async:
mock_send_async.return_value = 7
async def run():
b = AsyncBrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
return await b.tabs.count("github")
assert asyncio.run(run()) == 7
mock_send_async.assert_called_once_with(
"tabs.count",
{"pattern": "github"},
profile="work",
remote="browser-host.example:443",
key="agent",
)
def test_async_command_and_clients(self):
with patch("browser_cli.client.send_command_async") as mock_send_async:
mock_send_async.side_effect = [{"ok": True}, [{"profile": "work"}]]
async def run():
b = AsyncBrowserCLI()
return await b.command("custom.command", {"x": 1}), await b.clients()
assert asyncio.run(run()) == ({"ok": True}, [{"profile": "work"}])
assert mock_send_async.mock_calls == [
call("custom.command", {"x": 1}, profile=None, remote=None, key=None),
call("clients.list", {}, profile=None, remote=None, key=None),
]
def test_async_decorator_new_tab_closes_after_function(self):
with patch("browser_cli.client.send_command_async") as mock_send_async:
mock_send_async.return_value = {"id": 55, "url": "https://example.com"}
async def run():
b = AsyncBrowserCLI()
@b.decorators.new_tab("https://example.com", close=True)
async def work(*, tab):
return tab.id
return await work()
assert asyncio.run(run()) == 55
assert mock_send_async.mock_calls == [
call(
"navigate.open",
{"url": "https://example.com", "background": True, "focus": False, "window": None, "group": None},
profile=None,
remote=None,
key=None,
),
call("tabs.close", {"tabId": 55, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}, profile=None, remote=None, key=None),
]
def test_async_retry_decorator_accepts_sync_function(self):
async def run():
b = AsyncBrowserCLI()
calls = []
@b.decorators.retry(times=2)
def flaky():
calls.append("try")
if len(calls) == 1:
raise RuntimeError("not yet")
return "ok"
return await flaky(), calls
assert asyncio.run(run()) == ("ok", ["try", "try"])