diff --git a/ceki_sdk/__init__.py b/ceki_sdk/__init__.py index 62b6f6e..a5893ff 100644 --- a/ceki_sdk/__init__.py +++ b/ceki_sdk/__init__.py @@ -21,7 +21,7 @@ from ._profile import BrowserProfile from .humanize import HumanProfile -__version__ = "2.36.1" +__version__ = "2.36.2" __all__ = [ "connect", "ConnectOptions", diff --git a/ceki_sdk/_browser.py b/ceki_sdk/_browser.py index 902635a..f8a536e 100644 --- a/ceki_sdk/_browser.py +++ b/ceki_sdk/_browser.py @@ -40,6 +40,7 @@ TabOpenedCallback = Callable[[str], Awaitable[None]] SimpleCallback = Callable[[], Awaitable[None]] UserEventCallback = Callable[[list[dict[str, Any]]], Awaitable[None]] +CaptureFrameCallback = Callable[[dict[str, Any]], Awaitable[None]] _ERROR_TERMINAL = {-1011, -1012, -1015, -1018} @@ -98,6 +99,7 @@ def __init__(self, client: "Client", match: Match, *, human="natural") -> None: self._provider_disconnected_callbacks: list[SimpleCallback] = [] self._provider_reconnected_callbacks: list[SimpleCallback] = [] self._user_event_callbacks: list[UserEventCallback] = [] + self._capture_frame_callbacks: list[CaptureFrameCallback] = [] self._ended = asyncio.Event() self._ended_reason: str | None = None @@ -254,6 +256,39 @@ def on_provider_reconnected(self, callback: SimpleCallback) -> None: def on_user_event(self, callback: UserEventCallback) -> None: self._user_event_callbacks.append(callback) + def on_capture_frame(self, callback: CaptureFrameCallback) -> None: + """Register a callback that receives screencast video frames. + + Frames arrive on the P2P ``ceki-capture`` data channel — the extension + intercepts ``Page.startScreencast`` and streams frames there via its + capture bridge instead of emitting CDP ``Page.screencastFrame`` events. + Each callback is invoked with the raw capture frame dict:: + + {"type": "video-frame", "data": "", + "width": ..., "height": ..., "timestamp": ...} + + Frames that exceed the chunk threshold arrive as ``capture-chunk`` + fragments and are reassembled transparently before delivery. + """ + self._capture_frame_callbacks.append(callback) + + async def start_screencast(self, **params: Any) -> dict[str, Any]: + """Start streaming video frames to :meth:`on_capture_frame` callbacks. + + Sends ``Page.startScreencast`` (intercepted by the extension and served + by its capture bridge). Supported params mirror CDP:: + + maxWidth, maxHeight, quality, everyNthFrame, maxFrameRate + + Frames arrive asynchronously on the capture data channel and are + delivered to every registered callback. + """ + return await self.send({"method": "Page.startScreencast", "params": params}) + + async def stop_screencast(self) -> dict[str, Any]: + """Stop the screencast stream (sends ``Page.stopScreencast``).""" + return await self.send({"method": "Page.stopScreencast"}) + async def switch_tab(self) -> None: await self._client._ws_send({"type": "switch_tab", "session_id": self.session_id}) @@ -944,6 +979,18 @@ async def _on_cdp_event(self, msg: dict[str, Any]) -> None: for cb in self._event_callbacks: asyncio.create_task(cast(Coroutine, cb(method, params))) + async def _on_capture_data(self, msg: dict[str, Any]) -> None: + """Dispatch a capture-DC message to :meth:`on_capture_frame` callbacks. + + Only ``video-frame`` messages are forwarded — ``video-stopped`` and + screenshot messages that also travel on the capture DC are not frames + and are ignored by the screencast API. + """ + if msg.get("type") != "video-frame": + return + for cb in self._capture_frame_callbacks: + asyncio.create_task(cast(Coroutine, cb(msg))) + async def _on_tab_opened(self, msg: dict[str, Any]) -> None: url = msg.get("url", "") for cb in self._tab_opened_callbacks: diff --git a/ceki_sdk/_client.py b/ceki_sdk/_client.py index 73e2fa0..12757e4 100644 --- a/ceki_sdk/_client.py +++ b/ceki_sdk/_client.py @@ -684,6 +684,17 @@ async def _on_cdp(msg: dict[str, Any]) -> None: transport.on_cdp_message = _on_cdp + # Wire capture-data callback → route capture frames to the active + # browser. video-frame messages arrive on the ceki-capture DC (the + # extension intercepts Page.startScreencast and streams frames via + # capture-bridge) rather than as CDP screencastFrame events. + async def _on_capture(msg: dict[str, Any]) -> None: + browser = self._active_browsers.get(session_id) + if browser: + await browser._on_capture_data(msg) + + transport.on_capture_data = _on_capture + # Wire connection state callback for lifecycle monitoring async def _on_conn_state(state: str) -> None: log.info("p2p: connection state -> %s", state) @@ -793,6 +804,15 @@ async def _on_cdp(msg_inner: dict[str, Any]) -> None: transport.on_cdp_message = _on_cdp + # Wire capture-data callback → route capture frames to the active + # browser (same as _init_p2p — video-frame arrives on ceki-capture DC). + async def _on_capture(msg_inner: dict[str, Any]) -> None: + browser = self._active_browsers.get(session_id) + if browser: + await browser._on_capture_data(msg_inner) + + transport.on_capture_data = _on_capture + # Wire connection state callback async def _on_conn_state(state: str) -> None: log.info("p2p: connection state -> %s", state) diff --git a/ceki_sdk/_webrtc.py b/ceki_sdk/_webrtc.py index 3de2c8a..be34cdd 100644 --- a/ceki_sdk/_webrtc.py +++ b/ceki_sdk/_webrtc.py @@ -22,6 +22,7 @@ import logging import os import re +import time from typing import Any, Callable, Coroutine log = logging.getLogger(__name__) @@ -126,6 +127,7 @@ def __init__( ) -> None: self._pc: Any = None # aiortc.RTCPeerConnection self._cmd_dc: Any = None # aiortc.RTCDataChannel + self._capture_dc: Any = None # aiortc.RTCDataChannel (ceki-capture) # ICE servers: constructor arg → CEKI_TURN_SERVERS env → default STUN env_servers_raw = os.environ.get("CEKI_TURN_SERVERS") @@ -175,6 +177,9 @@ def __init__( # Callbacks — set by consumer (_client.py) self.on_ice_candidate: Callable[[dict[str, Any]], Coroutine[Any, Any, None] | None] | None = None self.on_cdp_message: Callable[[dict[str, Any]], Coroutine[Any, Any, None] | None] | None = None + self.on_capture_data: ( + Callable[[dict[str, Any]], Coroutine[Any, Any, None] | None] | None + ) = None self.on_connection_state: Callable[[str], Coroutine[Any, Any, None] | None] | None = None self.on_data_channel_state: Callable[[str], Coroutine[Any, Any, None] | None] | None = None @@ -188,6 +193,15 @@ def __init__( # a lost chunk surfaces as an SDK-side timeout, existing mechanism. self._pending_chunks: dict[str, dict[str, Any]] = {} + # Capture-chunk reassembly buffer for large video-frame/screenshot data + # sent over the ceki-capture DC. Keyed by frameId → + # {chunks:[slice,...], received, total, received_at}. The capture DC is + # created with ordered:false, so fragments can arrive out of order and + # can be dropped — incomplete frames are pruned after + # ``_capture_stale_ms`` (mirrors extension CaptureChunkReassembler). + self._pending_capture_frames: dict[str, dict[str, Any]] = {} + self._capture_stale_ms = 5000 + async def _ensure_pc(self) -> Any: """Lazy-create the RTCPeerConnection on first use.""" if self._pc is not None: @@ -240,8 +254,8 @@ def _on_dc(channel: Any) -> None: self._cmd_dc = channel self._wire_cmd_dc(channel) elif channel.label == "ceki-capture": - # Agent doesn't process capture frames, but log it - log.info("webrtc: ceki-capture channel opened (no-op for agent)") + self._capture_dc = channel + self._wire_capture_dc(channel) return self._pc @@ -283,6 +297,104 @@ async def _on_message(message: str | bytes) -> None: if self.on_cdp_message: await self.on_cdp_message(data) + def _wire_capture_dc(self, channel: Any) -> None: + """Set up message/close handlers on the ceki-capture data channel. + + Mirror of ``_wire_cmd_dc`` for the capture channel: small frames + (single ``video-frame`` / screenshot messages) are forwarded to + ``on_capture_data`` unchanged, while ``capture-chunk`` fragments are + reassembled transparently before delivery. + """ + + @channel.on("open") + async def _on_open() -> None: + log.info("webrtc: ceki-capture DC opened") + + @channel.on("close") + async def _on_close() -> None: + log.info("webrtc: ceki-capture DC closed") + + @channel.on("message") + async def _on_message(message: str | bytes) -> None: + try: + data = json.loads(message if isinstance(message, str) else message.decode()) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + log.warning("webrtc: failed to parse capture DC message: %s", exc) + return + + # Chunked capture frames (large messages from the extension) are + # reassembled transparently here — individual chunks are never + # forwarded to on_capture_data. + if data.get("type") == "capture-chunk": + restored = self._buffer_capture_chunk(data) + if restored is None: + return # not complete yet (or malformed) + if self.on_capture_data: + await self.on_capture_data(restored) + return + + if self.on_capture_data: + await self.on_capture_data(data) + + def _buffer_capture_chunk(self, chunk: dict[str, Any]) -> dict[str, Any] | None: + """Buffer one capture-chunk fragment and return the reassembled frame. + + Mirrors the extension's ``CaptureChunkReassembler.handle``: fragments + are buffered per ``frameId`` until all ``total`` have arrived (in any + order — the capture DC is ordered:false), then the concatenated + payload is parsed back into the original frame and returned. + Incomplete frames are pruned after ``_capture_stale_ms`` so a dropped + fragment cannot leak memory forever. + """ + frame_id = chunk.get("frameId") + seq = chunk.get("seq") + total = chunk.get("total") + payload = chunk.get("payload") + if ( + not isinstance(frame_id, str) + or not isinstance(seq, int) + or not isinstance(total, int) + or seq < 0 + or total <= 0 + or seq >= total + or not isinstance(payload, str) + ): + log.warning("webrtc: malformed capture-chunk, dropping") + return None + + now = time.monotonic() + for fid in [ + fid + for fid, entry in self._pending_capture_frames.items() + if now - entry["received_at"] > self._capture_stale_ms + ]: + log.debug("webrtc: pruning stale capture-chunk frame %s", fid) + del self._pending_capture_frames[fid] + + entry = self._pending_capture_frames.get(frame_id) + if entry is None: + entry = { + "chunks": [""] * total, + "received": 0, + "total": total, + "received_at": now, + } + self._pending_capture_frames[frame_id] = entry + + if entry["chunks"][seq] == "": + entry["chunks"][seq] = payload + entry["received"] += 1 + + if entry["received"] != entry["total"]: + return None + + del self._pending_capture_frames[frame_id] + try: + return json.loads("".join(entry["chunks"])) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + log.warning("webrtc: failed to reassemble capture-chunk frame %s: %s", frame_id, exc) + return None + def _buffer_chunk(self, chunk: dict[str, Any]) -> dict[str, Any] | None: """Buffer one CDP chunk fragment and return the reassembled message. @@ -520,6 +632,12 @@ async def close(self) -> None: except Exception: pass self._cmd_dc = None + if self._capture_dc is not None: + try: + self._capture_dc.close() + except Exception: + pass + self._capture_dc = None if self._pc is not None: try: await self._pc.close() @@ -529,4 +647,6 @@ async def close(self) -> None: self._dc_open_event.clear() self._local_fingerprint = None self._pending_remote_candidates.clear() + self._pending_chunks.clear() + self._pending_capture_frames.clear() log.info("webrtc: transport closed") diff --git a/examples/smoke/e2e_capture_chunk_dev312.py b/examples/smoke/e2e_capture_chunk_dev312.py new file mode 100644 index 0000000..93fe25c --- /dev/null +++ b/examples/smoke/e2e_capture_chunk_dev312.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""E2E: capture-chunk reassembly vs live provider dev312 (schedule 40634). + +Rents the dev312 provider (extension v0.6.312 with sendCaptureChunked), +navigates to a heavy page, starts screencast and asserts that real frames +>48KB arrive (reassembled from capture-chunk fragments on the ceki-capture DC). + +Env: + CEKI_API_KEY — rent agent token (required) + SCHEDULE_ID — provider schedule (default 40634 = dev312) +""" +from __future__ import annotations + +import asyncio +import base64 +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from ceki_sdk import connect + +API_KEY = os.environ.get("CEKI_API_KEY", "") +SCHEDULE_ID = int(os.environ.get("SCHEDULE_ID", "40634")) + + +async def main() -> int: + if not API_KEY: + print("FAIL: CEKI_API_KEY not set") + return 1 + + frames: list[dict] = [] + reassembled_big = [] # frames whose data len > 48000 base64 chars (chunk threshold) + raw_count = 0 + + client = await connect(API_KEY) + try: + print(f"connected: schedule={SCHEDULE_ID}") + browser = await client.rent(SCHEDULE_ID) + print(f"rent ok: session={browser.session_id}") + + async def on_frame(frame: dict) -> None: + nonlocal raw_count + raw_count += 1 + frames.append(frame) + data = frame.get("data") or "" + if len(data) > 48000: + reassembled_big.append(frame) + print( + f" [frame] big>48KB: data_len={len(data)} " + f"w={frame.get('width')} h={frame.get('height')} " + f"ts={frame.get('timestamp')}" + ) + + browser.on_capture_frame(on_frame) + + # Navigate to a content-heavy page so frames are large, not black. + await browser.navigate("https://www.wikipedia.org") + await asyncio.sleep(3) + + print("starting screencast ...") + await browser.start_screencast( + maxWidth=1920, maxHeight=1080, quality=90, everyNthFrame=1, maxFrameRate=1 + ) + await asyncio.sleep(12) # ~12 frames at 1fps + await browser.stop_screencast() + + await asyncio.sleep(1) + await browser.close() + finally: + await client.close() + + print(f"\nraw video-frame messages (capture DC): {raw_count}") + print(f"frames with data > 48000 base64 chars (reassembled): {len(reassembled_big)}") + if not reassembled_big: + # Show a sample to diagnose + if frames: + sample = frames[0] + print("sample frame keys:", list(sample.keys())) + print("sample data len:", len(sample.get("data") or "")) + else: + print("no frames received at all") + return 2 + + # Verify the big frame is a real JPEG, not empty/black. + ok = 0 + for f in reassembled_big[:3]: + data = f.get("data") or "" + try: + img = base64.b64decode(data) + # Check JPEG magic + is_jpeg = img[:3] == b"\xff\xd8\xff" + # rough blackness check: sample some bytes + print( + f" decode: {len(img)} bytes jpeg_magic={is_jpeg} " + f"w={f.get('width')} h={f.get('height')}" + ) + if is_jpeg: + ok += 1 + except Exception as e: + print(f" decode error: {e}") + + print(f"\nRESULT: big frames received={len(reassembled_big)}, valid jpeg={ok}") + return 0 if ok > 0 else 3 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/pyproject.toml b/pyproject.toml index b1ced32..6fc041b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ceki-sdk" -version = "2.36.1" +version = "2.36.2" description = "Python SDK for browser.ceki.me — rent real browsers from real people" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_webrtc_p2p.py b/tests/test_webrtc_p2p.py index 70a7f0c..c7d4912 100644 --- a/tests/test_webrtc_p2p.py +++ b/tests/test_webrtc_p2p.py @@ -837,3 +837,348 @@ async def capture(msg: dict[str, Any]) -> None: assert received == [] assert t._pending_chunks == {} + + +# ────────────────────────────────────────────────────────────────────────────── +# Tests — capture-chunk reassembly on the ceki-capture DC +# ────────────────────────────────────────────────────────────────────────────── + +_CHUNK_SIZE = 48000 # mirror the extension's sendCaptureChunked constant + + +def _make_transport_with_capture_message_handler(): + from ceki_sdk._webrtc import WebRTCTransport + + t = WebRTCTransport() + dc = _RecordingDC() + t._wire_capture_dc(dc) + return t, dc + + +def _capture_chunks_for( + original: dict[str, Any], + frame_id: str, + order: str = "asc", +) -> list[dict[str, Any]]: + """Split a capture frame the way the extension's sendCaptureChunked does.""" + full = json.dumps(original) + total = (len(full) + _CHUNK_SIZE - 1) // _CHUNK_SIZE + chunks: list[dict[str, Any]] = [] + for i in range(total): + chunk: dict[str, Any] = { + "type": "capture-chunk", + "frameId": frame_id, + "seq": i, + "total": total, + "payload": full[i * _CHUNK_SIZE : (i + 1) * _CHUNK_SIZE], + } + if i == 0: + meta: dict[str, Any] = {} + for key in ("type", "timestamp", "width", "height", "error"): + if key in original: + meta[key] = original[key] + chunk["meta"] = meta + chunks.append(chunk) + return chunks if order == "asc" else list(reversed(chunks)) + + +@pytest.mark.asyncio +async def test_capture_dc_small_frame_passes_through_unchanged(): + """A non-chunk capture message (small video-frame) reaches on_capture_data verbatim.""" + t, dc = _make_transport_with_capture_message_handler() + received: list[dict[str, Any]] = [] + + async def capture(msg: dict[str, Any]) -> None: + received.append(msg) + + t.on_capture_data = capture + + frame = {"type": "video-frame", "timestamp": 1234, "data": "tiny"} + await dc.handlers["message"](json.dumps(frame)) + + assert received == [frame] + assert t._pending_capture_frames == {} + + +@pytest.mark.asyncio +async def test_capture_dc_chunked_frame_reassembled(): + """A chunked capture frame reassembles into the original frame object.""" + t, dc = _make_transport_with_capture_message_handler() + received: list[dict[str, Any]] = [] + + async def capture(msg: dict[str, Any]) -> None: + received.append(msg) + + t.on_capture_data = capture + + original = {"type": "video-frame", "timestamp": 7, "data": "x" * 120_000} + for chunk in _capture_chunks_for(original, "frame-test-1"): + await dc.handlers["message"](json.dumps(chunk)) + + assert len(received) == 1 + assert received[0] == original + assert t._pending_capture_frames == {} + + +@pytest.mark.asyncio +async def test_capture_dc_chunk_reassembly_out_of_order(): + """Chunks may arrive out of order (capture DC is ordered:false); reassembly still works.""" + t, dc = _make_transport_with_capture_message_handler() + received: list[dict[str, Any]] = [] + + async def capture(msg: dict[str, Any]) -> None: + received.append(msg) + + t.on_capture_data = capture + + original = {"type": "video-frame", "timestamp": 9, "data": "y" * 120_000} + for chunk in _capture_chunks_for(original, "frame-oo", order="desc"): + await dc.handlers["message"](json.dumps(chunk)) + + assert len(received) == 1 + assert received[0] == original + assert t._pending_capture_frames == {} + + +@pytest.mark.asyncio +async def test_capture_dc_incomplete_chunk_set_not_forwarded(): + """An incomplete capture-chunk set must not reach on_capture_data.""" + t, dc = _make_transport_with_capture_message_handler() + received: list[dict[str, Any]] = [] + + async def capture(msg: dict[str, Any]) -> None: + received.append(msg) + + t.on_capture_data = capture + + original = {"type": "video-frame", "timestamp": 1, "data": "z" * 120_000} + full = json.dumps(original) + total = (len(full) + _CHUNK_SIZE - 1) // _CHUNK_SIZE + assert total > 1 + + # Send only the first two fragments of a multi-chunk frame + for i in range(min(2, total - 1)): + chunk = { + "type": "capture-chunk", + "frameId": "frame-incomplete", + "seq": i, + "total": total, + "payload": full[i * _CHUNK_SIZE : (i + 1) * _CHUNK_SIZE], + } + await dc.handlers["message"](json.dumps(chunk)) + + assert received == [] + assert len(t._pending_capture_frames) == 1 # buffered, awaiting remaining fragments + + +@pytest.mark.asyncio +async def test_capture_dc_malformed_chunk_dropped(): + """A malformed capture-chunk message is dropped without touching the buffer.""" + t, dc = _make_transport_with_capture_message_handler() + received: list[dict[str, Any]] = [] + + async def capture(msg: dict[str, Any]) -> None: + received.append(msg) + + t.on_capture_data = capture + + bad = {"type": "capture-chunk", "seq": 0, "total": 2} # no frameId/payload + await dc.handlers["message"](json.dumps(bad)) + + assert received == [] + assert t._pending_capture_frames == {} + + +@pytest.mark.asyncio +async def test_capture_dc_stale_frames_pruned(): + """Incomplete frames older than _capture_stale_ms are pruned on the next chunk.""" + t, dc = _make_transport_with_capture_message_handler() + received: list[dict[str, Any]] = [] + + async def capture(msg: dict[str, Any]) -> None: + received.append(msg) + + t.on_capture_data = capture + + original = {"type": "video-frame", "timestamp": 1, "data": "w" * 120_000} + full = json.dumps(original) + total = (len(full) + _CHUNK_SIZE - 1) // _CHUNK_SIZE + + # Buffer one incomplete frame + await dc.handlers["message"](json.dumps({ + "type": "capture-chunk", + "frameId": "frame-stale", + "seq": 0, + "total": total, + "payload": full[: _CHUNK_SIZE], + })) + assert len(t._pending_capture_frames) == 1 + + # Force every buffered frame to look stale on the next handle call + t._capture_stale_ms = -1 + + # A chunk for a different frame triggers the prune pass + await dc.handlers["message"](json.dumps({ + "type": "capture-chunk", + "frameId": "frame-other", + "seq": 0, + "total": total, + "payload": full[: _CHUNK_SIZE], + })) + + assert "frame-stale" not in t._pending_capture_frames + assert "frame-other" in t._pending_capture_frames + assert received == [] + + +# ────────────────────────────────────────────────────────────────────────────── +# Tests — public screencast API (Browser.on_capture_frame / start_screencast) +# ────────────────────────────────────────────────────────────────────────────── + + +def _make_browser(): + from ceki_sdk._browser import Browser + from ceki_sdk._models import Match + + client = MagicMock() + client._p2p = None + client._ws_send = AsyncMock() + + match = MagicMock(spec=Match) + match.session_id = "test-session" + match.schedule_id = 42 + match.browser_info = {} + match.provider_user_id = 1 + match.event_id = 999 + match.chat_topic_id = None + + browser = Browser(client=client, match=match) + browser._ended.is_set = MagicMock(return_value=False) + return browser, client + + +def test_on_capture_frame_registers_callback(): + browser, _ = _make_browser() + + async def cb(msg): + pass + + browser.on_capture_frame(cb) + assert browser._capture_frame_callbacks == [cb] + + +@pytest.mark.asyncio +async def test_start_screencast_sends_page_start(): + browser, _ = _make_browser() + browser.send = AsyncMock(return_value={}) + + result = await browser.start_screencast(maxWidth=1280, maxHeight=720, quality=80) + + browser.send.assert_called_once_with({ + "method": "Page.startScreencast", + "params": {"maxWidth": 1280, "maxHeight": 720, "quality": 80}, + }) + assert result == {} + + +@pytest.mark.asyncio +async def test_start_screencast_no_params_sends_empty(): + browser, _ = _make_browser() + browser.send = AsyncMock(return_value={}) + + await browser.start_screencast() + + browser.send.assert_called_once_with({ + "method": "Page.startScreencast", + "params": {}, + }) + + +@pytest.mark.asyncio +async def test_stop_screencast_sends_page_stop(): + browser, _ = _make_browser() + browser.send = AsyncMock(return_value={}) + + await browser.stop_screencast() + + browser.send.assert_called_once_with({"method": "Page.stopScreencast"}) + + +@pytest.mark.asyncio +async def test_capture_data_delivers_video_frame_to_callback(): + browser, _ = _make_browser() + received: list[dict[str, Any]] = [] + + async def cb(msg): + received.append(msg) + + browser.on_capture_frame(cb) + + frame = {"type": "video-frame", "timestamp": 1, "data": "abc"} + await browser._on_capture_data(frame) + await asyncio.sleep(0) # dispatch is fire-and-forget (create_task) + + assert received == [frame] + + +@pytest.mark.asyncio +async def test_capture_data_ignores_non_video_frame(): + browser, _ = _make_browser() + received: list[dict[str, Any]] = [] + + async def cb(msg): + received.append(msg) + + browser.on_capture_frame(cb) + + await browser._on_capture_data({"type": "video-stopped"}) + await browser._on_capture_data({"type": "screenshot", "data": "x"}) + + assert received == [] + + +@pytest.mark.asyncio +async def test_init_p2p_wires_on_capture_data_to_browser(): + """_init_p2p must wire transport.on_capture_data and route frames to the browser.""" + from ceki_sdk._browser import Browser + from ceki_sdk._client import Client + from ceki_sdk._models import Match + + with patch("ceki_sdk._client.WebRTCTransport") as mock_cls: + transport = MagicMock() + transport.create_offer = AsyncMock(return_value="v=0") + transport.extract_fingerprint = MagicMock(return_value="fp") + transport.wait_dc_open = AsyncMock() + mock_cls.return_value = transport + + c = Client(api_key="test", relay_url="ws://localhost:9999", + api_url="https://api.example.com", chat_url="https://chat.example.com") + c._ws_send = AsyncMock() + + match = MagicMock(spec=Match) + match.session_id = "sess-1" + match.schedule_id = 42 + match.browser_info = {} + match.provider_user_id = 1 + match.event_id = 999 + match.chat_topic_id = None + + browser = Browser(client=c, match=match) + c._active_browsers["sess-1"] = browser + + received: list[dict[str, Any]] = [] + + async def cb(msg): + received.append(msg) + + browser.on_capture_frame(cb) + + await c._init_p2p("sess-1") + + # transport.on_capture_data must be wired + assert transport.on_capture_data is not None + # frame delivered to the registered callback (reassembly itself is + # covered at the transport level — test_capture_dc_chunked_frame_reassembled) + await transport.on_capture_data({"type": "video-frame", "data": "abc"}) + await asyncio.sleep(0) # dispatch is fire-and-forget (create_task) + assert received == [{"type": "video-frame", "data": "abc"}]