fix(humanize): use shared deadline for timeout budget in frame and ElementHandle methods (#307)

Frame-level methods (click, dblclick, hover, dragAndDrop) passed the raw
timeout to each sequential operation independently, causing 3x actual
wait time when elements don't exist. ElementHandle methods had a similar
2x issue between actionability and pointer-events checks.

Port the deadline + remainingMs() pattern already used by page-level
methods. Also fix bot detection test selector after site added a hidden
duplicate submit button.
This commit is contained in:
CloakHQ
2026-05-24 23:12:07 +02:00
parent 8028ddefef
commit 58ccdb683c
5 changed files with 291 additions and 62 deletions
+86 -32
View File
@@ -1257,13 +1257,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg) info = _move_to_element(call_cfg)
if info is None: if info is None:
return _orig_click(**kwargs) return _orig_click(**kwargs)
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg) human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() --- # --- el.dblclick() ---
@@ -1271,13 +1274,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg) info = _move_to_element(call_cfg)
if info is None: if info is None:
return _orig_dblclick(**kwargs) return _orig_dblclick(**kwargs)
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
raw_mouse.down(click_count=2) raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60)) sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2) raw_mouse.up(click_count=2)
@@ -1287,8 +1293,11 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg) info = _move_to_element(call_cfg)
if info is None: if info is None:
return _orig_hover(**kwargs) return _orig_hover(**kwargs)
@@ -1298,13 +1307,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg) info = _move_to_element(call_cfg)
if info is None: if info is None:
return _orig_type(text, **kwargs) return _orig_type(text, **kwargs)
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg) human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250)) sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session) human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
@@ -1314,13 +1326,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg) info = _move_to_element(call_cfg)
if info is None: if info is None:
return _orig_fill(value, **kwargs) return _orig_fill(value, **kwargs)
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg) human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250)) sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL) originals.keyboard_press(_SELECT_ALL)
@@ -1367,8 +1382,11 @@ def _patch_single_element_handle_sync(
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any: def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = _move_to_element() info = _move_to_element()
if info is None: if info is None:
return _orig_select_option(value, **kwargs) return _orig_select_option(value, **kwargs)
@@ -1380,8 +1398,11 @@ def _patch_single_element_handle_sync(
def _human_el_check(**kwargs: Any) -> None: def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try: try:
if el.is_checked(): if el.is_checked():
return return
@@ -1391,15 +1412,18 @@ def _patch_single_element_handle_sync(
if info is None: if info is None:
return _orig_check(**kwargs) return _orig_check(**kwargs)
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg) human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() --- # --- el.uncheck() ---
def _human_el_uncheck(**kwargs: Any) -> None: def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try: try:
if not el.is_checked(): if not el.is_checked():
return return
@@ -1409,15 +1433,18 @@ def _patch_single_element_handle_sync(
if info is None: if info is None:
return _orig_uncheck(**kwargs) return _orig_uncheck(**kwargs)
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg) human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() --- # --- el.set_checked() ---
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None: def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force) ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try: try:
current = el.is_checked() current = el.is_checked()
if current == checked: if current == checked:
@@ -1429,7 +1456,7 @@ def _patch_single_element_handle_sync(
return _orig_set_checked(checked, **kwargs) return _orig_set_checked(checked, **kwargs)
if info: if info:
if not force: if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg) human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() --- # --- el.tap() ---
@@ -2158,13 +2185,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg) info = await _move_to_element(call_cfg)
if info is None: if info is None:
return await _orig_click(**kwargs) return await _orig_click(**kwargs)
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg) await async_human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() --- # --- el.dblclick() ---
@@ -2172,13 +2202,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg) info = await _move_to_element(call_cfg)
if info is None: if info is None:
return await _orig_dblclick(**kwargs) return await _orig_dblclick(**kwargs)
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await raw_mouse.down(click_count=2) await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60)) await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2) await raw_mouse.up(click_count=2)
@@ -2188,8 +2221,11 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg) info = await _move_to_element(call_cfg)
if info is None: if info is None:
return await _orig_hover(**kwargs) return await _orig_hover(**kwargs)
@@ -2199,13 +2235,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg) info = await _move_to_element(call_cfg)
if info is None: if info is None:
return await _orig_type(text, **kwargs) return await _orig_type(text, **kwargs)
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg) await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250)) await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp() cdp = await _get_cdp()
@@ -2216,13 +2255,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config")) call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg) info = await _move_to_element(call_cfg)
if info is None: if info is None:
return await _orig_fill(value, **kwargs) return await _orig_fill(value, **kwargs)
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg) await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250)) await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL) await originals.keyboard_press(_SELECT_ALL)
@@ -2269,8 +2311,11 @@ def _patch_single_element_handle_async(
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any: async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = await _move_to_element() info = await _move_to_element()
if info is None: if info is None:
return await _orig_select_option(value, **kwargs) return await _orig_select_option(value, **kwargs)
@@ -2282,8 +2327,11 @@ def _patch_single_element_handle_async(
async def _human_el_check(**kwargs: Any) -> None: async def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try: try:
if await el.is_checked(): if await el.is_checked():
return return
@@ -2293,15 +2341,18 @@ def _patch_single_element_handle_async(
if info is None: if info is None:
return await _orig_check(**kwargs) return await _orig_check(**kwargs)
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg) await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() --- # --- el.uncheck() ---
async def _human_el_uncheck(**kwargs: Any) -> None: async def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try: try:
if not await el.is_checked(): if not await el.is_checked():
return return
@@ -2311,15 +2362,18 @@ def _patch_single_element_handle_async(
if info is None: if info is None:
return await _orig_uncheck(**kwargs) return await _orig_uncheck(**kwargs)
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg) await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() --- # --- el.set_checked() ---
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None: async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False) force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000) timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force: if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force) await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try: try:
current = await el.is_checked() current = await el.is_checked()
if current == checked: if current == checked:
@@ -2331,7 +2385,7 @@ def _patch_single_element_handle_async(
return await _orig_set_checked(checked, **kwargs) return await _orig_set_checked(checked, **kwargs)
if info: if info:
if not force: if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000)) await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg) await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() --- # --- el.tap() ---
+34 -16
View File
@@ -196,10 +196,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
const info = await moveToElement(callCfg); const info = await moveToElement(callCfg);
if (!info) return origElClick(options); if (!info) return origElClick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg); await humanClick(raw, info.isInp, callCfg);
}; };
@@ -216,10 +218,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
const info = await moveToElement(callCfg); const info = await moveToElement(callCfg);
if (!info) return origElDblclick(options); if (!info) return origElDblclick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await raw.down({ clickCount: 2 }); await raw.down({ clickCount: 2 });
await sleep(rand(30, 60)); await sleep(rand(30, 60));
await raw.up({ clickCount: 2 }); await raw.up({ clickCount: 2 });
@@ -235,7 +239,9 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force);
const info = await moveToElement(callCfg); const info = await moveToElement(callCfg);
if (!info) return origElHover(options); if (!info) return origElHover(options);
}; };
@@ -248,10 +254,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = (options as any)?.force ?? false; const force = (options as any)?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
const info = await moveToElement(callCfg); const info = await moveToElement(callCfg);
if (!info) return origElType(text, options); if (!info) return origElType(text, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg); await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null; let cdpSession: CDPSession | null = null;
@@ -267,10 +275,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
const info = await moveToElement(callCfg); const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options); if (!info) return origElFill(value, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg); await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250)); await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL); await originals.keyboardPress(SELECT_ALL);
@@ -298,7 +308,9 @@ export function patchSingleElementHandle(
}) => { }) => {
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force);
const info = await moveToElement(); const info = await moveToElement();
if (!info) return origElSelectOption(values, options); if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg); await humanClick(raw, false, cfg);
@@ -316,14 +328,16 @@ export function patchSingleElementHandle(
}) => { }) => {
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try { try {
const checked = await el.isChecked(); const checked = await el.isChecked();
if (checked) return; if (checked) return;
} catch {} } catch {}
const info = await moveToElement(); const info = await moveToElement();
if (!info) return origElCheck(options); if (!info) return origElCheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, cfg);
}; };
@@ -337,14 +351,16 @@ export function patchSingleElementHandle(
}) => { }) => {
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try { try {
const checked = await el.isChecked(); const checked = await el.isChecked();
if (!checked) return; if (!checked) return;
} catch {} } catch {}
const info = await moveToElement(); const info = await moveToElement();
if (!info) return origElUncheck(options); if (!info) return origElUncheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, cfg);
}; };
@@ -359,14 +375,16 @@ export function patchSingleElementHandle(
}) => { }) => {
const force = options?.force ?? false; const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000; const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try { try {
const current = await el.isChecked(); const current = await el.isChecked();
if (current === checked) return; if (current === checked) return;
} catch {} } catch {}
const info = await moveToElement(); const info = await moveToElement();
if (!info) return origElSetChecked(checked, options); if (!info) return origElSetChecked(checked, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000)); if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg); await humanClick(raw, info.isInp, cfg);
}; };
} }
+29 -12
View File
@@ -691,7 +691,12 @@ function patchSingleFrame(
const origFrameTap = (frame as any).tap?.bind(frame); const origFrameTap = (frame as any).tap?.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame); const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => { const moveToFrameSelector = async (
selector: string,
options: HumanActionOptions | undefined,
inputBias: boolean,
remainingMs: () => number,
) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options); const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) { if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg); await humanIdle(raw, cursor.x, cursor.y, callCfg);
@@ -699,9 +704,9 @@ function patchSingleFrame(
const locator = firstFrameLocator(frame, selector); const locator = firstFrameLocator(frame, selector);
if (typeof locator.scrollIntoViewIfNeeded === 'function') { if (typeof locator.scrollIntoViewIfNeeded === 'function') {
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined); await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
} }
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null); const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
if (!box) return null; if (!box) return null;
const isInput = inputBias || await isFrameInputElement(frame, selector); const isInput = inputBias || await isFrameInputElement(frame, selector);
@@ -713,23 +718,32 @@ function patchSingleFrame(
}; };
const frameClick = async (selector: string, options?: HumanActionOptions) => { const frameClick = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options); const timeout = options?.timeout ?? 30000;
if (!moved) return origFrameClick(selector, options); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await humanClick(raw, moved.isInput, moved.callCfg); await humanClick(raw, moved.isInput, moved.callCfg);
}; };
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null); const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
const frameHover = async (selector: string, options?: HumanActionOptions) => { const frameHover = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options, false); const timeout = options?.timeout ?? 30000;
if (!moved) return origFrameHover(selector, options); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
}; };
(frame as any).click = frameClick; (frame as any).click = frameClick;
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => { (frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options); const timeout = options?.timeout ?? 30000;
if (!moved) return origFrameDblclick(selector, options); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await raw.down({ clickCount: 2 }); await raw.down({ clickCount: 2 });
await sleep(rand(30, 60)); await sleep(rand(30, 60));
await raw.up({ clickCount: 2 }); await raw.up({ clickCount: 2 });
@@ -820,8 +834,11 @@ function patchSingleFrame(
timeout?: number; timeout?: number;
trial?: boolean; trial?: boolean;
}) => { }) => {
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null); const timeout = options?.timeout ?? 30000;
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null); const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(1, deadline - Date.now());
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null);
if (srcBox && tgtBox) { if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2; const sx = srcBox.x + srcBox.width / 2;
@@ -837,7 +854,7 @@ function patchSingleFrame(
await sleep(rand(80, 150)); await sleep(rand(80, 150));
await originals.mouseUp(); await originals.mouseUp();
} else { } else {
return origFrameDragAndDrop(source, target, options); return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
} }
}; };
} }
+80
View File
@@ -1479,3 +1479,83 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
spy.mockRestore(); spy.mockRestore();
}); });
}); });
// =========================================================================
// Issue #307: frame.click timeout should not multiply
// =========================================================================
describe("frame.click timeout budget (#307)", () => {
it("total wait time should not exceed the specified timeout", async () => {
const { patchPage } = await import("../src/human/index.js");
const TIMEOUT_MS = 500;
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
// Build a frame where the element does NOT exist:
// scrollIntoViewIfNeeded and boundingBox each wait until their
// individual timeout before failing, and origFrameClick does the same.
const frameLoc: any = {
boundingBox: vi.fn(async (opts?: { timeout?: number }) => {
await delay(opts?.timeout ?? 30000);
return null;
}),
scrollIntoViewIfNeeded: vi.fn(async (opts?: { timeout?: number }) => {
await delay(opts?.timeout ?? 30000);
throw new Error("timeout");
}),
evaluate: vi.fn(async () => ({ hit: true })),
isChecked: vi.fn(async () => false),
};
frameLoc.first = vi.fn(() => frameLoc);
const origClickFn = vi.fn(async (_sel: string, opts?: any) => {
await delay(opts?.timeout ?? 30000);
throw new Error("timeout");
});
const childFrame: any = {
click: origClickFn,
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => frameLoc),
childFrames: vi.fn(() => []),
};
const mainFrame = {
...buildMockFrame(),
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default", {
mouse_min_steps: 1,
mouse_max_steps: 1,
idle_between_actions: false,
});
const cursor = { x: 0, y: 0, initialized: true };
patchPage(page as any, cfg, cursor as any);
const start = Date.now();
try {
await (childFrame as any).click("#does-not-exist", { timeout: TIMEOUT_MS });
} catch {
// expected — element doesn't exist
}
const elapsed = Date.now() - start;
// With the bug, elapsed ≈ 3 * TIMEOUT_MS (scrollIntoView + boundingBox + origClick).
// Fixed: elapsed should be ≈ 1 * TIMEOUT_MS (shared deadline).
// Allow 1.8x as upper bound to account for test overhead but catch the 3x bug.
expect(elapsed).toBeLessThan(TIMEOUT_MS * 1.8);
});
});
+62 -2
View File
@@ -708,7 +708,7 @@ class TestBrowserBotDetection:
time.sleep(0.3) time.sleep(0.3)
page.locator('#password').fill('SecurePass!123') page.locator('#password').fill('SecurePass!123')
time.sleep(0.5) time.sleep(0.5)
page.locator('button[type="submit"]').click() page.locator('#loginForm button[type="submit"]').click()
time.sleep(5) time.sleep(5)
body = page.locator('body').text_content() body = page.locator('body').text_content()
assert '"superHumanSpeed": true' not in body assert '"superHumanSpeed": true' not in body
@@ -725,7 +725,7 @@ class TestBrowserBotDetection:
t0 = time.time() t0 = time.time()
page.locator('#email').fill('test@example.com') page.locator('#email').fill('test@example.com')
page.locator('#password').fill('MyPassword!99') page.locator('#password').fill('MyPassword!99')
page.locator('button[type="submit"]').click() page.locator('#loginForm button[type="submit"]').click()
elapsed_ms = int((time.time() - t0) * 1000) elapsed_ms = int((time.time() - t0) * 1000)
time.sleep(3) time.sleep(3)
assert elapsed_ms > 3000 assert elapsed_ms > 3000
@@ -1828,6 +1828,66 @@ class TestScrollIntoViewIfNeeded:
assert cursor.x == 200 and cursor.y == 200 assert cursor.x == 200 and cursor.y == 200
# =========================================================================
# Issue #307: frame/page click timeout should not multiply
# =========================================================================
class TestTimeoutBudget307:
"""Verify timeout budget is shared across sequential operations."""
def test_page_click_total_time_within_budget(self):
"""page.click on a missing element should not exceed ~1x the timeout."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
TIMEOUT_MS = 500
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page.click = MagicMock()
page.dblclick = MagicMock()
page.hover = MagicMock()
page.type = MagicMock()
page.fill = MagicMock()
page.goto = 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 = []
loc = MagicMock()
loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0))
loc.is_visible = MagicMock(return_value=False)
loc.first = loc
page.locator = MagicMock(return_value=loc)
h.patch_page(page, cfg, cursor)
start = time.monotonic()
try:
page.click("#does-not-exist", timeout=TIMEOUT_MS)
except Exception:
pass
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < TIMEOUT_MS * 1.8, (
f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms"
)
# ========================================================================= # =========================================================================
# Direct runner (backwards compat) # Direct runner (backwards compat)
# ========================================================================= # =========================================================================