e1e4adbb25
- Position browser-cli as a CLI plus Python SDK in docs and package metadata. - Add public target properties and a raw command escape hatch for unsupported commands. - Add convenience helpers for opening, finding, closing, and accessing tabs. - Add plural group aliases and a wait_for_selector DOM convenience alias. - Extend bound Tab objects with screenshot, pin, refresh, load wait, and URL watch helpers. - Preserve remote auth key configuration when binding remote Tab and Group objects. - Bump project and extension versions to 0.9.9 and cover SDK additions with tests.
889 lines
35 KiB
Python
889 lines
35 KiB
Python
"""Unit tests for the BrowserCLI Python API (BrowserCLI, Tab, Group).
|
|
|
|
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
|
|
|
|
|
|
# ── 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_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)
|
|
|
|
|
|
# ── Internal factories ────────────────────────────────────────────────────────
|
|
|
|
class TestMakeTab:
|
|
def test_all_fields(self, b):
|
|
tab = b._make_tab(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._make_tab(TAB_DATA)
|
|
assert tab._browser is b
|
|
|
|
def test_missing_optional_fields_use_defaults(self, b):
|
|
tab = b._make_tab({"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._make_tab({**TAB_DATA, "groupId": 99})
|
|
assert tab.group_id == 99
|
|
|
|
|
|
class TestMakeGroup:
|
|
def test_all_fields(self, b):
|
|
group = b._make_group(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._make_group(GROUP_DATA)
|
|
assert group._browser is b
|
|
|
|
def test_missing_optional_fields_use_defaults(self, b):
|
|
group = b._make_group({"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.open("https://example.com")
|
|
mock_send.assert_called_once_with(
|
|
"navigate.open",
|
|
{"url": "https://example.com", "background": False, "window": None, "group": None},
|
|
profile=None, remote=None, key=None,
|
|
)
|
|
|
|
def test_open_background(self, b, mock_send):
|
|
b.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.open("https://x.com", group="Work")
|
|
assert mock_send.call_args[0][1]["group"] == "Work"
|
|
|
|
def test_open_tab_returns_bound_tab(self, b, mock_send):
|
|
mock_send.return_value = {"id": 123, "url": "https://example.com"}
|
|
|
|
tab = b.open_tab("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, "window": None, "group": None},
|
|
profile=None,
|
|
remote=None,
|
|
key=None,
|
|
)
|
|
|
|
def test_open_tab_wait_uses_open_wait(self, b, mock_send):
|
|
mock_send.return_value = TAB_DATA
|
|
|
|
tab = b.open_tab("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": False, "window": None, "group": None},
|
|
profile=None,
|
|
remote=None,
|
|
key=None,
|
|
)
|
|
|
|
def test_open_tab_unexpected_response_raises(self, b, mock_send):
|
|
mock_send.return_value = None
|
|
with pytest.raises(RuntimeError, match="navigate.open returned unexpected data"):
|
|
b.open_tab("https://example.com")
|
|
|
|
def test_reload(self, b, mock_send):
|
|
b.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.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.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.forward(tab_id=3)
|
|
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
|
|
|
|
def test_focus_url(self, b, mock_send):
|
|
b.focus_url("github.com")
|
|
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
|
|
|
|
def test_navigate_tab(self, b, mock_send):
|
|
b.navigate_tab(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.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.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.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"] or "python+asyncio" in call_args[1]["url"]
|
|
|
|
def test_unknown_engine_raises(self, b):
|
|
with pytest.raises(ValueError, match="Unknown search engine"):
|
|
b.search("nonexistent", "query")
|
|
|
|
def test_search_alias_ddg(self, b, mock_send):
|
|
b.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.search("bing", "test", background=True)
|
|
assert mock_send.call_args[0][1]["background"] is True
|
|
|
|
|
|
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):
|
|
b.extract_markdown("article")
|
|
|
|
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
|
|
|
|
|
|
# ── 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, "inactive": False, "duplicates": False},
|
|
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_active(self, b, mock_send):
|
|
b.tabs_active(10)
|
|
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
|
|
|
|
def test_window_active_tab(self, b, mock_send):
|
|
mock_send.return_value = TAB_DATA
|
|
tab = b.window_active_tab(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_window_active_tab_missing_raises(self, b, mock_send):
|
|
mock_send.return_value = None
|
|
with pytest.raises(RuntimeError, match="No active tab found for window 1"):
|
|
b.window_active_tab(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_alias_and_active_tab(self, b, mock_send):
|
|
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
|
|
|
|
tabs = b.tabs()
|
|
active = b.active_tab()
|
|
|
|
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_tab_find_and_close_helpers(self, b, mock_send):
|
|
mock_send.side_effect = [TAB_DATA, [TAB_DATA], [], {"closed": 1}, {"closed": 1}]
|
|
|
|
tab = b.tab(10)
|
|
found = b.find_tab("Example")
|
|
missing = b.find_tab("Missing")
|
|
closed_by_object = b.close_tab(tab)
|
|
closed_by_id = b.close_tab(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, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
|
|
call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
|
|
]
|
|
|
|
def test_find_tabs_alias(self, b, mock_send):
|
|
mock_send.return_value = [TAB_DATA]
|
|
|
|
tabs = b.find_tabs("Example")
|
|
|
|
assert [tab.id for tab in tabs] == [10]
|
|
mock_send.assert_called_once_with("tabs.query", {"search": "Example"}, 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)
|
|
|
|
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"}, 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
|
|
|
|
|
|
# ── Groups ────────────────────────────────────────────────────────────────────
|
|
|
|
class TestGroups:
|
|
def test_group_list_returns_group_objects(self, b, mock_send):
|
|
mock_send.return_value = [GROUP_DATA]
|
|
groups = b.group_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.group_list() == []
|
|
|
|
def test_group_tabs(self, b, mock_send):
|
|
mock_send.return_value = [TAB_DATA]
|
|
tabs = b.group_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.group_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.group_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.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", 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.group_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_aliases(self, b, mock_send):
|
|
mock_send.side_effect = [[GROUP_DATA], [GROUP_DATA], 1, [GROUP_DATA], GROUP_DATA, GROUP_DATA]
|
|
|
|
assert b.groups()[0].id == 42
|
|
assert b.groups_list()[0].id == 42
|
|
assert b.groups_count() == 1
|
|
assert b.groups_query("Work")[0].id == 42
|
|
assert b.groups_create("Work").id == 42
|
|
assert b.group_open("Work").id == 42
|
|
|
|
assert mock_send.call_args_list == [
|
|
call("group.list", {}, profile=None, remote=None, key=None),
|
|
call("group.list", {}, profile=None, remote=None, key=None),
|
|
call("group.count", {}, profile=None, remote=None, key=None),
|
|
call("group.query", {"search": "Work"}, profile=None, remote=None, key=None),
|
|
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
|
|
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
|
|
]
|
|
|
|
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.group_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.group_query("Work")
|
|
assert isinstance(groups[0], Group)
|
|
|
|
def test_group_close(self, b, mock_send):
|
|
b.group_close(42)
|
|
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, key=None)
|
|
|
|
def test_group_create_dict_response(self, b, mock_send):
|
|
mock_send.return_value = GROUP_DATA
|
|
group = b.group_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.group_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.group_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.group_add_tab(42) == 55
|
|
|
|
def test_group_move_forward(self, b, mock_send):
|
|
b.group_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 TestDomConvenience:
|
|
def test_wait_for_selector_alias(self, b, mock_send):
|
|
mock_send.return_value = {"selector": "#done", "found": True}
|
|
|
|
result = b.wait_for_selector("#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,
|
|
)
|
|
|
|
|
|
class TestSession:
|
|
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._make_tab(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._make_group(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()
|