diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acadbf0..3ccc1117 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cloakbrowser/human/__init__.py b/cloakbrowser/human/__init__.py index 4861f2ed..cad6a40c 100644 --- a/cloakbrowser/human/__init__.py +++ b/cloakbrowser/human/__init__.py @@ -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) @@ -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) @@ -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: @@ -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: @@ -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) @@ -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: @@ -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: @@ -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) diff --git a/tests/test_humanize_unit.py b/tests/test_humanize_unit.py index 4c3c0ea1..2f4496e6 100644 --- a/tests/test_humanize_unit.py +++ b/tests/test_humanize_unit.py @@ -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) # =========================================================================