Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi

## [Unreleased]

- **[wrapper]** Humanize: extend the shared timeout budget to `select_option` on page, frame, ElementHandle, and Locator (sync + async) — the native select now uses the time remaining after actionability/hover instead of restarting the full clock, so `select_option(timeout=N)` no longer waits up to ~2x N (follow-up to #307)

## [0.3.31] — 2026-05-26

- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
Expand Down
62 changes: 44 additions & 18 deletions cloakbrowser/human/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,11 +485,10 @@ def _humanized_set_checked(self, checked, **kwargs):

def _humanized_select_option(self, value=None, **kwargs):
if _is_humanized(self):
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
self.page.hover(selector, **fwd)
sleep_ms(rand(100, 300))
_orig_select_option(self, value, **kwargs)
# Delegate to the page-level select_option (like click/hover/fill) so
# the hover + native select share a single timeout budget instead of
# each restarting the full clock (~2x bug).
self.page.select_option(_get_selector(self), value, **_forward_kwargs(kwargs))
else:
_orig_select_option(self, value, **kwargs)

Expand Down Expand Up @@ -731,11 +730,10 @@ async def _humanized_set_checked(self, checked, **kwargs):

async def _humanized_select_option(self, value=None, **kwargs):
if _is_humanized(self):
fwd = _forward_kwargs(kwargs)
selector = _get_selector(self)
await self.page.hover(selector, **fwd)
await async_sleep_ms(rand(100, 300))
await _orig_select_option(self, value, **kwargs)
# Delegate to the page-level select_option (like click/hover/fill) so
# the hover + native select share a single timeout budget instead of
# each restarting the full clock (~2x bug).
await self.page.select_option(_get_selector(self), value, **_forward_kwargs(kwargs))
else:
await _orig_select_option(self, value, **kwargs)

Expand Down Expand Up @@ -1071,6 +1069,10 @@ def _remaining_ms():
_human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
sleep_ms(rand(100, 300))
pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")}
# Share the timeout budget: the native select_option must use the time
# remaining after actionability + hover, not restart the full clock
# (otherwise a slow element can wait up to ~2x the requested timeout).
pw_kwargs["timeout"] = max(1, _remaining_ms())
return originals.select_option(selector, value, **pw_kwargs)

def _human_press(selector: str, key: str, **kwargs: Any) -> None:
Expand Down Expand Up @@ -1388,11 +1390,13 @@ def _remaining_ms():
if not force:
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = _move_to_element()
# Share the timeout budget so the native select_option uses the time
# remaining after actionability + move, not a fresh full timeout.
if info is None:
return _orig_select_option(value, **kwargs)
return _orig_select_option(value, **{**kwargs, "timeout": max(1, _remaining_ms())})
human_click(raw_mouse, False, cfg)
sleep_ms(rand(100, 300))
return _orig_select_option(value, **kwargs)
return _orig_select_option(value, **{**kwargs, "timeout": max(1, _remaining_ms())})

# --- el.check() ---
def _human_el_check(**kwargs: Any) -> None:
Expand Down Expand Up @@ -1592,9 +1596,17 @@ def _frame_uncheck(selector: str, **kwargs: Any) -> None:
page.uncheck(selector, **kwargs)

def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
page.hover(selector, **kwargs)
# Share one deadline across the hover and the native select_option so the
# two sequential waits don't each consume the full timeout (~2x bug).
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0

def _remaining_ms():
return max(1, (deadline - time.monotonic()) * 1000)

page.hover(selector, **{**kwargs, "timeout": _remaining_ms()})
sleep_ms(rand(100, 300))
return _orig_frame_select_option(selector, value, **kwargs)
return _orig_frame_select_option(selector, value, **{**kwargs, "timeout": _remaining_ms()})

def _frame_press(selector: str, key: str, **kwargs: Any) -> None:
page.press(selector, key, **kwargs)
Expand Down Expand Up @@ -2013,6 +2025,10 @@ def _remaining_ms():
await _human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config"))
await async_sleep_ms(rand(100, 300))
pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")}
# Share the timeout budget: the native select_option must use the time
# remaining after actionability + hover, not restart the full clock
# (otherwise a slow element can wait up to ~2x the requested timeout).
pw_kwargs["timeout"] = max(1, _remaining_ms())
return await originals.select_option(selector, value, **pw_kwargs)

async def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None:
Expand Down Expand Up @@ -2317,11 +2333,13 @@ def _remaining_ms():
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = await _move_to_element()
# Share the timeout budget so the native select_option uses the time
# remaining after actionability + move, not a fresh full timeout.
if info is None:
return await _orig_select_option(value, **kwargs)
return await _orig_select_option(value, **{**kwargs, "timeout": max(1, _remaining_ms())})
await async_human_click(raw_mouse, False, cfg)
await async_sleep_ms(rand(100, 300))
return await _orig_select_option(value, **kwargs)
return await _orig_select_option(value, **{**kwargs, "timeout": max(1, _remaining_ms())})

# --- el.check() ---
async def _human_el_check(**kwargs: Any) -> None:
Expand Down Expand Up @@ -2521,9 +2539,17 @@ async def _frame_uncheck(selector: str, **kwargs: Any) -> None:
await page.uncheck(selector, **kwargs)

async def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any:
await page.hover(selector, **kwargs)
# Share one deadline across the hover and the native select_option so the
# two sequential waits don't each consume the full timeout (~2x bug).
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0

def _remaining_ms():
return max(1, (deadline - time.monotonic()) * 1000)

await page.hover(selector, **{**kwargs, "timeout": _remaining_ms()})
await async_sleep_ms(rand(100, 300))
return await _orig_frame_select_option(selector, value, **kwargs)
return await _orig_frame_select_option(selector, value, **{**kwargs, "timeout": _remaining_ms()})

async def _frame_press(selector: str, key: str, **kwargs: Any) -> None:
await page.press(selector, key, **kwargs)
Expand Down
119 changes: 119 additions & 0 deletions tests/test_humanize_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,125 @@ def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}"


# =========================================================================
# 15b. select_option shared timeout budget (issue #307 follow-up)
# =========================================================================

class TestSelectOptionTimeoutBudget:
"""select_option must share ONE timeout budget across actionability +
hover + the native select, instead of restarting the full clock on the
native call (which let a slow element wait up to ~2x the requested
timeout). Same class of bug as #307, on the select_option paths the
original fix didn't cover."""

def _build_page_mock(self):
from unittest.mock import MagicMock

page = MagicMock()
page.click = MagicMock()
page.dblclick = MagicMock()
page.hover = MagicMock()
page.type = MagicMock()
page.fill = MagicMock()
page.goto = MagicMock()
page.select_option = MagicMock()
page.is_checked = MagicMock(return_value=False)
page.viewport_size = {"width": 1280, "height": 720}
page.evaluate = MagicMock(return_value={"hit": True})
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
page.mouse = MagicMock()
page.keyboard = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=None)
page.main_frame = MagicMock()
page.main_frame.child_frames = []
return page

def test_page_select_option_uses_remaining_timeout(self):
"""The native select_option receives the REMAINING budget, not 5000."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import patch

cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100

page = self._build_page_mock()
# Captured before patch — this is the object that `originals.select_option`
# will call inside the humanized wrapper.
native = page.select_option

def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy, False)

with patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
patch.object(h, "ensure_actionable"), \
patch.object(h, "check_pointer_events"):
h.patch_page(page, cfg, cursor)
page.select_option("#dropdown", "opt1", timeout=5000)

assert native.called, "native select_option should be invoked"
passed_timeout = native.call_args.kwargs.get("timeout")
assert passed_timeout is not None, "native must receive a timeout kwarg"
# hover + the 100-300ms human pause consume part of the budget, so the
# native call must get strictly less than the requested 5000 (and > 0).
assert 0 < passed_timeout < 5000, f"expected reduced timeout, got {passed_timeout}"

def test_locator_select_option_delegates_to_page(self):
"""Locator.select_option delegates to page.select_option (one budget)."""
_ensure_locator_patched()
from unittest.mock import MagicMock
from playwright.sync_api._generated import Locator

page = MagicMock()
page._original = MagicMock()
page._human_cfg = MagicMock()
page.select_option = MagicMock()

loc = MagicMock()
loc.page = page
loc._impl_obj = MagicMock()
loc._impl_obj._selector = "#dropdown"

Locator.select_option(loc, "opt1", timeout=5000)

page.select_option.assert_called_once()
args, kwargs = page.select_option.call_args
assert args[0] == "#dropdown"
assert args[1] == "opt1"
assert kwargs.get("timeout") == 5000

def test_frame_select_option_uses_remaining_timeout(self):
"""Frame.select_option shares the deadline across hover + native select."""
from cloakbrowser.human import _patch_single_frame_sync, _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock

cfg = resolve_config("default", None)
cursor = _CursorState()
page = MagicMock()
page._original = MagicMock()
page.hover = MagicMock()
frame = MagicMock()
frame._human_patched = False
native = frame.select_option # captured before patch

_patch_single_frame_sync(
frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original
)
frame.select_option("#dropdown", "opt1", timeout=5000)

assert native.called, "native frame.select_option should be invoked"
passed_timeout = native.call_args.kwargs.get("timeout")
assert passed_timeout is not None
assert 0 < passed_timeout < 5000, f"expected reduced timeout, got {passed_timeout}"


# =========================================================================
# 16. Per-call human_config override (typing speed customization)
# =========================================================================
Expand Down