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

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:
2026-06-11 13:58:41 +02:00
parent 0813ae2de9
commit fd5447cbb9
52 changed files with 3344 additions and 2348 deletions
+114 -138
View File
@@ -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)