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:
@@ -1,19 +1,16 @@
|
|||||||
# browser-cli
|
# browser-cli
|
||||||
|
Control your real, running browser from the terminal or the Python SDK — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
|
||||||
Control your real, running browser from the terminal or a Python script — no headless browser, no Playwright, no virtual display. Your actual open tabs, windows, and tab groups respond to your commands.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
|
You have 40 tabs open. You want to close all the duplicates, group the GitHub ones, save your session before a meeting, and open a few URLs into a specific group — all from a script. That is what browser-cli is for.
|
||||||
|
|
||||||
It works by pairing a small browser extension with a Python CLI tool. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI talks to it in real time over a local IPC channel.
|
It works by pairing a small browser extension with a Python package that provides both a CLI and SDK. The extension has full access to your browser's tabs, windows, groups, and page DOM. The CLI and SDK talk to it in real time over a local IPC channel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
terminal / python script
|
terminal / python script
|
||||||
│
|
│
|
||||||
@@ -79,9 +76,9 @@ Only the `browser-cli` command needs to be on your `PATH`. The browser launches
|
|||||||
```text
|
```text
|
||||||
browser-cli/
|
browser-cli/
|
||||||
├── browser_cli/
|
├── browser_cli/
|
||||||
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
|
│ ├── __init__.py # Python SDK — BrowserCLI class and SDK entry point
|
||||||
│ ├── cli.py # Click CLI entry point
|
│ ├── cli.py # Click CLI entry point
|
||||||
│ ├── client.py # Local IPC client used by CLI and API
|
│ ├── client.py # Local IPC client used by CLI and SDK
|
||||||
│ ├── models.py # Tab and Group helper models
|
│ ├── models.py # Tab and Group helper models
|
||||||
│ ├── native_host.py # Native messaging host launched by the browser
|
│ ├── native_host.py # Native messaging host launched by the browser
|
||||||
│ └── commands/
|
│ └── commands/
|
||||||
@@ -99,7 +96,7 @@ browser-cli/
|
|||||||
│ └── src/ # TypeScript source split by command area
|
│ └── src/ # TypeScript source split by command area
|
||||||
│ └── index.ts # Builds generated extension/background.js
|
│ └── index.ts # Builds generated extension/background.js
|
||||||
├── examples/
|
├── examples/
|
||||||
│ ├── demo.py # Python API walkthrough
|
│ ├── demo.py # Python SDK walkthrough
|
||||||
│ └── demo.sh # Bash CLI walkthrough
|
│ └── demo.sh # Bash CLI walkthrough
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── conftest.py # shared pytest fixtures
|
│ ├── conftest.py # shared pytest fixtures
|
||||||
@@ -285,7 +282,7 @@ browser-cli completion zsh --script # output raw completion script
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Python API
|
## Python SDK
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from browser_cli import BrowserCLI
|
from browser_cli import BrowserCLI
|
||||||
@@ -293,11 +290,13 @@ from browser_cli import BrowserCLI
|
|||||||
b = BrowserCLI()
|
b = BrowserCLI()
|
||||||
```
|
```
|
||||||
|
|
||||||
Every CLI command has a corresponding method. The call blocks until the browser responds and returns the data directly as a Python object.
|
Every CLI command has a corresponding SDK method. The call blocks until the browser responds and returns the data directly as a Python object.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Navigation
|
# Navigation
|
||||||
b.open("https://example.com")
|
b.open("https://example.com")
|
||||||
|
tab = b.open_tab("https://example.com") # returns a bound Tab object
|
||||||
|
tab = b.open_tab("https://example.com", wait=True, timeout=10)
|
||||||
b.open("https://example.com", background=True)
|
b.open("https://example.com", background=True)
|
||||||
b.open("https://example.com", window="work")
|
b.open("https://example.com", window="work")
|
||||||
b.reload()
|
b.reload()
|
||||||
@@ -308,8 +307,14 @@ b.focus_url("github")
|
|||||||
|
|
||||||
# Tabs
|
# Tabs
|
||||||
tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set
|
tabs = b.tabs_list() # list[Tab]; in multi-browser mode each tab.browser is set
|
||||||
|
tabs = b.tabs() # short alias for tabs_list()
|
||||||
|
active = b.active_tab() # active Tab object
|
||||||
|
tab = b.tab(1234) # tab by ID
|
||||||
|
tab = b.find_tab("github") # first matching tab or None
|
||||||
|
tabs = b.find_tabs("github") # alias for tabs_query()
|
||||||
b.tabs_active(1234)
|
b.tabs_active(1234)
|
||||||
b.tabs_close(1234)
|
b.tabs_close(1234)
|
||||||
|
b.close_tab(tab) # accepts Tab or tab ID
|
||||||
b.tabs_close_inactive()
|
b.tabs_close_inactive()
|
||||||
b.tabs_close_duplicates()
|
b.tabs_close_duplicates()
|
||||||
b.tabs_filter("youtube") # list of matching tabs
|
b.tabs_filter("youtube") # list of matching tabs
|
||||||
@@ -320,9 +325,19 @@ b.tabs_sort(by="domain")
|
|||||||
b.tabs_merge_windows()
|
b.tabs_merge_windows()
|
||||||
b.tabs_dedupe()
|
b.tabs_dedupe()
|
||||||
|
|
||||||
|
# Bound Tab helpers
|
||||||
|
tab = b.active_tab()
|
||||||
|
tab.pin()
|
||||||
|
tab.screenshot()
|
||||||
|
tab.refresh()
|
||||||
|
tab.wait_for_load(timeout=10)
|
||||||
|
tab.watch_url(r"/done$")
|
||||||
|
|
||||||
# Tab groups
|
# Tab groups
|
||||||
groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set
|
groups = b.group_list() # list[Group]; in multi-browser mode each group.browser is set
|
||||||
b.group_open("research") # creates group, returns { id, name }
|
groups = b.groups() # short alias for group_list()
|
||||||
|
b.groups_create("research") # plural alias for group_create()
|
||||||
|
b.group_create("research") # creates group, returns Group
|
||||||
b.group_close(42)
|
b.group_close(42)
|
||||||
b.group_tabs(42) # tabs inside a group
|
b.group_tabs(42) # tabs inside a group
|
||||||
b.group_count() # int, or BrowserCounts(...) in multi-browser mode
|
b.group_count() # int, or BrowserCounts(...) in multi-browser mode
|
||||||
@@ -341,6 +356,7 @@ attrs = b.dom_attr("a", "href") # list of strings
|
|||||||
exists = b.dom_exists(".cookie-banner")# bool
|
exists = b.dom_exists(".cookie-banner")# bool
|
||||||
b.dom_click(".accept-button")
|
b.dom_click(".accept-button")
|
||||||
b.dom_type("#search", "hello world")
|
b.dom_type("#search", "hello world")
|
||||||
|
b.wait_for_selector("#results", visible=True, timeout=10)
|
||||||
|
|
||||||
# Extract
|
# Extract
|
||||||
links = b.extract_links() # list of { text, href }
|
links = b.extract_links() # list of { text, href }
|
||||||
@@ -359,6 +375,7 @@ b.session_auto_save(True)
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
clients = b.clients()
|
clients = b.clients()
|
||||||
|
raw = b.command("tabs.count", {"pattern": "github"}) # escape hatch for raw commands
|
||||||
```
|
```
|
||||||
|
|
||||||
**Error handling**
|
**Error handling**
|
||||||
|
|||||||
+109
-1
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
browser_cli — Python API for controlling your running browser.
|
browser_cli — Python SDK for controlling your running browser.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from browser_cli import BrowserCLI
|
from browser_cli import BrowserCLI
|
||||||
@@ -50,9 +50,32 @@ class BrowserCLI:
|
|||||||
self._remote = remote
|
self._remote = remote
|
||||||
self._key = key if key else None
|
self._key = key if key else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browser(self) -> str | None:
|
||||||
|
"""Target browser/profile alias, equivalent to ``--browser``."""
|
||||||
|
return self._browser
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remote(self) -> str | None:
|
||||||
|
"""Remote endpoint used by this client, if any."""
|
||||||
|
return self._remote
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str | None:
|
||||||
|
"""Ed25519 key spec used for remote auth, if explicitly configured."""
|
||||||
|
return self._key
|
||||||
|
|
||||||
def _cmd(self, command: str, args: dict | None = None):
|
def _cmd(self, command: str, args: dict | None = None):
|
||||||
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
|
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
|
||||||
|
|
||||||
|
def command(self, command: str, args: dict | None = None):
|
||||||
|
"""Send a raw browser-cli command and return its response.
|
||||||
|
|
||||||
|
This is the SDK escape hatch for commands that do not have a dedicated
|
||||||
|
convenience method yet.
|
||||||
|
"""
|
||||||
|
return self._cmd(command, args or {})
|
||||||
|
|
||||||
def _multi_browser_targets(self):
|
def _multi_browser_targets(self):
|
||||||
if self._browser is not None:
|
if self._browser is not None:
|
||||||
return []
|
return []
|
||||||
@@ -109,6 +132,7 @@ class BrowserCLI:
|
|||||||
tab._browser = self if browser_profile is None else BrowserCLI(
|
tab._browser = self if browser_profile is None else BrowserCLI(
|
||||||
browser=browser_profile,
|
browser=browser_profile,
|
||||||
remote=browser_remote,
|
remote=browser_remote,
|
||||||
|
key=self._key,
|
||||||
)
|
)
|
||||||
return tab
|
return tab
|
||||||
|
|
||||||
@@ -131,6 +155,7 @@ class BrowserCLI:
|
|||||||
group._browser = self if browser_profile is None else BrowserCLI(
|
group._browser = self if browser_profile is None else BrowserCLI(
|
||||||
browser=browser_profile,
|
browser=browser_profile,
|
||||||
remote=browser_remote,
|
remote=browser_remote,
|
||||||
|
key=self._key,
|
||||||
)
|
)
|
||||||
return group
|
return group
|
||||||
|
|
||||||
@@ -139,6 +164,27 @@ class BrowserCLI:
|
|||||||
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
|
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
|
||||||
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||||
|
|
||||||
|
def open_tab(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
wait: bool = False,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
background: bool = False,
|
||||||
|
window: str | None = None,
|
||||||
|
group: str | None = None,
|
||||||
|
) -> "Tab":
|
||||||
|
"""Open URL in a new tab and return a bound :class:`Tab` object.
|
||||||
|
|
||||||
|
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
||||||
|
"""
|
||||||
|
if wait:
|
||||||
|
return self.open_wait(url, timeout=timeout, background=background, window=window, group=group)
|
||||||
|
data = self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||||
|
if not isinstance(data, dict) or "id" not in data:
|
||||||
|
raise RuntimeError("navigate.open returned unexpected data")
|
||||||
|
return self._make_tab(data)
|
||||||
|
|
||||||
def reload(self, tab_id: int | None = None) -> None:
|
def reload(self, tab_id: int | None = None) -> None:
|
||||||
self._cmd("navigate.reload", {"tabId": tab_id})
|
self._cmd("navigate.reload", {"tabId": tab_id})
|
||||||
|
|
||||||
@@ -216,6 +262,32 @@ class BrowserCLI:
|
|||||||
|
|
||||||
# ── Tabs ──────────────────────────────────────────────────────────────
|
# ── Tabs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def tabs(self) -> list[Tab]:
|
||||||
|
"""Alias for :meth:`tabs_list`."""
|
||||||
|
return self.tabs_list()
|
||||||
|
|
||||||
|
def tab(self, tab_id: int) -> Tab:
|
||||||
|
"""Return a specific tab by ID."""
|
||||||
|
return self.tabs_status(tab_id)
|
||||||
|
|
||||||
|
def active_tab(self) -> Tab:
|
||||||
|
"""Return the active tab."""
|
||||||
|
return self.tabs_status()
|
||||||
|
|
||||||
|
def find_tabs(self, search: str) -> list[Tab]:
|
||||||
|
"""Alias for :meth:`tabs_query`."""
|
||||||
|
return self.tabs_query(search)
|
||||||
|
|
||||||
|
def find_tab(self, search: str) -> Tab | None:
|
||||||
|
"""Return the first tab matching *search*, or ``None``."""
|
||||||
|
matches = self.tabs_query(search)
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
def close_tab(self, tab: int | Tab) -> int:
|
||||||
|
"""Close a tab by ID or :class:`Tab` object. Returns count closed."""
|
||||||
|
tab_id = tab.id if isinstance(tab, Tab) else tab
|
||||||
|
return self.tabs_close(tab_id)
|
||||||
|
|
||||||
def tabs_list(self) -> list[Tab]:
|
def tabs_list(self) -> list[Tab]:
|
||||||
"""Return all open tabs across all windows.
|
"""Return all open tabs across all windows.
|
||||||
|
|
||||||
@@ -370,6 +442,14 @@ class BrowserCLI:
|
|||||||
|
|
||||||
# ── Tab Groups ────────────────────────────────────────────────────────
|
# ── Tab Groups ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def groups(self) -> list[Group]:
|
||||||
|
"""Alias for :meth:`group_list`."""
|
||||||
|
return self.group_list()
|
||||||
|
|
||||||
|
def groups_list(self) -> list[Group]:
|
||||||
|
"""Alias for :meth:`group_list`."""
|
||||||
|
return self.group_list()
|
||||||
|
|
||||||
def group_list(self) -> list[Group]:
|
def group_list(self) -> list[Group]:
|
||||||
"""Return all tab groups.
|
"""Return all tab groups.
|
||||||
|
|
||||||
@@ -394,6 +474,10 @@ class BrowserCLI:
|
|||||||
"""Return all tabs inside a group."""
|
"""Return all tabs inside a group."""
|
||||||
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
|
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
|
||||||
|
|
||||||
|
def groups_count(self) -> int | BrowserCounts:
|
||||||
|
"""Alias for :meth:`group_count`."""
|
||||||
|
return self.group_count()
|
||||||
|
|
||||||
def group_count(self) -> int | BrowserCounts:
|
def group_count(self) -> int | BrowserCounts:
|
||||||
"""Return the number of tab groups.
|
"""Return the number of tab groups.
|
||||||
|
|
||||||
@@ -405,6 +489,10 @@ class BrowserCLI:
|
|||||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||||
return self._cmd("group.count", {})
|
return self._cmd("group.count", {})
|
||||||
|
|
||||||
|
def groups_query(self, search: str) -> list[Group]:
|
||||||
|
"""Alias for :meth:`group_query`."""
|
||||||
|
return self.group_query(search)
|
||||||
|
|
||||||
def group_query(self, search: str) -> list[Group]:
|
def group_query(self, search: str) -> list[Group]:
|
||||||
"""Search groups by name."""
|
"""Search groups by name."""
|
||||||
return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])]
|
return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])]
|
||||||
@@ -413,6 +501,14 @@ class BrowserCLI:
|
|||||||
"""Ungroup (and close) a tab group by ID."""
|
"""Ungroup (and close) a tab group by ID."""
|
||||||
self._cmd("group.close", {"groupId": group_id})
|
self._cmd("group.close", {"groupId": group_id})
|
||||||
|
|
||||||
|
def groups_create(self, name: str) -> Group:
|
||||||
|
"""Alias for :meth:`group_create`."""
|
||||||
|
return self.group_create(name)
|
||||||
|
|
||||||
|
def group_open(self, name: str) -> Group:
|
||||||
|
"""Alias for :meth:`group_create`."""
|
||||||
|
return self.group_create(name)
|
||||||
|
|
||||||
def group_create(self, name: str) -> Group:
|
def group_create(self, name: str) -> Group:
|
||||||
"""Create a new tab group with *name*. Returns the created Group."""
|
"""Create a new tab group with *name*. Returns the created Group."""
|
||||||
data = self._cmd("group.open", {"name": name})
|
data = self._cmd("group.open", {"name": name})
|
||||||
@@ -536,6 +632,18 @@ class BrowserCLI:
|
|||||||
"tabId": tab_id,
|
"tabId": tab_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def wait_for_selector(
|
||||||
|
self,
|
||||||
|
selector: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
visible: bool = False,
|
||||||
|
hidden: bool = False,
|
||||||
|
tab_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Alias for :meth:`dom_wait_for`."""
|
||||||
|
return self.dom_wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
|
||||||
|
|
||||||
def dom_wait_for(
|
def dom_wait_for(
|
||||||
self,
|
self,
|
||||||
selector: str,
|
selector: str,
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ class Tab:
|
|||||||
"""Return the full HTML source of this tab."""
|
"""Return the full HTML source of this tab."""
|
||||||
return self._b()._cmd("tabs.html", {"tabId": self.id})
|
return self._b()._cmd("tabs.html", {"tabId": self.id})
|
||||||
|
|
||||||
|
def screenshot(self, *, format: str = "png", quality: int | None = None) -> str:
|
||||||
|
"""Capture this tab's visible area. Returns a base64 data URL."""
|
||||||
|
return self._b().tabs_screenshot(self.id, format=format, quality=quality)
|
||||||
|
|
||||||
|
def pin(self) -> None:
|
||||||
|
"""Pin this tab."""
|
||||||
|
self._b()._cmd("tabs.pin", {"tabId": self.id})
|
||||||
|
|
||||||
|
def unpin(self) -> None:
|
||||||
|
"""Unpin this tab."""
|
||||||
|
self._b()._cmd("tabs.unpin", {"tabId": self.id})
|
||||||
|
|
||||||
|
def refresh(self) -> Tab:
|
||||||
|
"""Return a fresh snapshot of this tab."""
|
||||||
|
return self._b().tabs_status(self.id)
|
||||||
|
|
||||||
|
def wait_for_load(self, *, timeout: float = 30.0, ready_state: str = "complete") -> Tab:
|
||||||
|
"""Wait until this tab reaches the requested readyState."""
|
||||||
|
return self._b().wait_for_load(self.id, timeout=timeout, ready_state=ready_state)
|
||||||
|
|
||||||
|
def watch_url(self, pattern: str, *, timeout: float = 30.0) -> Tab:
|
||||||
|
"""Wait until this tab's URL matches regex *pattern*."""
|
||||||
|
return self._b().tabs_watch_url(pattern, tab_id=self.id, timeout=timeout)
|
||||||
|
|
||||||
def open(self, url: str, *, background: bool = False) -> None:
|
def open(self, url: str, *, background: bool = False) -> None:
|
||||||
"""Navigate this tab to *url* in place."""
|
"""Navigate this tab to *url* in place."""
|
||||||
self._b().navigate_tab(self.id, url)
|
self._b().navigate_tab(self.id, url)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.9.8",
|
"version": "0.9.9",
|
||||||
"description": "Control your browser from the terminal via browser-cli",
|
"description": "Control your browser from the terminal or Python SDK",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
"tabGroups",
|
"tabGroups",
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.8"
|
version = "0.9.9"
|
||||||
description = "Control your real running browser from the terminal via a browser extension"
|
description = "Control your real running browser from the terminal or Python SDK"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"click>=8",
|
"click>=8",
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ class TestBrowserCLIInit:
|
|||||||
assert b._browser == "work"
|
assert b._browser == "work"
|
||||||
assert b._remote == "host:8765"
|
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 ────────────────────────────────────────────────────────
|
# ── Internal factories ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -140,6 +165,41 @@ class TestNavigation:
|
|||||||
b.open("https://x.com", group="Work")
|
b.open("https://x.com", group="Work")
|
||||||
assert mock_send.call_args[0][1]["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):
|
def test_reload(self, b, mock_send):
|
||||||
b.reload(tab_id=5)
|
b.reload(tab_id=5)
|
||||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, key=None)
|
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),
|
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):
|
def test_tabs_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.active_browser_targets",
|
"browser_cli.active_browser_targets",
|
||||||
@@ -463,6 +581,40 @@ class TestGroups:
|
|||||||
call("group.close", {"groupId": 42}, profile="work", remote="host:8765", key=None),
|
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):
|
def test_group_count_multi_browser_returns_browser_counts(self, b, mock_send):
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.active_browser_targets",
|
"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)
|
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:
|
class TestSession:
|
||||||
def test_session_list(self, b, mock_send):
|
def test_session_list(self, b, mock_send):
|
||||||
mock_send.return_value = [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
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
|
"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):
|
def test_open_background_changes_same_tab(self, tab, mock_send):
|
||||||
tab.open("https://new.example.com", background=True)
|
tab.open("https://new.example.com", background=True)
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.9.8"
|
version = "0.9.9"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user