From eaa86e3f3d7885160c22b486538928315f664b6f Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 9 Apr 2026 10:42:36 +0200 Subject: [PATCH] add test for the api --- tests/test_api.py | 440 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 tests/test_api.py diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..cefdfc8 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,440 @@ +"""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 + + +# ── 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_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 = "" + assert b.tabs_html(10) == "" + + 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 = "" + assert tab.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()