Endpoint resolution consults remembered remotes, so tests that assert the bare-domain :443 default failed on any machine with an explicit-port remote remembered for the same host, while passing in CI. That made a real behavior difference look like a local-only flake. Isolate the registry for the whole suite instead of patching the two affected tests, since any test touching endpoint resolution has the same hidden dependency. Tests that need remembered entries still override the path. Also replace the hardcoded personal remote host with a documentation domain.
732 lines
28 KiB
Python
732 lines
28 KiB
Python
import asyncio
|
|
import json
|
|
import socket
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from browser_cli.client import (
|
|
BrowserNotConnected,
|
|
BrowserTarget,
|
|
_looks_like_domain,
|
|
_normalize_endpoint,
|
|
_resolve_connect_endpoint,
|
|
active_browser_targets,
|
|
display_browser_name,
|
|
send_command,
|
|
send_command_async,
|
|
remote_target_for_alias,
|
|
)
|
|
from browser_cli.client.targets import resolve_socket
|
|
from browser_cli.platform import endpoint_for_alias
|
|
from browser_cli.remote.registry import key_for_remote
|
|
|
|
def _listening_unix_socket(path: Path) -> socket.socket:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.bind(str(path))
|
|
sock.listen(1)
|
|
return sock
|
|
|
|
def test_resolve_socket_raises_when_registry_missing(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
|
|
|
with pytest.raises(BrowserNotConnected, match="Cannot resolve a browser socket automatically"):
|
|
resolve_socket()
|
|
|
|
def test_resolve_socket_uses_only_active_registry_entry(monkeypatch, tmp_path):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
|
|
socket_path = tmp_path / "browser.sock"
|
|
listener = _listening_unix_socket(socket_path)
|
|
registry_path = tmp_path / "registry.json"
|
|
registry_path.write_text(json.dumps({"abc-uuid": str(socket_path)}))
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
|
|
|
try:
|
|
assert resolve_socket() == str(socket_path)
|
|
finally:
|
|
listener.close()
|
|
|
|
def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_path):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
|
|
first_socket = tmp_path / "one.sock"
|
|
second_socket = tmp_path / "two.sock"
|
|
first_listener = _listening_unix_socket(first_socket)
|
|
second_listener = _listening_unix_socket(second_socket)
|
|
registry_path = tmp_path / "registry.json"
|
|
registry_path.write_text(json.dumps({"uuid-1": str(first_socket), "uuid-2": str(second_socket)}))
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
|
|
|
try:
|
|
with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"):
|
|
resolve_socket()
|
|
finally:
|
|
first_listener.close()
|
|
second_listener.close()
|
|
|
|
def test_display_browser_name_uses_uuid_stem_for_default():
|
|
assert display_browser_name("default", "/tmp/.browser_cli/550e8400-e29b-41d4-a716-446655440000.sock") == (
|
|
"550e8400-e29b-41d4-a716-446655440000"
|
|
)
|
|
|
|
def test_resolve_socket_uses_platform_endpoint_for_explicit_alias(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.setattr("browser_cli.client.targets.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):
|
|
active_socket = tmp_path / "work.sock"
|
|
listener = _listening_unix_socket(active_socket)
|
|
stale_socket = tmp_path / "stale.sock"
|
|
stale_listener = _listening_unix_socket(stale_socket)
|
|
stale_listener.close()
|
|
registry_path = tmp_path / "registry.json"
|
|
registry_path.write_text(json.dumps({"work": str(active_socket), "default": str(stale_socket)}))
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
|
|
|
try:
|
|
targets = active_browser_targets(include_remotes=False)
|
|
finally:
|
|
listener.close()
|
|
|
|
assert len(targets) == 1
|
|
assert targets[0].profile == "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.targets.REGISTRY_PATH", registry_path)
|
|
monkeypatch.setattr("browser_cli.client.targets.is_windows", lambda: True)
|
|
|
|
targets = active_browser_targets(include_remotes=False)
|
|
|
|
assert len(targets) == 1
|
|
assert targets[0].socket_path == r"\\.\pipe\browser-cli-work"
|
|
|
|
def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
sent = {}
|
|
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core.remote_browser_targets",
|
|
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
|
|
)
|
|
|
|
def fake_send_remote(endpoint, msg, private_key=None):
|
|
sent.update(msg)
|
|
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
|
|
|
assert send_command("tabs.list", remote="host:8765", key=None) == "ok"
|
|
assert sent["_route"] == "work"
|
|
assert "token" not in sent
|
|
|
|
def test_send_command_uses_env_profile_for_local_transport(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "work")
|
|
seen = {}
|
|
|
|
def fake_send_local(profile, payload, resolve_socket):
|
|
seen["profile"] = profile
|
|
seen["payload"] = json.loads(payload)
|
|
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.is_active_local_profile", lambda profile: True)
|
|
monkeypatch.setattr("browser_cli.client.core.local_transport.send_local_sync", fake_send_local)
|
|
|
|
assert send_command("tabs.list") == "ok"
|
|
assert seen["profile"] == "work"
|
|
assert seen["payload"]["command"] == "tabs.list"
|
|
|
|
async def _async_local_profile_result(monkeypatch):
|
|
seen = {}
|
|
|
|
async def fake_send_local(profile, payload, resolve_socket):
|
|
seen["profile"] = profile
|
|
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.is_active_local_profile", lambda profile: True)
|
|
monkeypatch.setattr("browser_cli.client.core.local_transport.send_local_async", fake_send_local)
|
|
result = await send_command_async("tabs.list")
|
|
return result, seen
|
|
|
|
def test_send_command_async_uses_env_profile_for_local_transport(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "work")
|
|
|
|
result, seen = asyncio.run(_async_local_profile_result(monkeypatch))
|
|
|
|
assert result == "ok"
|
|
assert seen["profile"] == "work"
|
|
|
|
def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monkeypatch, tmp_path):
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
socket_path = tmp_path / "work.sock"
|
|
socket_path.write_text("")
|
|
registry_path = tmp_path / "registry.json"
|
|
registry_path.write_text(json.dumps({"work": str(socket_path)}), encoding="utf-8")
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.remote_target_for_alias",
|
|
lambda alias: pytest.fail("active local profile must not trigger remote alias discovery"),
|
|
)
|
|
monkeypatch.setattr("browser_cli.client.targets.is_active_local_profile", lambda profile: True)
|
|
|
|
payload = json.dumps({"success": True, "data": "local-ok"}).encode("utf-8")
|
|
framed = len(payload).to_bytes(4, "little") + payload
|
|
|
|
class FakeSocket:
|
|
def __init__(self, *args, **kwargs):
|
|
self.sent = b""
|
|
self._response = bytearray(framed)
|
|
self.connected_to = None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def connect(self, path):
|
|
self.connected_to = path
|
|
|
|
def sendall(self, data):
|
|
self.sent += data
|
|
|
|
def recv(self, n):
|
|
chunk = bytes(self._response[:n])
|
|
del self._response[:n]
|
|
return chunk
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.socket.socket", lambda *args, **kwargs: FakeSocket())
|
|
|
|
assert send_command("tabs.list", profile="work") == "local-ok"
|
|
|
|
def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work")
|
|
sent = {}
|
|
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core._remote_browser_targets",
|
|
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
|
)
|
|
|
|
def fake_send_remote(endpoint, msg, private_key=None):
|
|
sent["endpoint"] = endpoint
|
|
sent.update(msg)
|
|
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
|
|
|
assert send_command("tabs.list") == []
|
|
assert sent["endpoint"] == "host:8765"
|
|
assert sent["_route"] == "work"
|
|
assert "token" not in sent
|
|
|
|
def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core._remote_browser_targets",
|
|
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
|
)
|
|
|
|
target = remote_target_for_alias("host:8765:work")
|
|
|
|
assert target is not None
|
|
assert target.profile == "work"
|
|
assert target.remote == "host:8765"
|
|
|
|
def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkeypatch):
|
|
remote_host = "browser-host.example"
|
|
remote_endpoint = f"{remote_host}:8765"
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core._remote_browser_targets",
|
|
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
|
)
|
|
|
|
target = remote_target_for_alias(remote_host)
|
|
|
|
assert target is not None
|
|
assert target.profile == "work"
|
|
assert target.remote == remote_endpoint
|
|
|
|
def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
|
remote_host = "browser-host.example"
|
|
remote_endpoint = f"{remote_host}:8765"
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", remote_host)
|
|
sent = {}
|
|
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core._remote_browser_targets",
|
|
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
|
)
|
|
|
|
def fake_send_remote(endpoint, msg, private_key=None):
|
|
sent["endpoint"] = endpoint
|
|
sent.update(msg)
|
|
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
|
|
|
assert send_command("tabs.list") == []
|
|
assert sent["endpoint"] == remote_endpoint
|
|
assert sent["_route"] == "work"
|
|
assert "token" not in sent
|
|
|
|
def test_remote_target_for_alias_reports_ambiguous_host_for_multiple_targets(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core._remote_browser_targets",
|
|
lambda: [
|
|
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
|
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
|
],
|
|
)
|
|
|
|
with pytest.raises(BrowserNotConnected) as exc:
|
|
remote_target_for_alias("host")
|
|
|
|
msg = str(exc.value)
|
|
assert "Multiple remote browser instances are active on host: main, work" in msg
|
|
assert "browser-cli --remote host:8765 --browser main" in msg
|
|
assert "browser-cli --browser host:work" in msg
|
|
|
|
def test_send_command_reports_ambiguous_remote_browser_alias(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.setenv("BROWSER_CLI_PROFILE", "host")
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
|
monkeypatch.setattr("browser_cli.client.targets.is_windows", lambda: False)
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core._remote_browser_targets",
|
|
lambda: [
|
|
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
|
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
|
],
|
|
)
|
|
|
|
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active on host: main, work"):
|
|
send_command("tabs.list")
|
|
|
|
def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core.remote_browser_targets",
|
|
lambda endpoint, key=None: [
|
|
BrowserTarget("main", "host:main", "", remote=endpoint),
|
|
BrowserTarget("furry", "host:furry", "", remote=endpoint),
|
|
],
|
|
)
|
|
|
|
with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"):
|
|
send_command("tabs.list", remote="host:8765")
|
|
|
|
def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
|
remotes_path = tmp_path / "remotes.json"
|
|
endpoint = "browser-host.example:8765"
|
|
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
|
|
|
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
|
assert command == "browser-cli.targets"
|
|
assert remote == endpoint
|
|
return [{"profile": "work", "displayName": "work", "browserName": "Firefox"}]
|
|
|
|
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
|
|
|
targets = active_browser_targets()
|
|
|
|
assert len(targets) == 1
|
|
assert targets[0].profile == "work"
|
|
assert targets[0].display_name == "browser-host.example:work"
|
|
assert targets[0].remote == endpoint
|
|
assert targets[0].browser_name == "Firefox"
|
|
assert targets[0].display_group == "browser-host.example"
|
|
|
|
def test_looks_like_domain():
|
|
assert _looks_like_domain("browser-host.example") is True
|
|
assert _looks_like_domain("browser-host.example") is True
|
|
assert _looks_like_domain("sub.domain.org") is True
|
|
assert _looks_like_domain("localhost") is False
|
|
assert _looks_like_domain("127.0.0.1") is False
|
|
assert _looks_like_domain("203.0.113.100") is False
|
|
assert _looks_like_domain("host") is False # no dot
|
|
|
|
def test_normalize_endpoint_strips_443_for_domains():
|
|
assert _normalize_endpoint("browser-host.example:443") == "browser-host.example"
|
|
assert _normalize_endpoint("browser-host.example") == "browser-host.example"
|
|
assert _normalize_endpoint("203.0.113.1:443") == "203.0.113.1:443" # IP: keep port
|
|
assert _normalize_endpoint("localhost:443") == "localhost:443" # localhost: keep port
|
|
assert _normalize_endpoint("host:8765") == "host:8765" # non-443 port: unchanged
|
|
assert _normalize_endpoint("browser-host.example:8765") == "browser-host.example:8765"
|
|
|
|
def test_resolve_connect_endpoint_adds_443_for_domain():
|
|
assert _resolve_connect_endpoint("browser-host.example") == "browser-host.example:443"
|
|
assert _resolve_connect_endpoint("browser-host.example:443") == "browser-host.example:443"
|
|
assert _resolve_connect_endpoint("browser-host.example:8765") == "browser-host.example:8765"
|
|
assert _resolve_connect_endpoint("host:8765") == "host:8765"
|
|
|
|
def test_resolve_connect_endpoint_raises_for_bare_non_domain():
|
|
with pytest.raises(BrowserNotConnected, match="expected host:port"):
|
|
_resolve_connect_endpoint("localhost")
|
|
|
|
def test_send_command_normalizes_domain_port_443(monkeypatch):
|
|
"""--remote domain:443 is normalized; _send_remote gets the portless domain."""
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
sent_to = {}
|
|
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core.remote_browser_targets",
|
|
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
|
)
|
|
|
|
def fake_send_remote(endpoint, msg, private_key=None):
|
|
sent_to["endpoint"] = endpoint
|
|
return json.dumps({"success": True, "data": "ok"}).encode()
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
|
|
|
result = send_command("tabs.list", remote="browser-host.example:443")
|
|
assert result == "ok"
|
|
assert sent_to["endpoint"] == "browser-host.example" # stored/routed without port
|
|
|
|
def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
|
"""--remote domain (no port) is treated as :443."""
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
sent_to = {}
|
|
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core.remote_browser_targets",
|
|
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
|
)
|
|
|
|
def fake_send_remote(endpoint, msg, private_key=None):
|
|
sent_to["endpoint"] = endpoint
|
|
return json.dumps({"success": True, "data": "ok"}).encode()
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
|
|
|
result = send_command("tabs.list", remote="browser-host.example")
|
|
assert result == "ok"
|
|
assert sent_to["endpoint"] == "browser-host.example"
|
|
|
|
def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
|
"""Domain endpoints stored without :443 display as 'domain:profile', not 'domain:443:profile'."""
|
|
remotes_path = tmp_path / "remotes.json"
|
|
endpoint = "browser-host.example"
|
|
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
|
|
|
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
|
return [{"profile": "automatisation", "displayName": "automatisation"}]
|
|
|
|
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
|
|
|
targets = active_browser_targets()
|
|
|
|
assert len(targets) == 1
|
|
assert targets[0].display_name == "browser-host.example:automatisation"
|
|
assert targets[0].remote == endpoint
|
|
|
|
def test_domain_display_name_backward_compat_with_stored_443(monkeypatch, tmp_path):
|
|
"""Old remotes.json with :443 still displays cleanly without the port."""
|
|
remotes_path = tmp_path / "remotes.json"
|
|
endpoint = "browser-host.example:443" # old format
|
|
remotes_path.write_text(json.dumps({endpoint: {}}), encoding="utf-8")
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
|
|
|
def fake_send_command(command, args=None, profile=None, remote=None, key=None):
|
|
return [{"profile": "automatisation", "displayName": "automatisation"}]
|
|
|
|
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
|
|
|
targets = active_browser_targets()
|
|
|
|
assert len(targets) == 1
|
|
assert targets[0].display_name == "browser-host.example:automatisation"
|
|
|
|
def test_send_command_explicit_key_does_not_persist_remote_key(monkeypatch, tmp_path):
|
|
"""--key is a one-shot override; use `browser-cli remote trust` to remember it."""
|
|
import json as _json
|
|
|
|
remotes_path = tmp_path / "remotes.json"
|
|
remotes_path.write_text("{}", encoding="utf-8")
|
|
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_KEY", raising=False)
|
|
|
|
from pathlib import Path as _Path
|
|
used_keys = []
|
|
|
|
def fake_load_private_key(key_path=None):
|
|
used_keys.append(str(key_path) if key_path is not None else None)
|
|
return None # no actual key needed for this test
|
|
|
|
monkeypatch.setattr("browser_cli.client.auth.load_private_key", fake_load_private_key)
|
|
monkeypatch.setattr(
|
|
"browser_cli.client.core.remote_browser_targets",
|
|
lambda endpoint, key=None: [BrowserTarget("default", "host:default", "", remote=endpoint)],
|
|
)
|
|
|
|
def fake_send_remote(endpoint, msg, private_key=None):
|
|
return _json.dumps({"success": True, "data": "ok"}).encode()
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote", fake_send_remote)
|
|
|
|
send_command("tabs.list", remote="host:8765", key=_Path("agent"))
|
|
assert used_keys[-1] == "agent"
|
|
assert key_for_remote("host:8765") is None
|
|
|
|
send_command("tabs.list", remote="host:8765")
|
|
assert used_keys[-1] is None
|
|
|
|
# ── async command transport ──────────────────────────────────────────────────
|
|
|
|
def test_send_command_async_uses_async_remote_transport(monkeypatch):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
sent = {}
|
|
|
|
async def fake_send_remote_async(endpoint, msg, private_key=None):
|
|
sent["endpoint"] = endpoint
|
|
sent.update(msg)
|
|
return json.dumps({"success": True, "data": "ok"}).encode()
|
|
|
|
monkeypatch.setattr("browser_cli.client.core._send_remote_async", fake_send_remote_async)
|
|
monkeypatch.setattr("browser_cli.client.core.remote_browser_targets_async", lambda endpoint, key=None: _async_targets(endpoint))
|
|
|
|
async def run():
|
|
return await send_command_async("tabs.list", remote="host:8765", key=None)
|
|
|
|
async def _async_targets(endpoint):
|
|
return [BrowserTarget("work", "host:work", "", remote=endpoint)]
|
|
|
|
assert asyncio.run(run()) == "ok"
|
|
assert sent["endpoint"] == "host:8765"
|
|
assert sent["_route"] == "work"
|
|
assert sent["user_agent"].startswith("browser-cli/")
|
|
|
|
def test_send_command_async_local_unix_roundtrip(monkeypatch, tmp_path):
|
|
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
|
monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False)
|
|
sock_path = tmp_path / "browser.sock"
|
|
seen = []
|
|
|
|
async def run():
|
|
async def handle(reader, writer):
|
|
raw_len = await reader.readexactly(4)
|
|
msg_len = struct.unpack("<I", raw_len)[0]
|
|
payload = await reader.readexactly(msg_len)
|
|
seen.append(json.loads(payload))
|
|
response = json.dumps({"success": True, "data": "local-ok"}).encode()
|
|
writer.write(struct.pack("<I", len(response)) + response)
|
|
await writer.drain()
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
server = await asyncio.start_unix_server(handle, path=str(sock_path))
|
|
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda profile=None: str(sock_path))
|
|
async with server:
|
|
result = await send_command_async("tabs.list", profile="work")
|
|
server.close()
|
|
await server.wait_closed()
|
|
return result
|
|
|
|
assert asyncio.run(run()) == "local-ok"
|
|
assert seen[0]["command"] == "tabs.list"
|
|
assert seen[0]["args"] == {}
|
|
|
|
def test_run_concurrent_preserves_order_and_surfaces_exceptions():
|
|
"""_run_concurrent mirrors input order and returns exceptions in-slot."""
|
|
from browser_cli.client.core import _run_concurrent
|
|
|
|
async def ok(value):
|
|
return value
|
|
|
|
async def boom():
|
|
raise RuntimeError("nope")
|
|
|
|
results = _run_concurrent([lambda: ok("a"), lambda: boom(), lambda: ok("c")])
|
|
assert results[0] == "a"
|
|
assert isinstance(results[1], RuntimeError)
|
|
assert results[2] == "c"
|
|
|
|
def test_run_concurrent_falls_back_when_loop_running():
|
|
"""Inside a running loop, _run_concurrent stays correct (sequential fallback)."""
|
|
from browser_cli.client.core import _run_concurrent
|
|
|
|
async def ok(value):
|
|
return value
|
|
|
|
async def driver():
|
|
# asyncio.run would raise here; _run_concurrent must fall back instead.
|
|
return _run_concurrent([lambda: ok(1), lambda: ok(2)])
|
|
|
|
assert asyncio.run(driver()) == [1, 2]
|
|
|
|
def test_remote_browser_targets_query_endpoints_concurrently(monkeypatch, tmp_path):
|
|
"""Multiple remotes are discovered in parallel, not serially."""
|
|
import threading
|
|
import time
|
|
|
|
remotes_path = tmp_path / "remotes.json"
|
|
remotes_path.write_text(
|
|
json.dumps({"host-a.example:8765": {}, "host-b.example:8765": {}, "host-c.example:8765": {}}),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
monkeypatch.setattr("browser_cli.remote.registry.REMOTE_REGISTRY_PATH", remotes_path)
|
|
|
|
active = 0
|
|
max_active = 0
|
|
lock = threading.Lock()
|
|
|
|
def fake_send_command(command, args=None, profile=None, remote=None, key=None, **kwargs):
|
|
nonlocal active, max_active
|
|
with lock:
|
|
active += 1
|
|
max_active = max(max_active, active)
|
|
time.sleep(0.1)
|
|
with lock:
|
|
active -= 1
|
|
return [{"profile": "work", "displayName": "work"}]
|
|
|
|
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
|
|
|
targets = active_browser_targets()
|
|
|
|
assert len(targets) == 3
|
|
assert max_active >= 2, f"expected concurrent endpoint discovery, peak was {max_active}"
|
|
|
|
def test_collect_browser_clients_uses_cached_target_version(monkeypatch, tmp_path):
|
|
"""A remote target advertising version/extVersion skips the clients.list roundtrip."""
|
|
from browser_cli.client import collect_browser_clients
|
|
import browser_cli.client.core as core
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
|
|
cached_target = BrowserTarget(
|
|
profile="work",
|
|
display_name="host.example:work",
|
|
socket_path="",
|
|
remote="host.example:8765",
|
|
browser_name="Firefox",
|
|
display_group="host.example",
|
|
version="151.0",
|
|
extension_version="0.15.6",
|
|
)
|
|
monkeypatch.setattr(core, "active_browser_targets", lambda **kw: [cached_target])
|
|
|
|
calls = []
|
|
monkeypatch.setattr(core, "send_command", lambda *a, **k: calls.append((a, k)) or [])
|
|
|
|
rows = collect_browser_clients(registry_path=tmp_path / "missing-registry.json")
|
|
|
|
assert calls == [] # no clients.list roundtrip was needed
|
|
assert rows == [{
|
|
"profile": "host.example:work",
|
|
"profileGroup": "host.example",
|
|
"name": "Firefox",
|
|
"version": "151.0",
|
|
"extensionVersion": "0.15.6",
|
|
}]
|
|
|
|
def test_collect_browser_clients_with_explicit_remote_lists_all_targets(monkeypatch, tmp_path):
|
|
"""`browser-cli --remote host clients` should list all profiles, not auto-route and fail as ambiguous."""
|
|
from browser_cli.client import collect_browser_clients
|
|
import browser_cli.client.core as core
|
|
|
|
targets = [
|
|
BrowserTarget(
|
|
profile="main",
|
|
display_name="browser-host.example:main",
|
|
socket_path="",
|
|
remote="browser-host.example:8765",
|
|
browser_name="Chrome",
|
|
display_group="browser-host.example",
|
|
version="149.0.0.0",
|
|
extension_version="0.16.4",
|
|
),
|
|
BrowserTarget(
|
|
profile="work",
|
|
display_name="browser-host.example:work",
|
|
socket_path="",
|
|
remote="browser-host.example:8765",
|
|
browser_name="Firefox",
|
|
display_group="browser-host.example",
|
|
version="151.0",
|
|
extension_version="0.16.4",
|
|
),
|
|
]
|
|
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
|
|
monkeypatch.setattr(core, "send_command", lambda *a, **k: pytest.fail("clients.list must not auto-route for cached targets"))
|
|
|
|
rows = collect_browser_clients(remote="browser-host.example:8765", registry_path=tmp_path / "missing-registry.json")
|
|
|
|
assert [row["profile"] for row in rows] == ["main", "work"]
|
|
assert [row.get("profileGroup") for row in rows] == [None, None]
|
|
assert [row["name"] for row in rows] == ["Chrome", "Firefox"]
|
|
|
|
def test_collect_browser_clients_with_explicit_remote_and_browser_filters_target(monkeypatch, tmp_path):
|
|
from browser_cli.client import collect_browser_clients
|
|
import browser_cli.client.core as core
|
|
|
|
targets = [
|
|
BrowserTarget("main", "browser-host.example:main", "", remote="browser-host.example:8765", version="1"),
|
|
BrowserTarget("work", "browser-host.example:work", "", remote="browser-host.example:8765", version="1"),
|
|
]
|
|
monkeypatch.setattr(core, "remote_browser_targets", lambda endpoint, key=None: targets)
|
|
|
|
rows = collect_browser_clients(remote="browser-host.example:8765", browser_alias="work", registry_path=tmp_path / "missing-registry.json")
|
|
|
|
assert [row["profile"] for row in rows] == ["work"]
|
|
assert rows[0].get("profileGroup") is None
|
|
|
|
def test_collect_browser_clients_falls_back_when_version_unknown(monkeypatch, tmp_path):
|
|
"""An older remote (no advertised version) still triggers a clients.list query."""
|
|
from browser_cli.client import collect_browser_clients
|
|
import browser_cli.client.core as core
|
|
|
|
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
|
|
|
legacy_target = BrowserTarget(
|
|
profile="work",
|
|
display_name="host.example:work",
|
|
socket_path="",
|
|
remote="host.example:8765",
|
|
browser_name="Firefox",
|
|
display_group="host.example",
|
|
)
|
|
monkeypatch.setattr(core, "active_browser_targets", lambda **kw: [legacy_target])
|
|
|
|
calls = []
|
|
|
|
def fake_send(command, args=None, profile=None, remote=None, key=None, **kw):
|
|
calls.append(command)
|
|
return [{"name": "Firefox", "version": "151.0", "extensionVersion": "0.15.2"}]
|
|
|
|
monkeypatch.setattr(core, "send_command", fake_send)
|
|
|
|
rows = collect_browser_clients(registry_path=tmp_path / "missing-registry.json")
|
|
|
|
assert calls == ["clients.list"] # fell back to a query
|
|
assert rows[0]["version"] == "151.0"
|