refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Restructure the Python API and internals around composable namespaces and a standalone transport/endpoint layer. Bump to 0.12.0. Python API: - Replace flat methods (b.tabs_list(), b.group_list()) with namespaces: b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage, b.cookies, b.session, b.perf, b.extension. - Shrink browser_cli/__init__.py to a thin composition root; move all behaviour into browser_cli/sdk/ (one module per namespace + factories, base, routing). Internals: - Add browser_cli/transport.py and remote_transport.py to isolate IPC from command logic; client.py now delegates instead of owning transport. - Add browser_cli/endpoints.py for endpoint resolution and browser_cli/errors.py for shared error types. - Extract markdown rendering into browser_cli/markdown.py (out of extract). - Add USER_AGENT to version_manager. Tooling & tests: - Add justfile with common dev tasks. - Update CLI commands and demo to the namespaced API. - Rework tests for the new layout; add test_transport.py and test_refactor_boundaries.py to lock in module boundaries. BREAKING CHANGE: flat API methods are removed in favour of namespaces (e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
+125
-7
@@ -6,6 +6,7 @@ import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.auth import generate_keypair, load_private_key, new_nonce, pq_decrypt, pq_encrypt, sign
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.commands.serve import _handle_client
|
||||
@@ -18,7 +19,6 @@ FAKE_UA = "browser-cli/0.9.3"
|
||||
def _send_framed(sock: socket.socket, data: bytes) -> None:
|
||||
sock.sendall(struct.pack("<I", len(data)) + data)
|
||||
|
||||
|
||||
def _recv_framed(sock: socket.socket) -> dict:
|
||||
raw = b""
|
||||
while len(raw) < 4:
|
||||
@@ -35,7 +35,6 @@ def _recv_framed(sock: socket.socket) -> dict:
|
||||
data += chunk
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
@@ -45,15 +44,12 @@ def _spawn(server_sock: socket.socket, auth_keys_path) -> threading.Thread:
|
||||
t.start()
|
||||
return t
|
||||
|
||||
|
||||
def _pair():
|
||||
return socket.socketpair()
|
||||
|
||||
|
||||
def _mock_no_browser(*_args, **_kwargs):
|
||||
raise BrowserNotConnected("no browser")
|
||||
|
||||
|
||||
# ── challenge frame ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestChallenge:
|
||||
@@ -89,7 +85,6 @@ class TestChallenge:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── rejection paths ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRejection:
|
||||
@@ -199,7 +194,6 @@ class TestRejection:
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
# ── auth success paths ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuthSuccess:
|
||||
@@ -371,3 +365,127 @@ class TestAuthSuccess:
|
||||
assert "unauthorized" not in resp.get("error", "").lower()
|
||||
client.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
# ── response encoding (compression / msgpack) ───────────────────────────────────
|
||||
|
||||
def _recv_framed_raw(sock: socket.socket) -> bytes:
|
||||
raw = b""
|
||||
while len(raw) < 4:
|
||||
chunk = sock.recv(4 - len(raw))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed before response header")
|
||||
raw += chunk
|
||||
n = struct.unpack("<I", raw)[0]
|
||||
data = b""
|
||||
while len(data) < n:
|
||||
chunk = sock.recv(n - len(data))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-response")
|
||||
data += chunk
|
||||
return data
|
||||
|
||||
class _FakeNativeHost:
|
||||
"""Minimal AF_UNIX server speaking the 4-byte framed protocol the proxy expects."""
|
||||
|
||||
def __init__(self, sock_path, response_obj):
|
||||
self.sock_path = str(sock_path)
|
||||
self.response = json.dumps(response_obj).encode()
|
||||
self.srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.srv.bind(self.sock_path)
|
||||
self.srv.listen(1)
|
||||
self.thread = threading.Thread(target=self._serve, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _serve(self):
|
||||
try:
|
||||
conn, _ = self.srv.accept()
|
||||
with conn:
|
||||
hdr = conn.recv(4)
|
||||
n = struct.unpack("<I", hdr)[0]
|
||||
got = b""
|
||||
while len(got) < n:
|
||||
got += conn.recv(n - len(got))
|
||||
conn.sendall(struct.pack("<I", len(self.response)) + self.response)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.srv.close()
|
||||
|
||||
class TestResponseEncoding:
|
||||
def test_client_accept_encoding_yields_decodable_tagged_response(self, tmp_path, monkeypatch):
|
||||
if socket.AF_UNIX is None: # pragma: no cover
|
||||
pytest.skip("AF_UNIX unavailable")
|
||||
|
||||
big = {"id": "x", "success": True,
|
||||
"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))
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None) # no auth
|
||||
_recv_framed(client) # challenge
|
||||
|
||||
msg = {
|
||||
"id": "x", "command": "tabs.list", "args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"accept_encoding": {"ser": ["json"], "comp": ["gzip"]},
|
||||
}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
raw = _recv_framed_raw(client)
|
||||
|
||||
assert raw[:1] not in (b"{", b"[") # tagged, not plain JSON
|
||||
assert len(raw) < len(json.dumps(big)) # compressed
|
||||
assert transport.decode_response(raw) == big
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_no_accept_encoding_stays_plain_json(self, tmp_path, monkeypatch):
|
||||
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))
|
||||
|
||||
client, server = _pair()
|
||||
t = _spawn(server, None)
|
||||
_recv_framed(client)
|
||||
|
||||
msg = {"id": "y", "command": "tabs.list", "args": {}, "user_agent": "browser-cli/0.9.5"}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
raw = _recv_framed_raw(client)
|
||||
|
||||
assert raw[:1] in (b"{", b"[") # old client → plain JSON
|
||||
assert json.loads(raw) == big
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_no_compress_flag_forces_plain_json(self, tmp_path, monkeypatch):
|
||||
big = {"id": "z", "success": True,
|
||||
"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))
|
||||
|
||||
client, server = _pair()
|
||||
t = threading.Thread(
|
||||
target=_handle_client,
|
||||
args=(server, ("127.0.0.1", 9999), None, None, False), # compress=False
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
_recv_framed(client)
|
||||
|
||||
msg = {"id": "z", "command": "tabs.list", "args": {},
|
||||
"user_agent": "browser-cli/0.9.5",
|
||||
"accept_encoding": {"ser": ["msgpack"], "comp": ["zstd"]}}
|
||||
_send_framed(client, json.dumps(msg).encode())
|
||||
raw = _recv_framed_raw(client)
|
||||
|
||||
assert raw[:1] in (b"{", b"[") # server disabled encoding
|
||||
assert json.loads(raw) == big
|
||||
client.close()
|
||||
host.close()
|
||||
t.join(timeout=2)
|
||||
|
||||
Reference in New Issue
Block a user