61b774a7a4
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
568 lines
21 KiB
Python
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()
|