Skip to content

Commit f9024bd

Browse files
cursoragentP4X-ng
andcommitted
Add CDPConnection.wait_for event helper with tests
Co-authored-by: P4x-ng <P4X-ng@users.noreply.github.com>
1 parent 2140462 commit f9024bd

File tree

6 files changed

+207
-41
lines changed

6 files changed

+207
-41
lines changed

IMPLEMENTATION.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ This implementation addresses the issue requesting I/O support and command multi
2828
### 4. Event Handling
2929
- Events dispatched to an asyncio.Queue
3030
- Async iterator interface for consumption
31+
- `wait_for()` helper for waiting on one event type with optional predicate filtering
3132
- Non-blocking get method available
3233
- Automatic event parsing using existing event registry
3334

@@ -101,8 +102,9 @@ class CDPConnection:
101102
2. **Command execution** - Success, error, timeout
102103
3. **Multiplexing** - Multiple concurrent commands
103104
4. **Event handling** - Async iterator, non-blocking get
104-
5. **Error handling** - Connection errors, command errors
105-
6. **Resource cleanup** - Pending commands cancelled on close
105+
5. **Event waiting helper** - Type matching, predicate filtering, timeout behavior
106+
6. **Error handling** - Connection errors, command errors
107+
7. **Resource cleanup** - Pending commands cancelled on close
106108

107109
### Mock Strategy
108110

README.md

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ async def main():
5757
# Connect to a Chrome DevTools Protocol endpoint
5858
async with CDPConnection("ws://localhost:9222/devtools/page/YOUR_PAGE_ID") as conn:
5959
# Navigate to a URL
60-
frame_id, loader_id, error = await conn.execute(
60+
frame_id, *_ = await conn.execute(
6161
page.navigate(url="https://example.com")
6262
)
6363
print(f"Navigated to example.com, frame_id: {frame_id}")
@@ -71,10 +71,24 @@ asyncio.run(main())
7171
- **JSON-RPC Framing**: Automatic message ID assignment and request/response matching
7272
- **Command Multiplexing**: Execute multiple commands concurrently with proper tracking
7373
- **Event Handling**: Async iterator for receiving browser events
74+
- **Event Waiting Helpers**: Wait for specific events with optional filtering
7475
- **Error Handling**: Comprehensive error handling with typed exceptions
7576

7677
See the [examples directory](examples/) for more usage patterns.
7778

79+
### Waiting for a specific event
80+
81+
```python
82+
from cdp import page
83+
84+
async with CDPConnection("ws://localhost:9222/devtools/page/YOUR_PAGE_ID") as conn:
85+
await conn.execute(page.enable())
86+
await conn.execute(page.navigate(url="https://example.com"))
87+
88+
load_event = await conn.wait_for(page.LoadEventFired, timeout=5.0)
89+
print(load_event.timestamp)
90+
```
91+
7892
## Sans-I/O Mode (Original)
7993

8094
For users who prefer to manage their own I/O:
@@ -100,25 +114,6 @@ For detailed API documentation, see:
100114
- `cdp.<domain>` - Type wrappers for each CDP domain (e.g., `cdp.page`, `cdp.network`, `cdp.runtime`)
101115
- Each domain module provides types, commands, and events for that CDP domain
102116

103-
## Contributing
104-
105-
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
106-
107-
- Setting up your development environment
108-
- Running tests and type checking
109-
- Submitting pull requests
110-
- Reporting issues
111-
112-
Please also read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
113-
114-
## Security
115-
116-
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
117-
118-
## License
119-
120-
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
121-
122117
## API Reference
123118

124119
The library provides Python wrappers for all Chrome DevTools Protocol domains:
@@ -145,12 +140,16 @@ All CDP types, commands, and events are fully typed with Python type hints, prov
145140
## Contributing
146141

147142
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on:
148-
- How to report bugs and request features
149-
- Development setup and workflow
150-
- Coding standards and testing requirements
151-
- Pull request process
143+
- Setting up your development environment
144+
- Running tests and type checking
145+
- Submitting pull requests
146+
- Reporting issues
147+
148+
Please also read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing.
149+
150+
## Security
152151

153-
For questions or discussions, feel free to open an issue on GitHub.
152+
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
154153

155154
## License
156155

cdp/connection.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010
import asyncio
11+
from collections import deque
1112
import json
1213
import logging
1314
import typing
@@ -25,6 +26,7 @@
2526

2627

2728
logger = logging.getLogger(__name__)
29+
EventT = typing.TypeVar('EventT')
2830

2931

3032
class CDPError(Exception):
@@ -96,6 +98,7 @@ def __init__(self, url: str, timeout: float = 30.0):
9698
self._next_command_id = 1
9799
self._pending_commands: typing.Dict[int, PendingCommand] = {}
98100
self._event_queue: asyncio.Queue = asyncio.Queue()
101+
self._event_buffer: typing.Deque[typing.Any] = deque()
99102
self._recv_task: typing.Optional[asyncio.Task] = None
100103
self._closed = False
101104

@@ -216,6 +219,26 @@ async def _handle_event(self, data: T_JSON_DICT) -> None:
216219
await self._event_queue.put(event)
217220
except Exception as e:
218221
logger.error(f"Failed to parse event: {e}")
222+
223+
def _restore_deferred_events(self, events: typing.List[typing.Any]) -> None:
224+
"""Put deferred events back at the front of the buffer."""
225+
for event in reversed(events):
226+
self._event_buffer.appendleft(event)
227+
228+
async def _next_event(self, timeout: typing.Optional[float] = None) -> typing.Any:
229+
"""
230+
Read the next event from the local buffer or queue.
231+
232+
Buffered events are always consumed first to preserve ordering for events
233+
temporarily skipped by ``wait_for``.
234+
"""
235+
if self._event_buffer:
236+
return self._event_buffer.popleft()
237+
238+
if timeout is None:
239+
return await self._event_queue.get()
240+
241+
return await asyncio.wait_for(self._event_queue.get(), timeout=timeout)
219242

220243
async def execute(
221244
self,
@@ -311,13 +334,67 @@ async def listen(self) -> typing.AsyncIterator[typing.Any]:
311334
"""
312335
while not self._closed:
313336
try:
314-
event = await asyncio.wait_for(self._event_queue.get(), timeout=1.0)
337+
event = await self._next_event(timeout=1.0)
315338
yield event
316339
except asyncio.TimeoutError:
317340
# Check if connection is still alive
318341
if self._closed:
319342
break
320343
continue
344+
345+
async def wait_for(
346+
self,
347+
event_type: typing.Type[EventT],
348+
timeout: typing.Optional[float] = None,
349+
predicate: typing.Optional[typing.Callable[[EventT], bool]] = None,
350+
) -> EventT:
351+
"""
352+
Wait for the next event matching a type (and optional predicate).
353+
354+
Non-matching events are not discarded; they are restored and remain
355+
available to ``listen()`` and ``get_event_nowait()``.
356+
357+
Args:
358+
event_type: Event class to match (e.g. ``page.LoadEventFired``).
359+
timeout: Maximum seconds to wait for a matching event.
360+
predicate: Optional callback for additional filtering.
361+
362+
Returns:
363+
The first matching event instance.
364+
365+
Raises:
366+
asyncio.TimeoutError: If no matching event arrives in time.
367+
CDPConnectionError: If the connection is closed while waiting.
368+
"""
369+
deferred: typing.List[typing.Any] = []
370+
deadline: typing.Optional[float] = None
371+
if timeout is not None:
372+
deadline = asyncio.get_running_loop().time() + timeout
373+
374+
try:
375+
while True:
376+
if (
377+
self._closed
378+
and not self._event_buffer
379+
and self._event_queue.empty()
380+
):
381+
raise CDPConnectionError("Connection closed while waiting for event")
382+
383+
remaining: typing.Optional[float] = None
384+
if deadline is not None:
385+
remaining = deadline - asyncio.get_running_loop().time()
386+
if remaining <= 0:
387+
raise asyncio.TimeoutError()
388+
389+
event = await self._next_event(timeout=remaining)
390+
if isinstance(event, event_type) and (
391+
predicate is None or predicate(event)
392+
):
393+
return event
394+
395+
deferred.append(event)
396+
finally:
397+
self._restore_deferred_events(deferred)
321398

322399
def get_event_nowait(self) -> typing.Optional[typing.Any]:
323400
"""
@@ -327,6 +404,8 @@ def get_event_nowait(self) -> typing.Optional[typing.Any]:
327404
A CDP event object, or None if no events are available
328405
"""
329406
try:
407+
if self._event_buffer:
408+
return self._event_buffer.popleft()
330409
return self._event_queue.get_nowait()
331410
except asyncio.QueueEmpty:
332411
return None

docs/connection.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ async def main():
2121
# Connect using async context manager
2222
async with CDPConnection("ws://localhost:9222/devtools/page/YOUR_PAGE_ID") as conn:
2323
# Execute a command
24-
frame_id, loader_id, error = await conn.execute(
24+
frame_id, *_ = await conn.execute(
2525
page.navigate(url="https://example.com")
2626
)
2727

@@ -112,6 +112,17 @@ async with CDPConnection(url) as conn:
112112
print(f"Navigated to {event.frame.url}")
113113
```
114114

115+
Wait for one specific event with optional filtering:
116+
117+
```python
118+
async with CDPConnection(url) as conn:
119+
await conn.execute(page.enable())
120+
await conn.execute(page.navigate(url="https://example.com"))
121+
122+
load_event = await conn.wait_for(page.LoadEventFired, timeout=5.0)
123+
print(load_event.timestamp)
124+
```
125+
115126
You can also get events without blocking:
116127

117128
```python
@@ -161,6 +172,7 @@ class CDPConnection:
161172
async def close(self) -> None
162173
async def execute(self, cmd, timeout: Optional[float] = None) -> Any
163174
async def listen(self) -> AsyncIterator[Any]
175+
async def wait_for(self, event_type, timeout: Optional[float] = None, predicate=None) -> Any
164176
def get_event_nowait(self) -> Optional[Any]
165177

166178
@property

examples/connection_example.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,14 @@ async def basic_example():
2727
await conn.execute(page.enable())
2828

2929
# Navigate to a URL
30-
frame_id, loader_id, error = await conn.execute(
30+
frame_id, *_ = await conn.execute(
3131
page.navigate(url="https://example.com")
3232
)
3333
print(f"Navigated to example.com, frame_id: {frame_id}")
3434

35-
# Wait a bit for the page to load
36-
await asyncio.sleep(2)
35+
# Wait for the next page load event
36+
load_event = await conn.wait_for(page.LoadEventFired, timeout=5.0)
37+
print(f"Load event timestamp: {load_event.timestamp}")
3738

3839
# Evaluate some JavaScript
3940
result, exception = await conn.execute(
@@ -61,15 +62,9 @@ async def event_handling_example():
6162
conn.execute(page.navigate(url="https://example.com"))
6263
)
6364

64-
# Listen for events while navigation is in progress
65-
event_count = 0
66-
async for event in conn.listen():
67-
print(f"Received event: {type(event).__name__}")
68-
event_count += 1
69-
70-
# Stop after receiving a few events
71-
if event_count >= 3:
72-
break
65+
# Wait for a specific event while navigation is in progress
66+
load_event = await conn.wait_for(page.LoadEventFired, timeout=10.0)
67+
print(f"Received {type(load_event).__name__} at {load_event.timestamp}")
7368

7469
# Wait for navigation to complete
7570
await nav_task

test/test_connection.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,85 @@ async def test_get_event_nowait():
287287
assert isinstance(received_event, page.LoadEventFired)
288288

289289

290+
@pytest.mark.asyncio
291+
async def test_wait_for_returns_matching_event_and_preserves_non_matching():
292+
"""wait_for should return a match and keep skipped events available."""
293+
mock_ws = MockWebSocket()
294+
295+
mock_ws.queue_message({
296+
"method": "Page.frameStartedLoading",
297+
"params": {"frameId": "frame-1"},
298+
})
299+
mock_ws.queue_message({
300+
"method": "Page.loadEventFired",
301+
"params": {"timestamp": 123.0},
302+
})
303+
304+
with patch('cdp.connection.websockets.connect', new_callable=AsyncMock) as mock_connect:
305+
mock_connect.return_value = mock_ws
306+
307+
async with CDPConnection("ws://localhost:9222/test") as conn:
308+
matched = await conn.wait_for(page.LoadEventFired, timeout=1.0)
309+
assert isinstance(matched, page.LoadEventFired)
310+
assert matched.timestamp == 123.0
311+
312+
skipped = conn.get_event_nowait()
313+
assert isinstance(skipped, page.FrameStartedLoading)
314+
assert skipped.frame_id == page.FrameId("frame-1")
315+
316+
317+
@pytest.mark.asyncio
318+
async def test_wait_for_supports_predicate_filtering():
319+
"""wait_for should support filtering matching event instances."""
320+
mock_ws = MockWebSocket()
321+
322+
mock_ws.queue_message({
323+
"method": "Page.loadEventFired",
324+
"params": {"timestamp": 1.0},
325+
})
326+
mock_ws.queue_message({
327+
"method": "Page.loadEventFired",
328+
"params": {"timestamp": 2.0},
329+
})
330+
331+
with patch('cdp.connection.websockets.connect', new_callable=AsyncMock) as mock_connect:
332+
mock_connect.return_value = mock_ws
333+
334+
async with CDPConnection("ws://localhost:9222/test") as conn:
335+
matched = await conn.wait_for(
336+
page.LoadEventFired,
337+
timeout=1.0,
338+
predicate=lambda event: event.timestamp > 1.5,
339+
)
340+
assert matched.timestamp == 2.0
341+
342+
skipped = conn.get_event_nowait()
343+
assert isinstance(skipped, page.LoadEventFired)
344+
assert skipped.timestamp == 1.0
345+
346+
347+
@pytest.mark.asyncio
348+
async def test_wait_for_timeout_restores_skipped_events():
349+
"""wait_for timeout should not drop events that did not match."""
350+
mock_ws = MockWebSocket()
351+
352+
mock_ws.queue_message({
353+
"method": "Page.frameStartedLoading",
354+
"params": {"frameId": "frame-timeout"},
355+
})
356+
357+
with patch('cdp.connection.websockets.connect', new_callable=AsyncMock) as mock_connect:
358+
mock_connect.return_value = mock_ws
359+
360+
async with CDPConnection("ws://localhost:9222/test") as conn:
361+
with pytest.raises(asyncio.TimeoutError):
362+
await conn.wait_for(page.LoadEventFired, timeout=0.1)
363+
364+
skipped = conn.get_event_nowait()
365+
assert isinstance(skipped, page.FrameStartedLoading)
366+
assert skipped.frame_id == page.FrameId("frame-timeout")
367+
368+
290369
@pytest.mark.asyncio
291370
async def test_pending_command_count():
292371
"""Test tracking pending command count."""

0 commit comments

Comments
 (0)