Files
browser-use/tests/ci/test_cdp_timeout.py
Saurav Panda 847ad78365 address cubic review: validate constructor timeout + exercise real send_raw
Two P2 comments from cubic on 9a09c4d7:

1. TimeoutWrappedCDPClient.__init__ trusted its cdp_request_timeout_s arg
   blindly. nan / inf / <=0 would either make every CDP call time out
   immediately (nan) or disable the guard (inf / <=0) — same defensive
   gap we already fixed for the env-var path. Extracted _coerce_valid_
   timeout() that mirrors _parse_env_cdp_timeout's validation; constructor
   now routes through it, so both entry points are equally safe.

2. test_send_raw_times_out_on_silent_server used an inline copy of the
   wrapper logic rather than the real TimeoutWrappedCDPClient.send_raw.
   A regression in the production method — e.g. accidentally removing
   the asyncio.wait_for — would not fail the test. Rewrote to:
   - Construct via __new__ (skip CDPClient.__init__'s WebSocket setup)
   - unittest.mock.patch the parent CDPClient.send_raw with a hanging
     coroutine
   - Call the real TimeoutWrappedCDPClient.send_raw, which does
     super().send_raw(...) → our patched stub
   - Assert it raises TimeoutError within the cap

Also added test_send_raw_passes_through_when_fast (fast-path regression
guard) and test_constructor_rejects_invalid_timeout (validation for
fix #1). All 14 tests in the timeout suite pass locally.
2026-04-20 17:54:37 -07:00

117 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Regression tests for TimeoutWrappedCDPClient.
cdp_use.CDPClient.send_raw awaits a future that only resolves when the browser
sends a matching response. When the server goes silent (observed against cloud
browsers whose WebSocket stays connected at TCP/keepalive layer but never
replies), send_raw hangs forever. The wrapper turns that hang into a fast
TimeoutError.
"""
from __future__ import annotations
import asyncio
import time
from unittest.mock import patch
import pytest
from browser_use.browser._cdp_timeout import (
DEFAULT_CDP_REQUEST_TIMEOUT_S,
TimeoutWrappedCDPClient,
_coerce_valid_timeout,
_parse_env_cdp_timeout,
)
def _make_wrapped_client_without_websocket(timeout_s: float) -> TimeoutWrappedCDPClient:
"""Build a TimeoutWrappedCDPClient without opening a real WebSocket.
Calling `CDPClient.__init__` directly would try to construct a working
client. We only want to exercise the timeout-wrapper `send_raw` path, so
we construct the object via __new__ and set the single attribute the
wrapper needs.
"""
client = TimeoutWrappedCDPClient.__new__(TimeoutWrappedCDPClient)
client._cdp_request_timeout_s = timeout_s
return client
@pytest.mark.asyncio
async def test_send_raw_times_out_on_silent_server():
"""The production TimeoutWrappedCDPClient.send_raw must cap a hung parent
send_raw within the configured timeout.
We deliberately exercise the real `send_raw` (not an inline copy) so
regressions in the wrapper itself — e.g. accidentally removing the
asyncio.wait_for — fail this test.
"""
client = _make_wrapped_client_without_websocket(timeout_s=0.5)
call_count = {'n': 0}
async def _hanging_super_send_raw(self, method, params=None, session_id=None):
call_count['n'] += 1
await asyncio.sleep(30)
return {}
# Patch the parent class's send_raw so TimeoutWrappedCDPClient.send_raw's
# `super().send_raw(...)` call lands on our hanging stub.
with patch('browser_use.browser._cdp_timeout.CDPClient.send_raw', _hanging_super_send_raw):
start = time.monotonic()
with pytest.raises(TimeoutError) as exc:
await client.send_raw('Target.getTargets')
elapsed = time.monotonic() - start
assert call_count['n'] == 1
# Returned within the cap (plus scheduling margin), not after the full 30s.
assert elapsed < 2.0, f'wrapper did not enforce timeout; took {elapsed:.2f}s'
assert 'Target.getTargets' in str(exc.value)
# Error message mentions "within 0s" (0.5 rounded with %.0f) or "within 1s".
assert 'within' in str(exc.value)
@pytest.mark.asyncio
async def test_send_raw_passes_through_when_fast():
"""A parent send_raw that returns quickly should bubble the result up unchanged."""
client = _make_wrapped_client_without_websocket(timeout_s=5.0)
async def _fast_super_send_raw(self, method, params=None, session_id=None):
return {'ok': True, 'method': method}
with patch('browser_use.browser._cdp_timeout.CDPClient.send_raw', _fast_super_send_raw):
result = await client.send_raw('Target.getTargets')
assert result == {'ok': True, 'method': 'Target.getTargets'}
def test_constructor_rejects_invalid_timeout():
"""Non-finite / non-positive constructor args must fall back to the default,
mirroring the env-var path in _parse_env_cdp_timeout."""
# None → default.
assert _coerce_valid_timeout(None) == DEFAULT_CDP_REQUEST_TIMEOUT_S
# Invalid values → default, with a warning.
for bad in (float('nan'), float('inf'), float('-inf'), 0.0, -5.0, -0.01):
assert _coerce_valid_timeout(bad) == DEFAULT_CDP_REQUEST_TIMEOUT_S, f'Expected fallback for {bad!r}, got something else'
# Valid finite positives are preserved.
assert _coerce_valid_timeout(0.1) == 0.1
assert _coerce_valid_timeout(30.0) == 30.0
def test_default_cdp_timeout_is_reasonable():
"""Default must give headroom above typical slow CDP calls but stay below
the 180s agent step_timeout so hangs surface before step-level kills."""
assert 10.0 <= DEFAULT_CDP_REQUEST_TIMEOUT_S <= 120.0, (
f'Default CDP timeout ({DEFAULT_CDP_REQUEST_TIMEOUT_S}s) is outside the sensible 10120s range'
)
def test_parse_env_rejects_malformed_values():
"""Mirrors the defensive parse used for BROWSER_USE_ACTION_TIMEOUT_S."""
for bad in ('', 'nan', 'NaN', 'inf', '-inf', '0', '-5', 'abc'):
assert _parse_env_cdp_timeout(bad) == 60.0, f'Expected fallback for {bad!r}'
# Finite positive values take effect.
assert _parse_env_cdp_timeout('30') == 30.0
assert _parse_env_cdp_timeout('15.5') == 15.5
# None (env var not set) also falls back.
assert _parse_env_cdp_timeout(None) == 60.0