4b2abbbfc5
Security: - serve.py: server now sends nonce challenge before accepting any command; clients sign nonce + SHA256(canonical_payload) with Ed25519 key - New --authorized-keys FILE option for serve; token auth still works as fallback - Connection limit: BoundedSemaphore(64) in serve.py - Secure file creation with os.open(..., 0o600) for token/key files - New auth.py module: keygen, file key load/save, SSH agent protocol (pure Python), sign/verify helpers compatible with both file keys and agent-held keys (YubiKey, TPM, gpg-agent) Features: - YubiKey support via SSH agent protocol — no new runtime deps, just $SSH_AUTH_SOCK - New `browser-cli auth` command group: keygen, trust, show, keys - Global --key PATH flag (or BROWSER_CLI_KEY env) selects signing key; pass "agent" or "agent:<selector>" to use SSH agent key - BrowserCLI Python API gains key= parameter Bug fixes (11 issues across two review passes): - client.py: check response is not None before json.loads - native_host.py: _read_exact_stream loop handles EINTR short reads; fix Windows Listener leak on accept error - __init__.py: open_wait / tabs_watch_url raise RuntimeError instead of silent None - extension/tabs.ts: dedupe skips tabs without URL; tabsSort uses pendingUrl fallback - extension/session.ts: removeListener before addListener prevents duplicate handlers Breaking: TCP serve protocol now sends a challenge frame first (v0.9.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
692 lines
27 KiB
Python
692 lines
27 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
|
|
|
|
import browser_cli
|
|
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"
|
|
|
|
def test_remote_options_stored(self):
|
|
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
|
|
assert b._browser == "work"
|
|
assert b._remote == "host:8765"
|
|
assert b._token == "secret"
|
|
|
|
|
|
# ── 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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=None)
|
|
|
|
def test_navigate_tab(self, b, mock_send):
|
|
b.navigate_tab(5, "https://example.com")
|
|
mock_send.assert_called_once_with(
|
|
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None, key=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", remote=None, token=None, key=None)
|
|
|
|
def test_remote_forwarded(self, mock_send):
|
|
b = BrowserCLI(browser="work", remote="host:8765", token="secret", key=None)
|
|
b.reload()
|
|
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", token="secret", key=None)
|
|
|
|
|
|
# ── 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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=None)
|
|
|
|
def test_window_active_tab(self, b, mock_send):
|
|
mock_send.return_value = TAB_DATA
|
|
tab = b.window_active_tab(1)
|
|
assert isinstance(tab, Tab)
|
|
assert tab.id == 10
|
|
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None, key=None)
|
|
|
|
def test_window_active_tab_missing_raises(self, b, mock_send):
|
|
mock_send.return_value = None
|
|
with pytest.raises(RuntimeError, match="No active tab found for window 1"):
|
|
b.window_active_tab(1)
|
|
|
|
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)
|
|
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", remote=None, token=None, key=None),
|
|
]
|
|
|
|
def test_tabs_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
|
b = BrowserCLI(remote="host:8765", token="secret", key=None)
|
|
with patch(
|
|
"browser_cli.active_browser_targets",
|
|
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
|
), patch(
|
|
"browser_cli.remote_browser_targets",
|
|
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
|
):
|
|
mock_send.side_effect = [[TAB_DATA], None]
|
|
tabs = b.tabs_list()
|
|
tabs[0].close()
|
|
|
|
assert [tab.browser for tab in tabs] == ["host:work"]
|
|
assert mock_send.call_args_list == [
|
|
call("tabs.list", {}, profile="work", remote="host:8765", token="secret", key=None),
|
|
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", token="secret", key=None),
|
|
]
|
|
|
|
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, remote=None, token=None, key=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, remote=None, token=None, key=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", remote=None, token=None, key=None),
|
|
]
|
|
|
|
def test_group_list_remote_uses_only_requested_remote_and_binds_actions(self, mock_send):
|
|
b = BrowserCLI(remote="host:8765", token="secret", key=None)
|
|
with patch(
|
|
"browser_cli.active_browser_targets",
|
|
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
|
), patch(
|
|
"browser_cli.remote_browser_targets",
|
|
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
|
):
|
|
mock_send.side_effect = [[GROUP_DATA], None]
|
|
groups = b.group_list()
|
|
groups[0].close()
|
|
|
|
assert [group.browser for group in groups] == ["host:work"]
|
|
assert mock_send.call_args_list == [
|
|
call("group.list", {}, profile="work", remote="host:8765", token="secret", key=None),
|
|
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", token="secret", key=None),
|
|
]
|
|
|
|
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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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"},
|
|
]
|
|
|
|
def test_windows_open_without_url(self, b, mock_send):
|
|
mock_send.return_value = {"id": 5}
|
|
|
|
result = b.windows_open()
|
|
|
|
assert result == {"id": 5}
|
|
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None, key=None)
|
|
|
|
def test_windows_open_with_url(self, b, mock_send):
|
|
mock_send.return_value = {"id": 9}
|
|
|
|
result = b.windows_open("https://example.com")
|
|
|
|
assert result == {"id": 9}
|
|
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None, key=None)
|
|
|
|
|
|
class TestSession:
|
|
def test_session_list(self, b, mock_send):
|
|
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
|
|
|
result = b.session_list()
|
|
|
|
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
|
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None, key=None)
|
|
|
|
def test_session_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 = [
|
|
[{"name": "first", "tabs": 2, "savedAt": 1712707200000}],
|
|
[{"name": "second", "tabs": 5, "savedAt": 1712707300000}],
|
|
]
|
|
result = b.session_list()
|
|
|
|
assert result == [
|
|
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"},
|
|
{"name": "second", "tabs": 5, "savedAt": 1712707300000, "browser": "work"},
|
|
]
|
|
assert mock_send.call_args_list == [
|
|
call("session.list", {}, profile="default"),
|
|
call("session.list", {}, profile="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, remote=None, token=None, key=None)
|
|
|
|
def test_activate(self, tab, mock_send):
|
|
tab.activate()
|
|
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None, key=None)
|
|
|
|
def test_reload(self, tab, mock_send):
|
|
tab.reload()
|
|
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=None)
|
|
|
|
def test_open(self, tab, mock_send):
|
|
tab.open("https://new.example.com")
|
|
mock_send.assert_called_once_with(
|
|
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None, key=None
|
|
)
|
|
|
|
def test_open_background_changes_same_tab(self, tab, mock_send):
|
|
tab.open("https://new.example.com", background=True)
|
|
mock_send.assert_called_once_with(
|
|
"navigate.to",
|
|
{"tabId": 10, "url": "https://new.example.com"},
|
|
profile=None, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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, remote=None, token=None, key=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()
|