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:
@@ -0,0 +1,117 @@
|
||||
"""Unit tests for the response transport codec (compression + msgpack)."""
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from browser_cli import transport
|
||||
|
||||
def _sample(n=200):
|
||||
return {
|
||||
"id": "abc",
|
||||
"success": True,
|
||||
"data": {"items": [{"url": f"https://example.com/{i}", "title": f"Tab {i}"} for i in range(n)]},
|
||||
}
|
||||
|
||||
# ── backward compatibility ──────────────────────────────────────────────────────
|
||||
|
||||
def test_no_accept_encoding_returns_bare_json():
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept=None)
|
||||
assert enc == json.dumps(obj).encode("utf-8") # byte-identical to old wire format
|
||||
assert enc[:1] in (b"{", b"[")
|
||||
|
||||
def test_empty_accept_returns_bare_json():
|
||||
obj = _sample()
|
||||
assert transport.encode_response(obj, accept={}) == json.dumps(obj).encode("utf-8")
|
||||
|
||||
def test_decode_plain_json_without_tag():
|
||||
obj = _sample()
|
||||
assert transport.decode_response(json.dumps(obj).encode("utf-8")) == obj
|
||||
|
||||
def test_decode_none_and_empty():
|
||||
assert transport.decode_response(None) is None
|
||||
with pytest.raises(ValueError):
|
||||
transport.decode_response(b"")
|
||||
|
||||
# ── compression roundtrips ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("comp", ["zlib", "gzip", "zstd"])
|
||||
def test_compression_roundtrip(comp):
|
||||
if comp == "zstd" and not transport.zstd_available():
|
||||
pytest.skip("zstandard not installed")
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": [comp]})
|
||||
assert enc[:1] not in (b"{", b"[") # tagged, not plain
|
||||
assert len(enc) < len(json.dumps(obj)) # actually smaller
|
||||
assert transport.decode_response(enc) == obj
|
||||
|
||||
def test_small_payload_not_compressed():
|
||||
obj = {"id": "x", "success": True, "data": "ok"}
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["zlib"]}, threshold=512)
|
||||
assert enc == json.dumps(obj).encode("utf-8") # below threshold → bare json
|
||||
|
||||
def test_unknown_compression_in_accept_falls_back_to_plain():
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["brotli"]})
|
||||
assert enc == json.dumps(obj).encode("utf-8")
|
||||
|
||||
# ── msgpack ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_msgpack_roundtrip():
|
||||
if not transport.msgpack_available():
|
||||
pytest.skip("msgpack not installed")
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": []})
|
||||
assert enc[0] >> 4 == transport.SER_MSGPACK
|
||||
assert transport.decode_response(enc) == obj
|
||||
|
||||
def test_msgpack_with_compression_roundtrip():
|
||||
if not (transport.msgpack_available() and transport.zstd_available()):
|
||||
pytest.skip("msgpack/zstd not installed")
|
||||
obj = _sample()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": ["zstd"]})
|
||||
tag = enc[0]
|
||||
assert tag >> 4 == transport.SER_MSGPACK
|
||||
assert tag & 0x0F == transport.COMP_ZSTD
|
||||
assert transport.decode_response(enc) == obj
|
||||
|
||||
# ── raw screenshot hoisting ───────────────────────────────────────────────────────
|
||||
|
||||
def _screenshot_obj():
|
||||
png = bytes(range(256)) * 40
|
||||
url = "data:image/png;base64," + base64.b64encode(png).decode("ascii")
|
||||
return {"id": "x", "success": True, "data": {"dataUrl": url}}, url
|
||||
|
||||
def test_screenshot_roundtrip_restores_data_url_exactly():
|
||||
if not transport.msgpack_available():
|
||||
pytest.skip("msgpack not installed")
|
||||
obj, url = _screenshot_obj()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": []}, command="tabs.screenshot")
|
||||
dec = transport.decode_response(enc)
|
||||
assert dec == obj
|
||||
assert dec["data"]["dataUrl"] == url
|
||||
|
||||
def test_screenshot_not_hoisted_for_other_commands():
|
||||
if not transport.msgpack_available():
|
||||
pytest.skip("msgpack not installed")
|
||||
import msgpack
|
||||
obj, _ = _screenshot_obj()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["msgpack"], "comp": []}, command="extract.text")
|
||||
body = msgpack.unpackb(enc[1:], raw=False)
|
||||
assert isinstance(body["data"]["dataUrl"], str) # left as base64 string
|
||||
|
||||
def test_screenshot_json_path_leaves_base64():
|
||||
obj, url = _screenshot_obj()
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["gzip"]}, command="tabs.screenshot")
|
||||
assert transport.decode_response(enc)["data"]["dataUrl"] == url
|
||||
|
||||
# ── negotiation preference ────────────────────────────────────────────────────────
|
||||
|
||||
def test_server_prefers_strongest_mutual_codec():
|
||||
if not transport.zstd_available():
|
||||
pytest.skip("zstandard not installed")
|
||||
obj = _sample()
|
||||
# client accepts gzip and zstd; server should pick zstd (its top preference)
|
||||
enc = transport.encode_response(obj, accept={"ser": ["json"], "comp": ["gzip", "zstd"]})
|
||||
assert enc[0] & 0x0F == transport.COMP_ZSTD
|
||||
Reference in New Issue
Block a user