Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2f982fa714
|
|||
|
6785b9f70c
|
@@ -33,16 +33,22 @@ class BrowserCounts:
|
|||||||
|
|
||||||
|
|
||||||
class BrowserCLI:
|
class BrowserCLI:
|
||||||
def __init__(self, browser: str | None = None):
|
def __init__(self, browser: str | None = None, remote: str | None = None, token: str | None = None):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
browser: Profile alias to target. Required when multiple browser
|
browser: Profile alias to target. Required when multiple browser
|
||||||
instances are active. Equivalent to ``--browser`` on the CLI.
|
instances are active. Equivalent to ``--browser`` on the CLI.
|
||||||
|
remote: Connect to a remote browser exposed via ``browser-cli serve``.
|
||||||
|
Format: ``"host:port"`` (e.g. ``"192.168.1.10:8765"``).
|
||||||
|
When set, ``browser`` is ignored.
|
||||||
|
token: Auth token for the remote serve instance.
|
||||||
"""
|
"""
|
||||||
self._browser = browser
|
self._browser = browser
|
||||||
|
self._remote = remote
|
||||||
|
self._token = token
|
||||||
|
|
||||||
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)
|
return send_command(command, args, profile=self._browser, remote=self._remote, token=self._token)
|
||||||
|
|
||||||
def _multi_browser_targets(self):
|
def _multi_browser_targets(self):
|
||||||
if self._browser is not None:
|
if self._browser is not None:
|
||||||
|
|||||||
+48
-20
@@ -24,6 +24,7 @@ from browser_cli.commands.search import search_group
|
|||||||
from browser_cli.commands.page import page_group
|
from browser_cli.commands.page import page_group
|
||||||
from browser_cli.commands.storage import storage_group
|
from browser_cli.commands.storage import storage_group
|
||||||
from browser_cli.commands.cookies import cookies_group
|
from browser_cli.commands.cookies import cookies_group
|
||||||
|
from browser_cli.commands.serve import cmd_serve
|
||||||
from browser_cli.client import (
|
from browser_cli.client import (
|
||||||
send_command,
|
send_command,
|
||||||
BrowserNotConnected,
|
BrowserNotConnected,
|
||||||
@@ -164,14 +165,28 @@ def _print_version(ctx, param, value):
|
|||||||
"--browser", default=None, metavar="ALIAS",
|
"--browser", default=None, metavar="ALIAS",
|
||||||
help="Browser profile alias to target (required when multiple browsers are active).",
|
help="Browser profile alias to target (required when multiple browsers are active).",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--remote", default=None, metavar="HOST:PORT",
|
||||||
|
help="Connect to a remote browser exposed via 'browser-cli serve'.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--token", default=None, metavar="TOKEN",
|
||||||
|
help="Auth token for the remote browser-cli serve instance.",
|
||||||
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def main(ctx, browser):
|
def main(ctx, browser, remote, token):
|
||||||
"""Control your running browser from the terminal via a Chrome extension."""
|
"""Control your running browser from the terminal via a Chrome extension."""
|
||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
ctx.obj["browser"] = browser
|
ctx.obj["browser"] = browser
|
||||||
ctx.obj["browser_explicit"] = browser is not None
|
ctx.obj["browser_explicit"] = browser is not None
|
||||||
if browser:
|
if browser:
|
||||||
os.environ["BROWSER_CLI_PROFILE"] = browser
|
os.environ["BROWSER_CLI_PROFILE"] = browser
|
||||||
|
ctx.obj["remote"] = remote
|
||||||
|
ctx.obj["token"] = token
|
||||||
|
if remote:
|
||||||
|
os.environ["BROWSER_CLI_REMOTE"] = remote
|
||||||
|
if token:
|
||||||
|
os.environ["BROWSER_CLI_TOKEN"] = token
|
||||||
|
|
||||||
|
|
||||||
# ── Sub-command groups ─────────────────────────────────────────────────────────
|
# ── Sub-command groups ─────────────────────────────────────────────────────────
|
||||||
@@ -186,6 +201,7 @@ main.add_command(search_group)
|
|||||||
main.add_command(page_group)
|
main.add_command(page_group)
|
||||||
main.add_command(storage_group)
|
main.add_command(storage_group)
|
||||||
main.add_command(cookies_group)
|
main.add_command(cookies_group)
|
||||||
|
main.add_command(cmd_serve)
|
||||||
|
|
||||||
|
|
||||||
# ── clients ────────────────────────────────────────────────────────────────────
|
# ── clients ────────────────────────────────────────────────────────────────────
|
||||||
@@ -197,29 +213,41 @@ def clients_group(ctx):
|
|||||||
if ctx.invoked_subcommand is not None:
|
if ctx.invoked_subcommand is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
profiles: dict[str, str] = {}
|
|
||||||
if REGISTRY_PATH.exists():
|
|
||||||
try:
|
|
||||||
profiles = json.loads(REGISTRY_PATH.read_text())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
all_clients = []
|
all_clients = []
|
||||||
for profile_name, sock_path in profiles.items():
|
|
||||||
display_profile = display_browser_name(profile_name, sock_path)
|
remote = (ctx.obj or {}).get("remote") or os.environ.get("BROWSER_CLI_REMOTE")
|
||||||
|
if remote:
|
||||||
try:
|
try:
|
||||||
result = send_command("clients.list", profile=profile_name)
|
result = send_command("clients.list", profile=(ctx.obj or {}).get("browser"))
|
||||||
for c in (result or []):
|
for c in (result or []):
|
||||||
c["profile"] = display_profile
|
c["profile"] = c.get("profile") or (ctx.obj or {}).get("browser") or "remote"
|
||||||
all_clients.append(c)
|
all_clients.append(c)
|
||||||
except (BrowserNotConnected, RuntimeError):
|
except (BrowserNotConnected, RuntimeError) as e:
|
||||||
# Socket registered but browser no longer connected
|
console.print(f"[red]Error:[/red] {e}")
|
||||||
all_clients.append({
|
sys.exit(1)
|
||||||
"profile": display_profile,
|
else:
|
||||||
"name": "—",
|
profiles: dict[str, str] = {}
|
||||||
"version": "—",
|
if REGISTRY_PATH.exists():
|
||||||
"extensionVersion": "disconnected",
|
try:
|
||||||
})
|
profiles = json.loads(REGISTRY_PATH.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for profile_name, sock_path in profiles.items():
|
||||||
|
display_profile = display_browser_name(profile_name, sock_path)
|
||||||
|
try:
|
||||||
|
result = send_command("clients.list", profile=profile_name)
|
||||||
|
for c in (result or []):
|
||||||
|
c["profile"] = display_profile
|
||||||
|
all_clients.append(c)
|
||||||
|
except (BrowserNotConnected, RuntimeError):
|
||||||
|
# Socket registered but browser no longer connected
|
||||||
|
all_clients.append({
|
||||||
|
"profile": display_profile,
|
||||||
|
"name": "—",
|
||||||
|
"version": "—",
|
||||||
|
"extensionVersion": "disconnected",
|
||||||
|
})
|
||||||
|
|
||||||
if not all_clients:
|
if not all_clients:
|
||||||
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
console.print("[yellow]No browser clients found. Start a browser with the extension enabled first.[/yellow]")
|
||||||
|
|||||||
+25
-3
@@ -98,28 +98,50 @@ def _resolve_socket(profile: str | None = None) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_command(command: str, args: dict | None = None, profile: str | None = None) -> Any:
|
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None) -> Any:
|
||||||
"""Send a command to the browser and return the response data."""
|
"""Send a command to the browser and return the response data."""
|
||||||
sock_path = _resolve_socket(profile)
|
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||||
|
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN")
|
||||||
msg = {
|
msg = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"command": command,
|
"command": command,
|
||||||
"args": args or {},
|
"args": args or {},
|
||||||
}
|
}
|
||||||
|
if remote_endpoint:
|
||||||
|
if resolved_token:
|
||||||
|
msg["token"] = resolved_token
|
||||||
|
route_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||||
|
if route_profile:
|
||||||
|
msg["_route"] = route_profile
|
||||||
payload = json.dumps(msg).encode("utf-8")
|
payload = json.dumps(msg).encode("utf-8")
|
||||||
framed = struct.pack("<I", len(payload)) + payload
|
framed = struct.pack("<I", len(payload)) + payload
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if is_windows():
|
if remote_endpoint:
|
||||||
|
host, _, port_str = remote_endpoint.rpartition(":")
|
||||||
|
if not host or not port_str:
|
||||||
|
raise BrowserNotConnected(f"Invalid remote endpoint '{remote_endpoint}': expected host:port")
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.connect((host, int(port_str)))
|
||||||
|
sock.sendall(framed)
|
||||||
|
response = _recv_all(sock)
|
||||||
|
elif is_windows():
|
||||||
|
sock_path = _resolve_socket(profile)
|
||||||
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||||
conn.send_bytes(payload)
|
conn.send_bytes(payload)
|
||||||
response = conn.recv_bytes()
|
response = conn.recv_bytes()
|
||||||
else:
|
else:
|
||||||
|
sock_path = _resolve_socket(profile)
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||||
sock.connect(sock_path)
|
sock.connect(sock_path)
|
||||||
sock.sendall(framed)
|
sock.sendall(framed)
|
||||||
response = _recv_all(sock)
|
response = _recv_all(sock)
|
||||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
||||||
|
if remote_endpoint:
|
||||||
|
raise BrowserNotConnected(
|
||||||
|
f"Cannot connect to remote browser at {remote_endpoint}.\n"
|
||||||
|
"Make sure browser-cli serve is running on the remote host."
|
||||||
|
)
|
||||||
profile_hint = f" (profile: {profile})" if profile else ""
|
profile_hint = f" (profile: {profile})" if profile else ""
|
||||||
raise BrowserNotConnected(
|
raise BrowserNotConnected(
|
||||||
f"Cannot connect to browser{profile_hint}.\n"
|
f"Cannot connect to browser{profile_hint}.\n"
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import threading, secrets, socket, struct, click, json, sys
|
||||||
|
from rich.console import Console
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def _recv_exact(sock:socket.socket, n:int) -> bytes:
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = sock.recv(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise ConnectionError("Connection closed")
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
|
||||||
|
ts = datetime.now().strftime("%H:%M:%S")
|
||||||
|
addr_str = f"{addr[0]}:{addr[1]}"
|
||||||
|
profile_str = f"[dim]{profile}[/dim] " if profile else ""
|
||||||
|
if error:
|
||||||
|
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
|
||||||
|
else:
|
||||||
|
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
|
||||||
|
|
||||||
|
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None) -> None:
|
||||||
|
from browser_cli.client import _resolve_socket, BrowserNotConnected
|
||||||
|
from browser_cli.platform import is_windows
|
||||||
|
|
||||||
|
try:
|
||||||
|
header = _recv_exact(client_sock, 4)
|
||||||
|
msg_len = struct.unpack("<I", header)[0]
|
||||||
|
payload = _recv_exact(client_sock, msg_len)
|
||||||
|
except (ConnectionError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
def _send_error(msg_id, msg:str) -> None:
|
||||||
|
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
|
||||||
|
try:
|
||||||
|
client_sock.sendall(struct.pack("<I", len(err)) + err)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(payload)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
_send_error(None, "invalid JSON")
|
||||||
|
_log(addr, "?", None, "ERROR", "invalid JSON")
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_id = msg.get("id")
|
||||||
|
command = msg.get("command", "?")
|
||||||
|
|
||||||
|
if server_token is not None:
|
||||||
|
if msg.get("token") != server_token:
|
||||||
|
_send_error(msg_id, "unauthorized: invalid or missing token")
|
||||||
|
_log(addr, command, None, "DENIED", "bad token")
|
||||||
|
return
|
||||||
|
|
||||||
|
resolved_profile = msg.get("_route") or profile
|
||||||
|
|
||||||
|
strip = {"token", "_route"}
|
||||||
|
if strip & msg.keys():
|
||||||
|
clean_payload = json.dumps({k: v for k, v in msg.items() if k not in strip}).encode()
|
||||||
|
clean_header = struct.pack("<I", len(clean_payload))
|
||||||
|
else:
|
||||||
|
clean_payload = payload
|
||||||
|
clean_header = header
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock_path = _resolve_socket(resolved_profile)
|
||||||
|
except BrowserNotConnected as e:
|
||||||
|
_send_error(msg_id, str(e))
|
||||||
|
_log(addr, command, resolved_profile, "ERROR", "browser not connected")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_windows():
|
||||||
|
from multiprocessing.connection import Client as PipeClient
|
||||||
|
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
||||||
|
pipe.send_bytes(clean_payload)
|
||||||
|
resp = pipe.recv_bytes()
|
||||||
|
client_sock.sendall(struct.pack("<I", len(resp)) + resp)
|
||||||
|
else:
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
|
||||||
|
local.connect(sock_path)
|
||||||
|
local.sendall(clean_header + clean_payload)
|
||||||
|
resp_header = _recv_exact(local, 4)
|
||||||
|
resp_len = struct.unpack("<I", resp_header)[0]
|
||||||
|
resp_payload = _recv_exact(local, resp_len)
|
||||||
|
client_sock.sendall(resp_header + resp_payload)
|
||||||
|
|
||||||
|
resp_data = json.loads(resp_payload if not is_windows() else resp)
|
||||||
|
if resp_data.get("success", True):
|
||||||
|
_log(addr, command, resolved_profile, "OK")
|
||||||
|
else:
|
||||||
|
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||||
|
except OSError as e:
|
||||||
|
_send_error(msg_id, str(e))
|
||||||
|
_log(addr, command, resolved_profile, "ERROR", str(e))
|
||||||
|
|
||||||
|
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, server_token:str|None) -> None:
|
||||||
|
with client_sock:
|
||||||
|
_proxy_request(client_sock, addr, profile, server_token)
|
||||||
|
|
||||||
|
@click.command("serve")
|
||||||
|
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
|
||||||
|
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
|
||||||
|
@click.option("--token", default=None, metavar="TOKEN", help="Auth token (auto-generated if omitted).")
|
||||||
|
@click.option("--no-auth", is_flag=True, default=False, help="Disable token authentication.")
|
||||||
|
@click.pass_context
|
||||||
|
def cmd_serve(ctx, host, port, token, no_auth):
|
||||||
|
"""Expose this browser over TCP so remote hosts can control it."""
|
||||||
|
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||||
|
|
||||||
|
if host in ("0.0.0.0", "::"):
|
||||||
|
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
|
||||||
|
|
||||||
|
if no_auth:
|
||||||
|
server_token = None
|
||||||
|
else:
|
||||||
|
server_token = token or secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
server.bind((host, port))
|
||||||
|
except OSError as e:
|
||||||
|
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
server.listen(16)
|
||||||
|
|
||||||
|
browser_hint = f" (browser: {profile})" if profile else ""
|
||||||
|
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan]")
|
||||||
|
|
||||||
|
if server_token:
|
||||||
|
console.print(f" Token: [bold yellow]{server_token}[/bold yellow]")
|
||||||
|
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} --token {server_token} tabs list[/dim]")
|
||||||
|
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\", token=\"{server_token}\").tabs_list()[/dim]")
|
||||||
|
else:
|
||||||
|
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
|
||||||
|
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs_list()[/dim]")
|
||||||
|
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
|
||||||
|
|
||||||
|
console.print("Ctrl-C to stop.\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
conn, addr = server.accept()
|
||||||
|
threading.Thread(target=_handle_client, args=(conn, addr, profile, server_token), daemon=True).start()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("[yellow]Stopped.[/yellow]")
|
||||||
|
finally:
|
||||||
|
server.close()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "browser-cli",
|
"name": "browser-cli",
|
||||||
"version": "0.7.1",
|
"version": "0.8.1",
|
||||||
"description": "Control your browser from the terminal via browser-cli",
|
"description": "Control your browser from the terminal via browser-cli",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.7.1"
|
version = "0.8.1"
|
||||||
description = "Control your real running browser from the terminal via a browser extension"
|
description = "Control your real running browser from the terminal via a browser extension"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+35
-35
@@ -122,7 +122,7 @@ class TestNavigation:
|
|||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"navigate.open",
|
"navigate.open",
|
||||||
{"url": "https://example.com", "background": False, "window": None, "group": None},
|
{"url": "https://example.com", "background": False, "window": None, "group": None},
|
||||||
profile=None,
|
profile=None, remote=None, token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_open_background(self, b, mock_send):
|
def test_open_background(self, b, mock_send):
|
||||||
@@ -136,33 +136,33 @@ class TestNavigation:
|
|||||||
|
|
||||||
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)
|
mock_send.assert_called_once_with("navigate.reload", {"tabId": 5}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_hard_reload(self, b, mock_send):
|
def test_hard_reload(self, b, mock_send):
|
||||||
b.hard_reload(tab_id=7)
|
b.hard_reload(tab_id=7)
|
||||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None)
|
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 7}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_back(self, b, mock_send):
|
def test_back(self, b, mock_send):
|
||||||
b.back(tab_id=3)
|
b.back(tab_id=3)
|
||||||
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None)
|
mock_send.assert_called_once_with("navigate.back", {"tabId": 3}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_forward(self, b, mock_send):
|
def test_forward(self, b, mock_send):
|
||||||
b.forward(tab_id=3)
|
b.forward(tab_id=3)
|
||||||
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None)
|
mock_send.assert_called_once_with("navigate.forward", {"tabId": 3}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_focus_url(self, b, mock_send):
|
def test_focus_url(self, b, mock_send):
|
||||||
b.focus_url("github.com")
|
b.focus_url("github.com")
|
||||||
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None)
|
mock_send.assert_called_once_with("navigate.focus", {"pattern": "github.com"}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_navigate_tab(self, b, mock_send):
|
def test_navigate_tab(self, b, mock_send):
|
||||||
b.navigate_tab(5, "https://example.com")
|
b.navigate_tab(5, "https://example.com")
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None
|
"navigate.to", {"tabId": 5, "url": "https://example.com"}, profile=None, remote=None, token=None
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_profile_forwarded(self, b_profile, mock_send):
|
def test_profile_forwarded(self, b_profile, mock_send):
|
||||||
b_profile.reload()
|
b_profile.reload()
|
||||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave")
|
mock_send.assert_called_once_with("navigate.reload", {"tabId": None}, profile="brave", remote=None, token=None)
|
||||||
|
|
||||||
|
|
||||||
# ── Search ────────────────────────────────────────────────────────────────────
|
# ── Search ────────────────────────────────────────────────────────────────────
|
||||||
@@ -195,12 +195,12 @@ class TestExtract:
|
|||||||
result = b.extract_markdown()
|
result = b.extract_markdown()
|
||||||
|
|
||||||
assert result == "# Title"
|
assert result == "# Title"
|
||||||
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None)
|
mock_send.assert_called_once_with("extract.markdown", {"selector": None}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_extract_markdown_selector(self, b, mock_send):
|
def test_extract_markdown_selector(self, b, mock_send):
|
||||||
b.extract_markdown("article")
|
b.extract_markdown("article")
|
||||||
|
|
||||||
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None)
|
mock_send.assert_called_once_with("extract.markdown", {"selector": "article"}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
|
|
||||||
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -235,7 +235,7 @@ class TestTabs:
|
|||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"tabs.close",
|
"tabs.close",
|
||||||
{"tabId": 10, "inactive": False, "duplicates": False},
|
{"tabId": 10, "inactive": False, "duplicates": False},
|
||||||
profile=None,
|
profile=None, remote=None, token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tabs_move(self, b, mock_send):
|
def test_tabs_move(self, b, mock_send):
|
||||||
@@ -243,19 +243,19 @@ class TestTabs:
|
|||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"tabs.move",
|
"tabs.move",
|
||||||
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||||
profile=None,
|
profile=None, remote=None, token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tabs_active(self, b, mock_send):
|
def test_tabs_active(self, b, mock_send):
|
||||||
b.tabs_active(10)
|
b.tabs_active(10)
|
||||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
|
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_window_active_tab(self, b, mock_send):
|
def test_window_active_tab(self, b, mock_send):
|
||||||
mock_send.return_value = TAB_DATA
|
mock_send.return_value = TAB_DATA
|
||||||
tab = b.window_active_tab(1)
|
tab = b.window_active_tab(1)
|
||||||
assert isinstance(tab, Tab)
|
assert isinstance(tab, Tab)
|
||||||
assert tab.id == 10
|
assert tab.id == 10
|
||||||
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None)
|
mock_send.assert_called_once_with("tabs.active_in_window", {"windowId": 1}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_window_active_tab_missing_raises(self, b, mock_send):
|
def test_window_active_tab_missing_raises(self, b, mock_send):
|
||||||
mock_send.return_value = None
|
mock_send.return_value = None
|
||||||
@@ -308,7 +308,7 @@ class TestTabs:
|
|||||||
assert mock_send.call_args_list == [
|
assert mock_send.call_args_list == [
|
||||||
call("tabs.list", {}, profile="default"),
|
call("tabs.list", {}, profile="default"),
|
||||||
call("tabs.list", {}, profile="work"),
|
call("tabs.list", {}, profile="work"),
|
||||||
call("tabs.close", {"tabId": 11}, profile="work"),
|
call("tabs.close", {"tabId": 11}, profile="work", remote=None, token=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):
|
||||||
@@ -351,7 +351,7 @@ class TestTabs:
|
|||||||
|
|
||||||
def test_tabs_sort(self, b, mock_send):
|
def test_tabs_sort(self, b, mock_send):
|
||||||
b.tabs_sort(by="title")
|
b.tabs_sort(by="title")
|
||||||
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None)
|
mock_send.assert_called_once_with("tabs.sort", {"by": "title"}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_tabs_merge_windows(self, b, mock_send):
|
def test_tabs_merge_windows(self, b, mock_send):
|
||||||
mock_send.return_value = {"moved": 4}
|
mock_send.return_value = {"moved": 4}
|
||||||
@@ -384,7 +384,7 @@ class TestGroups:
|
|||||||
mock_send.return_value = [TAB_DATA]
|
mock_send.return_value = [TAB_DATA]
|
||||||
tabs = b.group_tabs(42)
|
tabs = b.group_tabs(42)
|
||||||
assert isinstance(tabs[0], Tab)
|
assert isinstance(tabs[0], Tab)
|
||||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None)
|
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_group_count(self, b, mock_send):
|
def test_group_count(self, b, mock_send):
|
||||||
mock_send.return_value = 7
|
mock_send.return_value = 7
|
||||||
@@ -412,7 +412,7 @@ class TestGroups:
|
|||||||
assert mock_send.call_args_list == [
|
assert mock_send.call_args_list == [
|
||||||
call("group.list", {}, profile="default"),
|
call("group.list", {}, profile="default"),
|
||||||
call("group.list", {}, profile="work"),
|
call("group.list", {}, profile="work"),
|
||||||
call("group.close", {"groupId": 99}, profile="work"),
|
call("group.close", {"groupId": 99}, profile="work", remote=None, token=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):
|
||||||
@@ -435,7 +435,7 @@ class TestGroups:
|
|||||||
|
|
||||||
def test_group_close(self, b, mock_send):
|
def test_group_close(self, b, mock_send):
|
||||||
b.group_close(42)
|
b.group_close(42)
|
||||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None)
|
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_group_create_dict_response(self, b, mock_send):
|
def test_group_create_dict_response(self, b, mock_send):
|
||||||
mock_send.return_value = GROUP_DATA
|
mock_send.return_value = GROUP_DATA
|
||||||
@@ -455,7 +455,7 @@ class TestGroups:
|
|||||||
tab_id = b.group_add_tab(42, "https://example.com")
|
tab_id = b.group_add_tab(42, "https://example.com")
|
||||||
assert tab_id == 55
|
assert tab_id == 55
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None
|
"group.add_tab", {"group": "42", "url": "https://example.com"}, profile=None, remote=None, token=None
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_group_add_tab_non_dict_response(self, b, mock_send):
|
def test_group_add_tab_non_dict_response(self, b, mock_send):
|
||||||
@@ -465,7 +465,7 @@ class TestGroups:
|
|||||||
def test_group_move_forward(self, b, mock_send):
|
def test_group_move_forward(self, b, mock_send):
|
||||||
b.group_move(42, forward=True)
|
b.group_move(42, forward=True)
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None
|
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ class TestWindows:
|
|||||||
result = b.windows_open()
|
result = b.windows_open()
|
||||||
|
|
||||||
assert result == {"id": 5}
|
assert result == {"id": 5}
|
||||||
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None)
|
mock_send.assert_called_once_with("windows.open", {"url": None}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_windows_open_with_url(self, b, mock_send):
|
def test_windows_open_with_url(self, b, mock_send):
|
||||||
mock_send.return_value = {"id": 9}
|
mock_send.return_value = {"id": 9}
|
||||||
@@ -503,7 +503,7 @@ class TestWindows:
|
|||||||
result = b.windows_open("https://example.com")
|
result = b.windows_open("https://example.com")
|
||||||
|
|
||||||
assert result == {"id": 9}
|
assert result == {"id": 9}
|
||||||
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None)
|
mock_send.assert_called_once_with("windows.open", {"url": "https://example.com"}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
|
|
||||||
class TestSession:
|
class TestSession:
|
||||||
@@ -513,7 +513,7 @@ class TestSession:
|
|||||||
result = b.session_list()
|
result = b.session_list()
|
||||||
|
|
||||||
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
assert result == [{"name": "saved", "tabs": 3, "savedAt": 1712707200000}]
|
||||||
mock_send.assert_called_once_with("session.list", {}, profile=None)
|
mock_send.assert_called_once_with("session.list", {}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
|
def test_session_list_multi_browser_adds_browser(self, b, mock_send):
|
||||||
with patch(
|
with patch(
|
||||||
@@ -548,26 +548,26 @@ class TestTabModel:
|
|||||||
|
|
||||||
def test_close(self, tab, mock_send):
|
def test_close(self, tab, mock_send):
|
||||||
tab.close()
|
tab.close()
|
||||||
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None)
|
mock_send.assert_called_once_with("tabs.close", {"tabId": 10}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_activate(self, tab, mock_send):
|
def test_activate(self, tab, mock_send):
|
||||||
tab.activate()
|
tab.activate()
|
||||||
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None)
|
mock_send.assert_called_once_with("tabs.active", {"tabId": 10}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_reload(self, tab, mock_send):
|
def test_reload(self, tab, mock_send):
|
||||||
tab.reload()
|
tab.reload()
|
||||||
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None)
|
mock_send.assert_called_once_with("navigate.reload", {"tabId": 10}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_hard_reload(self, tab, mock_send):
|
def test_hard_reload(self, tab, mock_send):
|
||||||
tab.hard_reload()
|
tab.hard_reload()
|
||||||
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None)
|
mock_send.assert_called_once_with("navigate.hard_reload", {"tabId": 10}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_move_forward(self, tab, mock_send):
|
def test_move_forward(self, tab, mock_send):
|
||||||
tab.move(forward=True)
|
tab.move(forward=True)
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"tabs.move",
|
"tabs.move",
|
||||||
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
{"tabId": 10, "forward": True, "backward": False, "groupId": None, "windowId": None, "index": None},
|
||||||
profile=None,
|
profile=None, remote=None, token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_move_to_group(self, tab, mock_send):
|
def test_move_to_group(self, tab, mock_send):
|
||||||
@@ -577,12 +577,12 @@ class TestTabModel:
|
|||||||
def test_html(self, tab, mock_send):
|
def test_html(self, tab, mock_send):
|
||||||
mock_send.return_value = "<html/>"
|
mock_send.return_value = "<html/>"
|
||||||
assert tab.html() == "<html/>"
|
assert tab.html() == "<html/>"
|
||||||
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None)
|
mock_send.assert_called_once_with("tabs.html", {"tabId": 10}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_open(self, tab, mock_send):
|
def test_open(self, tab, mock_send):
|
||||||
tab.open("https://new.example.com")
|
tab.open("https://new.example.com")
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None
|
"navigate.to", {"tabId": 10, "url": "https://new.example.com"}, profile=None, remote=None, token=None
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_open_background_changes_same_tab(self, tab, mock_send):
|
def test_open_background_changes_same_tab(self, tab, mock_send):
|
||||||
@@ -590,7 +590,7 @@ class TestTabModel:
|
|||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"navigate.to",
|
"navigate.to",
|
||||||
{"tabId": 10, "url": "https://new.example.com"},
|
{"tabId": 10, "url": "https://new.example.com"},
|
||||||
profile=None,
|
profile=None, remote=None, token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_unbound_raises(self):
|
def test_unbound_raises(self):
|
||||||
@@ -608,18 +608,18 @@ class TestGroupModel:
|
|||||||
|
|
||||||
def test_close(self, group, mock_send):
|
def test_close(self, group, mock_send):
|
||||||
group.close()
|
group.close()
|
||||||
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None)
|
mock_send.assert_called_once_with("group.close", {"groupId": 42}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_tabs(self, group, mock_send):
|
def test_tabs(self, group, mock_send):
|
||||||
mock_send.return_value = [TAB_DATA]
|
mock_send.return_value = [TAB_DATA]
|
||||||
tabs = group.tabs()
|
tabs = group.tabs()
|
||||||
assert isinstance(tabs[0], Tab)
|
assert isinstance(tabs[0], Tab)
|
||||||
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None)
|
mock_send.assert_called_once_with("group.tabs", {"groupId": 42}, profile=None, remote=None, token=None)
|
||||||
|
|
||||||
def test_move_forward(self, group, mock_send):
|
def test_move_forward(self, group, mock_send):
|
||||||
group.move(forward=True)
|
group.move(forward=True)
|
||||||
mock_send.assert_called_once_with(
|
mock_send.assert_called_once_with(
|
||||||
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None
|
"group.move", {"group": "42", "forward": True, "backward": False}, profile=None, remote=None, token=None
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_move_backward(self, group, mock_send):
|
def test_move_backward(self, group, mock_send):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
@@ -140,6 +141,32 @@ def test_clients_exits_cleanly_when_registry_is_missing():
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "No browser clients found" in result.output
|
assert "No browser clients found" in result.output
|
||||||
|
|
||||||
|
def test_clients_remote_uses_remote_endpoint_without_local_registry():
|
||||||
|
def fake_send_command(command, args=None, profile=None):
|
||||||
|
assert command == "clients.list"
|
||||||
|
assert profile is None
|
||||||
|
return [{"name": "Chrome", "version": "1", "extensionVersion": "2.3.4"}]
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {}, clear=True), patch(
|
||||||
|
"browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")
|
||||||
|
), patch("browser_cli.cli.send_command", side_effect=fake_send_command) as send_command:
|
||||||
|
result = CliRunner().invoke(main, ["--remote", "127.0.0.1:8765", "--token", "test", "clients"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
send_command.assert_called_once()
|
||||||
|
assert "remote" in result.output
|
||||||
|
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"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
send_command.assert_called_once_with("clients.list", profile="work")
|
||||||
|
|
||||||
|
|
||||||
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
||||||
registry_path = tmp_path / "registry.json"
|
registry_path = tmp_path / "registry.json"
|
||||||
default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock"
|
default_socket = tmp_path / "550e8400-e29b-41d4-a716-446655440000.sock"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "browser-cli"
|
name = "browser-cli"
|
||||||
version = "0.7.1"
|
version = "0.8.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user