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:
+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
|
||||
|
||||
Reference in New Issue
Block a user