refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Restructure the Python API and internals around composable namespaces and a standalone transport/endpoint layer. Bump to 0.12.0. Python API: - Replace flat methods (b.tabs_list(), b.group_list()) with namespaces: b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage, b.cookies, b.session, b.perf, b.extension. - Shrink browser_cli/__init__.py to a thin composition root; move all behaviour into browser_cli/sdk/ (one module per namespace + factories, base, routing). Internals: - Add browser_cli/transport.py and remote_transport.py to isolate IPC from command logic; client.py now delegates instead of owning transport. - Add browser_cli/endpoints.py for endpoint resolution and browser_cli/errors.py for shared error types. - Extract markdown rendering into browser_cli/markdown.py (out of extract). - Add USER_AGENT to version_manager. Tooling & tests: - Add justfile with common dev tasks. - Update CLI commands and demo to the namespaced API. - Rework tests for the new layout; add test_transport.py and test_refactor_boundaries.py to lock in module boundaries. BREAKING CHANGE: flat API methods are removed in favour of namespaces (e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
+221
-135
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for the BrowserCLI Python API (BrowserCLI, Tab, Group).
|
||||
"""Unit tests for the BrowserCLI Python SDK (BrowserCLI, namespaces, Tab, Group).
|
||||
|
||||
These tests mock `send_command` so no live browser connection is required.
|
||||
Commands live on namespace accessors: b.tabs.list(), b.dom.click(), b.groups.create(), ...
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
@@ -29,7 +30,6 @@ GROUP_DATA = {
|
||||
"tabCount": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_send():
|
||||
"""Patch send_command for the duration of one test.
|
||||
@@ -40,19 +40,16 @@ def mock_send():
|
||||
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:
|
||||
@@ -75,6 +72,14 @@ class TestBrowserCLIInit:
|
||||
assert b.remote == "browser-host.example:443"
|
||||
assert b.key == "agent"
|
||||
|
||||
def test_namespaces_present_and_bound(self):
|
||||
b = BrowserCLI()
|
||||
for name in ("nav", "tabs", "groups", "windows", "dom", "extract",
|
||||
"page", "storage", "cookies", "session", "perf", "extension"):
|
||||
ns = getattr(b, name)
|
||||
assert ns is not None
|
||||
assert ns._c is b
|
||||
|
||||
def test_raw_command_escape_hatch(self, mock_send):
|
||||
mock_send.return_value = {"ok": True}
|
||||
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
|
||||
@@ -94,7 +99,6 @@ class TestBrowserCLIInit:
|
||||
b.command("custom.command")
|
||||
mock_send.assert_called_once_with("custom.command", {}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
# ── Internal factories ────────────────────────────────────────────────────────
|
||||
|
||||
class TestMakeTab:
|
||||
@@ -144,12 +148,11 @@ class TestMakeGroup:
|
||||
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")
|
||||
b.nav.open("https://example.com")
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.open",
|
||||
{"url": "https://example.com", "background": False, "window": None, "group": None},
|
||||
@@ -157,18 +160,18 @@ class TestNavigation:
|
||||
)
|
||||
|
||||
def test_open_background(self, b, mock_send):
|
||||
b.open("https://example.com", background=True)
|
||||
b.nav.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")
|
||||
b.nav.open("https://x.com", group="Work")
|
||||
assert mock_send.call_args[0][1]["group"] == "Work"
|
||||
|
||||
def test_open_tab_returns_bound_tab(self, b, mock_send):
|
||||
def test_tabs_open_returns_bound_tab(self, b, mock_send):
|
||||
mock_send.return_value = {"id": 123, "url": "https://example.com"}
|
||||
|
||||
tab = b.open_tab("https://example.com", background=True)
|
||||
tab = b.tabs.open("https://example.com", background=True)
|
||||
|
||||
assert tab.id == 123
|
||||
assert tab.url == "https://example.com"
|
||||
@@ -181,10 +184,10 @@ class TestNavigation:
|
||||
key=None,
|
||||
)
|
||||
|
||||
def test_open_tab_wait_uses_open_wait(self, b, mock_send):
|
||||
def test_tabs_open_wait_uses_open_wait(self, b, mock_send):
|
||||
mock_send.return_value = TAB_DATA
|
||||
|
||||
tab = b.open_tab("https://example.com", wait=True, timeout=1.5)
|
||||
tab = b.tabs.open("https://example.com", wait=True, timeout=1.5)
|
||||
|
||||
assert tab.id == 10
|
||||
mock_send.assert_called_once_with(
|
||||
@@ -195,185 +198,199 @@ class TestNavigation:
|
||||
key=None,
|
||||
)
|
||||
|
||||
def test_open_tab_unexpected_response_raises(self, b, mock_send):
|
||||
def test_tabs_open_unexpected_response_raises(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
with pytest.raises(RuntimeError, match="navigate.open returned unexpected data"):
|
||||
b.open_tab("https://example.com")
|
||||
b.tabs.open("https://example.com")
|
||||
|
||||
def test_nav_open_wait_unexpected_response_raises(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
with pytest.raises(RuntimeError, match="navigate.open_wait returned unexpected data"):
|
||||
b.nav.open_wait("https://example.com")
|
||||
|
||||
def test_reload(self, b, mock_send):
|
||||
b.reload(tab_id=5)
|
||||
b.nav.reload(tab_id=5)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_hard_reload(self, b, mock_send):
|
||||
b.hard_reload(tab_id=7)
|
||||
b.nav.hard_reload(tab_id=7)
|
||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_back(self, b, mock_send):
|
||||
b.back(tab_id=3)
|
||||
b.nav.back(tab_id=3)
|
||||
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_forward(self, b, mock_send):
|
||||
b.forward(tab_id=3)
|
||||
b.nav.forward(tab_id=3)
|
||||
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_focus_url(self, b, mock_send):
|
||||
b.focus_url("github.com")
|
||||
def test_focus(self, b, mock_send):
|
||||
b.nav.focus("github.com")
|
||||
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_navigate_tab(self, b, mock_send):
|
||||
b.navigate_tab(5, "https://example.com")
|
||||
def test_nav_to(self, b, mock_send):
|
||||
b.nav.to(5, "https://example.com")
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_profile_forwarded(self, b_profile, mock_send):
|
||||
b_profile.reload()
|
||||
b_profile.nav.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, key=None)
|
||||
|
||||
def test_remote_forwarded(self, mock_send):
|
||||
b = BrowserCLI(browser="work", remote="host:8765", key=None)
|
||||
b.reload()
|
||||
b.nav.reload()
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="work", remote="host:8765", key=None)
|
||||
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSearch:
|
||||
def test_known_engine_builds_url(self, b, mock_send):
|
||||
b.search("google", "python asyncio")
|
||||
b.nav.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"]
|
||||
assert "python+asyncio" in call_args[1]["url"] or "python%20asyncio" in call_args[1]["url"]
|
||||
|
||||
def test_unknown_engine_raises(self, b):
|
||||
with pytest.raises(ValueError, match="Unknown search engine"):
|
||||
b.search("nonexistent", "query")
|
||||
b.nav.search("nonexistent", "query")
|
||||
|
||||
def test_search_alias_ddg(self, b, mock_send):
|
||||
b.search("ddg", "hello")
|
||||
b.nav.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)
|
||||
b.nav.search("bing", "test", background=True)
|
||||
assert mock_send.call_args[0][1]["background"] is True
|
||||
|
||||
# ── Extract ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestExtract:
|
||||
def test_extract_markdown_default(self, b, mock_send):
|
||||
mock_send.return_value = "# Title"
|
||||
|
||||
result = b.extract_markdown()
|
||||
result = b.extract.markdown()
|
||||
|
||||
assert result == "# Title"
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_markdown_selector(self, b, mock_send):
|
||||
b.extract_markdown("article")
|
||||
mock_send.return_value = "## Post"
|
||||
|
||||
assert b.extract.markdown("article") == "## Post"
|
||||
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_links(self, b, mock_send):
|
||||
mock_send.return_value = [{"href": "https://x"}]
|
||||
assert b.extract.links() == [{"href": "https://x"}]
|
||||
mock_send.assert_called_once_with("extract.links", {}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_text_none(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
assert b.extract.text() == ""
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTabs:
|
||||
def test_tabs_list_returns_tab_objects(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = b.tabs_list()
|
||||
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() == []
|
||||
assert b.tabs.list() == []
|
||||
|
||||
def test_tabs_list_none_response(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
assert b.tabs_list() == []
|
||||
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
|
||||
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
|
||||
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)
|
||||
b.tabs.close(tab_id=10)
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.close",
|
||||
{"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False},
|
||||
{"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"},
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_close_by_ids(self, b, mock_send):
|
||||
mock_send.return_value = {"closed": 3}
|
||||
assert b.tabs_close(tab_ids=[10, 20, 30]) == 3
|
||||
assert b.tabs.close(tab_ids=[10, 20, 30]) == 3
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.close",
|
||||
{"tabId": None, "tabIds": [10, 20, 30], "inactive": False, "duplicates": False},
|
||||
{"tabId": None, "tabIds": [10, 20, 30], "inactive": False, "duplicates": False, "gentleMode": "auto"},
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_close_by_ids_accepts_tab_objects(self, b, mock_send):
|
||||
tabs = [b._make_tab({**TAB_DATA, "id": 10}), b._make_tab({**TAB_DATA, "id": 20})]
|
||||
mock_send.return_value = {"closed": 2}
|
||||
assert b.tabs_close(tab_ids=tabs) == 2
|
||||
assert b.tabs.close(tab_ids=tabs) == 2
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.close",
|
||||
{"tabId": None, "tabIds": [10, 20], "inactive": False, "duplicates": False},
|
||||
{"tabId": None, "tabIds": [10, 20], "inactive": False, "duplicates": False, "gentleMode": "auto"},
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_move(self, b, mock_send):
|
||||
b.tabs_move(10, forward=True)
|
||||
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, key=None,
|
||||
)
|
||||
|
||||
def test_tabs_active(self, b, mock_send):
|
||||
b.tabs_active(10)
|
||||
def test_tabs_activate(self, b, mock_send):
|
||||
b.tabs.activate(10)
|
||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_window_active_tab(self, b, mock_send):
|
||||
def test_tabs_active_in_window(self, b, mock_send):
|
||||
mock_send.return_value = TAB_DATA
|
||||
tab = b.window_active_tab(1)
|
||||
tab = b.tabs.active_in_window(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, key=None)
|
||||
|
||||
def test_window_active_tab_missing_raises(self, b, mock_send):
|
||||
def test_tabs_active_in_window_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)
|
||||
b.tabs.active_in_window(1)
|
||||
|
||||
def test_tabs_filter(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = b.tabs_filter("example")
|
||||
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") == []
|
||||
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)
|
||||
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])
|
||||
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
|
||||
assert b.tabs.count() == 5
|
||||
|
||||
def test_tabs_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send):
|
||||
with patch(
|
||||
@@ -389,7 +406,7 @@ class TestTabs:
|
||||
None,
|
||||
]
|
||||
|
||||
tabs = b.tabs_list()
|
||||
tabs = b.tabs.list()
|
||||
tabs[1].close()
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["uuid-1", "work"]
|
||||
@@ -410,7 +427,7 @@ class TestTabs:
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
):
|
||||
mock_send.side_effect = [[TAB_DATA], None]
|
||||
tabs = b.tabs_list()
|
||||
tabs = b.tabs.list()
|
||||
tabs[0].close()
|
||||
|
||||
assert [tab.browser for tab in tabs] == ["host:work"]
|
||||
@@ -426,7 +443,7 @@ class TestTabs:
|
||||
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
|
||||
):
|
||||
mock_send.side_effect = [[TAB_DATA], None]
|
||||
tabs = b.tabs_list()
|
||||
tabs = b.tabs.list()
|
||||
tabs[0].close()
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
@@ -434,11 +451,11 @@ class TestTabs:
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_alias_and_active_tab(self, b, mock_send):
|
||||
def test_tabs_active_returns_active_tab(self, b, mock_send):
|
||||
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
|
||||
|
||||
tabs = b.tabs()
|
||||
active = b.active_tab()
|
||||
tabs = b.tabs.list()
|
||||
active = b.tabs.active()
|
||||
|
||||
assert tabs[0].id == 10
|
||||
assert active.id == 10
|
||||
@@ -447,14 +464,14 @@ class TestTabs:
|
||||
call("tabs.status", {"tabId": None}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tab_find_and_close_helpers(self, b, mock_send):
|
||||
def test_tabs_get_first_and_close_helpers(self, b, mock_send):
|
||||
mock_send.side_effect = [TAB_DATA, [TAB_DATA], [], {"closed": 1}, {"closed": 1}]
|
||||
|
||||
tab = b.tab(10)
|
||||
found = b.find_tab("Example")
|
||||
missing = b.find_tab("Missing")
|
||||
closed_by_object = b.close_tab(tab)
|
||||
closed_by_id = b.close_tab(10)
|
||||
tab = b.tabs.get(10)
|
||||
found = b.tabs.first("Example")
|
||||
missing = b.tabs.first("Missing")
|
||||
closed_by_object = b.tabs.close(tab.id)
|
||||
closed_by_id = b.tabs.close(10)
|
||||
|
||||
assert tab.id == 10
|
||||
assert found and found.id == 10
|
||||
@@ -465,18 +482,10 @@ class TestTabs:
|
||||
call("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None),
|
||||
call("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None),
|
||||
call("tabs.query", {"search": "Missing"}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", {"tabId": 10, "tabIds": None, "inactive": False, "duplicates": False, "gentleMode": "auto"}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_find_tabs_alias(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
|
||||
tabs = b.find_tabs("Example")
|
||||
|
||||
assert [tab.id for tab in tabs] == [10]
|
||||
mock_send.assert_called_once_with("tabs.query", {"search": "Example"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
@@ -486,7 +495,7 @@ class TestTabs:
|
||||
],
|
||||
):
|
||||
mock_send.side_effect = [3, 4]
|
||||
result = b.tabs_count("github")
|
||||
result = b.tabs.count("github")
|
||||
|
||||
assert result == BrowserCounts(total=7, by_browser={"uuid-1": 3, "work": 4})
|
||||
assert mock_send.call_args_list == [
|
||||
@@ -496,65 +505,81 @@ class TestTabs:
|
||||
|
||||
def test_tabs_query(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
result = b.tabs_query("example")
|
||||
result = b.tabs.query("example")
|
||||
assert isinstance(result[0], Tab)
|
||||
mock_send.assert_called_once_with("tabs.query", {"search": "example"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs_html(self, b, mock_send):
|
||||
mock_send.return_value = "<html></html>"
|
||||
assert b.tabs_html(10) == "<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() == ""
|
||||
assert b.tabs.html() == ""
|
||||
|
||||
def test_tabs_dedupe(self, b, mock_send):
|
||||
mock_send.return_value = {"closed": 2}
|
||||
assert b.tabs_dedupe() == 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
|
||||
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, key=None)
|
||||
b.tabs.sort(by="title")
|
||||
mock_send.assert_called_once_with("tabs.sort", {"by": "title", "gentleMode": "auto"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_tabs_merge_windows(self, b, mock_send):
|
||||
mock_send.return_value = {"moved": 4}
|
||||
assert b.tabs_merge_windows() == 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
|
||||
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
|
||||
assert b.tabs.close_duplicates() == 1
|
||||
|
||||
def test_tabs_mute_pin_return_tab_id(self, b, mock_send):
|
||||
mock_send.side_effect = [{"tabId": 10}, None]
|
||||
assert b.tabs.mute(10) == 10
|
||||
assert b.tabs.pin() == 0
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.mute", {"tabId": 10}, profile=None, remote=None, key=None),
|
||||
call("tabs.pin", {"tabId": None}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tabs_screenshot(self, b, mock_send):
|
||||
mock_send.return_value = {"dataUrl": "data:image/png;base64,abc"}
|
||||
assert b.tabs.screenshot(10) == "data:image/png;base64,abc"
|
||||
mock_send.assert_called_once_with(
|
||||
"tabs.screenshot", {"tabId": 10, "format": "png", "quality": None}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
# ── Groups ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGroups:
|
||||
def test_group_list_returns_group_objects(self, b, mock_send):
|
||||
mock_send.return_value = [GROUP_DATA]
|
||||
groups = b.group_list()
|
||||
groups = b.groups.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() == []
|
||||
assert b.groups.list() == []
|
||||
|
||||
def test_group_tabs(self, b, mock_send):
|
||||
mock_send.return_value = [TAB_DATA]
|
||||
tabs = b.group_tabs(42)
|
||||
tabs = b.groups.tabs(42)
|
||||
assert isinstance(tabs[0], Tab)
|
||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_group_count(self, b, mock_send):
|
||||
mock_send.return_value = 7
|
||||
assert b.group_count() == 7
|
||||
assert b.groups.count() == 7
|
||||
|
||||
def test_group_list_multi_browser_annotates_browser_and_binds_actions(self, b, mock_send):
|
||||
with patch(
|
||||
@@ -570,7 +595,7 @@ class TestGroups:
|
||||
None,
|
||||
]
|
||||
|
||||
groups = b.group_list()
|
||||
groups = b.groups.list()
|
||||
groups[1].close()
|
||||
|
||||
assert [group.browser for group in groups] == ["uuid-1", "work"]
|
||||
@@ -591,7 +616,7 @@ class TestGroups:
|
||||
return_value=[BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
):
|
||||
mock_send.side_effect = [[GROUP_DATA], None]
|
||||
groups = b.group_list()
|
||||
groups = b.groups.list()
|
||||
groups[0].close()
|
||||
|
||||
assert [group.browser for group in groups] == ["host:work"]
|
||||
@@ -607,7 +632,7 @@ class TestGroups:
|
||||
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
|
||||
):
|
||||
mock_send.side_effect = [[GROUP_DATA], None]
|
||||
groups = b.group_list()
|
||||
groups = b.groups.list()
|
||||
groups[0].close()
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
@@ -615,25 +640,6 @@ class TestGroups:
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_group_aliases(self, b, mock_send):
|
||||
mock_send.side_effect = [[GROUP_DATA], [GROUP_DATA], 1, [GROUP_DATA], GROUP_DATA, GROUP_DATA]
|
||||
|
||||
assert b.groups()[0].id == 42
|
||||
assert b.groups_list()[0].id == 42
|
||||
assert b.groups_count() == 1
|
||||
assert b.groups_query("Work")[0].id == 42
|
||||
assert b.groups_create("Work").id == 42
|
||||
assert b.group_open("Work").id == 42
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile=None, remote=None, key=None),
|
||||
call("group.list", {}, profile=None, remote=None, key=None),
|
||||
call("group.count", {}, profile=None, remote=None, key=None),
|
||||
call("group.query", {"search": "Work"}, profile=None, remote=None, key=None),
|
||||
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
|
||||
call("group.open", {"name": "Work"}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||
with patch(
|
||||
"browser_cli.active_browser_targets",
|
||||
@@ -643,35 +649,36 @@ class TestGroups:
|
||||
],
|
||||
):
|
||||
mock_send.side_effect = [2, 5]
|
||||
result = b.group_count()
|
||||
result = b.groups.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")
|
||||
groups = b.groups.query("Work")
|
||||
assert isinstance(groups[0], Group)
|
||||
mock_send.assert_called_once_with("group.query", {"search": "Work"}, profile=None, remote=None, key=None)
|
||||
|
||||
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, key=None)
|
||||
b.groups.close(42)
|
||||
mock_send.assert_called_once_with("group.close", {"groupId": 42, "gentleMode": "auto"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_group_create_dict_response(self, b, mock_send):
|
||||
mock_send.return_value = GROUP_DATA
|
||||
group = b.group_create("Work")
|
||||
group = b.groups.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")
|
||||
group = b.groups.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")
|
||||
tab_id = b.groups.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, key=None
|
||||
@@ -679,15 +686,14 @@ class TestGroups:
|
||||
|
||||
def test_group_add_tab_non_dict_response(self, b, mock_send):
|
||||
mock_send.return_value = 55
|
||||
assert b.group_add_tab(42) == 55
|
||||
assert b.groups.add_tab(42) == 55
|
||||
|
||||
def test_group_move_forward(self, b, mock_send):
|
||||
b.group_move(42, forward=True)
|
||||
b.groups.move(42, forward=True)
|
||||
mock_send.assert_called_once_with(
|
||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
|
||||
class TestWindows:
|
||||
def test_windows_list_multi_browser_adds_browser(self, b, mock_send):
|
||||
with patch(
|
||||
@@ -701,7 +707,7 @@ class TestWindows:
|
||||
[{"id": 1, "tabCount": 2, "state": "normal"}],
|
||||
[{"id": 2, "tabCount": 3, "state": "maximized"}],
|
||||
]
|
||||
result = b.windows_list()
|
||||
result = b.windows.list()
|
||||
|
||||
assert result == [
|
||||
{"id": 1, "tabCount": 2, "state": "normal", "browser": "uuid-1"},
|
||||
@@ -711,7 +717,7 @@ class TestWindows:
|
||||
def test_windows_open_without_url(self, b, mock_send):
|
||||
mock_send.return_value = {"id": 5}
|
||||
|
||||
result = b.windows_open()
|
||||
result = b.windows.open()
|
||||
|
||||
assert result == {"id": 5}
|
||||
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, key=None)
|
||||
@@ -719,17 +725,32 @@ class TestWindows:
|
||||
def test_windows_open_with_url(self, b, mock_send):
|
||||
mock_send.return_value = {"id": 9}
|
||||
|
||||
result = b.windows_open("https://example.com")
|
||||
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, key=None)
|
||||
|
||||
class TestDom:
|
||||
def test_dom_click(self, b, mock_send):
|
||||
b.dom.click("#go")
|
||||
mock_send.assert_called_once_with("dom.click", {"selector": "#go"}, profile=None, remote=None, key=None)
|
||||
|
||||
class TestDomConvenience:
|
||||
def test_wait_for_selector_alias(self, b, mock_send):
|
||||
def test_dom_type(self, b, mock_send):
|
||||
b.dom.type("#in", "hello")
|
||||
mock_send.assert_called_once_with("dom.type", {"selector": "#in", "text": "hello"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_dom_query_none(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
assert b.dom.query("a") == []
|
||||
|
||||
def test_dom_exists_falsy(self, b, mock_send):
|
||||
mock_send.return_value = None
|
||||
assert b.dom.exists("a") is False
|
||||
|
||||
def test_dom_wait_for(self, b, mock_send):
|
||||
mock_send.return_value = {"selector": "#done", "found": True}
|
||||
|
||||
result = b.wait_for_selector("#done", timeout=2.5, visible=True, tab_id=10)
|
||||
result = b.dom.wait_for("#done", timeout=2.5, visible=True, tab_id=10)
|
||||
|
||||
assert result == {"selector": "#done", "found": True}
|
||||
mock_send.assert_called_once_with(
|
||||
@@ -740,12 +761,78 @@ class TestDomConvenience:
|
||||
key=None,
|
||||
)
|
||||
|
||||
def test_dom_eval(self, b, mock_send):
|
||||
mock_send.return_value = 42
|
||||
assert b.dom.eval("1+41", tab_id=3) == 42
|
||||
mock_send.assert_called_once_with("dom.eval", {"code": "1+41", "tabId": 3}, profile=None, remote=None, key=None)
|
||||
|
||||
class TestPageStorageCookies:
|
||||
def test_page_info(self, b, mock_send):
|
||||
mock_send.return_value = {"title": "X"}
|
||||
assert b.page.info() == {"title": "X"}
|
||||
mock_send.assert_called_once_with("page.info", {}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_storage_get(self, b, mock_send):
|
||||
mock_send.return_value = "v"
|
||||
assert b.storage.get("k") == "v"
|
||||
mock_send.assert_called_once_with("storage.get", {"key": "k", "type": "local", "tabId": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_storage_set(self, b, mock_send):
|
||||
b.storage.set("k", "v", type="session")
|
||||
mock_send.assert_called_once_with(
|
||||
"storage.set", {"key": "k", "value": "v", "type": "session", "tabId": None}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_cookies_list(self, b, mock_send):
|
||||
mock_send.return_value = [{"name": "c"}]
|
||||
assert b.cookies.list(domain="example.com") == [{"name": "c"}]
|
||||
mock_send.assert_called_once_with(
|
||||
"cookies.list", {"url": None, "domain": "example.com", "name": None}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
class TestPerf:
|
||||
def test_perf_status(self, b, mock_send):
|
||||
mock_send.return_value = {"profile": "auto"}
|
||||
assert b.perf.status() == {"profile": "auto"}
|
||||
mock_send.assert_called_once_with("perf.status", {}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_perf_set_profile(self, b, mock_send):
|
||||
mock_send.return_value = {"profile": "gentle"}
|
||||
assert b.perf.set_profile("gentle") == {"profile": "gentle"}
|
||||
mock_send.assert_called_once_with("perf.set_profile", {"profile": "gentle"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_perf_job_status(self, b, mock_send):
|
||||
mock_send.return_value = {"phase": "done"}
|
||||
assert b.perf.job_status("j1") == {"phase": "done"}
|
||||
mock_send.assert_called_once_with("jobs.status", {"jobId": "j1"}, profile=None, remote=None, key=None)
|
||||
|
||||
class TestExtension:
|
||||
def test_extension_reload(self, b, mock_send):
|
||||
b.extension.reload()
|
||||
mock_send.assert_called_once_with("extension.reload", {}, profile=None, remote=None, key=None)
|
||||
|
||||
class TestSession:
|
||||
def test_session_save(self, b, mock_send):
|
||||
b.session.save("work")
|
||||
mock_send.assert_called_once_with("session.save", {"name": "work"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_session_load(self, b, mock_send):
|
||||
b.session.load("work")
|
||||
mock_send.assert_called_once_with(
|
||||
"session.load",
|
||||
{"name": "work", "gentleMode": "auto", "discardBackgroundTabs": False, "lazy": False, "eagerTabs": 10},
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
def test_session_load_background(self, b, mock_send):
|
||||
mock_send.return_value = {"jobId": "j1"}
|
||||
assert b.session.load_background("work") == {"jobId": "j1"}
|
||||
assert mock_send.call_args[0][1]["__background"] is True
|
||||
|
||||
def test_session_list(self, b, mock_send):
|
||||
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
||||
|
||||
result = b.session_list()
|
||||
result = b.session.list()
|
||||
|
||||
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
||||
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, key=None)
|
||||
@@ -762,7 +849,7 @@ class TestSession:
|
||||
[{"name": "first", "tabs": 2, "savedAt": 1712707200000}],
|
||||
[{"name": "second", "tabs": 5, "savedAt": 1712707300000}],
|
||||
]
|
||||
result = b.session_list()
|
||||
result = b.session.list()
|
||||
|
||||
assert result == [
|
||||
{"name": "first", "tabs": 2, "savedAt": 1712707200000, "browser": "uuid-1"},
|
||||
@@ -863,7 +950,6 @@ class TestTabModel:
|
||||
with pytest.raises(RuntimeError, match="not bound"):
|
||||
tab.close()
|
||||
|
||||
|
||||
# ── Group model ───────────────────────────────────────────────────────────────
|
||||
|
||||
class TestGroupModel:
|
||||
|
||||
+37
-67
@@ -79,7 +79,6 @@ def test_install_help_lists_supported_browsers():
|
||||
assert result.exit_code == 0
|
||||
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
|
||||
|
||||
|
||||
def test_install_windows_registers_native_host(tmp_path):
|
||||
writes = []
|
||||
|
||||
@@ -125,7 +124,6 @@ def test_write_native_host_exe_unix(tmp_path):
|
||||
assert "from browser_cli.native_host import main" in content
|
||||
assert host.stat().st_mode & 0o111 # executable bit set
|
||||
|
||||
|
||||
def test_write_native_host_exe_windows(tmp_path):
|
||||
from browser_cli.cli import _write_native_host_exe
|
||||
|
||||
@@ -138,7 +136,6 @@ def test_write_native_host_exe_windows(tmp_path):
|
||||
assert "@echo off" in content
|
||||
assert "browser_cli.native_host" in content
|
||||
|
||||
|
||||
def test_clients_exits_cleanly_when_registry_is_missing():
|
||||
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
|
||||
"browser_cli.cli.active_browser_targets", return_value=[]
|
||||
@@ -148,12 +145,11 @@ def test_clients_exits_cleanly_when_registry_is_missing():
|
||||
assert result.exit_code == 1
|
||||
assert "No browser clients found" in result.output
|
||||
|
||||
|
||||
def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text('{"main": "/tmp/.browser_cli/main.sock"}"}', encoding="utf-8")
|
||||
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
assert profile == "main"
|
||||
return [{"profile": "main", "name": "Chrome", "version": "1", "extensionVersion": "0.8.2"}]
|
||||
@@ -185,7 +181,6 @@ def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||
assert "Chrome" in result.output
|
||||
assert "2.3.4" in result.output
|
||||
|
||||
|
||||
def test_clients_remote_respects_global_browser_route():
|
||||
with patch.dict(os.environ, {}, clear=True), patch("browser_cli.cli.send_command", return_value=[]) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--browser", "work", "clients"])
|
||||
@@ -193,7 +188,6 @@ def test_clients_remote_respects_global_browser_route():
|
||||
assert result.exit_code == 1
|
||||
send_command.assert_called_once_with("clients.list", profile="work", remote="127.0.0.1:8765", key=None)
|
||||
|
||||
|
||||
def test_clients_browser_alias_resolves_to_remote():
|
||||
"""--browser <host> without --remote resolves the alias, fetches all targets from that remote,
|
||||
and shows only clients from that host (not local profiles)."""
|
||||
@@ -225,7 +219,6 @@ def test_clients_browser_alias_resolves_to_remote():
|
||||
assert "Chrome" in result.output
|
||||
assert "0.8.5" in result.output
|
||||
|
||||
|
||||
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock"
|
||||
@@ -240,7 +233,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
"work": [{"profile": "default", "name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}],
|
||||
}
|
||||
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
||||
assert command == "clients.list"
|
||||
return responses[profile]
|
||||
|
||||
@@ -255,19 +248,18 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||
assert "Extension Version" in result.output
|
||||
assert "2.3.4" in result.output
|
||||
|
||||
|
||||
def test_tabs_list_multi_browser_shows_browser_column():
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
assert command == "tabs.list"
|
||||
return [{"id": 1 if profile == "default" else 2, "windowId": 1, "active": True, "title": profile, "url": "https://example.com"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "550e8400-e29b-41d4-a716-446655440000", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -275,16 +267,15 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
||||
assert "550e8400-e29b-41d4-a716-446655440000" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
|
||||
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||
), patch(
|
||||
"browser_cli.commands.remote_browser_targets",
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765")],
|
||||
), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
"browser_cli.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "tabs", "list"])
|
||||
@@ -292,26 +283,24 @@ def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||
assert result.exit_code == 0
|
||||
assert "remote-host:work" in result.output
|
||||
assert "Remote" in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765")
|
||||
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", key=None)
|
||||
|
||||
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
"browser_cli.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Example", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Browser" not in result.output
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile=None)
|
||||
|
||||
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote=None, key=None)
|
||||
|
||||
def test_tabs_count_multi_browser_shows_total():
|
||||
counts = {"default": 3, "work": 4}
|
||||
@@ -322,12 +311,12 @@ def test_tabs_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["tabs", "count", "github"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -335,7 +324,6 @@ def test_tabs_count_multi_browser_shows_total():
|
||||
assert "Total" in result.output
|
||||
assert "7" in result.output
|
||||
|
||||
|
||||
def test_group_count_multi_browser_shows_total():
|
||||
counts = {"default": 1, "work": 2}
|
||||
|
||||
@@ -344,12 +332,12 @@ def test_group_count_multi_browser_shows_total():
|
||||
return counts[profile]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["groups", "count"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -357,10 +345,9 @@ def test_group_count_multi_browser_shows_total():
|
||||
assert "Total" in result.output
|
||||
assert "3" in result.output
|
||||
|
||||
|
||||
def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
with patch(
|
||||
"browser_cli.commands.send_command",
|
||||
"browser_cli.send_command",
|
||||
return_value=[{"id": 42, "title": "", "color": "grey", "collapsed": False, "tabCount": 1}],
|
||||
):
|
||||
result = CliRunner().invoke(main, ["groups", "list"])
|
||||
@@ -370,41 +357,38 @@ def test_group_list_leaves_unnamed_group_cell_empty():
|
||||
assert "42" in result.output
|
||||
assert "grey" in result.output
|
||||
|
||||
|
||||
def test_tabs_move_accepts_right_short_alias():
|
||||
with patch("browser_cli.commands.send_command") as send_command:
|
||||
with patch("browser_cli.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "move", "12", "-r"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with(
|
||||
"tabs.move",
|
||||
{"tabId": 12, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||
profile=None,
|
||||
profile=None, remote=None, key=None,
|
||||
)
|
||||
|
||||
|
||||
def test_groups_move_accepts_left_short_alias():
|
||||
with patch("browser_cli.commands.send_command") as send_command:
|
||||
with patch("browser_cli.send_command") as send_command:
|
||||
result = CliRunner().invoke(main, ["groups", "move", "research", "-l"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_called_once_with(
|
||||
"group.move", {"group": "research", "forward": False, "backward": True}, profile=None
|
||||
"group.move", {"group": "research", "forward": False, "backward": True}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
|
||||
def test_windows_list_multi_browser_shows_browser_column():
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
assert command == "windows.list"
|
||||
return [{"id": 1, "alias": profile, "focused": True, "tabCount": 2, "state": "normal"}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["windows", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -413,19 +397,18 @@ def test_windows_list_multi_browser_shows_browser_column():
|
||||
assert "uuid-1" in result.output
|
||||
assert "work" in result.output
|
||||
|
||||
|
||||
def test_session_list_multi_browser_shows_browser_column():
|
||||
def fake_send_command(command, args=None, profile=None):
|
||||
assert command == "session.list"
|
||||
return [{"name": f"{profile}-session", "tabs": 2, "savedAt": 1712707200000}]
|
||||
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch("browser_cli.commands.send_command", side_effect=fake_send_command):
|
||||
), patch("browser_cli.send_command", side_effect=fake_send_command):
|
||||
result = CliRunner().invoke(main, ["session", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -435,59 +418,54 @@ def test_session_list_multi_browser_shows_browser_column():
|
||||
assert "default-session" in result.output
|
||||
assert "work-session" in result.output
|
||||
|
||||
|
||||
def test_session_list_with_explicit_browser_does_not_show_browser_column():
|
||||
with patch(
|
||||
"browser_cli.commands.active_browser_targets",
|
||||
"browser_cli.active_browser_targets",
|
||||
return_value=[
|
||||
BrowserTarget("default", "uuid-1", "/tmp/default.sock"),
|
||||
BrowserTarget("work", "work", "/tmp/work.sock"),
|
||||
],
|
||||
), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
"browser_cli.send_command",
|
||||
return_value=[{"name": "work-session", "tabs": 2, "savedAt": 1712707200000}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["--browser", "work", "session", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Browser" not in result.output
|
||||
send_command.assert_called_once_with("session.list", {}, profile=None)
|
||||
|
||||
send_command.assert_called_once_with("session.list", {}, profile="work", remote=None, key=None)
|
||||
|
||||
def test_windows_open_passes_url():
|
||||
with patch("browser_cli.commands.send_command", return_value={"id": 7}) as send_command:
|
||||
with patch("browser_cli.send_command", return_value={"id": 7}) as send_command:
|
||||
result = CliRunner().invoke(main, ["windows", "open", "https://example.com"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "https://example.com" in result.output
|
||||
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
||||
send_command.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_markdown_command():
|
||||
with patch("browser_cli.commands.send_command", return_value="# Title") as send_command:
|
||||
with patch("browser_cli.send_command", return_value="# Title") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "# Title\n"
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_extract_markdown_command_with_selector():
|
||||
with patch("browser_cli.commands.send_command", return_value="## Post") as send_command:
|
||||
with patch("browser_cli.send_command", return_value="## Post") as send_command:
|
||||
result = CliRunner().invoke(main, ["extract", "markdown", "--selector", "article"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert result.output == "## Post\n"
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
|
||||
|
||||
send_command.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_clean_markdown_output_removes_escaped_underscores_and_dashes():
|
||||
assert _clean_markdown_output(r"hello\_world \- item") == "hello_world - item"
|
||||
|
||||
|
||||
def test_clean_markdown_output_trims_useless_whitespace():
|
||||
raw = " # Title \n\n\n paragraph with space \n next line\t \n"
|
||||
assert _clean_markdown_output(raw) == "# Title\n\nparagraph with space\nnext line"
|
||||
|
||||
|
||||
def test_clean_markdown_output_repairs_empty_table_header_rows():
|
||||
raw = (
|
||||
"| | | |\n"
|
||||
@@ -501,12 +479,10 @@ def test_clean_markdown_output_repairs_empty_table_header_rows():
|
||||
"| A | B | C |"
|
||||
)
|
||||
|
||||
|
||||
def test_clean_markdown_output_preserves_graph_code_blocks():
|
||||
raw = "```\n\nA\n │\n ▼\nB\n\n```"
|
||||
assert _clean_markdown_output(raw) == "```\nA\n │\n ▼\nB\n```"
|
||||
|
||||
|
||||
def test_clean_markdown_output_renders_code_block_list_branches():
|
||||
raw = "```\nPlattformen\n- Omnifact\n- Open WebUI + Ollama\n- Le Chat\n```"
|
||||
assert _clean_markdown_output(raw) == (
|
||||
@@ -518,7 +494,6 @@ def test_clean_markdown_output_renders_code_block_list_branches():
|
||||
"```"
|
||||
)
|
||||
|
||||
|
||||
def test_clean_markdown_output_unflattens_graph_code_blocks():
|
||||
raw = (
|
||||
"```\n"
|
||||
@@ -550,7 +525,6 @@ def test_clean_markdown_output_unflattens_graph_code_blocks():
|
||||
"```"
|
||||
)
|
||||
|
||||
|
||||
def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
||||
raw = (
|
||||
"| | | |\n"
|
||||
@@ -561,7 +535,7 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
||||
"Golden Set │ ▼Promptfoo(Testausführung) │ ▼Plattformen├ Omnifact└ Le Chat\n"
|
||||
"```"
|
||||
)
|
||||
with patch("browser_cli.commands.send_command", return_value=raw):
|
||||
with patch("browser_cli.send_command", return_value=raw):
|
||||
result = CliRunner().invoke(main, ["extract", "markdown"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
@@ -571,7 +545,6 @@ def test_extract_markdown_command_repairs_malformed_tables_and_code_blocks():
|
||||
assert "├ Omnifact" in result.output
|
||||
assert "└ Le Chat" in result.output
|
||||
|
||||
|
||||
def test_convert_html_to_markdown_normalizes_blank_table_header_rows():
|
||||
html = """
|
||||
<main>
|
||||
@@ -586,7 +559,6 @@ def test_convert_html_to_markdown_normalizes_blank_table_header_rows():
|
||||
assert "| Risiko | Beschreibung | Auswirkung | Gegenmaßnahme |" in markdown
|
||||
assert "| | | | |" not in markdown
|
||||
|
||||
|
||||
def test_convert_html_to_markdown_preserves_codemirror_graph_blocks():
|
||||
html = """
|
||||
<main>
|
||||
@@ -611,7 +583,6 @@ def test_convert_html_to_markdown_preserves_codemirror_graph_blocks():
|
||||
assert "├ Omnifact" in markdown
|
||||
assert "└ Le Chat" in markdown
|
||||
|
||||
|
||||
def test_convert_html_to_markdown_indents_multiline_list_items():
|
||||
html = """
|
||||
<main>
|
||||
@@ -629,7 +600,6 @@ def test_convert_html_to_markdown_indents_multiline_list_items():
|
||||
" Local LLMs / API Modelle / Spezialmodelle"
|
||||
) in markdown
|
||||
|
||||
|
||||
def test_tabs_list_multi_browser_queries_remote_target():
|
||||
endpoint = "browser-host.example:8765"
|
||||
remote_target = BrowserTarget(
|
||||
@@ -639,12 +609,12 @@ def test_tabs_list_multi_browser_queries_remote_target():
|
||||
remote=endpoint,
|
||||
)
|
||||
|
||||
with patch("browser_cli.commands.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.commands.send_command",
|
||||
with patch("browser_cli.active_browser_targets", return_value=[remote_target, BrowserTarget("local", "local", "/tmp/local.sock")]), patch(
|
||||
"browser_cli.send_command",
|
||||
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||
) as send_command:
|
||||
result = CliRunner().invoke(main, ["tabs", "list"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint)
|
||||
send_command.assert_any_call("tabs.list", {}, profile="work", remote=endpoint, key=None)
|
||||
assert "browser-host.example:work" in result.output
|
||||
|
||||
+114
-138
@@ -13,27 +13,16 @@ from click.testing import CliRunner
|
||||
|
||||
def _run(group, args, return_value):
|
||||
"""Invoke a Click group/command with a mocked send_command return value."""
|
||||
with patch("browser_cli.commands.send_command", return_value=return_value):
|
||||
with patch("browser_cli.send_command", return_value=return_value):
|
||||
result = CliRunner().invoke(group, args)
|
||||
return result
|
||||
|
||||
def _run_error(group, args, exc):
|
||||
"""Invoke a Click group/command where send_command raises exc."""
|
||||
with patch("browser_cli.commands.send_command", side_effect=exc):
|
||||
with patch("browser_cli.send_command", side_effect=exc):
|
||||
result = CliRunner().invoke(group, args)
|
||||
return result
|
||||
|
||||
def _run_no_multi(group, args, return_value, module_path=None):
|
||||
"""Invoke a command patching send_command AND _multi_browser_targets → single-browser mode."""
|
||||
ctx1 = patch("browser_cli.commands.send_command", return_value=return_value)
|
||||
if module_path:
|
||||
ctx2 = patch(f"{module_path}._multi_browser_targets", return_value=[])
|
||||
with ctx1, ctx2:
|
||||
return CliRunner().invoke(group, args)
|
||||
else:
|
||||
with ctx1:
|
||||
return CliRunner().invoke(group, args)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dom commands
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -419,7 +408,7 @@ def test_cli_nav_open_wait_no_title():
|
||||
assert "Loaded" in result.output
|
||||
|
||||
def test_cli_nav_wait():
|
||||
result = _run(nav_group, ["wait"], {"url": "https://example.com", "title": "Example"})
|
||||
result = _run(nav_group, ["wait"], {"id": 1, "url": "https://example.com", "title": "Example"})
|
||||
assert result.exit_code == 0
|
||||
assert "Ready" in result.output
|
||||
|
||||
@@ -451,50 +440,50 @@ _SAMPLE_TAB = {"id": 1, "windowId": 1, "active": True, "muted": False,
|
||||
"title": "Example", "url": "https://example.com", "groupId": -1}
|
||||
|
||||
def test_cli_tabs_list_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]), \
|
||||
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No tabs found" in result.output
|
||||
|
||||
def test_cli_tabs_list_with_tabs():
|
||||
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]), \
|
||||
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[_SAMPLE_TAB]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "example.com" in result.output
|
||||
|
||||
def test_cli_tabs_close_by_id():
|
||||
with patch("browser_cli.commands.send_command", return_value={"closed": 1}):
|
||||
with patch("browser_cli.send_command", return_value={"closed": 1}):
|
||||
result = CliRunner().invoke(tabs_group, ["close", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "Closed 1" in result.output
|
||||
|
||||
def test_cli_tabs_close_inactive():
|
||||
with patch("browser_cli.commands.send_command", return_value={"closed": 3}):
|
||||
with patch("browser_cli.send_command", return_value={"closed": 3}):
|
||||
result = CliRunner().invoke(tabs_group, ["close", "--inactive"])
|
||||
assert result.exit_code == 0
|
||||
assert "Closed 3" in result.output
|
||||
|
||||
def test_cli_tabs_close_duplicates():
|
||||
with patch("browser_cli.commands.send_command", return_value={"closed": 2}):
|
||||
with patch("browser_cli.send_command", return_value={"closed": 2}):
|
||||
result = CliRunner().invoke(tabs_group, ["close", "--duplicates"])
|
||||
assert result.exit_code == 0
|
||||
assert "Closed 2" in result.output
|
||||
|
||||
def test_cli_tabs_move_forward():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(tabs_group, ["move", "42", "--forward"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tab moved" in result.output
|
||||
|
||||
def test_cli_tabs_move_to_window():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(tabs_group, ["move", "42", "--window", "2"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_cli_tabs_active():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(tabs_group, ["active", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "42" in result.output
|
||||
@@ -502,100 +491,100 @@ def test_cli_tabs_active():
|
||||
def test_cli_tabs_status():
|
||||
tab = {"id": 5, "windowId": 1, "active": True, "muted": False,
|
||||
"title": "MyPage", "url": "https://my.page"}
|
||||
with patch("browser_cli.commands.send_command", return_value=tab):
|
||||
with patch("browser_cli.send_command", return_value=tab):
|
||||
result = CliRunner().invoke(tabs_group, ["status"])
|
||||
assert result.exit_code == 0
|
||||
assert "MyPage" in result.output
|
||||
|
||||
def test_cli_tabs_filter():
|
||||
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]):
|
||||
with patch("browser_cli.send_command", return_value=[_SAMPLE_TAB]):
|
||||
result = CliRunner().invoke(tabs_group, ["filter", "example"])
|
||||
assert result.exit_code == 0
|
||||
assert "example.com" in result.output
|
||||
|
||||
def test_cli_tabs_filter_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["filter", "nope"])
|
||||
assert result.exit_code == 0
|
||||
assert "No tabs" in result.output
|
||||
|
||||
def test_cli_tabs_count():
|
||||
with patch("browser_cli.commands.send_command", return_value=7), \
|
||||
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=7), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["count"])
|
||||
assert result.exit_code == 0
|
||||
assert "7" in result.output
|
||||
|
||||
def test_cli_tabs_count_with_pattern():
|
||||
with patch("browser_cli.commands.send_command", return_value=3), \
|
||||
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=3), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["count", "http"])
|
||||
assert result.exit_code == 0
|
||||
assert "3" in result.output
|
||||
assert "http" in result.output
|
||||
|
||||
def test_cli_tabs_query():
|
||||
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_TAB]):
|
||||
with patch("browser_cli.send_command", return_value=[_SAMPLE_TAB]):
|
||||
result = CliRunner().invoke(tabs_group, ["query", "example"])
|
||||
assert result.exit_code == 0
|
||||
assert "example.com" in result.output
|
||||
|
||||
def test_cli_tabs_html():
|
||||
with patch("browser_cli.commands.send_command", return_value="<html><body>hello</body></html>"):
|
||||
with patch("browser_cli.send_command", return_value="<html><body>hello</body></html>"):
|
||||
result = CliRunner().invoke(tabs_group, ["html"])
|
||||
assert result.exit_code == 0
|
||||
assert "hello" in result.output
|
||||
|
||||
def test_cli_tabs_dedupe():
|
||||
with patch("browser_cli.commands.send_command", return_value={"closed": 4}):
|
||||
with patch("browser_cli.send_command", return_value={"closed": 4}):
|
||||
result = CliRunner().invoke(tabs_group, ["dedupe"])
|
||||
assert result.exit_code == 0
|
||||
assert "4 duplicate" in result.output
|
||||
|
||||
def test_cli_tabs_sort():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(tabs_group, ["sort", "--by", "title"])
|
||||
assert result.exit_code == 0
|
||||
assert "title" in result.output
|
||||
|
||||
def test_cli_tabs_merge_windows():
|
||||
with patch("browser_cli.commands.send_command", return_value={"moved": 2}):
|
||||
with patch("browser_cli.send_command", return_value={"moved": 2}):
|
||||
result = CliRunner().invoke(tabs_group, ["merge-windows"])
|
||||
assert result.exit_code == 0
|
||||
assert "2" in result.output
|
||||
|
||||
def test_cli_tabs_mute():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
|
||||
with patch("browser_cli.send_command", return_value={"tabId": 5}):
|
||||
result = CliRunner().invoke(tabs_group, ["mute", "5"])
|
||||
assert result.exit_code == 0
|
||||
assert "Muted tab 5" in result.output
|
||||
|
||||
def test_cli_tabs_unmute():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
|
||||
with patch("browser_cli.send_command", return_value={"tabId": 5}):
|
||||
result = CliRunner().invoke(tabs_group, ["unmute", "5"])
|
||||
assert result.exit_code == 0
|
||||
assert "Unmuted tab 5" in result.output
|
||||
|
||||
def test_cli_tabs_pin():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
|
||||
with patch("browser_cli.send_command", return_value={"tabId": 5}):
|
||||
result = CliRunner().invoke(tabs_group, ["pin", "5"])
|
||||
assert result.exit_code == 0
|
||||
assert "Pinned tab 5" in result.output
|
||||
|
||||
def test_cli_tabs_unpin():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabId": 5}):
|
||||
with patch("browser_cli.send_command", return_value={"tabId": 5}):
|
||||
result = CliRunner().invoke(tabs_group, ["unpin", "5"])
|
||||
assert result.exit_code == 0
|
||||
assert "Unpinned tab 5" in result.output
|
||||
|
||||
def test_cli_tabs_watch_url():
|
||||
with patch("browser_cli.commands.send_command", return_value={"url": "https://done.com"}):
|
||||
with patch("browser_cli.send_command", return_value={"id": 1, "url": "https://done.com"}):
|
||||
result = CliRunner().invoke(tabs_group, ["watch-url", "done\\.com"])
|
||||
assert result.exit_code == 0
|
||||
assert "done.com" in result.output
|
||||
|
||||
def test_cli_tabs_screenshot_stdout():
|
||||
with patch("browser_cli.commands.send_command", return_value={"dataUrl": "data:image/png;base64,abc"}):
|
||||
with patch("browser_cli.send_command", return_value={"dataUrl": "data:image/png;base64,abc"}):
|
||||
result = CliRunner().invoke(tabs_group, ["screenshot"])
|
||||
assert result.exit_code == 0
|
||||
assert "data:image/png" in result.output
|
||||
@@ -605,7 +594,7 @@ def test_cli_tabs_screenshot_to_file(tmp_path):
|
||||
png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 # minimal header
|
||||
data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode()
|
||||
out = tmp_path / "shot.png"
|
||||
with patch("browser_cli.commands.send_command", return_value={"dataUrl": data_url}):
|
||||
with patch("browser_cli.send_command", return_value={"dataUrl": data_url}):
|
||||
result = CliRunner().invoke(tabs_group, ["screenshot", str(out)])
|
||||
assert result.exit_code == 0
|
||||
assert "saved" in result.output.lower()
|
||||
@@ -622,95 +611,95 @@ _GROUPS_MOD = "browser_cli.commands.groups"
|
||||
_SAMPLE_GROUP = {"id": 10, "title": "Work", "color": "blue", "collapsed": False, "tabCount": 3}
|
||||
|
||||
def test_cli_groups_list_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]), \
|
||||
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(group_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No groups found" in result.output
|
||||
|
||||
def test_cli_groups_list_with_groups():
|
||||
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_GROUP]), \
|
||||
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[_SAMPLE_GROUP]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(group_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "Work" in result.output
|
||||
|
||||
def test_cli_groups_tabs():
|
||||
tabs = [_SAMPLE_TAB]
|
||||
with patch("browser_cli.commands.send_command", return_value=tabs):
|
||||
with patch("browser_cli.send_command", return_value=tabs):
|
||||
result = CliRunner().invoke(group_group, ["tabs", "10"])
|
||||
assert result.exit_code == 0
|
||||
assert "example.com" in result.output
|
||||
|
||||
def test_cli_groups_tabs_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]):
|
||||
result = CliRunner().invoke(group_group, ["tabs", "10"])
|
||||
assert result.exit_code == 0
|
||||
assert "No tabs" in result.output
|
||||
|
||||
def test_cli_groups_count():
|
||||
with patch("browser_cli.commands.send_command", return_value=5), \
|
||||
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=5), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(group_group, ["count"])
|
||||
assert result.exit_code == 0
|
||||
assert "5" in result.output
|
||||
|
||||
def test_cli_groups_query_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]):
|
||||
result = CliRunner().invoke(group_group, ["query", "nothing"])
|
||||
assert result.exit_code == 0
|
||||
assert "No groups" in result.output
|
||||
|
||||
def test_cli_groups_query_found():
|
||||
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_GROUP]):
|
||||
with patch("browser_cli.send_command", return_value=[_SAMPLE_GROUP]):
|
||||
result = CliRunner().invoke(group_group, ["query", "Work"])
|
||||
assert result.exit_code == 0
|
||||
assert "Work" in result.output
|
||||
|
||||
def test_cli_groups_close():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(group_group, ["close", "10"])
|
||||
assert result.exit_code == 0
|
||||
assert "10" in result.output
|
||||
|
||||
def test_cli_groups_create():
|
||||
with patch("browser_cli.commands.send_command", return_value={"id": 42}):
|
||||
with patch("browser_cli.send_command", return_value={"id": 42}):
|
||||
result = CliRunner().invoke(group_group, ["create", "Research"])
|
||||
assert result.exit_code == 0
|
||||
assert "Research" in result.output
|
||||
assert "42" in result.output
|
||||
|
||||
def test_cli_groups_add_tab_no_url():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabId": 7}):
|
||||
with patch("browser_cli.send_command", return_value={"tabId": 7}):
|
||||
result = CliRunner().invoke(group_group, ["add-tab", "Work"])
|
||||
assert result.exit_code == 0
|
||||
assert "Work" in result.output
|
||||
|
||||
def test_cli_groups_add_tab_with_url():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabId": 9}):
|
||||
with patch("browser_cli.send_command", return_value={"tabId": 9}):
|
||||
result = CliRunner().invoke(group_group, ["add-tab", "Work", "https://docs.example.com"])
|
||||
assert result.exit_code == 0
|
||||
assert "docs.example.com" in result.output
|
||||
|
||||
def test_cli_groups_move_forward():
|
||||
with patch("browser_cli.commands.send_command", return_value={"moved": True}):
|
||||
with patch("browser_cli.send_command", return_value={"moved": True}):
|
||||
result = CliRunner().invoke(group_group, ["move", "10", "--forward"])
|
||||
assert result.exit_code == 0
|
||||
assert "forward" in result.output
|
||||
|
||||
def test_cli_groups_move_backward():
|
||||
with patch("browser_cli.commands.send_command", return_value={"moved": True}):
|
||||
with patch("browser_cli.send_command", return_value={"moved": True}):
|
||||
result = CliRunner().invoke(group_group, ["move", "10", "--backward"])
|
||||
assert result.exit_code == 0
|
||||
assert "backward" in result.output
|
||||
|
||||
def test_cli_groups_move_no_direction():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(group_group, ["move", "10"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_groups_move_already_at_end():
|
||||
with patch("browser_cli.commands.send_command", return_value={"moved": False}):
|
||||
with patch("browser_cli.send_command", return_value={"moved": False}):
|
||||
result = CliRunner().invoke(group_group, ["move", "10", "--forward"])
|
||||
assert result.exit_code == 0
|
||||
assert "already at" in result.output
|
||||
@@ -724,103 +713,103 @@ from browser_cli.commands.session import session_group
|
||||
_SESSION_MOD = "browser_cli.commands.session"
|
||||
|
||||
def test_cli_session_save():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabs": 5}):
|
||||
with patch("browser_cli.send_command", return_value={"tabs": 5}):
|
||||
result = CliRunner().invoke(session_group, ["save", "work"])
|
||||
assert result.exit_code == 0
|
||||
assert "work" in result.output
|
||||
assert "5" in result.output
|
||||
|
||||
def test_cli_session_load():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabs": 8}):
|
||||
with patch("browser_cli.send_command", return_value={"tabs": 8}):
|
||||
result = CliRunner().invoke(session_group, ["load", "work"])
|
||||
assert result.exit_code == 0
|
||||
assert "work" in result.output
|
||||
assert "8" in result.output
|
||||
|
||||
def test_cli_session_load_background():
|
||||
with patch("browser_cli.commands.send_command", return_value={"jobId": "j-abc", "status": "running"}):
|
||||
with patch("browser_cli.send_command", return_value={"jobId": "j-abc", "status": "running"}):
|
||||
result = CliRunner().invoke(session_group, ["load", "work", "--background"])
|
||||
assert result.exit_code == 0
|
||||
assert "j-abc" in result.output
|
||||
|
||||
def test_cli_session_load_lazy():
|
||||
with patch("browser_cli.commands.send_command", return_value={"tabs": 20}):
|
||||
with patch("browser_cli.send_command", return_value={"tabs": 20}):
|
||||
result = CliRunner().invoke(session_group, ["load", "work", "--lazy", "--eager-tabs", "5"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_cli_session_diff_has_changes():
|
||||
diff = {"added": ["https://new.com"], "removed": ["https://old.com"]}
|
||||
with patch("browser_cli.commands.send_command", return_value=diff):
|
||||
with patch("browser_cli.send_command", return_value=diff):
|
||||
result = CliRunner().invoke(session_group, ["diff", "a", "b"])
|
||||
assert result.exit_code == 0
|
||||
assert "new.com" in result.output
|
||||
assert "old.com" in result.output
|
||||
|
||||
def test_cli_session_diff_identical():
|
||||
with patch("browser_cli.commands.send_command", return_value={"added": [], "removed": []}):
|
||||
with patch("browser_cli.send_command", return_value={"added": [], "removed": []}):
|
||||
result = CliRunner().invoke(session_group, ["diff", "a", "b"])
|
||||
assert result.exit_code == 0
|
||||
assert "identical" in result.output
|
||||
|
||||
def test_cli_session_diff_no_data():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(session_group, ["diff", "a", "b"])
|
||||
assert result.exit_code == 0
|
||||
assert "No diff" in result.output
|
||||
|
||||
def test_cli_session_list_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]), \
|
||||
patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(session_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No saved sessions" in result.output
|
||||
|
||||
def test_cli_session_list_with_sessions():
|
||||
sessions = [{"name": "work", "tabs": 3, "savedAt": 1700000000000}]
|
||||
with patch("browser_cli.commands.send_command", return_value=sessions), \
|
||||
patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=sessions), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(session_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "work" in result.output
|
||||
|
||||
def test_cli_session_remove():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(session_group, ["remove", "old-session"])
|
||||
assert result.exit_code == 0
|
||||
assert "old-session" in result.output
|
||||
|
||||
def test_cli_session_job_status_running():
|
||||
with patch("browser_cli.commands.send_command", return_value={"status": "running", "percent": 42}):
|
||||
with patch("browser_cli.send_command", return_value={"status": "running", "percent": 42}):
|
||||
result = CliRunner().invoke(session_group, ["job-status", "j-xyz"])
|
||||
assert result.exit_code == 0
|
||||
assert "running" in result.output
|
||||
|
||||
def test_cli_session_job_status_with_error():
|
||||
with patch("browser_cli.commands.send_command", return_value={"status": "failed", "error": "something broke"}):
|
||||
with patch("browser_cli.send_command", return_value={"status": "failed", "error": "something broke"}):
|
||||
result = CliRunner().invoke(session_group, ["job-status", "j-bad"])
|
||||
assert result.exit_code == 0
|
||||
assert "something broke" in result.output
|
||||
|
||||
def test_cli_session_job_status_with_result():
|
||||
with patch("browser_cli.commands.send_command", return_value={"status": "done", "result": "Opened 5 tabs"}):
|
||||
with patch("browser_cli.send_command", return_value={"status": "done", "result": "Opened 5 tabs"}):
|
||||
result = CliRunner().invoke(session_group, ["job-status", "j-done"])
|
||||
assert result.exit_code == 0
|
||||
assert "Opened 5 tabs" in result.output
|
||||
|
||||
def test_cli_session_job_cancel():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(session_group, ["job-cancel", "j-running"])
|
||||
assert result.exit_code == 0
|
||||
assert "j-running" in result.output
|
||||
|
||||
def test_cli_session_auto_save_on():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(session_group, ["auto-save", "on"])
|
||||
assert result.exit_code == 0
|
||||
assert "on" in result.output
|
||||
|
||||
def test_cli_session_auto_save_off():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(session_group, ["auto-save", "off"])
|
||||
assert result.exit_code == 0
|
||||
assert "off" in result.output
|
||||
@@ -829,7 +818,7 @@ def test_cli_session_auto_save_off():
|
||||
# multi-browser error paths (None targets → "Cannot resolve" exit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from browser_cli.client import BrowserTarget
|
||||
from browser_cli.client import BrowserNotConnected, BrowserTarget
|
||||
|
||||
def _fake_target(name="browser-a"):
|
||||
return BrowserTarget(profile=name, display_name=name, socket_path="/tmp/fake.sock")
|
||||
@@ -837,45 +826,40 @@ def _fake_target(name="browser-a"):
|
||||
def test_cli_tabs_list_multi_browser_all_none():
|
||||
"""If every multi-browser target returns None, show error and exit 1."""
|
||||
target = _fake_target()
|
||||
with patch("browser_cli.commands.send_command", return_value=None), \
|
||||
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[target]), \
|
||||
patch(f"{_TABS_MOD}._handle_multi", return_value=None):
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("gone")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[target, _fake_target("browser-b")]):
|
||||
result = CliRunner().invoke(tabs_group, ["list"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_tabs_count_multi_browser_all_none():
|
||||
"""If every multi-browser count returns None, show error and exit 1."""
|
||||
target = _fake_target()
|
||||
with patch("browser_cli.commands.send_command", return_value=None), \
|
||||
patch(f"{_TABS_MOD}._multi_browser_targets", return_value=[target]), \
|
||||
patch(f"{_TABS_MOD}._handle_multi", return_value=None):
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("gone")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[target, _fake_target("browser-b")]):
|
||||
result = CliRunner().invoke(tabs_group, ["count"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_groups_list_multi_browser_all_none():
|
||||
"""If every multi-browser group list returns None, exit 1."""
|
||||
target = _fake_target()
|
||||
with patch("browser_cli.commands.send_command", return_value=None), \
|
||||
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[target]), \
|
||||
patch(f"{_GROUPS_MOD}._handle_multi", return_value=None):
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("gone")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[target, _fake_target("browser-b")]):
|
||||
result = CliRunner().invoke(group_group, ["list"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_groups_count_multi_browser_all_none():
|
||||
"""If every multi-browser group count returns None, exit 1."""
|
||||
target = _fake_target()
|
||||
with patch("browser_cli.commands.send_command", return_value=None), \
|
||||
patch(f"{_GROUPS_MOD}._multi_browser_targets", return_value=[target]), \
|
||||
patch(f"{_GROUPS_MOD}._handle_multi", return_value=None):
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("gone")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[target, _fake_target("browser-b")]):
|
||||
result = CliRunner().invoke(group_group, ["count"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_session_list_multi_browser_all_none():
|
||||
"""If every multi-browser session list returns None, exit 1."""
|
||||
target = _fake_target()
|
||||
with patch("browser_cli.commands.send_command", return_value=None), \
|
||||
patch(f"{_SESSION_MOD}._multi_browser_targets", return_value=[target]), \
|
||||
patch(f"{_SESSION_MOD}._handle_multi", return_value=None):
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("gone")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[target, _fake_target("browser-b")]):
|
||||
result = CliRunner().invoke(session_group, ["list"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -886,14 +870,14 @@ def test_cli_session_list_multi_browser_all_none():
|
||||
def test_cli_tabs_screenshot_bad_dataurl(tmp_path):
|
||||
"""Screenshot command exits non-zero when dataUrl has wrong format."""
|
||||
out = tmp_path / "bad.png"
|
||||
with patch("browser_cli.commands.send_command", return_value={"dataUrl": "not-a-dataurl"}):
|
||||
with patch("browser_cli.send_command", return_value={"dataUrl": "not-a-dataurl"}):
|
||||
result = CliRunner().invoke(tabs_group, ["screenshot", str(out)])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_tabs_screenshot_bad_base64(tmp_path):
|
||||
"""Screenshot command exits non-zero when base64 data is corrupt."""
|
||||
out = tmp_path / "bad.png"
|
||||
with patch("browser_cli.commands.send_command", return_value={"dataUrl": "data:image/png;base64,!!!NOT_VALID_BASE64!!!"}):
|
||||
with patch("browser_cli.send_command", return_value={"dataUrl": "data:image/png;base64,!!!NOT_VALID_BASE64!!!"}):
|
||||
result = CliRunner().invoke(tabs_group, ["screenshot", str(out)])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -908,48 +892,47 @@ _WINDOWS_MOD = "browser_cli.commands.windows"
|
||||
_SAMPLE_WINDOW = {"id": 1, "alias": "main", "tabCount": 5, "state": "normal"}
|
||||
|
||||
def test_cli_windows_list_empty():
|
||||
with patch("browser_cli.commands.send_command", return_value=[]), \
|
||||
patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(windows_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No windows found" in result.output
|
||||
|
||||
def test_cli_windows_list_with_windows():
|
||||
with patch("browser_cli.commands.send_command", return_value=[_SAMPLE_WINDOW]), \
|
||||
patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[]):
|
||||
with patch("browser_cli.send_command", return_value=[_SAMPLE_WINDOW]), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(windows_group, ["list"])
|
||||
assert result.exit_code == 0
|
||||
assert "main" in result.output
|
||||
|
||||
def test_cli_windows_list_multi_all_none():
|
||||
target = _fake_target()
|
||||
with patch("browser_cli.commands.send_command", return_value=None), \
|
||||
patch(f"{_WINDOWS_MOD}._multi_browser_targets", return_value=[target]), \
|
||||
patch(f"{_WINDOWS_MOD}._handle_multi", return_value=None):
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("gone")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[target, _fake_target("browser-b")]):
|
||||
result = CliRunner().invoke(windows_group, ["list"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_cli_windows_rename():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(windows_group, ["rename", "1", "work"])
|
||||
assert result.exit_code == 0
|
||||
assert "work" in result.output
|
||||
assert "1" in result.output
|
||||
|
||||
def test_cli_windows_close():
|
||||
with patch("browser_cli.commands.send_command", return_value=None):
|
||||
with patch("browser_cli.send_command", return_value=None):
|
||||
result = CliRunner().invoke(windows_group, ["close", "2"])
|
||||
assert result.exit_code == 0
|
||||
assert "2" in result.output
|
||||
|
||||
def test_cli_windows_open_no_url():
|
||||
with patch("browser_cli.commands.send_command", return_value={"id": 5}):
|
||||
with patch("browser_cli.send_command", return_value={"id": 5}):
|
||||
result = CliRunner().invoke(windows_group, ["open"])
|
||||
assert result.exit_code == 0
|
||||
assert "5" in result.output
|
||||
|
||||
def test_cli_windows_open_with_url():
|
||||
with patch("browser_cli.commands.send_command", return_value={"id": 6}):
|
||||
with patch("browser_cli.send_command", return_value={"id": 6}):
|
||||
result = CliRunner().invoke(windows_group, ["open", "https://example.com"])
|
||||
assert result.exit_code == 0
|
||||
assert "example.com" in result.output
|
||||
@@ -958,36 +941,29 @@ def test_cli_windows_open_with_url():
|
||||
# commands/__init__.py error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from browser_cli.commands import _handle, _handle_multi
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.commands import client_from_ctx, handle_errors
|
||||
|
||||
def test_handle_raises_system_exit_on_browser_not_connected():
|
||||
"""_handle converts BrowserNotConnected into SystemExit(1)."""
|
||||
with patch("browser_cli.commands.send_command", side_effect=BrowserNotConnected("no socket")):
|
||||
with pytest.raises(SystemExit):
|
||||
_handle("tabs.list")
|
||||
def test_handle_errors_converts_browser_not_connected_to_exit():
|
||||
"""handle_errors turns BrowserNotConnected into a clean SystemExit(1)."""
|
||||
with patch("browser_cli.send_command", side_effect=BrowserNotConnected("no socket")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["list"])
|
||||
assert result.exit_code == 1
|
||||
assert "no socket" in result.output
|
||||
|
||||
def test_handle_raises_system_exit_on_runtime_error():
|
||||
"""_handle converts RuntimeError into SystemExit(1)."""
|
||||
with patch("browser_cli.commands.send_command", side_effect=RuntimeError("browser blew up")):
|
||||
with pytest.raises(SystemExit):
|
||||
_handle("tabs.list")
|
||||
def test_handle_errors_converts_runtime_error_to_exit():
|
||||
"""handle_errors turns a browser RuntimeError into a clean SystemExit(1)."""
|
||||
with patch("browser_cli.send_command", side_effect=RuntimeError("browser blew up")), \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(tabs_group, ["list"])
|
||||
assert result.exit_code == 1
|
||||
assert "browser blew up" in result.output
|
||||
|
||||
def test_handle_multi_returns_none_on_error():
|
||||
"""_handle_multi silently returns None on BrowserNotConnected."""
|
||||
with patch("browser_cli.commands.send_command", side_effect=BrowserNotConnected("gone")):
|
||||
result = _handle_multi("tabs.list")
|
||||
assert result is None
|
||||
|
||||
def test_handle_multi_returns_none_on_runtime_error():
|
||||
"""_handle_multi silently returns None on RuntimeError."""
|
||||
with patch("browser_cli.commands.send_command", side_effect=RuntimeError("oops")):
|
||||
result = _handle_multi("tabs.list")
|
||||
assert result is None
|
||||
|
||||
def test_handle_multi_with_remote():
|
||||
"""_handle_multi routes through remote when remote arg is set."""
|
||||
with patch("browser_cli.commands.send_command", return_value={"ok": True}) as mock_send:
|
||||
result = _handle_multi("tabs.list", profile="brave", remote="host:8765")
|
||||
assert result == {"ok": True}
|
||||
mock_send.assert_called_once_with("tabs.list", {}, profile="brave", remote="host:8765")
|
||||
def test_client_from_ctx_forwards_global_options():
|
||||
"""client_from_ctx builds a BrowserCLI from the root --browser/--remote options."""
|
||||
from browser_cli.cli import main
|
||||
with patch("browser_cli.send_command", return_value=[]) as mock_send, \
|
||||
patch("browser_cli.active_browser_targets", return_value=[]):
|
||||
result = CliRunner().invoke(main, ["--browser", "brave", "--remote", "host:8765", "tabs", "list"])
|
||||
assert result.exit_code == 0
|
||||
mock_send.assert_called_once_with("tabs.list", {}, profile="brave", remote="host:8765", key=None)
|
||||
|
||||
@@ -111,7 +111,6 @@ def test_large_extension_operations_yield_between_batches():
|
||||
assert "perf.set_profile" in perf
|
||||
assert "__background" in connection
|
||||
|
||||
|
||||
def test_session_autosave_is_debounced_and_non_overlapping():
|
||||
# The autosave lifecycle moved out of session.ts into a dedicated
|
||||
# AutoSaveManager (autosave.ts) during the structure refactor; the shared
|
||||
@@ -137,31 +136,34 @@ def test_session_autosave_is_debounced_and_non_overlapping():
|
||||
assert "setTimeout(() => this.runAutoSave(), delayMs)" in autosave
|
||||
assert "clearTimeout(this.autoSaveTimer)" in autosave
|
||||
|
||||
|
||||
def test_cli_and_sdk_expose_gentle_restore_controls():
|
||||
session_cli = (ROOT / "browser_cli" / "commands" / "session.py").read_text()
|
||||
tabs_cli = (ROOT / "browser_cli" / "commands" / "tabs.py").read_text()
|
||||
groups_cli = (ROOT / "browser_cli" / "commands" / "groups.py").read_text()
|
||||
sdk = (ROOT / "browser_cli" / "__init__.py").read_text()
|
||||
commands_init = (ROOT / "browser_cli" / "commands" / "__init__.py").read_text()
|
||||
sdk_session = (ROOT / "browser_cli" / "sdk" / "session.py").read_text()
|
||||
sdk_perf = (ROOT / "browser_cli" / "sdk" / "perf.py").read_text()
|
||||
|
||||
assert "--gentle-mode" in session_cli
|
||||
# The --gentle-mode flag is defined once via the shared gentle_mode_option helper.
|
||||
assert '"--gentle-mode"' in commands_init
|
||||
assert "gentle_mode_option" in session_cli
|
||||
assert "--discard-background-tabs" in session_cli
|
||||
assert "--background" in session_cli
|
||||
assert "--lazy" in session_cli
|
||||
assert "--eager-tabs" in session_cli
|
||||
assert "job-status" in session_cli
|
||||
assert "job-cancel" in session_cli
|
||||
assert "discardBackgroundTabs" in session_cli
|
||||
assert "--gentle-mode" in tabs_cli
|
||||
assert "gentleMode" in tabs_cli
|
||||
assert "--gentle-mode" in groups_cli
|
||||
assert "discard_background_tabs" in sdk
|
||||
assert "discardBackgroundTabs" in sdk
|
||||
assert "session_load_background" in sdk
|
||||
assert "job_status" in sdk
|
||||
assert "job_cancel" in sdk
|
||||
assert "perf_status" in sdk
|
||||
assert "set_performance_profile" in sdk
|
||||
assert "discard_background_tabs" in session_cli
|
||||
assert "gentle_mode_option" in tabs_cli
|
||||
assert "gentle_mode" in tabs_cli
|
||||
assert "gentle_mode_option" in groups_cli
|
||||
assert "discard_background_tabs" in sdk_session
|
||||
assert "discardBackgroundTabs" in sdk_session
|
||||
assert "def load_background" in sdk_session
|
||||
assert "def job_status" in sdk_perf
|
||||
assert "def job_cancel" in sdk_perf
|
||||
assert "def status" in sdk_perf
|
||||
assert "def set_profile" in sdk_perf
|
||||
|
||||
perf_cli = (ROOT / "browser_cli" / "commands" / "perf.py").read_text()
|
||||
root_cli = (ROOT / "browser_cli" / "cli.py").read_text()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Guards for the module boundaries introduced by the modularity refactor.
|
||||
|
||||
Three things must hold so the split stays invisible to callers:
|
||||
* the new leaf modules own their logic and behave correctly,
|
||||
* ``browser_cli.client`` still re-exports the names that moved out of it, and
|
||||
* ``BrowserCLI`` still exposes the factory/routing helpers (now mixins).
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import browser_cli
|
||||
from browser_cli import BrowserCLI, BrowserCounts
|
||||
|
||||
# ── endpoints module ────────────────────────────────────────────────────────
|
||||
|
||||
class TestEndpoints:
|
||||
def test_normalize_strips_default_https_port_for_domains(self):
|
||||
from browser_cli.endpoints import _normalize_endpoint
|
||||
|
||||
assert _normalize_endpoint("example.com:443") == "example.com"
|
||||
assert _normalize_endpoint("example.com:8765") == "example.com:8765"
|
||||
assert _normalize_endpoint("192.168.1.10:443") == "192.168.1.10:443"
|
||||
|
||||
def test_resolve_connect_endpoint_defaults_domain_to_443(self):
|
||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||
|
||||
assert _resolve_connect_endpoint("example.com") == "example.com:443"
|
||||
assert _resolve_connect_endpoint("host:9000") == "host:9000"
|
||||
|
||||
def test_resolve_connect_endpoint_rejects_bare_non_domain(self):
|
||||
from browser_cli.errors import BrowserNotConnected
|
||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||
|
||||
try:
|
||||
_resolve_connect_endpoint("localhost")
|
||||
except BrowserNotConnected:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected BrowserNotConnected for bare 'localhost'")
|
||||
|
||||
def test_looks_like_domain(self):
|
||||
from browser_cli.endpoints import _looks_like_domain
|
||||
|
||||
assert _looks_like_domain("example.com") is True
|
||||
assert _looks_like_domain("localhost") is False
|
||||
assert _looks_like_domain("127.0.0.1") is False
|
||||
|
||||
# ── markdown module ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestRenderMarkdown:
|
||||
def test_html_payload_is_converted(self):
|
||||
from browser_cli.markdown import render_markdown
|
||||
|
||||
assert render_markdown("<h1>Title</h1>") == "# Title"
|
||||
|
||||
def test_markdown_payload_is_cleaned_not_html_parsed(self):
|
||||
from browser_cli.markdown import render_markdown
|
||||
|
||||
assert render_markdown(r"hello\_world \- item") == "hello_world - item"
|
||||
|
||||
def test_none_and_empty_are_safe(self):
|
||||
from browser_cli.markdown import render_markdown
|
||||
|
||||
assert render_markdown(None) == ""
|
||||
assert render_markdown("") == ""
|
||||
|
||||
# ── backward-compatible re-exports ──────────────────────────────────────────
|
||||
|
||||
class TestReExports:
|
||||
def test_client_still_exposes_moved_names(self):
|
||||
from browser_cli import client
|
||||
|
||||
# moved to endpoints / remote_transport / _errors but still reachable here
|
||||
for name in (
|
||||
"BrowserNotConnected",
|
||||
"_normalize_endpoint",
|
||||
"_resolve_connect_endpoint",
|
||||
"display_browser_name",
|
||||
"_remote_display_name",
|
||||
"_send_remote",
|
||||
"_recv_all",
|
||||
"_recv_exact",
|
||||
):
|
||||
assert hasattr(client, name), f"browser_cli.client.{name} missing after refactor"
|
||||
|
||||
def test_browsercounts_reexported_from_package(self):
|
||||
# BrowserCounts moved to models but is still part of the public API.
|
||||
from browser_cli.models import BrowserCounts as ModelsBrowserCounts
|
||||
|
||||
assert BrowserCounts is ModelsBrowserCounts
|
||||
|
||||
def test_patching_client_send_remote_still_intercepts(self):
|
||||
# send_command resolves _send_remote as a browser_cli.client global, so
|
||||
# patching there must still take effect after the move to remote_transport.
|
||||
with patch("browser_cli.client._send_remote", return_value=None) as fake:
|
||||
assert browser_cli.client._send_remote is fake
|
||||
|
||||
# ── BrowserCLI mixin composition ─────────────────────────────────────────────
|
||||
|
||||
class TestMixinComposition:
|
||||
def test_factory_builds_bound_tab(self):
|
||||
b = BrowserCLI()
|
||||
tab = b._make_tab({"id": 7, "windowId": 1, "title": "t", "url": "u"})
|
||||
assert tab.id == 7
|
||||
assert tab._browser is b
|
||||
|
||||
def test_factory_tab_for_remote_target_binds_sibling_client(self):
|
||||
b = BrowserCLI()
|
||||
|
||||
class _Target:
|
||||
profile = "work"
|
||||
display_name = "host:work"
|
||||
remote = "host:8765"
|
||||
|
||||
tab = b._make_tab_for({"id": 1, "windowId": 0}, _Target())
|
||||
assert tab.browser == "host:work"
|
||||
assert isinstance(tab._browser, BrowserCLI)
|
||||
assert tab._browser is not b
|
||||
assert tab._browser.browser == "work"
|
||||
|
||||
def test_field_helper_handles_non_dict(self):
|
||||
b = BrowserCLI()
|
||||
assert b._field({"tabId": 5}, "tabId") == 5
|
||||
assert b._field("not-a-dict", "tabId", fallback=9) == 9
|
||||
+125
-7
@@ -6,6 +6,7 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, pq_decrypt, pq_encrypt, sign
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
@@ -18,7 +19,6 @@ FAKE_UA = "browser-cli/0.9.3"
|
||||
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw = b""
|
||||
while len(raw) < 4:
|
||||
@@ -35,7 +35,6 @@ def _recv_framed(sock: socket.socket) -> dict:
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
@@ -45,15 +44,12 @@ def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _pair():
|
||||
return socket.socketpair()
|
||||
|
||||
|
||||
def _mock_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
|
||||
# ── challenge frame ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestChallenge:
|
||||
@@ -89,7 +85,6 @@ class TestChallenge:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── rejection paths ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRejection:
|
||||
@@ -199,7 +194,6 @@ class TestRejection:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── auth success paths ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuthSuccess:
|
||||
@@ -371,3 +365,127 @@ class TestAuthSuccess:
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
# ── response encoding (compression / msgpack) ───────────────────────────────────
|
||||
|
||||
def _recv_framed_raw(sock: socket.socket) -> bytes:
|
||||
raw = b""
|
||||
while len(raw) < 4:
|
||||
chunk = sock.recv(4 - len(raw))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw += chunk
|
||||
n = struct.unpack("<I", raw)[0]
|
||||
data = b""
|
||||
while len(data) < n:
|
||||
chunk = sock.recv(n - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
class _FakeNativeHost:
|
||||
"""Minimal AF_UNIX server speaking the 4-byte framed protocol the proxy expects."""
|
||||
|
||||
def __init__(self, sock_path, response_obj):
|
||||
self.sock_path = str(sock_path)
|
||||
self.response = json.dumps(response_obj).encode()
|
||||
self.srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.srv.bind(self.sock_path)
|
||||
self.srv.listen(1)
|
||||
self.thread = threading.Thread(target=self._serve, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _serve(self):
|
||||
try:
|
||||
conn, _ = self.srv.accept()
|
||||
with conn:
|
||||
hdr = conn.recv(4)
|
||||
n = struct.unpack("<I", hdr)[0]
|
||||
got = b""
|
||||
while len(got) < n:
|
||||
got += conn.recv(n - len(got))
|
||||
conn.sendall(struct.pack("<I", len(self.response)) + self.response)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.srv.close()
|
||||
|
||||
class TestResponseEncoding:
|
||||
def test_client_accept_encoding_yields_decodable_tagged_response(self, tmp_path, monkeypatch):
|
||||
if socket.AF_UNIX is None: # pragma: no cover
|
||||
pytest.skip("AF_UNIX unavailable")
|
||||
|
||||
big = {"id": "x", "success": True,
|
||||
"data": {"items": [{"url": f"https://example.com/{i}", "title": f"Tab {i}"} for i in range(300)]}}
|
||||
host_path = tmp_path / "native.sock"
|
||||
host = _FakeNativeHost(host_path, big)
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None) # no auth
|
||||
_recv_framed(client) # challenge
|
||||
|
||||
msg = {
|
||||
"id": "x", "command": "tabs.list", "args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"accept_encoding": {"ser": ["json"], "comp": ["gzip"]},
|
||||
}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
raw = _recv_framed_raw(client)
|
||||
|
||||
assert raw[:1] not in (b"{", b"[") # tagged, not plain JSON
|
||||
assert len(raw) < len(json.dumps(big)) # compressed
|
||||
assert transport.decode_response(raw) == big
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_no_accept_encoding_stays_plain_json(self, tmp_path, monkeypatch):
|
||||
big = {"id": "y", "success": True, "data": {"items": list(range(500))}}
|
||||
host_path = tmp_path / "native2.sock"
|
||||
host = _FakeNativeHost(host_path, big)
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
_recv_framed(client)
|
||||
|
||||
msg = {"id": "y", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5"}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
raw = _recv_framed_raw(client)
|
||||
|
||||
assert raw[:1] in (b"{", b"[") # old client → plain JSON
|
||||
assert json.loads(raw) == big
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_no_compress_flag_forces_plain_json(self, tmp_path, monkeypatch):
|
||||
big = {"id": "z", "success": True,
|
||||
"data": {"items": [{"url": f"https://e/{i}"} for i in range(300)]}}
|
||||
host_path = tmp_path / "native3.sock"
|
||||
host = _FakeNativeHost(host_path, big)
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, None, False), # compress=False
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
_recv_framed(client)
|
||||
|
||||
msg = {"id": "z", "command": "tabs.list", "args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"accept_encoding": {"ser": ["msgpack"], "comp": ["zstd"]}}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
raw = _recv_framed_raw(client)
|
||||
|
||||
assert raw[:1] in (b"{", b"[") # server disabled encoding
|
||||
assert json.loads(raw) == big
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for tabs.* commands."""
|
||||
import time
|
||||
import pytest
|
||||
|
||||
def test_tabs_list(browser):
|
||||
@@ -72,6 +73,16 @@ def test_tabs_dedupe(browser):
|
||||
id1, id2 = r1["id"], r2["id"]
|
||||
|
||||
try:
|
||||
# Dedupe keys on tab.url and intentionally skips tabs that haven't
|
||||
# resolved a URL yet, so wait for both tabs to finish loading first.
|
||||
for _ in range(30):
|
||||
tabs = {t["id"]: t for t in browser("tabs.list")}
|
||||
if all(tabs.get(tid, {}).get("url", "").startswith("http") for tid in (id1, id2)):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
pytest.skip("Duplicate test tabs did not finish loading")
|
||||
|
||||
result = browser("tabs.dedupe")
|
||||
assert isinstance(result, dict)
|
||||
assert result.get("closed", 0) >= 0
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Unit tests for the response transport codec (compression + msgpack)."""
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli import transport
|
||||
|
||||
def _sample(n=200):
|
||||
return {
|
||||
"id": "abc",
|
||||
"success": True,
|
||||
"data": {"items": [{"url": f"https://example.com/{i}", "title": f"Tab {i}"} for i in range(n)]},
|
||||
}
|
||||
|
||||
# ── backward compatibility ──────────────────────────────────────────────────────
|
||||
|
||||
def test_no_accept_encoding_returns_bare_json():
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept=None)
|
||||
assert enc == json.dumps(obj).encode("utf-8") # byte-identical to old wire format
|
||||
assert enc[:1] in (b"{", b"[")
|
||||
|
||||
def test_empty_accept_returns_bare_json():
|
||||
obj = _sample()
|
||||
assert transport.encode_response(obj, accept={}) == json.dumps(obj).encode("utf-8")
|
||||
|
||||
def test_decode_plain_json_without_tag():
|
||||
obj = _sample()
|
||||
assert transport.decode_response(json.dumps(obj).encode("utf-8")) == obj
|
||||
|
||||
def test_decode_none_and_empty():
|
||||
assert transport.decode_response(None) is None
|
||||
with pytest.raises(ValueError):
|
||||
transport.decode_response(b"")
|
||||
|
||||
# ── compression roundtrips ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("comp", ["zlib", "gzip", "zstd"])
|
||||
def test_compression_roundtrip(comp):
|
||||
if comp == "zstd" and not transport.zstd_available():
|
||||
pytest.skip("zstandard not installed")
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": [comp]})
|
||||
assert enc[:1] not in (b"{", b"[") # tagged, not plain
|
||||
assert len(enc) < len(json.dumps(obj)) # actually smaller
|
||||
assert transport.decode_response(enc) == obj
|
||||
|
||||
def test_small_payload_not_compressed():
|
||||
obj = {"id": "x", "success": True, "data": "ok"}
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["zlib"]}, threshold=512)
|
||||
assert enc == json.dumps(obj).encode("utf-8") # below threshold → bare json
|
||||
|
||||
def test_unknown_compression_in_accept_falls_back_to_plain():
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["brotli"]})
|
||||
assert enc == json.dumps(obj).encode("utf-8")
|
||||
|
||||
# ── msgpack ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_msgpack_roundtrip():
|
||||
if not transport.msgpack_available():
|
||||
pytest.skip("msgpack not installed")
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": []})
|
||||
assert enc[0] >> 4 == transport.SER_MSGPACK
|
||||
assert transport.decode_response(enc) == obj
|
||||
|
||||
def test_msgpack_with_compression_roundtrip():
|
||||
if not (transport.msgpack_available() and transport.zstd_available()):
|
||||
pytest.skip("msgpack/zstd not installed")
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": ["zstd"]})
|
||||
tag = enc[0]
|
||||
assert tag >> 4 == transport.SER_MSGPACK
|
||||
assert tag & 0x0F == transport.COMP_ZSTD
|
||||
assert transport.decode_response(enc) == obj
|
||||
|
||||
# ── raw screenshot hoisting ───────────────────────────────────────────────────────
|
||||
|
||||
def _screenshot_obj():
|
||||
png = bytes(range(256)) * 40
|
||||
url = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
||||
return {"id": "x", "success": True, "data": {"dataUrl": url}}, url
|
||||
|
||||
def test_screenshot_roundtrip_restores_data_url_exactly():
|
||||
if not transport.msgpack_available():
|
||||
pytest.skip("msgpack not installed")
|
||||
obj, url = _screenshot_obj()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": []}, command="tabs.screenshot")
|
||||
dec = transport.decode_response(enc)
|
||||
assert dec == obj
|
||||
assert dec["data"]["dataUrl"] == url
|
||||
|
||||
def test_screenshot_not_hoisted_for_other_commands():
|
||||
if not transport.msgpack_available():
|
||||
pytest.skip("msgpack not installed")
|
||||
import msgpack
|
||||
obj, _ = _screenshot_obj()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": []}, command="extract.text")
|
||||
body = msgpack.unpackb(enc[1:], raw=False)
|
||||
assert isinstance(body["data"]["dataUrl"], str) # left as base64 string
|
||||
|
||||
def test_screenshot_json_path_leaves_base64():
|
||||
obj, url = _screenshot_obj()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["gzip"]}, command="tabs.screenshot")
|
||||
assert transport.decode_response(enc)["data"]["dataUrl"] == url
|
||||
|
||||
# ── negotiation preference ────────────────────────────────────────────────────────
|
||||
|
||||
def test_server_prefers_strongest_mutual_codec():
|
||||
if not transport.zstd_available():
|
||||
pytest.skip("zstandard not installed")
|
||||
obj = _sample()
|
||||
# client accepts gzip and zstd; server should pick zstd (its top preference)
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["gzip", "zstd"]})
|
||||
assert enc[0] & 0x0F == transport.COMP_ZSTD
|
||||
Reference in New Issue
Block a user