implement windows support of the extension
Testing / test (push) Successful in 47s

This commit is contained in:
2026-04-13 11:02:54 +02:00
parent 080ca6da6d
commit 9dbe57c66c
9 changed files with 297 additions and 65 deletions
+5 -5
View File
@@ -8,7 +8,7 @@ Control your real, running browser from the terminal or a Python script — no h
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 Chrome/Brave 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 socket. It works by pairing a small Chrome/Brave 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.
--- ---
@@ -17,7 +17,7 @@ It works by pairing a small Chrome/Brave extension with a Python CLI tool. The e
``` ```
terminal / python script terminal / python script
Unix socket (/tmp/browser-cli.sock) Local IPC (Unix socket on Linux/macOS, named pipe on Windows)
Native Messaging Host (Python process, launched by the browser) Native Messaging Host (Python process, launched by the browser)
@@ -32,7 +32,7 @@ terminal / python script
1. The extension calls `chrome.runtime.connectNative('com.browsercli.host')` on startup. 1. The extension calls `chrome.runtime.connectNative('com.browsercli.host')` on startup.
2. The browser launches the native host Python process (registered in the OS). 2. The browser launches the native host Python process (registered in the OS).
3. The native host opens a Unix socket at `/tmp/browser-cli.sock`. 3. The native host opens a local IPC endpoint for the CLI.
4. CLI commands connect to that socket, send a JSON command, and wait for the result. 4. CLI commands connect to that socket, send a JSON command, and wait for the result.
5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI. 5. The native host relays the command to the extension via stdout, receives the result via stdin, and sends it back to the CLI.
@@ -70,7 +70,7 @@ The `install` command will:
After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup. After install, **fully restart your browser** (Quit and reopen — not just close the window). The extension will connect to the native host automatically on startup.
Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy. Only the `browser-cli` command needs to be on your `PATH`. The browser launches the native host wrapper directly from its absolute path in the native messaging manifest, and that wrapper points to the internally installed `native_host.py` copy. On Windows the install command also registers the host in the current user's Registry for the selected browser.
--- ---
@@ -81,7 +81,7 @@ browser-cli/
├── browser_cli/ ├── browser_cli/
│ ├── __init__.py # Python API — BrowserCLI class and Python API entry point │ ├── __init__.py # Python API — BrowserCLI class and Python API entry point
│ ├── cli.py # Click CLI entry point │ ├── cli.py # Click CLI entry point
│ ├── client.py # Unix socket client used by CLI and API │ ├── client.py # Local IPC client used by CLI and API
│ ├── 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/
+54 -7
View File
@@ -28,6 +28,7 @@ from browser_cli.client import (
active_browser_targets, active_browser_targets,
display_browser_name, display_browser_name,
) )
from browser_cli.platform import install_base_dir, is_windows
console = Console() console = Console()
@@ -56,6 +57,14 @@ NATIVE_HOST_DIRS = {
}, },
} }
WINDOWS_NATIVE_HOST_REGISTRY_KEYS = {
"chrome": [r"Software\Google\Chrome\NativeMessagingHosts"],
"chromium": [r"Software\Chromium\NativeMessagingHosts"],
"brave": [r"Software\BraveSoftware\Brave-Browser\NativeMessagingHosts"],
"edge": [r"Software\Microsoft\Edge\NativeMessagingHosts"],
"vivaldi": [r"Software\Vivaldi\NativeMessagingHosts"],
}
def _rename_target_profile(target_browser: str | None) -> str | None: def _rename_target_profile(target_browser: str | None) -> str | None:
if target_browser: if target_browser:
@@ -82,10 +91,9 @@ def _ensure_unique_browser_alias(alias: str, target_browser: str | None) -> None
def _native_host_wrapper_path() -> Path: def _native_host_wrapper_path() -> Path:
if sys.platform == "darwin": base_dir = install_base_dir()
base_dir = Path.home() / "Library" / "Application Support" / "browser-cli" if is_windows():
else: return base_dir / "libexec" / "native-host.cmd"
base_dir = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "browser-cli"
return base_dir / "libexec" / "native-host" return base_dir / "libexec" / "native-host"
@@ -93,6 +101,30 @@ def _native_host_script_path() -> Path:
return _native_host_wrapper_path().with_name("native_host.py") return _native_host_wrapper_path().with_name("native_host.py")
def _windows_registry_views():
import winreg
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
import winreg
installed = []
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
for view in _windows_registry_views():
try:
access = winreg.KEY_WRITE | view
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
with key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
installed.append(f"HKCU\\{full_key}")
except OSError as e:
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
return installed
def _project_version() -> str: def _project_version() -> str:
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml" pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
try: try:
@@ -235,12 +267,16 @@ def cmd_install(browser):
native_host_script_path = _native_host_script_path() native_host_script_path = _native_host_script_path()
wrapper_path.parent.mkdir(parents=True, exist_ok=True) wrapper_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path) shutil.copy2(Path(__file__).with_name("native_host.py"), native_host_script_path)
if not is_windows():
native_host_script_path.chmod( native_host_script_path.chmod(
native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH native_host_script_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
) )
wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n' wrapper_content = f'#!/bin/sh\nexec "{sys.executable}" "{native_host_script_path}" "$@"\n'
wrapper_path.write_text(wrapper_content) wrapper_path.write_text(wrapper_content)
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
else:
wrapper_content = f'@echo off\r\n"{sys.executable}" "{native_host_script_path}" %*\r\n'
wrapper_path.write_text(wrapper_content, encoding="utf-8")
# Ask for extension ID # Ask for extension ID
ext_urls = { ext_urls = {
@@ -269,11 +305,17 @@ def cmd_install(browser):
"allowed_origins": [f"chrome-extension://{extension_id}/"], "allowed_origins": [f"chrome-extension://{extension_id}/"],
} }
# Write to OS native messaging dirs installed = []
if is_windows():
manifest_dir = wrapper_path.parent
manifest_dir.mkdir(parents=True, exist_ok=True)
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
installed = _register_windows_native_host(browser, manifest_path)
else:
platform = "darwin" if sys.platform == "darwin" else "linux" platform = "darwin" if sys.platform == "darwin" else "linux"
dirs = NATIVE_HOST_DIRS[browser][platform] dirs = NATIVE_HOST_DIRS[browser][platform]
installed = []
for d in dirs: for d in dirs:
try: try:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@@ -288,11 +330,16 @@ def cmd_install(browser):
sys.exit(1) sys.exit(1)
for p in installed: for p in installed:
if is_windows():
console.print(f"[green]✓[/green] Registered native host: {p}")
else:
console.print(f"[green]✓[/green] Wrote native host manifest: {p}") console.print(f"[green]✓[/green] Wrote native host manifest: {p}")
console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}") console.print(f"[green]✓[/green] Installed native host script: {native_host_script_path}")
console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}") console.print(f"[green]✓[/green] Installed native host wrapper: {wrapper_path}")
if is_windows():
console.print("\n[green]✓[/green] Wrote native host manifest:", manifest_path)
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (Cmd/Ctrl+Q, then reopen)") console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
console.print("\n[green bold]✓ Installation complete![/green bold]") console.print("\n[green bold]✓ Installation complete![/green bold]")
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]") console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
+18 -10
View File
@@ -1,11 +1,11 @@
""" """
Unix socket client — sends commands to the native host relay socket. Local IPC client — sends commands to native host relay endpoint.
Used by both the CLI and the public Python API. Used by both CLI and public Python API.
Profile selection order: Profile selection order:
1. Explicit `profile` argument to send_command() 1. Explicit `profile` argument to send_command()
2. BROWSER_CLI_PROFILE environment variable 2. BROWSER_CLI_PROFILE environment variable
3. First entry in /tmp/.browser_cli/registry.json 3. First entry in runtime registry
4. Otherwise, no browser can be resolved automatically 4. Otherwise, no browser can be resolved automatically
""" """
import json import json
@@ -14,11 +14,13 @@ import socket
import struct import struct
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
SOCKET_DIR = Path("/tmp/.browser_cli") from browser_cli.platform import endpoint_for_alias, is_windows, registry_path
REGISTRY_PATH = SOCKET_DIR / "registry.json"
REGISTRY_PATH = registry_path()
class BrowserNotConnected(Exception): class BrowserNotConnected(Exception):
@@ -32,8 +34,10 @@ class BrowserTarget:
socket_path: str socket_path: str
def _active_sockets(reg: dict) -> dict: def _active_endpoints(reg: dict) -> dict:
"""Return only entries whose socket file exists on disk.""" """Return only entries whose endpoint appears reachable."""
if is_windows():
return dict(reg)
return {k: v for k, v in reg.items() if Path(v).exists()} return {k: v for k, v in reg.items() if Path(v).exists()}
@@ -52,7 +56,7 @@ def active_browser_targets() -> list[BrowserTarget]:
return [] return []
return [ return [
BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path) BrowserTarget(profile=profile, display_name=display_browser_name(profile, sock_path), socket_path=sock_path)
for profile, sock_path in _active_sockets(reg).items() for profile, sock_path in _active_endpoints(reg).items()
] ]
@@ -68,8 +72,7 @@ def _resolve_socket(profile: str | None = None) -> str:
return reg[target] return reg[target]
except Exception: except Exception:
pass pass
safe = target.replace(" ", "_").replace("/", "_") return endpoint_for_alias(target)
return str(SOCKET_DIR / f"{safe}.sock")
# Auto-detect: error when multiple browser instances are active # Auto-detect: error when multiple browser instances are active
try: try:
@@ -107,6 +110,11 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
framed = struct.pack("<I", len(payload)) + payload framed = struct.pack("<I", len(payload)) + payload
try: try:
if is_windows():
with PipeClient(sock_path, family="AF_PIPE") as conn:
conn.send_bytes(payload)
response = conn.recv_bytes()
else:
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)
+34 -18
View File
@@ -2,13 +2,9 @@
""" """
Native Messaging Host for browser-cli. Native Messaging Host for browser-cli.
Chrome launches this process when the extension calls connectNative(). Chrome launches this process when extension calls connectNative().
It relays messages between the Chrome extension (via stdin/stdout using the It relays messages between extension (stdin/stdout Native Messaging protocol)
Native Messaging protocol) and the CLI (via a Unix domain socket). and CLI (local IPC endpoint: Unix socket on Unix, named pipe on Windows).
Multi-browser support: the extension sends a "hello" message on startup
with a profile alias. The host uses that alias to create a unique socket
path and registers it in a shared registry file.
""" """
import json import json
import os import os
@@ -18,15 +14,15 @@ import struct
import sys import sys
import threading import threading
import uuid import uuid
from multiprocessing.connection import Listener
from pathlib import Path from pathlib import Path
SOCKET_DIR = Path("/tmp/.browser_cli") from browser_cli.platform import DEFAULT_ALIAS, endpoint_for_alias, is_windows, registry_path, runtime_dir
REGISTRY_PATH = SOCKET_DIR / "registry.json"
DEFAULT_ALIAS = "default"
SOCKET_PATH: str = "" # set after hello handshake SOCKET_PATH: str = "" # set after hello handshake
PENDING: dict[str, queue.Queue] = {} PENDING: dict[str, queue.Queue] = {}
PENDING_LOCK = threading.Lock() PENDING_LOCK = threading.Lock()
REGISTRY_PATH = registry_path()
# --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) --- # --- Native Messaging protocol (4-byte LE length prefix + UTF-8 JSON) ---
@@ -71,8 +67,7 @@ def _registry_remove(alias: str) -> None:
def _socket_path_for(alias: str) -> str: def _socket_path_for(alias: str) -> str:
safe = alias.replace(" ", "_").replace("/", "_") return endpoint_for_alias(alias)
return str(SOCKET_DIR / f"{safe}.sock")
def _resolve_profile_alias(first_msg: dict | None) -> str: def _resolve_profile_alias(first_msg: dict | None) -> str:
@@ -113,6 +108,16 @@ def stdin_reader(alias: str):
# --- Thread B: accept CLI socket connections --- # --- Thread B: accept CLI socket connections ---
def socket_server(sock_path: str): def socket_server(sock_path: str):
if is_windows():
while True:
try:
listener = Listener(sock_path, family="AF_PIPE")
conn = listener.accept()
except OSError:
break
threading.Thread(target=handle_cli_connection, args=(conn, listener), daemon=True).start()
return
path = Path(sock_path) path = Path(sock_path)
if path.exists(): if path.exists():
path.unlink() path.unlink()
@@ -126,12 +131,12 @@ def socket_server(sock_path: str):
conn, _ = sock.accept() conn, _ = sock.accept()
except OSError: except OSError:
break break
threading.Thread(target=handle_cli_connection, args=(conn,), daemon=True).start() threading.Thread(target=handle_cli_connection, args=(conn, None), daemon=True).start()
def handle_cli_connection(conn: socket.socket) -> None: def handle_cli_connection(conn, listener=None) -> None:
try: try:
data = _recv_all(conn) data = conn.recv_bytes() if is_windows() else _recv_all(conn)
if not data: if not data:
return return
cmd = json.loads(data) cmd = json.loads(data)
@@ -154,14 +159,24 @@ def handle_cli_connection(conn: socket.socket) -> None:
with PENDING_LOCK: with PENDING_LOCK:
PENDING.pop(msg_id, None) PENDING.pop(msg_id, None)
_send_all(conn, json.dumps(result).encode("utf-8")) response = json.dumps(result).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception as exc: except Exception as exc:
try: try:
_send_all(conn, json.dumps({"success": False, "error": str(exc)}).encode("utf-8")) response = json.dumps({"success": False, "error": str(exc)}).encode("utf-8")
if is_windows():
conn.send_bytes(response)
else:
_send_all(conn, response)
except Exception: except Exception:
pass pass
finally: finally:
conn.close() conn.close()
if listener is not None:
listener.close()
# --- Socket helpers (length-prefixed framing) --- # --- Socket helpers (length-prefixed framing) ---
@@ -191,6 +206,7 @@ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
def _cleanup(alias: str): def _cleanup(alias: str):
try: try:
if not is_windows():
Path(_socket_path_for(alias)).unlink(missing_ok=True) Path(_socket_path_for(alias)).unlink(missing_ok=True)
except Exception: except Exception:
pass pass
@@ -215,7 +231,7 @@ def main():
PENDING[msg_id] = q PENDING[msg_id] = q
write_native_message(sys.stdout.buffer, first_msg) write_native_message(sys.stdout.buffer, first_msg)
SOCKET_DIR.mkdir(mode=0o700, exist_ok=True) runtime_dir().mkdir(mode=0o700, exist_ok=True)
sock_path = _socket_path_for(alias) sock_path = _socket_path_for(alias)
_registry_add(alias, sock_path) _registry_add(alias, sock_path)
+36
View File
@@ -0,0 +1,36 @@
import os
import sys
from pathlib import Path
APP_NAME = "browser-cli"
RUNTIME_DIRNAME = ".browser_cli"
DEFAULT_ALIAS = "default"
def is_windows() -> bool:
return sys.platform.startswith("win")
def runtime_dir() -> Path:
if is_windows():
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / APP_NAME
return Path("/tmp") / RUNTIME_DIRNAME
def registry_path() -> Path:
return runtime_dir() / "registry.json"
def install_base_dir() -> Path:
if is_windows():
return runtime_dir()
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support" / APP_NAME
return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / APP_NAME
def sanitize_alias(alias:str) -> str:
cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in alias.strip())
return cleaned or DEFAULT_ALIAS
def endpoint_for_alias(alias:str) -> str:
safe = sanitize_alias(alias)
if is_windows():
return rf"\\.\pipe\browser-cli-{safe}"
return str(runtime_dir() / f"{safe}.sock")
+63 -2
View File
@@ -1,4 +1,6 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
import sys
from click.testing import CliRunner from click.testing import CliRunner
from unittest.mock import patch from unittest.mock import patch
@@ -31,14 +33,18 @@ def test_project_version_falls_back_to_installed_package_metadata():
assert _project_version() == "9.9.9" assert _project_version() == "9.9.9"
def test_clients_rename_uses_command_level_browser_target(): def test_clients_rename_uses_command_level_browser_target():
with patch("browser_cli.cli.send_command") as send_command: with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"]) result = CliRunner().invoke(main, ["clients", "rename", "--browser", "old-id", "work"])
assert result.exit_code == 0 assert result.exit_code == 0
send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id") send_command.assert_called_once_with("clients.rename_profile", {"alias": "work"}, profile="old-id")
def test_clients_rename_uses_global_browser_target_when_set(): def test_clients_rename_uses_global_browser_target_when_set():
with patch("browser_cli.cli.send_command") as send_command: with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch(
"browser_cli.cli.send_command"
) as send_command:
result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"]) result = CliRunner().invoke(main, ["--browser", "old-id", "clients", "rename", "work"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -72,6 +78,61 @@ def test_install_help_lists_supported_browsers():
assert result.exit_code == 0 assert result.exit_code == 0
assert "[chrome|chromium|brave|edge|vivaldi]" in result.output assert "[chrome|chromium|brave|edge|vivaldi]" in result.output
def test_install_windows_registers_native_host(tmp_path, monkeypatch):
local_app_data = tmp_path / "LocalAppData"
extension_dir = tmp_path / "extension"
extension_dir.mkdir()
native_host_src = tmp_path / "native_host.py"
native_host_src.write_text("print('ok')", encoding="utf-8")
writes = []
class FakeKey:
def __init__(self, path):
self.path = path
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
fake_winreg = SimpleNamespace(
HKEY_CURRENT_USER="HKCU",
KEY_WRITE=0x20006,
KEY_WOW64_32KEY=0x0200,
KEY_WOW64_64KEY=0x0100,
REG_SZ=1,
)
def fake_create_key(root, path, reserved, access):
return FakeKey(path)
def fake_set_value(key, name, reserved, reg_type, value):
writes.append((key.path, name, value))
fake_winreg.CreateKeyEx = fake_create_key
fake_winreg.SetValueEx = fake_set_value
monkeypatch.setenv("LOCALAPPDATA", str(local_app_data))
with patch("browser_cli.cli.is_windows", return_value=True), patch(
"browser_cli.cli.Path.home", return_value=tmp_path
), patch("browser_cli.cli.click.prompt", return_value="abc123"), patch(
"browser_cli.cli.shutil.copy2"
) as copy2, patch("browser_cli.cli.Path.write_text") as write_text, patch.dict(
sys.modules, {"winreg": fake_winreg}
):
copy2.side_effect = lambda src, dst: Path(dst).write_text(native_host_src.read_text(encoding="utf-8"), encoding="utf-8")
result = CliRunner().invoke(main, ["install", "edge"])
assert result.exit_code == 0
assert any("Software\\Microsoft\\Edge\\NativeMessagingHosts\\com.browsercli.host" in path for path, _, _ in writes)
assert "Registered native host" in result.output
assert "Wrote native host manifest" in result.output
wrapper_writes = [call.args[0] for call in write_text.call_args_list if call.args]
assert any("@echo off" in text for text in wrapper_writes)
def test_clients_exits_cleanly_when_registry_is_missing(): def test_clients_exits_cleanly_when_registry_is_missing():
with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")): with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")):
result = CliRunner().invoke(main, ["clients"]) result = CliRunner().invoke(main, ["clients"])
+21
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest import pytest
from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name from browser_cli.client import BrowserNotConnected, _resolve_socket, active_browser_targets, display_browser_name
from browser_cli.platform import endpoint_for_alias
def test_resolve_socket_raises_when_registry_missing(monkeypatch): def test_resolve_socket_raises_when_registry_missing(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
@@ -46,6 +47,13 @@ def test_display_browser_name_uses_uuid_stem_for_default():
) )
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
assert _resolve_socket("work") == endpoint_for_alias("work")
def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path): def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
active_socket = tmp_path / "work.sock" active_socket = tmp_path / "work.sock"
active_socket.write_text("") active_socket.write_text("")
@@ -60,3 +68,16 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
assert len(targets) == 1 assert len(targets) == 1
assert targets[0].profile == "work" assert targets[0].profile == "work"
assert targets[0].display_name == "work" assert targets[0].display_name == "work"
def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", registry_path)
monkeypatch.setattr("browser_cli.client.is_windows", lambda: True)
targets = active_browser_targets()
assert len(targets) == 1
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
+14 -1
View File
@@ -18,8 +18,9 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json" registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")})) registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
monkeypatch.setattr(native_host, "SOCKET_DIR", tmp_path)
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path) monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
monkeypatch.setattr(native_host, "is_windows", lambda: False)
native_host._cleanup(alias) native_host._cleanup(alias)
@@ -41,6 +42,18 @@ def test_stdin_reader_cleans_up_on_eof(monkeypatch):
assert cleaned == ["work"] assert cleaned == ["work"]
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
registry_path = tmp_path / "registry.json"
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
monkeypatch.setattr(native_host, "is_windows", lambda: True)
native_host._cleanup("work")
assert json.loads(registry_path.read_text()) == {}
def test_stdin_reader_cleans_up_on_bye(monkeypatch): def test_stdin_reader_cleans_up_on_bye(monkeypatch):
cleaned = [] cleaned = []
messages = iter([{"type": "bye"}]) messages = iter([{"type": "bye"}])
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from browser_cli.platform import endpoint_for_alias, install_base_dir, registry_path, runtime_dir
def test_runtime_dir_defaults_to_tmp_on_unix(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: False)
assert runtime_dir() == Path("/tmp/.browser_cli")
def test_runtime_dir_uses_localappdata_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert runtime_dir() == tmp_path / "LocalAppData" / "browser-cli"
assert registry_path() == tmp_path / "LocalAppData" / "browser-cli" / "registry.json"
def test_endpoint_for_alias_uses_pipe_on_windows(monkeypatch):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
assert endpoint_for_alias("work/browser") == r"\\.\pipe\browser-cli-work_browser"
def test_install_base_dir_uses_runtime_dir_on_windows(monkeypatch, tmp_path):
monkeypatch.setattr("browser_cli.platform.is_windows", lambda: True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData"))
assert install_base_dir() == tmp_path / "LocalAppData" / "browser-cli"