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
+37 -67
View File
@@ -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