Files
browser-cli/tests/test_api.py
T
daniel156161 61b774a7a4
Package Extension / package-extension (push) Successful in 12s
Build & Publish Package / publish (push) Successful in 22s
add multi browser mode to arragate data from all browsers by tabs list, tabs count, group list, group count and windows list
remove (unnamed) into the group names just leave it a empty string, remove Focused on windows how should the browser know what windows are focused
2026-04-10 12:49:51 +02:00

568 lines
21 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
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"
# ── 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,
)
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_reload(self, b, mock_send):
b.reload(tab_id=5)
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=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)
def test_back(self, b, mock_send):
b.back(tab_id=3)
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=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)
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)
def test_profile_forwarded(self, b_profile, mock_send):
b_profile.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
# ── 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)
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)
# ── 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,
)
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,
)
def test_tabs_active(self, b, mock_send):
b.tabs_active(10)
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
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)
print(tabs)
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"),
]
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)
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)
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"),
]
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)
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
)
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
)
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"},
]
# ── 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)
def test_activate(self, tab, mock_send):
tab.activate()
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
def test_reload(self, tab, mock_send):
tab.reload()
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=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)
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,
)
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)
def test_open(self, tab, mock_send):
tab.open("https://new.example.com")
mock_send.assert_called_once_with(
"navigate.open", {"url": "https://new.example.com", "background": False}, profile=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)
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)
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
)
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()