feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels. - Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips. - Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely. - Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows. - Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0. - Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
This commit is contained in:
+295
-275
@@ -9,412 +9,432 @@ 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)
|
||||
raise SystemExit(code)
|
||||
|
||||
def test_cleanup_removes_socket_and_registry_entry(monkeypatch, tmp_path):
|
||||
alias = "work"
|
||||
socket_path = tmp_path / "work.sock"
|
||||
socket_path.write_text("")
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
|
||||
alias = "work"
|
||||
socket_path = tmp_path / "work.sock"
|
||||
socket_path.write_text("")
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({alias: str(socket_path), "other": str(tmp_path / "other.sock")}))
|
||||
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "_socket_path_for", lambda alias: str(socket_path))
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: False)
|
||||
|
||||
native_host._cleanup(alias)
|
||||
native_host._cleanup(alias)
|
||||
|
||||
assert not socket_path.exists()
|
||||
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
|
||||
assert not socket_path.exists()
|
||||
assert json.loads(registry_path.read_text()) == {"other": str(tmp_path / "other.sock")}
|
||||
|
||||
def test_stdin_reader_cleans_up_on_eof(monkeypatch):
|
||||
cleaned = []
|
||||
cleaned = []
|
||||
|
||||
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()))
|
||||
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()))
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
|
||||
assert cleaned == ["work"]
|
||||
assert cleaned == ["work"]
|
||||
|
||||
def test_cleanup_windows_skips_socket_unlink(monkeypatch, tmp_path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(json.dumps({"work": r"\\.\pipe\browser-cli-work"}))
|
||||
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(native_host, "REGISTRY_PATH", registry_path)
|
||||
monkeypatch.setattr(native_host, "is_windows", lambda: True)
|
||||
|
||||
native_host._cleanup("work")
|
||||
native_host._cleanup("work")
|
||||
|
||||
assert json.loads(registry_path.read_text()) == {}
|
||||
assert json.loads(registry_path.read_text()) == {}
|
||||
|
||||
def test_stdin_reader_cleans_up_on_bye(monkeypatch):
|
||||
cleaned = []
|
||||
messages = iter([{"type": "bye"}])
|
||||
cleaned = []
|
||||
messages = iter([{"type": "bye"}])
|
||||
|
||||
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()))
|
||||
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()))
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
|
||||
assert cleaned == ["work"]
|
||||
assert cleaned == ["work"]
|
||||
|
||||
def test_stdin_reader_routes_response_messages(monkeypatch):
|
||||
response_queue = native_host.queue.Queue()
|
||||
native_host.PENDING["msg-1"] = response_queue
|
||||
messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None])
|
||||
response_queue = native_host.queue.Queue()
|
||||
native_host.PENDING["msg-1"] = response_queue
|
||||
messages = iter([{"type": "hello"}, {"id": "msg-1", "success": True}, None])
|
||||
|
||||
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()))
|
||||
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()))
|
||||
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
with pytest.raises(SystemExit, match="0"):
|
||||
native_host.stdin_reader("work")
|
||||
|
||||
assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
|
||||
native_host.PENDING.clear()
|
||||
assert response_queue.get_nowait() == {"id": "msg-1", "success": True}
|
||||
native_host.PENDING.clear()
|
||||
|
||||
def test_collect_paged_browser_command_accumulates_pages(monkeypatch):
|
||||
calls = []
|
||||
pages = iter([
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
|
||||
])
|
||||
calls = []
|
||||
pages = iter([
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [1, 2], "total": 3, "nextOffset": 2}},
|
||||
{"success": True, "data": {"__browserCliPage": True, "items": [3], "total": 3, "nextOffset": None}},
|
||||
])
|
||||
|
||||
def fake_send(cmd):
|
||||
calls.append(cmd)
|
||||
return next(pages)
|
||||
def fake_send(cmd):
|
||||
calls.append(cmd)
|
||||
return next(pages)
|
||||
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 2)
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", fake_send)
|
||||
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {"foo": "bar"}})
|
||||
|
||||
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
|
||||
assert [call["args"]["__page"] for call in calls] == [
|
||||
{"offset": 0, "limit": 2},
|
||||
{"offset": 2, "limit": 2},
|
||||
]
|
||||
assert all(call["args"]["foo"] == "bar" for call in calls)
|
||||
assert all(call["id"] != "orig" for call in calls)
|
||||
assert result == {"id": "orig", "success": True, "data": [1, 2, 3], "pageSize": 2, "total": 3}
|
||||
assert [call["args"]["__page"] for call in calls] == [
|
||||
{"offset": 0, "limit": 2},
|
||||
{"offset": 2, "limit": 2},
|
||||
]
|
||||
assert all(call["args"]["foo"] == "bar" for call in calls)
|
||||
assert all(call["id"] != "orig" for call in calls)
|
||||
|
||||
def test_collect_paged_browser_command_passes_through_non_paged_response(monkeypatch):
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: {"id": cmd["id"], "success": True, "data": {"value": 1}})
|
||||
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
result = native_host._collect_paged_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
|
||||
assert result == {"id": "orig", "success": True, "data": {"value": 1}}
|
||||
assert result == {"id": "orig", "success": True, "data": {"value": 1}}
|
||||
|
||||
def test_handle_browser_command_pages_known_list_commands(monkeypatch):
|
||||
seen = []
|
||||
seen = []
|
||||
|
||||
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
|
||||
monkeypatch.setattr(native_host, "_collect_paged_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": []})
|
||||
|
||||
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
result = native_host._handle_browser_command({"id": "orig", "command": "tabs.list", "args": {}})
|
||||
|
||||
assert result == {"success": True, "data": []}
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert result == {"success": True, "data": []}
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
|
||||
def test_handle_browser_command_sends_non_pageable_directly(monkeypatch):
|
||||
seen = []
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"})
|
||||
seen = []
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", lambda cmd: seen.append(cmd) or {"success": True, "data": "ok"})
|
||||
|
||||
result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}})
|
||||
result = native_host._handle_browser_command({"id": "x", "command": "navigate.open", "args": {}})
|
||||
|
||||
assert result == {"success": True, "data": "ok"}
|
||||
assert seen[0]["command"] == "navigate.open"
|
||||
assert result == {"success": True, "data": "ok"}
|
||||
assert seen[0]["command"] == "navigate.open"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_exact_stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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_protocol.read_exact_stream(stream, 5) == b"hello"
|
||||
"""Returns the exact bytes when stream delivers them in one shot."""
|
||||
import io
|
||||
stream = io.BytesIO(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."""
|
||||
import io
|
||||
"""Accumulates multiple short chunks until n bytes are read."""
|
||||
import io
|
||||
|
||||
class _ChunkyStream:
|
||||
def __init__(self, data, chunk_size):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._chunk_size = chunk_size
|
||||
class _ChunkyStream:
|
||||
def __init__(self, data, chunk_size):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
self._chunk_size = chunk_size
|
||||
|
||||
def read(self, n):
|
||||
end = min(self._pos + self._chunk_size, len(self._data))
|
||||
chunk = self._data[self._pos:end]
|
||||
self._pos = end
|
||||
return chunk
|
||||
def read(self, n):
|
||||
end = min(self._pos + self._chunk_size, len(self._data))
|
||||
chunk = self._data[self._pos:end]
|
||||
self._pos = end
|
||||
return chunk
|
||||
|
||||
stream = _ChunkyStream(b"abcdefgh", 3)
|
||||
assert native_protocol.read_exact_stream(stream, 8) == b"abcdefgh"
|
||||
stream = _ChunkyStream(b"abcdefgh", 3)
|
||||
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_protocol.read_exact_stream(stream, 4) is 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_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_protocol.read_exact_stream(stream, 1) is None
|
||||
"""Returns None on an empty stream."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
assert native_protocol.read_exact_stream(stream, 1) is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_native_message / read_native_message round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_write_and_read_native_message_roundtrip():
|
||||
"""write_native_message followed by read_native_message recovers the original dict."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
msg = {"id": "abc", "command": "tabs.list", "args": {}}
|
||||
native_protocol.write_native_message(buf, msg)
|
||||
buf.seek(0)
|
||||
result = native_protocol.read_native_message(buf)
|
||||
assert result == msg
|
||||
"""write_native_message followed by read_native_message recovers the original dict."""
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
msg = {"id": "abc", "command": "tabs.list", "args": {}}
|
||||
native_protocol.write_native_message(buf, msg)
|
||||
buf.seek(0)
|
||||
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_protocol.read_native_message(stream) is None
|
||||
"""Returns None when the stream is empty (no length prefix)."""
|
||||
import io
|
||||
stream = io.BytesIO(b"")
|
||||
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."""
|
||||
import io
|
||||
import struct
|
||||
# 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_protocol.read_native_message(stream) is None
|
||||
"""Returns None when the body is truncated after reading the length prefix."""
|
||||
import io
|
||||
import struct
|
||||
# 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_protocol.read_native_message(stream) is None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# framing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_recv_exact_accumulates_data():
|
||||
"""framing.recv_exact receives exactly n bytes from a socket-like object."""
|
||||
class _FakeSock:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
def recv(self, n):
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
"""framing.recv_exact receives exactly n bytes from a socket-like object."""
|
||||
class _FakeSock:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
self._pos = 0
|
||||
def recv(self, n):
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
sock = _FakeSock(b"0123456789")
|
||||
assert framing.recv_exact(sock, 5) == b"01234"
|
||||
assert framing.recv_exact(sock, 5) == b"56789"
|
||||
sock = _FakeSock(b"0123456789")
|
||||
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 framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None
|
||||
class _EmptySock:
|
||||
def recv(self, n):
|
||||
return b""
|
||||
assert framing.recv_exact(_EmptySock(), 4, allow_eof=True) is None
|
||||
|
||||
def test_send_all_and_recv_all():
|
||||
"""framing.send_frame frames data; framing.recv_frame strips it."""
|
||||
import socket
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
payload = b'{"command": "tabs.list"}'
|
||||
framing.send_frame(a, payload)
|
||||
received = framing.recv_frame(b, allow_eof=True)
|
||||
assert received == payload
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
"""framing.send_frame frames data; framing.recv_frame strips it."""
|
||||
import socket
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
payload = b'{"command": "tabs.list"}'
|
||||
framing.send_frame(a, payload)
|
||||
received = framing.recv_frame(b, allow_eof=True)
|
||||
assert received == payload
|
||||
finally:
|
||||
a.close()
|
||||
b.close()
|
||||
|
||||
def test_recv_all_truncated_body():
|
||||
"""_recv_all returns None when the body is shorter than the prefix promises."""
|
||||
import socket
|
||||
import struct
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
# Send a length of 100 but only 4 bytes of body
|
||||
a.sendall(struct.pack("<I", 100) + b"tiny")
|
||||
a.close()
|
||||
result = framing.recv_frame(b, allow_eof=True)
|
||||
assert result is None
|
||||
finally:
|
||||
b.close()
|
||||
"""_recv_all returns None when the body is shorter than the prefix promises."""
|
||||
import socket
|
||||
import struct
|
||||
a, b = socket.socketpair()
|
||||
try:
|
||||
# Send a length of 100 but only 4 bytes of body
|
||||
a.sendall(struct.pack("<I", 100) + b"tiny")
|
||||
a.close()
|
||||
result = framing.recv_frame(b, allow_eof=True)
|
||||
assert result is None
|
||||
finally:
|
||||
b.close()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send_browser_command — timeout path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_send_browser_command_timeout(monkeypatch):
|
||||
"""_send_browser_command returns an error dict when the response queue times out."""
|
||||
import io
|
||||
"""_send_browser_command returns an error dict when the response queue times out."""
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
buf = io.BytesIO()
|
||||
|
||||
monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf))
|
||||
# Do not put anything into the response queue → timeout after 0 s
|
||||
result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0)
|
||||
monkeypatch.setattr(native_host.sys, "stdout", SimpleNamespace(buffer=buf))
|
||||
# Do not put anything into the response queue → timeout after 0 s
|
||||
result = native_host._send_browser_command({"id": "t1", "command": "test", "args": {}}, timeout=0)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timeout" in result["error"]
|
||||
# Clean up PENDING
|
||||
native_host.PENDING.clear()
|
||||
assert result["success"] is False
|
||||
assert "timeout" in result["error"]
|
||||
# Clean up PENDING
|
||||
native_host.PENDING.clear()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collect_paged_browser_command — error and loop-guard paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_collect_paged_browser_command_propagates_error(monkeypatch):
|
||||
"""If _send_browser_command returns success=False the error is propagated."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "extension crash" in result["error"]
|
||||
"""If _send_browser_command returns success=False the error is propagated."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {"id": cmd["id"], "success": False, "error": "extension crash"},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "e1", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "extension crash" in result["error"]
|
||||
|
||||
def test_collect_paged_browser_command_max_pages_guard(monkeypatch):
|
||||
"""If paging never ends, the loop guard kicks in and returns an error."""
|
||||
monkeypatch.setattr(native_host, "PAGE_SIZE", 1)
|
||||
"""A runaway extension (empty pages, advancing nextOffset) trips the guard."""
|
||||
monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
|
||||
|
||||
call_count = [0]
|
||||
call_count = [0]
|
||||
|
||||
def _infinite_pages(cmd):
|
||||
call_count[0] += 1
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [call_count[0]], "total": 9999, "nextOffset": call_count[0]},
|
||||
}
|
||||
def _infinite_empty_pages(cmd):
|
||||
# Empty items so the item cap never bites — only the page guard can stop this.
|
||||
call_count[0] += 1
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [], "total": 9999, "nextOffset": call_count[0]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_pages)
|
||||
result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "paging loop exceeded" in result["error"]
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _infinite_empty_pages)
|
||||
result = native_host._collect_paged_browser_command({"id": "loop", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "paging loop exceeded" in result["error"]
|
||||
|
||||
def test_collect_paged_browser_command_stops_at_item_cap(monkeypatch):
|
||||
"""Paging stops once MAX_PAGED_ITEMS is reached, returning bounded data."""
|
||||
monkeypatch.setattr(native_host, "MAX_PAGED_ITEMS", 5)
|
||||
|
||||
offset = [0]
|
||||
|
||||
def _endless_items(cmd):
|
||||
offset[0] += 2
|
||||
return {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": [1, 2], "total": 100, "nextOffset": offset[0]},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(native_host, "_send_browser_command", _endless_items)
|
||||
result = native_host._collect_paged_browser_command({"id": "cap", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is True
|
||||
assert len(result["data"]) >= 5 # stopped at/just past the cap, not unbounded
|
||||
|
||||
def test_collect_paged_browser_command_invalid_items(monkeypatch):
|
||||
"""If items is not a list the command returns an error dict."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None},
|
||||
},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "invalid paged response" in result["error"]
|
||||
"""If items is not a list the command returns an error dict."""
|
||||
monkeypatch.setattr(
|
||||
native_host, "_send_browser_command",
|
||||
lambda cmd: {
|
||||
"id": cmd["id"],
|
||||
"success": True,
|
||||
"data": {"__browserCliPage": True, "items": "not-a-list", "total": 1, "nextOffset": None},
|
||||
},
|
||||
)
|
||||
result = native_host._collect_paged_browser_command({"id": "bad", "command": "tabs.list", "args": {}})
|
||||
assert result["success"] is False
|
||||
assert "invalid paged response" in result["error"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_profile_alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resolve_profile_alias_uses_hello_alias():
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"})
|
||||
assert alias == "brave-work"
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": "brave-work"})
|
||||
assert alias == "brave-work"
|
||||
|
||||
def test_resolve_profile_alias_no_hello_returns_uuid():
|
||||
alias = native_host._resolve_profile_alias(None)
|
||||
import uuid
|
||||
uuid.UUID(alias) # raises ValueError if not a valid UUID
|
||||
alias = native_host._resolve_profile_alias(None)
|
||||
import uuid
|
||||
uuid.UUID(alias) # raises ValueError if not a valid UUID
|
||||
|
||||
def test_resolve_profile_alias_default_alias_returns_uuid():
|
||||
from browser_cli.platform import DEFAULT_ALIAS
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
from browser_cli.platform import DEFAULT_ALIAS
|
||||
alias = native_host._resolve_profile_alias({"type": "hello", "alias": DEFAULT_ALIAS})
|
||||
import uuid
|
||||
uuid.UUID(alias)
|
||||
|
||||
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)
|
||||
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
|
||||
"""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()
|
||||
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()
|
||||
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())
|
||||
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
|
||||
"""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 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"},
|
||||
)
|
||||
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)
|
||||
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()
|
||||
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
|
||||
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]
|
||||
assert response["success"] is True
|
||||
assert response["data"] == "ok"
|
||||
assert seen[0]["command"] == "tabs.list"
|
||||
assert "id" in seen[0]
|
||||
|
||||
asyncio.run(run())
|
||||
asyncio.run(run())
|
||||
|
||||
Reference in New Issue
Block a user