feat(sdk): improve Python SDK ergonomics
- Position browser-cli as a CLI plus Python SDK in docs and package metadata. - Add public target properties and a raw command escape hatch for unsupported commands. - Add convenience helpers for opening, finding, closing, and accessing tabs. - Add plural group aliases and a wait_for_selector DOM convenience alias. - Extend bound Tab objects with screenshot, pin, refresh, load wait, and URL watch helpers. - Preserve remote auth key configuration when binding remote Tab and Group objects. - Bump project and extension versions to 0.9.9 and cover SDK additions with tests.
This commit is contained in:
@@ -69,6 +69,31 @@ class TestBrowserCLIInit:
|
||||
assert b._browser == "work"
|
||||
assert b._remote == "host:8765"
|
||||
|
||||
def test_public_target_properties(self):
|
||||
b = BrowserCLI(browser="work", remote="browser-host.example:443", key="agent")
|
||||
assert b.browser == "work"
|
||||
assert b.remote == "browser-host.example:443"
|
||||
assert b.key == "agent"
|
||||
|
||||
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")
|
||||
|
||||
result = b.command("custom.command", {"value": 1})
|
||||
|
||||
assert result == {"ok": True}
|
||||
mock_send.assert_called_once_with(
|
||||
"custom.command",
|
||||
{"value": 1},
|
||||
profile="work",
|
||||
remote="browser-host.example:443",
|
||||
key="agent",
|
||||
)
|
||||
|
||||
def test_raw_command_defaults_args_to_empty_dict(self, b, mock_send):
|
||||
b.command("custom.command")
|
||||
mock_send.assert_called_once_with("custom.command", {}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
# ── Internal factories ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -140,6 +165,41 @@ class TestNavigation:
|
||||
b.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):
|
||||
mock_send.return_value = {"id": 123, "url": "https://example.com"}
|
||||
|
||||
tab = b.open_tab("https://example.com", background=True)
|
||||
|
||||
assert tab.id == 123
|
||||
assert tab.url == "https://example.com"
|
||||
assert tab._browser is b
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.open",
|
||||
{"url": "https://example.com", "background": True, "window": None, "group": None},
|
||||
profile=None,
|
||||
remote=None,
|
||||
key=None,
|
||||
)
|
||||
|
||||
def test_open_tab_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)
|
||||
|
||||
assert tab.id == 10
|
||||
mock_send.assert_called_once_with(
|
||||
"navigate.open_wait",
|
||||
{"url": "https://example.com", "timeout": 1500, "background": False, "window": None, "group": None},
|
||||
profile=None,
|
||||
remote=None,
|
||||
key=None,
|
||||
)
|
||||
|
||||
def test_open_tab_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")
|
||||
|
||||
def test_reload(self, b, mock_send):
|
||||
b.reload(tab_id=5)
|
||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
|
||||
@@ -340,6 +400,64 @@ class TestTabs:
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_tabs_list_remote_bound_actions_preserve_key(self, mock_send):
|
||||
b = BrowserCLI(remote="browser-host.example", key="agent")
|
||||
with patch(
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
|
||||
):
|
||||
mock_send.side_effect = [[TAB_DATA], None]
|
||||
tabs = b.tabs_list()
|
||||
tabs[0].close()
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile="work", remote="browser-host.example", key="agent"),
|
||||
call("tabs.close", {"tabId": 10}, profile="work", remote="browser-host.example", key="agent"),
|
||||
]
|
||||
|
||||
def test_tabs_alias_and_active_tab(self, b, mock_send):
|
||||
mock_send.side_effect = [[TAB_DATA], TAB_DATA]
|
||||
|
||||
tabs = b.tabs()
|
||||
active = b.active_tab()
|
||||
|
||||
assert tabs[0].id == 10
|
||||
assert active.id == 10
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.list", {}, profile=None, remote=None, key=None),
|
||||
call("tabs.status", {"tabId": None}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_tab_find_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)
|
||||
|
||||
assert tab.id == 10
|
||||
assert found and found.id == 10
|
||||
assert missing is None
|
||||
assert closed_by_object == 1
|
||||
assert closed_by_id == 1
|
||||
assert mock_send.call_args_list == [
|
||||
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, "inactive": False, "duplicates": False}, profile=None, remote=None, key=None),
|
||||
call("tabs.close", {"tabId": 10, "inactive": False, "duplicates": False}, 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",
|
||||
@@ -463,6 +581,40 @@ class TestGroups:
|
||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
||||
]
|
||||
|
||||
def test_group_list_remote_bound_actions_preserve_key(self, mock_send):
|
||||
b = BrowserCLI(remote="browser-host.example", key="agent")
|
||||
with patch(
|
||||
"browser_cli.remote_browser_targets",
|
||||
return_value=[BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example")],
|
||||
):
|
||||
mock_send.side_effect = [[GROUP_DATA], None]
|
||||
groups = b.group_list()
|
||||
groups[0].close()
|
||||
|
||||
assert mock_send.call_args_list == [
|
||||
call("group.list", {}, profile="work", remote="browser-host.example", key="agent"),
|
||||
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",
|
||||
@@ -554,6 +706,22 @@ class TestWindows:
|
||||
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, key=None)
|
||||
|
||||
|
||||
class TestDomConvenience:
|
||||
def test_wait_for_selector_alias(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)
|
||||
|
||||
assert result == {"selector": "#done", "found": True}
|
||||
mock_send.assert_called_once_with(
|
||||
"dom.wait_for",
|
||||
{"selector": "#done", "timeout": 2500, "visible": True, "hidden": False, "tabId": 10},
|
||||
profile=None,
|
||||
remote=None,
|
||||
key=None,
|
||||
)
|
||||
|
||||
|
||||
class TestSession:
|
||||
def test_session_list(self, b, mock_send):
|
||||
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
||||
@@ -633,6 +801,36 @@ class TestTabModel:
|
||||
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, key=None
|
||||
)
|
||||
|
||||
def test_screenshot(self, tab, mock_send):
|
||||
mock_send.return_value = {"dataUrl": "data:image/png;base64,abc"}
|
||||
assert tab.screenshot() == "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
|
||||
)
|
||||
|
||||
def test_pin_unpin(self, tab, mock_send):
|
||||
tab.pin()
|
||||
tab.unpin()
|
||||
assert mock_send.call_args_list == [
|
||||
call("tabs.pin", {"tabId": 10}, profile=None, remote=None, key=None),
|
||||
call("tabs.unpin", {"tabId": 10}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_refresh(self, tab, mock_send):
|
||||
mock_send.return_value = {**TAB_DATA, "title": "Fresh"}
|
||||
fresh = tab.refresh()
|
||||
assert fresh.title == "Fresh"
|
||||
mock_send.assert_called_once_with("tabs.status", {"tabId": 10}, profile=None, remote=None, key=None)
|
||||
|
||||
def test_wait_for_load_and_watch_url(self, tab, mock_send):
|
||||
mock_send.side_effect = [TAB_DATA, TAB_DATA]
|
||||
tab.wait_for_load(timeout=1.5, ready_state="interactive")
|
||||
tab.watch_url("example", timeout=2)
|
||||
assert mock_send.call_args_list == [
|
||||
call("navigate.wait", {"tabId": 10, "timeout": 1500, "readyState": "interactive"}, profile=None, remote=None, key=None),
|
||||
call("tabs.watch_url", {"pattern": "example", "tabId": 10, "timeout": 2000}, profile=None, remote=None, key=None),
|
||||
]
|
||||
|
||||
def test_open_background_changes_same_tab(self, tab, mock_send):
|
||||
tab.open("https://new.example.com", background=True)
|
||||
mock_send.assert_called_once_with(
|
||||
|
||||
Reference in New Issue
Block a user