Files
browser-cli/tests/test_api.py
T
daniel156161 f2a7f85ee3
Package Extension / package-extension (push) Successful in 12s
Build & Publish Package / publish (push) Failing after 21s
adding new extract command to extract selector or main content as markdown, updateing version as 0.5.0
2026-04-10 03:44:49 +02:00

467 lines
17 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, Tab, Group
from browser_cli.client import BrowserNotConnected
# ── 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:
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_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_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
)
# ── 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()