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("browsercli.yiprawr.dev") 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("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev" assert _normalize_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev" 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("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev:8765" def test_resolve_connect_endpoint_adds_443_for_domain(): assert _resolve_connect_endpoint("browsercli.yiprawr.dev") == "browsercli.yiprawr.dev:443" assert _resolve_connect_endpoint("browsercli.yiprawr.dev:443") == "browsercli.yiprawr.dev:443" assert _resolve_connect_endpoint("browsercli.yiprawr.dev:8765") == "browsercli.yiprawr.dev: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="browsercli.yiprawr.dev:443") assert result == "ok" assert sent_to["endpoint"] == "browsercli.yiprawr.dev" # 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="browsercli.yiprawr.dev") assert result == "ok" assert sent_to["endpoint"] == "browsercli.yiprawr.dev" 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 = "browsercli.yiprawr.dev" 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 == "browsercli.yiprawr.dev: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 = "browsercli.yiprawr.dev: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 == "browsercli.yiprawr.dev:automatisation" def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path): """--key agent is saved on first use; omitting --key on subsequent calls reuses 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) # First call with explicit --key agent send_command("tabs.list", remote="host:8765", key=_Path("agent")) assert used_keys[-1] == "agent" # Key must be persisted now assert key_for_remote("host:8765") == "agent" # Second call without --key — should reuse saved "agent" send_command("tabs.list", remote="host:8765") assert used_keys[-1] == "agent" # ── 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("