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:
2026-06-13 23:31:24 +02:00
parent fd5447cbb9
commit 076914e5b7
88 changed files with 7491 additions and 5228 deletions
+99 -21
View File
@@ -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())