refactor: reorganize client transport and extension internals
- Split client, native, remote, serve, markdown, and SDK internals into focused packages with direct imports. - Move local and remote transport framing/protocol helpers behind clearer module boundaries. - Break up the extension injected DOM logic into a separate content dispatch bundle and dedicated content modules. - Add explicit client handling for passive remote discovery without noisy PQ warnings. - Keep behavior covered with updated unit, integration, and extension tests.
This commit is contained in:
+987
-765
File diff suppressed because it is too large
Load Diff
+10
-17
@@ -20,8 +20,7 @@ from browser_cli.auth import (
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
from browser_cli.client import _is_valid_key_spec
|
||||
|
||||
from browser_cli.remote.registry import is_valid_key_spec
|
||||
|
||||
class TestGenerateKeypair:
|
||||
def test_returns_pem_and_hex(self):
|
||||
@@ -34,7 +33,6 @@ class TestGenerateKeypair:
|
||||
_, pub2 = generate_keypair()
|
||||
assert pub1 != pub2
|
||||
|
||||
|
||||
class TestCanonicalPayload:
|
||||
def test_strips_auth_protocol_fields(self):
|
||||
msg = {"command": "tabs.list", "id": "x", "pubkey": "abc", "sig": "def", "pq_kex": {"alg": "ML-KEM-768"}}
|
||||
@@ -52,7 +50,6 @@ class TestCanonicalPayload:
|
||||
msg = {"b": 2, "a": 1}
|
||||
assert canonical_payload(msg) == canonical_payload(msg)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def keypair(tmp_path):
|
||||
pem, pub_hex = generate_keypair()
|
||||
@@ -61,7 +58,6 @@ def keypair(tmp_path):
|
||||
priv = load_private_key(key_path)
|
||||
return priv, pub_hex
|
||||
|
||||
|
||||
class TestSignVerify:
|
||||
def test_valid_signature_verifies(self, keypair):
|
||||
priv, pub_hex = keypair
|
||||
@@ -112,7 +108,6 @@ class TestSignVerify:
|
||||
def test_wrong_length_pubkey_returns_false_not_exception(self):
|
||||
assert verify("aabbcc", b"nonce", {}, "00" * 64) is False
|
||||
|
||||
|
||||
class TestPostQuantumKex:
|
||||
def test_mlkem_roundtrip_when_backend_supports_it(self):
|
||||
keypair = pq_kex_server_keypair()
|
||||
@@ -143,7 +138,6 @@ class TestPostQuantumKex:
|
||||
with pytest.raises(Exception):
|
||||
pq_decrypt(secret, "response", envelope)
|
||||
|
||||
|
||||
class TestAuthorizedKeys:
|
||||
def test_add_and_load(self, tmp_path):
|
||||
path = tmp_path / "authorized_keys"
|
||||
@@ -175,32 +169,31 @@ class TestAuthorizedKeys:
|
||||
def test_returns_empty_for_missing_file(self, tmp_path):
|
||||
assert load_authorized_keys(tmp_path / "nofile") == []
|
||||
|
||||
|
||||
class TestIsValidKeySpec:
|
||||
def test_agent_bare(self):
|
||||
assert _is_valid_key_spec("agent") is True
|
||||
assert is_valid_key_spec("agent") is True
|
||||
|
||||
def test_agent_with_selector(self):
|
||||
assert _is_valid_key_spec("agent:cardno:000012345678") is True
|
||||
assert is_valid_key_spec("agent:cardno:000012345678") is True
|
||||
|
||||
def test_absolute_pem_path(self):
|
||||
assert _is_valid_key_spec("/home/user/.config/browser-cli/client.key.pem") is True
|
||||
assert is_valid_key_spec("/home/user/.config/browser-cli/client.key.pem") is True
|
||||
|
||||
def test_dot_key_extension(self):
|
||||
assert _is_valid_key_spec("/tmp/mykey.key") is True
|
||||
assert is_valid_key_spec("/tmp/mykey.key") is True
|
||||
|
||||
def test_angled_bracket_pem_rejected(self):
|
||||
# regression: operator precedence bug allowed "<garbage>.pem" to pass
|
||||
assert _is_valid_key_spec("<garbage>.pem") is False
|
||||
assert is_valid_key_spec("<garbage>.pem") is False
|
||||
|
||||
def test_angled_bracket_key_rejected(self):
|
||||
assert _is_valid_key_spec("<garbage>.key") is False
|
||||
assert is_valid_key_spec("<garbage>.key") is False
|
||||
|
||||
def test_serialized_object_rejected(self):
|
||||
assert _is_valid_key_spec("<AgentKey(blob=b'...', comment='test')>.pem") is False
|
||||
assert is_valid_key_spec("<AgentKey(blob=b'...', comment='test')>.pem") is False
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
assert _is_valid_key_spec("") is False
|
||||
assert is_valid_key_spec("") is False
|
||||
|
||||
def test_bare_filename_no_slash_no_ext_rejected(self):
|
||||
assert _is_valid_key_spec("mykey") is False
|
||||
assert is_valid_key_spec("mykey") is False
|
||||
|
||||
+501
-470
File diff suppressed because it is too large
Load Diff
+108
-69
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -10,14 +12,15 @@ from browser_cli.client import (
|
||||
_looks_like_domain,
|
||||
_normalize_endpoint,
|
||||
_resolve_connect_endpoint,
|
||||
_resolve_socket,
|
||||
active_browser_targets,
|
||||
display_browser_name,
|
||||
key_for_remote,
|
||||
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)
|
||||
@@ -27,10 +30,10 @@ def _listening_unix_socket(path: Path) -> socket.socket:
|
||||
|
||||
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"))
|
||||
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()
|
||||
resolve_socket()
|
||||
|
||||
def test_resolve_socket_uses_only_active_registry_entry(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False)
|
||||
@@ -40,10 +43,10 @@ def test_resolve_socket_uses_only_active_registry_entry(monkeypatch, tmp_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)
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
|
||||
try:
|
||||
assert _resolve_socket() == str(socket_path)
|
||||
assert resolve_socket() == str(socket_path)
|
||||
finally:
|
||||
listener.close()
|
||||
|
||||
@@ -57,11 +60,11 @@ def test_resolve_socket_raises_when_multiple_active_entries(monkeypatch, tmp_pat
|
||||
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)
|
||||
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()
|
||||
resolve_socket()
|
||||
finally:
|
||||
first_listener.close()
|
||||
second_listener.close()
|
||||
@@ -73,9 +76,9 @@ 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"))
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
||||
|
||||
assert _resolve_socket("work") == endpoint_for_alias("work")
|
||||
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"
|
||||
@@ -86,7 +89,7 @@ def test_active_browser_targets_filters_stale_entries(monkeypatch, tmp_path):
|
||||
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)
|
||||
monkeypatch.setattr("browser_cli.client.targets.REGISTRY_PATH", registry_path)
|
||||
|
||||
try:
|
||||
targets = active_browser_targets(include_remotes=False)
|
||||
@@ -101,23 +104,21 @@ def test_active_browser_targets_keeps_windows_registry_entries(monkeypatch, tmp_
|
||||
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)
|
||||
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.remote_browser_targets",
|
||||
"browser_cli.client.core.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("work", "host:work", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
@@ -125,13 +126,12 @@ def test_send_command_auto_routes_single_remote_target(monkeypatch):
|
||||
sent.update(msg)
|
||||
return json.dumps({"success": True, "data": "ok"}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
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_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)
|
||||
@@ -139,12 +139,12 @@ def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monke
|
||||
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.REGISTRY_PATH", registry_path)
|
||||
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._is_active_local_profile", lambda profile: True)
|
||||
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
|
||||
@@ -172,18 +172,17 @@ def test_send_command_prefers_active_local_profile_over_saved_remote_alias(monke
|
||||
del self._response[:n]
|
||||
return chunk
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.socket.socket", lambda *args, **kwargs: FakeSocket())
|
||||
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._remote_browser_targets",
|
||||
"browser_cli.client.core._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
@@ -192,17 +191,16 @@ def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch):
|
||||
sent.update(msg)
|
||||
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
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._remote_browser_targets",
|
||||
"browser_cli.client.core._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", "host:work", "", remote="host:8765")],
|
||||
)
|
||||
|
||||
@@ -212,12 +210,11 @@ def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch):
|
||||
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._remote_browser_targets",
|
||||
"browser_cli.client.core._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
@@ -227,7 +224,6 @@ def test_remote_target_for_alias_accepts_host_when_only_one_remote_target(monkey
|
||||
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"
|
||||
@@ -236,7 +232,7 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
sent = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
"browser_cli.client.core._remote_browser_targets",
|
||||
lambda: [BrowserTarget("work", f"{remote_host}:work", "", remote=remote_endpoint)],
|
||||
)
|
||||
|
||||
@@ -245,17 +241,16 @@ def test_send_command_resolves_host_alias_to_single_remote_target(monkeypatch):
|
||||
sent.update(msg)
|
||||
return json.dumps({"success": True, "data": []}).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
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._remote_browser_targets",
|
||||
"browser_cli.client.core._remote_browser_targets",
|
||||
lambda: [
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
||||
@@ -270,14 +265,13 @@ def test_remote_target_for_alias_reports_ambiguous_host_for_multiple_targets(mon
|
||||
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.targets.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json"))
|
||||
monkeypatch.setattr("browser_cli.client.targets.is_windows", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client._remote_browser_targets",
|
||||
"browser_cli.client.core._remote_browser_targets",
|
||||
lambda: [
|
||||
BrowserTarget("main", "host:main", "", remote="host:8765"),
|
||||
BrowserTarget("work", "host:work", "", remote="host:8765"),
|
||||
@@ -287,11 +281,10 @@ def test_send_command_reports_ambiguous_remote_browser_alias(monkeypatch):
|
||||
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.remote_browser_targets",
|
||||
"browser_cli.client.core.remote_browser_targets",
|
||||
lambda endpoint, key=None: [
|
||||
BrowserTarget("main", "host:main", "", remote=endpoint),
|
||||
BrowserTarget("furry", "host:furry", "", remote=endpoint),
|
||||
@@ -301,20 +294,19 @@ def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch):
|
||||
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.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
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"}]
|
||||
|
||||
monkeypatch.setattr("browser_cli.client.send_command", fake_send_command)
|
||||
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
@@ -323,38 +315,33 @@ def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path):
|
||||
assert targets[0].display_name == "browser-host.example:work"
|
||||
assert targets[0].remote == endpoint
|
||||
|
||||
|
||||
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("192.168.1.100") 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("192.168.1.1:443") == "192.168.1.1:443" # IP: keep port
|
||||
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)
|
||||
@@ -362,7 +349,7 @@ def test_send_command_normalizes_domain_port_443(monkeypatch):
|
||||
sent_to = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
"browser_cli.client.core.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
@@ -370,13 +357,12 @@ def test_send_command_normalizes_domain_port_443(monkeypatch):
|
||||
sent_to["endpoint"] = endpoint
|
||||
return json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
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)
|
||||
@@ -384,7 +370,7 @@ def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||
sent_to = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
"browser_cli.client.core.remote_browser_targets",
|
||||
lambda endpoint, key=None: [BrowserTarget("default", f"{endpoint}:default", "", remote=endpoint)],
|
||||
)
|
||||
|
||||
@@ -392,25 +378,24 @@ def test_send_command_domain_without_port_defaults_to_443(monkeypatch):
|
||||
sent_to["endpoint"] = endpoint
|
||||
return json.dumps({"success": True, "data": "ok"}).encode()
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote)
|
||||
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.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
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.send_command", fake_send_command)
|
||||
monkeypatch.setattr("browser_cli.client.core.send_command", fake_send_command)
|
||||
|
||||
targets = active_browser_targets()
|
||||
|
||||
@@ -418,34 +403,32 @@ def test_domain_display_name_omits_port(monkeypatch, tmp_path):
|
||||
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.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
monkeypatch.setattr("browser_cli.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
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.send_command", fake_send_command)
|
||||
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.client.REMOTE_REGISTRY_PATH", remotes_path)
|
||||
monkeypatch.setattr("browser_cli.client.REGISTRY_PATH", tmp_path / "missing-registry.json")
|
||||
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)
|
||||
@@ -457,16 +440,16 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
|
||||
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._load_private_key", fake_load_private_key)
|
||||
monkeypatch.setattr("browser_cli.client.auth.load_private_key", fake_load_private_key)
|
||||
monkeypatch.setattr(
|
||||
"browser_cli.client.remote_browser_targets",
|
||||
"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._send_remote", fake_send_remote)
|
||||
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"))
|
||||
@@ -478,3 +461,59 @@ def test_send_command_auto_saves_and_reuses_key_for_remote(monkeypatch, tmp_path
|
||||
# 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("<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"] == {}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli.framing import (
|
||||
async_recv_frame,
|
||||
frame,
|
||||
recv_frame,
|
||||
send_frame,
|
||||
)
|
||||
from browser_cli.version_manager import MAX_MSG_BYTES
|
||||
|
||||
class FakeRecv:
|
||||
def __init__(self, chunks):
|
||||
self.chunks = list(chunks)
|
||||
|
||||
def recv(self, n):
|
||||
if not self.chunks:
|
||||
return b""
|
||||
chunk = self.chunks.pop(0)
|
||||
if len(chunk) > n:
|
||||
self.chunks.insert(0, chunk[n:])
|
||||
return chunk[:n]
|
||||
return chunk
|
||||
|
||||
def test_frame_prefixes_payload_length():
|
||||
assert frame(b"abc") == b"\x03\x00\x00\x00abc"
|
||||
|
||||
def test_recv_frame_reads_chunked_socket_payload():
|
||||
sock = FakeRecv([b"\x05", b"\x00\x00\x00he", b"llo"])
|
||||
assert recv_frame(sock) == b"hello"
|
||||
|
||||
def test_recv_frame_allow_eof_returns_none():
|
||||
assert recv_frame(FakeRecv([]), allow_eof=True) is None
|
||||
|
||||
def test_recv_frame_rejects_oversized_payload():
|
||||
sock = FakeRecv([struct.pack("<I", MAX_MSG_BYTES + 1)])
|
||||
with pytest.raises(ConnectionError, match="too large"):
|
||||
recv_frame(sock, label="message")
|
||||
|
||||
def test_send_frame_writes_prefixed_payload():
|
||||
sent = []
|
||||
|
||||
class FakeSend:
|
||||
def sendall(self, data):
|
||||
sent.append(data)
|
||||
|
||||
send_frame(FakeSend(), b"ok")
|
||||
assert sent == [b"\x02\x00\x00\x00ok"]
|
||||
|
||||
def test_async_recv_frame_reads_payload():
|
||||
async def run():
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(frame(b"async"))
|
||||
reader.feed_eof()
|
||||
return await async_recv_frame(reader)
|
||||
|
||||
assert asyncio.run(run()) == b"async"
|
||||
+99
-21
@@ -4,7 +4,9 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import browser_cli.native_host as native_host
|
||||
from browser_cli.native import host as native_host
|
||||
from browser_cli import framing, local_transport
|
||||
from browser_cli.native import local_server, protocol as native_protocol
|
||||
|
||||
def _raise_system_exit(code: int):
|
||||
raise SystemExit(code)
|
||||
@@ -28,7 +30,7 @@ def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
||||
def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
cleaned = []
|
||||
|
||||
monkeypatch.setattr(native_host, "read_native_message", lambda stream: None)
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: None)
|
||||
monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
@@ -53,7 +55,7 @@ def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||
cleaned = []
|
||||
messages = iter([{"type": "bye"}])
|
||||
|
||||
monkeypatch.setattr(native_host, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host, "_cleanup", cleaned.append)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
@@ -68,7 +70,7 @@ def test_stdin_reader_routes_response_messages(monkeypatch):
|
||||
native_host.PENDING["msg-1"] = response_queue
|
||||
messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None])
|
||||
|
||||
monkeypatch.setattr(native_host, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host.protocol, "read_native_message", lambda stream: next(messages))
|
||||
monkeypatch.setattr(native_host, "_cleanup", lambda alias: None)
|
||||
monkeypatch.setattr(native_host.os, "_exit", _raise_system_exit)
|
||||
monkeypatch.setattr(native_host.sys, "stdin", SimpleNamespace(buffer=object()))
|
||||
@@ -137,7 +139,7 @@ def test_read_exact_stream_full_read():
|
||||
"""Returns the exact bytes when stream delivers them in one shot."""
|
||||
import io
|
||||
stream = io.BytesIO(b"hello")
|
||||
assert native_host._read_exact_stream(stream, 5) == b"hello"
|
||||
assert native_protocol.read_exact_stream(stream, 5) == b"hello"
|
||||
|
||||
def test_read_exact_stream_partial_chunks():
|
||||
"""Accumulates multiple short chunks until n bytes are read."""
|
||||
@@ -156,19 +158,19 @@ def test_read_exact_stream_partial_chunks():
|
||||
return chunk
|
||||
|
||||
stream = _ChunkyStream(b"abcdefgh", 3)
|
||||
assert native_host._read_exact_stream(stream, 8) == b"abcdefgh"
|
||||
assert native_protocol.read_exact_stream(stream, 8) == b"abcdefgh"
|
||||
|
||||
def test_read_exact_stream_eof_returns_none():
|
||||
"""Returns None if stream is exhausted before n bytes are delivered."""
|
||||
import io
|
||||
stream = io.BytesIO(b"ab") # only 2 bytes, asking for 4
|
||||
assert native_host._read_exact_stream(stream, 4) is None
|
||||
assert native_protocol.read_exact_stream(stream, 4) is None
|
||||
|
||||
def test_read_exact_stream_immediate_eof():
|
||||
"""Returns None on an empty stream."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_host._read_exact_stream(stream, 1) is None
|
||||
assert native_protocol.read_exact_stream(stream, 1) is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_native_message / read_native_message round-trip
|
||||
@@ -179,16 +181,16 @@ def test_write_and_read_native_message_roundtrip():
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
msg = {"id": "abc", "command": "tabs.list", "args": {}}
|
||||
native_host.write_native_message(buf, msg)
|
||||
native_protocol.write_native_message(buf, msg)
|
||||
buf.seek(0)
|
||||
result = native_host.read_native_message(buf)
|
||||
result = native_protocol.read_native_message(buf)
|
||||
assert result == msg
|
||||
|
||||
def test_read_native_message_eof_at_length_prefix():
|
||||
"""Returns None when the stream is empty (no length prefix)."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_host.read_native_message(stream) is None
|
||||
assert native_protocol.read_native_message(stream) is None
|
||||
|
||||
def test_read_native_message_eof_at_body():
|
||||
"""Returns None when the body is truncated after reading the length prefix."""
|
||||
@@ -197,14 +199,14 @@ def test_read_native_message_eof_at_body():
|
||||
# Write a 10-byte length prefix but only 5 bytes of body
|
||||
buf = struct.pack("<I", 10) + b"hello"
|
||||
stream = io.BytesIO(buf)
|
||||
assert native_host.read_native_message(stream) is None
|
||||
assert native_protocol.read_native_message(stream) is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _recv_exact / _recv_all / _send_all
|
||||
# framing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_recv_exact_accumulates_data():
|
||||
"""_recv_exact receives exactly n bytes from a socket-like object."""
|
||||
"""framing.recv_exact receives exactly n bytes from a socket-like object."""
|
||||
class _FakeSock:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
@@ -215,23 +217,23 @@ def test_recv_exact_accumulates_data():
|
||||
return chunk
|
||||
|
||||
sock = _FakeSock(b"0123456789")
|
||||
assert native_host._recv_exact(sock, 5) == b"01234"
|
||||
assert native_host._recv_exact(sock, 5) == b"56789"
|
||||
assert framing.recv_exact(sock, 5) == b"01234"
|
||||
assert framing.recv_exact(sock, 5) == b"56789"
|
||||
|
||||
def test_recv_exact_eof_returns_none():
|
||||
class _EmptySock:
|
||||
def recv(self, n):
|
||||
return b""
|
||||
assert native_host._recv_exact(_EmptySock(), 4) is None
|
||||
assert framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None
|
||||
|
||||
def test_send_all_and_recv_all():
|
||||
"""_send_all frames data with length prefix; _recv_all strips it."""
|
||||
"""framing.send_frame frames data; framing.recv_frame strips it."""
|
||||
import socket
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
payload = b'{"command": "tabs.list"}'
|
||||
native_host._send_all(a, payload)
|
||||
received = native_host._recv_all(b)
|
||||
framing.send_frame(a, payload)
|
||||
received = framing.recv_frame(b, allow_eof=True)
|
||||
assert received == payload
|
||||
finally:
|
||||
a.close()
|
||||
@@ -246,7 +248,7 @@ def test_recv_all_truncated_body():
|
||||
# Send a length of 100 but only 4 bytes of body
|
||||
a.sendall(struct.pack("<I", 100) + b"tiny")
|
||||
a.close()
|
||||
result = native_host._recv_all(b)
|
||||
result = framing.recv_frame(b, allow_eof=True)
|
||||
assert result is None
|
||||
finally:
|
||||
b.close()
|
||||
@@ -340,3 +342,79 @@ def test_resolve_profile_alias_non_hello_type_returns_uuid():
|
||||
alias = native_host._resolve_profile_alias({"type": "bye", "alias": "some"})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# asyncio Unix-socket server path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_async_recv_all_and_send_all_roundtrip():
|
||||
"""local_transport async framing mirrors the sync length-prefixed socket framing."""
|
||||
import asyncio
|
||||
|
||||
async def run():
|
||||
async def handle(reader, writer):
|
||||
payload = await local_transport.async_recv_all(reader)
|
||||
await local_transport.async_send_all(writer, payload + b"-reply")
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
||||
host, port = server.sockets[0].getsockname()
|
||||
async with server:
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
await local_transport.async_send_all(writer, b"hello")
|
||||
assert await local_transport.async_recv_all(reader) == b"hello-reply"
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
def test_async_socket_server_handles_cli_request(monkeypatch, tmp_path):
|
||||
"""Unix CLI socket server accepts requests concurrently via asyncio."""
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
async def read_frame(reader):
|
||||
raw_len = await reader.readexactly(4)
|
||||
msg_len = struct.unpack("<I", raw_len)[0]
|
||||
return await reader.readexactly(msg_len)
|
||||
|
||||
async def run():
|
||||
sock_path = tmp_path / "browser.sock"
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
native_host,
|
||||
"_handle_browser_command",
|
||||
lambda cmd: seen.append(cmd) or {"id": cmd["id"], "success": True, "data": "ok"},
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
local_server.async_socket_server(
|
||||
str(sock_path),
|
||||
native_host._handle_cli_payload,
|
||||
native_host._error_response,
|
||||
)
|
||||
)
|
||||
for _ in range(100):
|
||||
if sock_path.exists():
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
reader, writer = await asyncio.open_unix_connection(str(sock_path))
|
||||
await local_transport.async_send_all(writer, json.dumps({"command": "tabs.list", "args": {}}).encode())
|
||||
response = json.loads((await read_frame(reader)).decode())
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["data"] == "ok"
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert "id" in seen[0]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
@@ -18,7 +18,7 @@ class TestEndpoints:
|
||||
|
||||
assert _normalize_endpoint("example.com:443") == "example.com"
|
||||
assert _normalize_endpoint("example.com:8765") == "example.com:8765"
|
||||
assert _normalize_endpoint("192.168.1.10:443") == "192.168.1.10:443"
|
||||
assert _normalize_endpoint("203.0.113.10:443") == "203.0.113.10:443"
|
||||
|
||||
def test_resolve_connect_endpoint_defaults_domain_to_443(self):
|
||||
from browser_cli.endpoints import _resolve_connect_endpoint
|
||||
@@ -69,7 +69,7 @@ class TestReExports:
|
||||
def test_client_still_exposes_moved_names(self):
|
||||
from browser_cli import client
|
||||
|
||||
# moved to endpoints / remote_transport / _errors but still reachable here
|
||||
# moved to endpoints / remote.transport / errors but still reachable here
|
||||
for name in (
|
||||
"BrowserNotConnected",
|
||||
"_normalize_endpoint",
|
||||
@@ -90,16 +90,16 @@ class TestReExports:
|
||||
|
||||
def test_patching_client_send_remote_still_intercepts(self):
|
||||
# send_command resolves _send_remote as a browser_cli.client global, so
|
||||
# patching there must still take effect after the move to remote_transport.
|
||||
with patch("browser_cli.client._send_remote", return_value=None) as fake:
|
||||
assert browser_cli.client._send_remote is fake
|
||||
# patching there must still take effect after the move to remote.transport.
|
||||
with patch("browser_cli.client.core._send_remote", return_value=None) as fake:
|
||||
assert browser_cli.client.core._send_remote is fake
|
||||
|
||||
# ── BrowserCLI mixin composition ─────────────────────────────────────────────
|
||||
|
||||
class TestMixinComposition:
|
||||
def test_factory_builds_bound_tab(self):
|
||||
b = BrowserCLI()
|
||||
tab = b._make_tab({"id": 7, "windowId": 1, "title": "t", "url": "u"})
|
||||
tab = b.tab_from({"id": 7, "windowId": 1, "title": "t", "url": "u"})
|
||||
assert tab.id == 7
|
||||
assert tab._browser is b
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestMixinComposition:
|
||||
display_name = "host:work"
|
||||
remote = "host:8765"
|
||||
|
||||
tab = b._make_tab_for({"id": 1, "windowId": 0}, _Target())
|
||||
tab = b.tab_from_target({"id": 1, "windowId": 0}, _Target())
|
||||
assert tab.browser == "host:work"
|
||||
assert isinstance(tab._browser, BrowserCLI)
|
||||
assert tab._browser is not b
|
||||
|
||||
@@ -62,7 +62,7 @@ def no_browser(monkeypatch):
|
||||
def _raise_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _raise_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _raise_no_browser)
|
||||
|
||||
def _connect(auth_keys_path):
|
||||
client, server = socket.socketpair()
|
||||
|
||||
+47
-8
@@ -206,7 +206,7 @@ class TestAuthSuccess:
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
@@ -238,7 +238,7 @@ class TestAuthSuccess:
|
||||
key_path.write_bytes(pem)
|
||||
priv = load_private_key(key_path)
|
||||
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, path)
|
||||
@@ -258,7 +258,7 @@ class TestAuthSuccess:
|
||||
|
||||
def test_post_quantum_kex_auth_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""ML-KEM shared secret is decapsulated and bound to the auth signature."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
||||
|
||||
@@ -298,7 +298,7 @@ class TestAuthSuccess:
|
||||
|
||||
def test_post_quantum_encrypted_transport_reaches_proxy(self, tmp_path, monkeypatch):
|
||||
"""New clients encrypt the command payload and receive encrypted responses."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_keypair", lambda: ("fake-private", b"fake-public"))
|
||||
monkeypatch.setattr("browser_cli.auth.pq_kex_server_decapsulate", lambda priv, ct: b"pq-secret")
|
||||
|
||||
@@ -347,7 +347,7 @@ class TestAuthSuccess:
|
||||
|
||||
def test_no_auth_mode_reaches_proxy(self, monkeypatch):
|
||||
"""auth_keys_path=None (--no-auth): no pubkey required, reaches proxy layer."""
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", _mock_no_browser)
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
@@ -421,7 +421,7 @@ class TestResponseEncoding:
|
||||
"data": {"items": [{"url": f"https://example.com/{i}", "title": f"Tab {i}"} for i in range(300)]}}
|
||||
host_path = tmp_path / "native.sock"
|
||||
host = _FakeNativeHost(host_path, big)
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None) # no auth
|
||||
@@ -446,7 +446,7 @@ class TestResponseEncoding:
|
||||
big = {"id": "y", "success": True, "data": {"items": list(range(500))}}
|
||||
host_path = tmp_path / "native2.sock"
|
||||
host = _FakeNativeHost(host_path, big)
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
@@ -467,7 +467,7 @@ class TestResponseEncoding:
|
||||
"data": {"items": [{"url": f"https://e/{i}"} for i in range(300)]}}
|
||||
host_path = tmp_path / "native3.sock"
|
||||
host = _FakeNativeHost(host_path, big)
|
||||
monkeypatch.setattr("browser_cli.client._resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", lambda *_a, **_k: str(host_path))
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
@@ -489,3 +489,42 @@ class TestResponseEncoding:
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
# ── async serve path ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_async_handle_client_sends_challenge_and_proxies_no_auth(monkeypatch):
|
||||
"""Async TCP handler mirrors the sync challenge + proxy error path."""
|
||||
import asyncio
|
||||
from browser_cli.commands import serve as serve_mod
|
||||
|
||||
async def run():
|
||||
monkeypatch.setattr("browser_cli.client.targets.resolve_socket", _mock_no_browser)
|
||||
|
||||
async def handle(reader, writer):
|
||||
await serve_mod._async_handle_client(
|
||||
reader,
|
||||
writer,
|
||||
("127.0.0.1", 9999),
|
||||
None,
|
||||
None,
|
||||
True,
|
||||
asyncio.Semaphore(64),
|
||||
)
|
||||
|
||||
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
||||
host, port = server.sockets[0].getsockname()
|
||||
async with server:
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
challenge = json.loads(await serve_mod._async_recv_all(reader))
|
||||
assert challenge["type"] == "challenge"
|
||||
|
||||
msg = {"id": "x", "command": "tabs.list", "args": {}, "user_agent": FAKE_UA}
|
||||
await serve_mod._async_framed_send(writer, json.dumps(msg).encode())
|
||||
resp = json.loads(await serve_mod._async_recv_all(reader))
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
assert resp["success"] is False
|
||||
assert "browser" in resp["error"].lower() or "connected" in resp["error"].lower()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
+14
-4
@@ -1,5 +1,6 @@
|
||||
"""Tests for tabs.* commands."""
|
||||
import time
|
||||
import uuid
|
||||
import pytest
|
||||
|
||||
def test_tabs_list(browser):
|
||||
@@ -67,9 +68,11 @@ def test_tabs_close_by_id(browser):
|
||||
assert tab_id not in [t["id"] for t in tabs]
|
||||
|
||||
def test_tabs_dedupe(browser):
|
||||
# Open the same URL twice
|
||||
r1 = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
r2 = browser("navigate.open", {"url": "https://example.com", "background": True})
|
||||
# Use a unique URL so pre-existing example.com tabs cannot be selected as
|
||||
# the survivor while both freshly opened duplicates stay open.
|
||||
url = f"https://example.com/?browser-cli-dedupe={uuid.uuid4().hex}"
|
||||
r1 = browser("navigate.open", {"url": url, "background": True})
|
||||
r2 = browser("navigate.open", {"url": url, "background": True})
|
||||
id1, id2 = r1["id"], r2["id"]
|
||||
|
||||
try:
|
||||
@@ -77,7 +80,7 @@ def test_tabs_dedupe(browser):
|
||||
# resolved a URL yet, so wait for both tabs to finish loading first.
|
||||
for _ in range(30):
|
||||
tabs = {t["id"]: t for t in browser("tabs.list")}
|
||||
if all(tabs.get(tid, {}).get("url", "").startswith("http") for tid in (id1, id2)):
|
||||
if all(tabs.get(tid, {}).get("url", "") == url for tid in (id1, id2)):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
@@ -85,6 +88,13 @@ def test_tabs_dedupe(browser):
|
||||
|
||||
result = browser("tabs.dedupe")
|
||||
assert isinstance(result, dict)
|
||||
if result.get("jobId"):
|
||||
for _ in range(100):
|
||||
status = browser("jobs.status", {"jobId": result["jobId"]})
|
||||
if status.get("status") in {"done", "error", "cancelled"}:
|
||||
result = status.get("result") or result
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert result.get("closed", 0) >= 0
|
||||
# At least one of the two duplicates should be gone
|
||||
remaining = browser("tabs.list")
|
||||
|
||||
Reference in New Issue
Block a user