"""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()