diff --git a/browser_cli/client.py b/browser_cli/client.py index 2a2d0d4..ad0015e 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -79,13 +79,24 @@ class BrowserTarget: socket_path: str remote: str | None = None +def _is_reachable_unix_endpoint(endpoint: str) -> bool: + """Return True when a Unix socket path exists and accepts connections.""" + path = Path(endpoint) + if not path.exists(): + return False + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(0.2) + sock.connect(endpoint) + return True + except OSError: + return False def _active_endpoints(reg: dict) -> dict: """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 _is_reachable_unix_endpoint(v)} def display_browser_name(profile_name: str, sock_path: str) -> str: if profile_name != "default": @@ -198,6 +209,23 @@ def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: endpoint_matches.append(target) if len(endpoint_matches) == 1: return endpoint_matches[0] + if len(endpoint_matches) > 1: + aliases = [target.profile for target in endpoint_matches] + endpoint = endpoint_matches[0].remote or alias + examples = "\n".join( + f" browser-cli --remote {endpoint} --browser {a} ..." + for a in aliases + ) + display_aliases = [target.display_name for target in endpoint_matches] + shorthand_examples = "\n".join( + f" browser-cli --browser {a} ..." + for a in display_aliases + ) + raise BrowserNotConnected( + f"Multiple remote browser instances are active on {alias}: {', '.join(aliases)}\n" + f"Use --browser with --remote to select one:\n{examples}\n" + f"Or use the full remote browser alias:\n{shorthand_examples}" + ) return None diff --git a/extension/manifest.json b/extension/manifest.json index fedc646..735e577 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "browser-cli", - "version": "0.10.1", + "version": "0.10.2", "description": "Control your browser from the terminal or Python SDK", "permissions": [ "tabs", diff --git a/pyproject.toml b/pyproject.toml index 887ea43..a25328a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "browser-cli" -version = "0.10.1" +version = "0.10.2" description = "Control your real running browser from the terminal or Python SDK" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_client.py b/tests/test_client.py index 44211e7..79f12a3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import json +import socket from pathlib import Path import pytest @@ -18,6 +19,12 @@ from browser_cli.client import ( ) from browser_cli.platform import endpoint_for_alias +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.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")) @@ -29,59 +36,67 @@ 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" - socket_path.write_text("") + 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.REGISTRY_PATH", registry_path) - assert _resolve_socket() == str(socket_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_socket.write_text("") - second_socket.write_text("") + 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.REGISTRY_PATH", registry_path) - with pytest.raises(BrowserNotConnected, match="Multiple browser instances are active: uuid-1, uuid-2"): - _resolve_socket() - + 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.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" - active_socket.write_text("") + 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.REGISTRY_PATH", registry_path) - targets = active_browser_targets(include_remotes=False) + 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"})) @@ -129,6 +144,7 @@ def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monke "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._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 @@ -237,7 +253,7 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch): assert "token" not in sent -def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets(monkeypatch): +def test_remote_target_for_alias_reports_ambiguous_host_for_multiple_targets(monkeypatch): monkeypatch.setattr( "browser_cli.client._remote_browser_targets", lambda: [ @@ -246,7 +262,30 @@ def test_remote_target_for_alias_keeps_host_alias_ambiguous_for_multiple_targets ], ) - assert remote_target_for_alias("host") is None + 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.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")) + monkeypatch.setattr("browser_cli.client.is_windows", lambda: False) + monkeypatch.setattr( + "browser_cli.client._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): diff --git a/uv.lock b/uv.lock index cc989cc..ce50412 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "browser-cli" -version = "0.10.1" +version = "0.10.2" source = { editable = "." } dependencies = [ { name = "click" },