make it easyer to connect to a remove browser allow it with --browser ip alias too
Testing / test (push) Failing after 13m59s
Testing / test (push) Failing after 13m59s
This commit is contained in:
+68
-22
@@ -93,29 +93,47 @@ def _remote_display_name(endpoint: str, profile_name: str, display_name: str) ->
|
|||||||
return f"{remote_name}:{display_name or profile_name}"
|
return f"{remote_name}:{display_name or profile_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def remote_browser_targets(endpoint: str, token: str | None = None) -> list[BrowserTarget]:
|
||||||
|
"""Return browser targets advertised by a single remote endpoint."""
|
||||||
|
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token)
|
||||||
|
targets: list[BrowserTarget] = []
|
||||||
|
for item in remote_targets or []:
|
||||||
|
profile = str(item.get("profile") or "default")
|
||||||
|
display = str(item.get("displayName") or profile)
|
||||||
|
targets.append(
|
||||||
|
BrowserTarget(
|
||||||
|
profile=profile,
|
||||||
|
display_name=_remote_display_name(endpoint, profile, display),
|
||||||
|
socket_path="",
|
||||||
|
remote=endpoint,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def _remote_browser_targets() -> list[BrowserTarget]:
|
def _remote_browser_targets() -> list[BrowserTarget]:
|
||||||
targets: list[BrowserTarget] = []
|
targets: list[BrowserTarget] = []
|
||||||
for endpoint, cfg in _load_remotes().items():
|
for endpoint, cfg in _load_remotes().items():
|
||||||
token = str(cfg.get("token") or "") or None
|
token = str(cfg.get("token") or "") or None
|
||||||
try:
|
try:
|
||||||
remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token)
|
targets.extend(remote_browser_targets(endpoint, token))
|
||||||
except (BrowserNotConnected, RuntimeError):
|
except (BrowserNotConnected, RuntimeError):
|
||||||
continue
|
continue
|
||||||
for item in remote_targets or []:
|
|
||||||
profile = str(item.get("profile") or "default")
|
|
||||||
display = str(item.get("displayName") or profile)
|
|
||||||
targets.append(
|
|
||||||
BrowserTarget(
|
|
||||||
profile=profile,
|
|
||||||
display_name=_remote_display_name(endpoint, profile, display),
|
|
||||||
socket_path="",
|
|
||||||
remote=endpoint,
|
|
||||||
token=token,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
def remote_target_for_alias(alias: str | None) -> BrowserTarget | None:
|
||||||
|
"""Resolve a user-facing remote alias such as 'host:profile' to a target."""
|
||||||
|
if not alias:
|
||||||
|
return None
|
||||||
|
for target in _remote_browser_targets():
|
||||||
|
endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None
|
||||||
|
if alias in {target.display_name, endpoint_profile}:
|
||||||
|
return target
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]:
|
def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]:
|
||||||
targets: list[BrowserTarget] = []
|
targets: list[BrowserTarget] = []
|
||||||
if REGISTRY_PATH.exists():
|
if REGISTRY_PATH.exists():
|
||||||
@@ -164,10 +182,42 @@ def _resolve_socket(profile: str | None = None) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_remote(endpoint: str, framed: bytes) -> bytes:
|
||||||
|
host, _, port_str = endpoint.rpartition(":")
|
||||||
|
if not host or not port_str:
|
||||||
|
raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port")
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
sock.connect((host, int(port_str)))
|
||||||
|
sock.sendall(framed)
|
||||||
|
return _recv_all(sock)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_route_remote(endpoint: str, token: str | None) -> str | None:
|
||||||
|
targets = remote_browser_targets(endpoint, token)
|
||||||
|
if len(targets) == 1:
|
||||||
|
return targets[0].profile
|
||||||
|
if len(targets) > 1:
|
||||||
|
aliases = [target.profile for target in targets]
|
||||||
|
examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases)
|
||||||
|
raise BrowserNotConnected(
|
||||||
|
f"Multiple remote browser instances are active: {', '.join(aliases)}\n"
|
||||||
|
f"Use --browser <alias> to select one:\n{examples}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: 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."""
|
||||||
|
requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
||||||
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE")
|
||||||
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or token_for_remote(remote_endpoint)
|
remote_alias_target = None
|
||||||
|
if not remote_endpoint and requested_profile:
|
||||||
|
remote_alias_target = remote_target_for_alias(requested_profile)
|
||||||
|
if remote_alias_target:
|
||||||
|
remote_endpoint = remote_alias_target.remote
|
||||||
|
requested_profile = remote_alias_target.profile
|
||||||
|
|
||||||
|
resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint)
|
||||||
msg = {
|
msg = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"command": command,
|
"command": command,
|
||||||
@@ -176,7 +226,9 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
|||||||
if remote_endpoint:
|
if remote_endpoint:
|
||||||
if resolved_token:
|
if resolved_token:
|
||||||
msg["token"] = resolved_token
|
msg["token"] = resolved_token
|
||||||
route_profile = profile or os.environ.get("BROWSER_CLI_PROFILE")
|
route_profile = requested_profile
|
||||||
|
if not route_profile and command != "browser-cli.targets":
|
||||||
|
route_profile = _auto_route_remote(remote_endpoint, resolved_token)
|
||||||
if route_profile:
|
if route_profile:
|
||||||
msg["_route"] = route_profile
|
msg["_route"] = route_profile
|
||||||
payload = json.dumps(msg).encode("utf-8")
|
payload = json.dumps(msg).encode("utf-8")
|
||||||
@@ -184,13 +236,7 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if remote_endpoint:
|
if remote_endpoint:
|
||||||
host, _, port_str = remote_endpoint.rpartition(":")
|
response = _send_remote(remote_endpoint, framed)
|
||||||
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():
|
elif is_windows():
|
||||||
sock_path = _resolve_socket(profile)
|
sock_path = _resolve_socket(profile)
|
||||||
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
with PipeClient(sock_path, family="AF_PIPE") as conn:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
@@ -30,7 +30,11 @@ def _multi_browser_targets():
|
|||||||
root = click.get_current_context().find_root()
|
root = click.get_current_context().find_root()
|
||||||
if root.obj.get("browser_explicit"):
|
if root.obj.get("browser_explicit"):
|
||||||
return []
|
return []
|
||||||
targets = active_browser_targets()
|
remote = root.obj.get("remote")
|
||||||
|
if remote:
|
||||||
|
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||||
|
else:
|
||||||
|
targets = active_browser_targets()
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||||
return []
|
return []
|
||||||
return targets
|
return targets
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import active_browser_targets, send_command, BrowserNotConnected
|
from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, BrowserNotConnected
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -29,7 +29,11 @@ def _multi_browser_targets():
|
|||||||
root = click.get_current_context().find_root()
|
root = click.get_current_context().find_root()
|
||||||
if root.obj.get("browser_explicit"):
|
if root.obj.get("browser_explicit"):
|
||||||
return []
|
return []
|
||||||
targets = active_browser_targets()
|
remote = root.obj.get("remote")
|
||||||
|
if remote:
|
||||||
|
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||||
|
else:
|
||||||
|
targets = active_browser_targets()
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||||
return []
|
return []
|
||||||
return targets
|
return targets
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import click
|
import click
|
||||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
@@ -31,7 +31,11 @@ def _multi_browser_targets():
|
|||||||
root = click.get_current_context().find_root()
|
root = click.get_current_context().find_root()
|
||||||
if root.obj.get("browser_explicit"):
|
if root.obj.get("browser_explicit"):
|
||||||
return []
|
return []
|
||||||
targets = active_browser_targets()
|
remote = root.obj.get("remote")
|
||||||
|
if remote:
|
||||||
|
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||||
|
else:
|
||||||
|
targets = active_browser_targets()
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||||
return []
|
return []
|
||||||
return targets
|
return targets
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import click
|
import click
|
||||||
from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command
|
from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
@@ -30,7 +30,11 @@ def _multi_browser_targets():
|
|||||||
root = click.get_current_context().find_root()
|
root = click.get_current_context().find_root()
|
||||||
if root.obj.get("browser_explicit"):
|
if root.obj.get("browser_explicit"):
|
||||||
return []
|
return []
|
||||||
targets = active_browser_targets()
|
remote = root.obj.get("remote")
|
||||||
|
if remote:
|
||||||
|
targets = remote_browser_targets(remote, root.obj.get("token"))
|
||||||
|
else:
|
||||||
|
targets = active_browser_targets()
|
||||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||||
return []
|
return []
|
||||||
return targets
|
return targets
|
||||||
|
|||||||
+24
-3
@@ -135,7 +135,9 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch):
|
|||||||
assert any("@echo off" in text for text in wrapper_writes)
|
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")), patch(
|
||||||
|
"browser_cli.cli.active_browser_targets", return_value=[]
|
||||||
|
):
|
||||||
result = CliRunner().invoke(main, ["clients"])
|
result = CliRunner().invoke(main, ["clients"])
|
||||||
|
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
@@ -153,7 +155,7 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path):
|
|||||||
|
|
||||||
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
||||||
"browser_cli.cli.send_command", side_effect=fake_send_command
|
"browser_cli.cli.send_command", side_effect=fake_send_command
|
||||||
):
|
), patch("browser_cli.cli.active_browser_targets", return_value=[]):
|
||||||
result = CliRunner().invoke(main, ["clients"])
|
result = CliRunner().invoke(main, ["clients"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -208,7 +210,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path):
|
|||||||
|
|
||||||
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch(
|
||||||
"browser_cli.cli.send_command", side_effect=fake_send_command
|
"browser_cli.cli.send_command", side_effect=fake_send_command
|
||||||
):
|
), patch("browser_cli.cli.active_browser_targets", return_value=[]):
|
||||||
result = CliRunner().invoke(main, ["clients"])
|
result = CliRunner().invoke(main, ["clients"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -238,6 +240,25 @@ def test_tabs_list_multi_browser_shows_browser_column():
|
|||||||
assert "work" in result.output
|
assert "work" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabs_list_with_remote_uses_only_remote_targets():
|
||||||
|
with patch(
|
||||||
|
"browser_cli.commands.tabs.active_browser_targets",
|
||||||
|
side_effect=AssertionError("local targets should not be used for explicit remote"),
|
||||||
|
), patch(
|
||||||
|
"browser_cli.commands.tabs.remote_browser_targets",
|
||||||
|
return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765", token="secret")],
|
||||||
|
), patch(
|
||||||
|
"browser_cli.commands.tabs.send_command",
|
||||||
|
return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}],
|
||||||
|
) as send_command, patch("browser_cli.cli.save_remote_token"):
|
||||||
|
result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "--token", "secret", "tabs", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "remote-host:work" in result.output
|
||||||
|
assert "Remote" in result.output
|
||||||
|
send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", token="secret")
|
||||||
|
|
||||||
|
|
||||||
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
def test_tabs_list_with_explicit_browser_does_not_show_browser_column():
|
||||||
with patch(
|
with patch(
|
||||||
"browser_cli.commands.tabs.active_browser_targets",
|
"browser_cli.commands.tabs.active_browser_targets",
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import pytest
|
|||||||
|
|
||||||
from browser_cli.client import (
|
from browser_cli.client import (
|
||||||
BrowserNotConnected,
|
BrowserNotConnected,
|
||||||
|
BrowserTarget,
|
||||||
_resolve_socket,
|
_resolve_socket,
|
||||||
active_browser_targets,
|
active_browser_targets,
|
||||||
display_browser_name,
|
display_browser_name,
|
||||||
save_remote_token,
|
save_remote_token,
|
||||||
|
send_command,
|
||||||
|
remote_target_for_alias,
|
||||||
token_for_remote,
|
token_for_remote,
|
||||||
)
|
)
|
||||||
from browser_cli.platform import endpoint_for_alias
|
from browser_cli.platform import endpoint_for_alias
|
||||||
@@ -104,6 +107,83 @@ def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||||
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||||
|
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||||
|
sent = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"browser_cli.client.remote_browser_targets",
|
||||||
|
lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_send_remote(endpoint, framed):
|
||||||
|
payload_len = int.from_bytes(framed[:4], "little")
|
||||||
|
msg = json.loads(framed[4:4 + payload_len])
|
||||||
|
sent.update(msg)
|
||||||
|
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||||
|
|
||||||
|
assert send_command("tabs.list", remote="host:8765", token="secret") == "ok"
|
||||||
|
assert sent["_route"] == "work"
|
||||||
|
assert sent["token"] == "secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||||
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
||||||
|
monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
|
||||||
|
sent = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"browser_cli.client._remote_browser_targets",
|
||||||
|
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_send_remote(endpoint, framed):
|
||||||
|
payload_len = int.from_bytes(framed[:4], "little")
|
||||||
|
msg = json.loads(framed[4:4 + payload_len])
|
||||||
|
sent["endpoint"] = endpoint
|
||||||
|
sent.update(msg)
|
||||||
|
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||||
|
|
||||||
|
assert send_command("tabs.list") == []
|
||||||
|
assert sent["endpoint"] == "host:8765"
|
||||||
|
assert sent["_route"] == "work"
|
||||||
|
assert sent["token"] == "secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"browser_cli.client._remote_browser_targets",
|
||||||
|
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")],
|
||||||
|
)
|
||||||
|
|
||||||
|
target = remote_target_for_alias("host:8765:work")
|
||||||
|
|
||||||
|
assert target is not None
|
||||||
|
assert target.profile == "work"
|
||||||
|
assert target.remote == "host:8765"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||||
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"browser_cli.client.remote_browser_targets",
|
||||||
|
lambda endpoint, token=None: [
|
||||||
|
BrowserTarget("main", "host:main", "", remote=endpoint, token=token),
|
||||||
|
BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
|
||||||
|
send_command("tabs.list", remote="host:8765", token="secret")
|
||||||
|
|
||||||
|
|
||||||
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||||
remotes_path = tmp_path / "remotes.json"
|
remotes_path = tmp_path / "remotes.json"
|
||||||
endpoint = "browser-host.example:8765"
|
endpoint = "browser-host.example:8765"
|
||||||
|
|||||||
Reference in New Issue
Block a user