Compare commits

...

7 Commits

Author SHA1 Message Date
daniel156161 6785b9f70c feat(serve): add remote browser control over TCP with token auth
Build & Publish Package / publish (push) Successful in 50s
Testing / test (push) Successful in 31s
Package Extension / package-extension (push) Successful in 27s
Exposes a local browser over a TCP socket so remote machines can
  control it using the same CLI and Python API. Token auth (auto-generated
  via secrets.token_urlsafe) is on by default; --no-auth disables it.
  Profile routing via _route message field lets clients target specific
  browser instances on the remote host. BROWSER_CLI_PROFILE is forwarded
  automatically so --browser flag works transparently over remote.
  - browser-cli serve [--host] [--port] [--token] [--no-auth]
  - browser-cli --remote HOST:PORT --token TOKEN <command>
  - BrowserCLI(remote="host:port", token="...").tabs_list()
2026-04-25 18:33:59 +02:00
daniel156161 1bf44c0eef update uv lock file
Testing / test (push) Successful in 28s
Package Extension / package-extension (push) Successful in 14s
Build & Publish Package / publish (push) Successful in 26s
2026-04-17 21:09:27 +02:00
daniel156161 cf0c9555d0 update version to 0.7.1
Testing / test (push) Successful in 28s
Package Extension / package-extension (push) Successful in 13s
Build & Publish Package / publish (push) Successful in 31s
2026-04-17 21:08:18 +02:00
daniel156161 a7da6cfab0 hardcode extension id and not prompt user
Testing / test (push) Has been cancelled
2026-04-17 21:07:30 +02:00
daniel156161 88b4f5ed11 add key generation script 2026-04-17 21:06:52 +02:00
daniel156161 36abde501c update version to 0.7.0
Testing / test (push) Successful in 23s
Package Extension / package-extension (push) Successful in 21s
Build & Publish Package / publish (push) Successful in 24s
2026-04-17 20:52:47 +02:00
daniel156161 1aff084429 add deterministic extension key
Testing / test (push) Waiting to run
Extension ID: bfpmkhngkjnfhabmfckgeohlilokodkg
2026-04-17 20:45:36 +02:00
9 changed files with 323 additions and 51 deletions
+8 -2
View File
@@ -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:
+19 -5
View File
@@ -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,
@@ -36,6 +37,7 @@ from browser_cli.platform import install_base_dir, is_windows
console = Console() console = Console()
NATIVE_HOST_NAME = "com.browsercli.host" NATIVE_HOST_NAME = "com.browsercli.host"
EXTENSION_ID = "bfpmkhngkjnfhabmfckgeohlilokodkg"
NATIVE_HOST_DIRS = { NATIVE_HOST_DIRS = {
"chrome": { "chrome": {
@@ -163,14 +165,26 @@ 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
if remote:
os.environ["BROWSER_CLI_REMOTE"] = remote
if token:
os.environ["BROWSER_CLI_TOKEN"] = token
# ── Sub-command groups ───────────────────────────────────────────────────────── # ── Sub-command groups ─────────────────────────────────────────────────────────
@@ -185,6 +199,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 ────────────────────────────────────────────────────────────────────
@@ -284,7 +299,7 @@ def cmd_install(browser):
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n' wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8") wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Ask for extension ID # Load extension
ext_urls = { ext_urls = {
"chrome": "chrome://extensions", "chrome": "chrome://extensions",
"chromium": "chrome://extensions", "chromium": "chrome://extensions",
@@ -297,10 +312,9 @@ def cmd_install(browser):
console.print(f" 1. Open [cyan]{ext_url}[/cyan]") console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)") console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]") console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent / 'extension'}[/cyan]")
console.print(" 4. Copy the [bold]Extension ID[/bold] shown on the extension card\n") console.print(f" 4. Extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)\n")
extension_id = click.prompt("Paste your extension ID here") extension_id = EXTENSION_ID
extension_id = extension_id.strip()
# Build native messaging manifest # Build native messaging manifest
manifest = { manifest = {
+25 -3
View File
@@ -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"
+153
View File
@@ -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()
+7 -4
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "browser-cli", "name": "browser-cli",
"version": "0.6.0", "version": "0.7.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",
@@ -13,7 +13,9 @@
"nativeMessaging", "nativeMessaging",
"cookies" "cookies"
], ],
"host_permissions": ["<all_urls>"], "host_permissions": [
"<all_urls>"
],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
@@ -29,5 +31,6 @@
"16": "icons/icon-16.png", "16": "icons/icon-16.png",
"32": "icons/icon-32.png" "32": "icons/icon-32.png"
} }
} },
} "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfCvygCocGbU2Bm2Rg6cnvHN0Lt25gJGJ/XX7VuAccrp4dH+Whj3Fw2vYSjgx90wuWuMl5fsWSsSX9H1k1vp7ImGzszCDnScn+o+KRWrVCQVRD1NEaKavuHoaHyc3Hs+njrM8c7c6u2ygdItZkggwPU0U1dKkixP/DWR9oG13Gr4u39p/xHxITiBh0DROYdoKBzw/J+vT7zWITKyG7QBgLMuoaYc15oqRIm7raBW1GIn1A5V2WPpBM9rMAli4vCyc9rbqsUqO1Yu4SrNIoG+wfz3MED3ajylDH6Jh1bsf1l5EZNDR/EpqBsSQcEV0VXX7nkqchqgzh3bgT9psiUQAQIDAQAB"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "browser-cli" name = "browser-cli"
version = "0.6.0" version = "0.8.0"
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 = [
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env -S uv run
"""Generate or derive Chrome extension key and ID.
Usage:
python scripts/gen_extension_key.py # generate new key pair
python scripts/gen_extension_key.py --from-manifest # derive ID from extension/manifest.json
python scripts/gen_extension_key.py --key <base64> # derive ID from given public key
"""
import argparse, hashlib
import base64, json, sys
from pathlib import Path
def public_key_to_extension_id(pub_key_der:bytes) -> str:
digest = hashlib.sha256(pub_key_der).hexdigest()
return "".join(chr(ord("a") + int(c, 16)) for c in digest[:32])
def derive_from_key_b64(key_b64:str) -> tuple[str, str]:
der = base64.b64decode(key_b64)
ext_id = public_key_to_extension_id(der)
return key_b64, ext_id
def generate_new_key() -> tuple[str, str, str]:
try:
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
PrivateFormat,
NoEncryption,
)
except ImportError:
print("Install 'cryptography' to generate new keys: pip install cryptography", file=sys.stderr)
sys.exit(1)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pub_der = private_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
priv_pem = private_key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption()).decode()
key_b64 = base64.b64encode(pub_der).decode()
ext_id = public_key_to_extension_id(pub_der)
return key_b64, ext_id, priv_pem
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Chrome extension key/ID tool")
group = parser.add_mutually_exclusive_group()
group.add_argument("--from-manifest", action="store_true", help="Derive ID from extension/manifest.json")
group.add_argument("--key", metavar="BASE64", help="Derive ID from given base64 public key")
args = parser.parse_args()
if args.from_manifest:
manifest_path = Path(__file__).parent.parent / "extension" / "manifest.json"
manifest = json.loads(manifest_path.read_text())
key_b64 = manifest.get("key")
if not key_b64:
print("No 'key' field in manifest.json", file=sys.stderr)
sys.exit(1)
key_b64, ext_id = derive_from_key_b64(key_b64)
print(f"Extension ID: {ext_id}")
print(f"Key (b64): {key_b64}")
elif args.key:
key_b64, ext_id = derive_from_key_b64(args.key)
print(f"Extension ID: {ext_id}")
else:
key_b64, ext_id, priv_pem = generate_new_key()
print(f"Extension ID: {ext_id}")
print(f"Key (b64): {key_b64}")
print()
print("Add this to extension/manifest.json:")
print(f' "key": "{key_b64}"')
print()
print("Private key (keep secret, needed to re-derive same ID):")
print(priv_pem)
+35 -35
View File
@@ -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):
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ requires-python = ">=3.10"
[[package]] [[package]]
name = "browser-cli" name = "browser-cli"
version = "0.5.12" version = "0.8.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },