3e3b8d529c
- Change nav open and open-wait to avoid activating newly created tabs unless --focus is explicitly requested. - Send background=true for default opens so older or remote extensions also avoid stealing focus even if they ignore the new focus flag. - Remove the redundant --bg flag from navigation and search CLI commands now that no-focus/background behavior is the default. - Thread focus support through the sync SDK, async SDK, tab helpers, and workflow decorators. - Update README and demo usage to document the new default and --focus opt-in. - Bump package and extension metadata to 0.12.3. - Add regression coverage for CLI help, wire payloads, and extension behavior.
1220 lines
45 KiB
Python
1220 lines
45 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,
|
|
"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_namespaces_present_and_bound(self):
|
|
b = BrowserCLI()
|
|
for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
|
|
"page", "storage", "cookies", "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_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] == ["host: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_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})
|
|
assert mock_send.call_args_list == [
|
|
call("tabs.count", {"pattern": "github"}, profile="default"),
|
|
call("tabs.count", {"pattern": "github"}, profile="work"),
|
|
]
|
|
|
|
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] == ["host: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})
|
|
|
|
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"},
|
|
{"id": 2, "tabCount": 3, "state": "maximized", "browser": "work"},
|
|
]
|
|
|
|
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
|
|
)
|
|
|
|
def test_cookies_list(self, b, mock_send):
|
|
mock_send.return_value = [{"name": "c"}]
|
|
assert b.cookies.list(domain="example.com") == [{"name": "c"}]
|
|
mock_send.assert_called_once_with(
|
|
"cookies.list", {"url": None, "domain": "example.com", "name": 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"},
|
|
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"},
|
|
]
|
|
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"])
|