mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: per-call human_config, timeout forwarding, humanized scrollIntoViewIfNeeded (#183)
This commit is contained in:
+304
-95
@@ -18,23 +18,23 @@ import logging
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
from .config import HumanConfig, HumanPreset, resolve_config
|
||||
from .config import HumanConfig, HumanPreset, resolve_config, merge_config
|
||||
from .config import rand, rand_range, sleep_ms, async_sleep_ms
|
||||
from .mouse import RawMouse, human_move, human_click, click_target, human_idle
|
||||
from .keyboard import RawKeyboard, human_type
|
||||
from .scroll import scroll_to_element
|
||||
from .scroll import scroll_to_element, human_scroll_into_view
|
||||
from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
|
||||
from .keyboard_async import AsyncRawKeyboard, async_human_type
|
||||
from .scroll_async import async_scroll_to_element
|
||||
from .scroll_async import async_scroll_to_element, async_human_scroll_into_view
|
||||
|
||||
_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"
|
||||
|
||||
__all__ = [
|
||||
"patch_browser", "patch_context", "patch_page",
|
||||
"patch_browser_async", "patch_context_async", "patch_page_async",
|
||||
"HumanConfig", "resolve_config",
|
||||
"HumanConfig", "resolve_config", "merge_config",
|
||||
"human_move", "human_click", "click_target", "human_idle",
|
||||
"human_type", "scroll_to_element",
|
||||
"human_type", "scroll_to_element", "human_scroll_into_view",
|
||||
]
|
||||
|
||||
logger = logging.getLogger("cloakbrowser.human")
|
||||
@@ -356,6 +356,7 @@ def _patch_locator_class_sync():
|
||||
_orig_tap = Locator.tap
|
||||
_orig_drag_to = Locator.drag_to
|
||||
_orig_clear = Locator.clear
|
||||
_orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None)
|
||||
|
||||
def _get_selector(self):
|
||||
return self._impl_obj._selector
|
||||
@@ -366,36 +367,76 @@ def _patch_locator_class_sync():
|
||||
def _get_cfg(self):
|
||||
return getattr(self.page, '_human_cfg', None)
|
||||
|
||||
# Forward only options the page-level humanized methods understand
|
||||
# (timeout, human_config). Other Locator-specific kwargs (force, trial,
|
||||
# noWaitAfter, ...) are silently dropped — the humanized path doesn't
|
||||
# consult them.
|
||||
def _forward_kwargs(kwargs):
|
||||
out = {}
|
||||
if "timeout" in kwargs:
|
||||
out["timeout"] = kwargs["timeout"]
|
||||
if "human_config" in kwargs:
|
||||
out["human_config"] = kwargs["human_config"]
|
||||
return out
|
||||
|
||||
def _humanized_fill(self, value, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.fill(_get_selector(self), value)
|
||||
self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_fill(self, value, **kwargs)
|
||||
|
||||
def _humanized_click(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.click(_get_selector(self))
|
||||
self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_click(self, **kwargs)
|
||||
|
||||
def _humanized_type(self, text, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.type(_get_selector(self), text)
|
||||
self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_type(self, text, **kwargs)
|
||||
|
||||
def _humanized_dblclick(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.dblclick(_get_selector(self))
|
||||
self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_dblclick(self, **kwargs)
|
||||
|
||||
def _humanized_hover(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
self.page.hover(_get_selector(self))
|
||||
self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
_orig_hover(self, **kwargs)
|
||||
|
||||
def _humanized_scroll_into_view_if_needed(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
page = self.page
|
||||
cfg = _get_cfg(self)
|
||||
cursor = getattr(page, '_human_cursor', None)
|
||||
raw = getattr(page, '_human_raw_mouse', None)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
|
||||
if call_cfg is None or cursor is None or raw is None:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
return _orig_scroll_into_view(self, **native_kwargs)
|
||||
return
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
try:
|
||||
_, nx, ny = human_scroll_into_view(
|
||||
page, raw,
|
||||
lambda: self.bounding_box(timeout=timeout),
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
_orig_scroll_into_view(self, **native_kwargs)
|
||||
elif _orig_scroll_into_view is not None:
|
||||
_orig_scroll_into_view(self, **kwargs)
|
||||
|
||||
def _humanized_check(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
cfg = _get_cfg(self)
|
||||
@@ -512,6 +553,8 @@ def _patch_locator_class_sync():
|
||||
Locator.tap = _humanized_tap
|
||||
Locator.drag_to = _humanized_drag_to
|
||||
Locator.clear = _humanized_clear
|
||||
if _orig_scroll_into_view is not None:
|
||||
Locator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -544,6 +587,7 @@ def _patch_locator_class_async():
|
||||
_orig_tap = AsyncLocator.tap
|
||||
_orig_drag_to = AsyncLocator.drag_to
|
||||
_orig_clear = AsyncLocator.clear
|
||||
_orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None)
|
||||
|
||||
def _get_selector(self):
|
||||
return self._impl_obj._selector
|
||||
@@ -554,36 +598,74 @@ def _patch_locator_class_async():
|
||||
def _get_cfg(self):
|
||||
return getattr(self.page, '_human_cfg', None)
|
||||
|
||||
def _forward_kwargs(kwargs):
|
||||
out = {}
|
||||
if "timeout" in kwargs:
|
||||
out["timeout"] = kwargs["timeout"]
|
||||
if "human_config" in kwargs:
|
||||
out["human_config"] = kwargs["human_config"]
|
||||
return out
|
||||
|
||||
async def _humanized_fill(self, value, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.fill(_get_selector(self), value)
|
||||
await self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_fill(self, value, **kwargs)
|
||||
|
||||
async def _humanized_click(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.click(_get_selector(self))
|
||||
await self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_click(self, **kwargs)
|
||||
|
||||
async def _humanized_type(self, text, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.type(_get_selector(self), text)
|
||||
await self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_type(self, text, **kwargs)
|
||||
|
||||
async def _humanized_dblclick(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.dblclick(_get_selector(self))
|
||||
await self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_dblclick(self, **kwargs)
|
||||
|
||||
async def _humanized_hover(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
await self.page.hover(_get_selector(self))
|
||||
await self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
|
||||
else:
|
||||
await _orig_hover(self, **kwargs)
|
||||
|
||||
async def _humanized_scroll_into_view_if_needed(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
page = self.page
|
||||
cfg = _get_cfg(self)
|
||||
cursor = getattr(page, '_human_cursor', None)
|
||||
raw = getattr(page, '_human_raw_mouse', None)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
|
||||
if call_cfg is None or cursor is None or raw is None:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
await _orig_scroll_into_view(self, **native_kwargs)
|
||||
return
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
|
||||
async def _get_box():
|
||||
return await self.bounding_box(timeout=timeout)
|
||||
try:
|
||||
_, nx, ny = await async_human_scroll_into_view(
|
||||
page, raw, _get_box,
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
await _orig_scroll_into_view(self, **native_kwargs)
|
||||
elif _orig_scroll_into_view is not None:
|
||||
await _orig_scroll_into_view(self, **kwargs)
|
||||
|
||||
async def _humanized_check(self, **kwargs):
|
||||
if _is_humanized(self):
|
||||
cfg = _get_cfg(self)
|
||||
@@ -708,6 +790,8 @@ def _patch_locator_class_async():
|
||||
AsyncLocator.tap = _humanized_tap
|
||||
AsyncLocator.drag_to = _humanized_drag_to
|
||||
AsyncLocator.clear = _humanized_clear
|
||||
if _orig_scroll_into_view is not None:
|
||||
AsyncLocator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -738,6 +822,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
page._human_cursor = cursor
|
||||
|
||||
# --- Stealth infrastructure ---
|
||||
try:
|
||||
@@ -764,6 +849,8 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
"insert_text": originals.keyboard_insert_text,
|
||||
})()
|
||||
|
||||
page._human_raw_mouse = raw_mouse
|
||||
|
||||
def _ensure_cursor_init() -> None:
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
@@ -780,32 +867,36 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
def _human_click(selector: str, **kwargs: Any) -> None:
|
||||
_ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = _is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
human_click(raw_mouse, is_input, cfg)
|
||||
human_click(raw_mouse, is_input, call_cfg)
|
||||
|
||||
def _human_dblclick(selector: str, **kwargs: Any) -> None:
|
||||
_ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = _is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
raw_mouse.down(click_count=2)
|
||||
@@ -814,33 +905,39 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
def _human_hover(selector: str, **kwargs: Any) -> None:
|
||||
_ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
target = click_target(box, False, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, False, call_cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
|
||||
def _human_type(selector: str, text: str, **kwargs: Any) -> None:
|
||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
_human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
# Forward kwargs so timeout / human_config also propagate to the click
|
||||
# that focuses the field.
|
||||
_human_click(selector, **kwargs)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||
sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
_human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
_human_click(selector, **kwargs)
|
||||
sleep_ms(rand(100, 250))
|
||||
originals.keyboard_press(_SELECT_ALL)
|
||||
sleep_ms(rand(30, 80))
|
||||
originals.keyboard_press("Backspace")
|
||||
sleep_ms(rand(50, 150))
|
||||
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
def _human_check(selector: str, **kwargs: Any) -> None:
|
||||
try:
|
||||
@@ -958,6 +1055,7 @@ def _patch_single_element_handle_sync(
|
||||
_orig_set_checked = getattr(el, 'set_checked', None)
|
||||
_orig_tap = el.tap
|
||||
_orig_focus = el.focus
|
||||
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
|
||||
|
||||
# Nested selectors
|
||||
_orig_qs = el.query_selector
|
||||
@@ -992,39 +1090,57 @@ def _patch_single_element_handle_sync(
|
||||
el.query_selector_all = _patched_qsa
|
||||
el.wait_for_selector = _patched_wfs
|
||||
|
||||
# Helper: move cursor to element
|
||||
def _move_to_element():
|
||||
# Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call
|
||||
# ``human_config`` overrides on type/fill carry through to mouse timing.
|
||||
# Also scrolls into view first so off-screen elements don't silently fall
|
||||
# back to the unpatched native method (#129, #172 follow-up).
|
||||
def _move_to_element(call_cfg: HumanConfig = cfg):
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
|
||||
# Scroll into view first — best-effort. If the element can't be located
|
||||
# we fall through to bounding_box() below which returns None and lets
|
||||
# the caller fall back to the original Playwright method.
|
||||
try:
|
||||
_, nx, ny = human_scroll_into_view(
|
||||
page, raw_mouse, lambda: el.bounding_box(),
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
box = el.bounding_box()
|
||||
if not box:
|
||||
return None
|
||||
|
||||
is_inp = _is_input_element_handle_sync(el)
|
||||
target = click_target(box, is_inp, cfg)
|
||||
target = click_target(box, is_inp, call_cfg)
|
||||
|
||||
if cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
if call_cfg.idle_between_actions:
|
||||
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
return {'box': box, 'is_inp': is_inp}
|
||||
|
||||
# --- el.click() ---
|
||||
def _human_el_click(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_click(**kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
def _human_el_dblclick(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_dblclick(**kwargs)
|
||||
raw_mouse.down(click_count=2)
|
||||
@@ -1033,32 +1149,62 @@ def _patch_single_element_handle_sync(
|
||||
|
||||
# --- el.hover() ---
|
||||
def _human_el_hover(**kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_hover(**kwargs)
|
||||
# Just move, no click
|
||||
|
||||
# --- el.type() ---
|
||||
def _human_el_type(text: str, **kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_type(text, **kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
# --- el.fill() ---
|
||||
def _human_el_fill(value: str, **kwargs: Any) -> None:
|
||||
info = _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return _orig_fill(value, **kwargs)
|
||||
human_click(raw_mouse, info['is_inp'], cfg)
|
||||
human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
sleep_ms(rand(100, 250))
|
||||
originals.keyboard_press(_SELECT_ALL)
|
||||
sleep_ms(rand(30, 80))
|
||||
originals.keyboard_press("Backspace")
|
||||
sleep_ms(rand(50, 150))
|
||||
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
|
||||
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
|
||||
|
||||
# --- el.scroll_into_view_if_needed() ---
|
||||
# Playwright's native version snaps the page — a strong bot signal.
|
||||
# Replace with the same accelerate → cruise → decelerate → overshoot wheel
|
||||
# sequence used by page.click(). Falls back to the native method if the
|
||||
# element is detached or scrolling fails.
|
||||
def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
try:
|
||||
originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_, nx, ny = human_scroll_into_view(
|
||||
page, raw_mouse, lambda: el.bounding_box(),
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
_orig_scroll_into_view(**native_kwargs)
|
||||
|
||||
# --- el.press() ---
|
||||
def _human_el_press(key: str, **kwargs: Any) -> None:
|
||||
@@ -1142,6 +1288,8 @@ def _patch_single_element_handle_sync(
|
||||
el.set_checked = _human_el_set_checked
|
||||
el.tap = _human_el_tap
|
||||
el.focus = _human_el_focus
|
||||
if _orig_scroll_into_view is not None:
|
||||
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
|
||||
|
||||
|
||||
def _patch_page_element_handles_sync(
|
||||
@@ -1425,6 +1573,7 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
page._original = originals
|
||||
page._human_cfg = cfg
|
||||
page._human_cursor = cursor
|
||||
|
||||
# --- Stealth infrastructure (lazy-initialized, async) ---
|
||||
stealth = _AsyncIsolatedWorld(page)
|
||||
@@ -1454,6 +1603,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
"insert_text": originals.keyboard_insert_text,
|
||||
})()
|
||||
|
||||
page._human_raw_mouse = raw_mouse
|
||||
|
||||
async def _ensure_cursor_init() -> None:
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
@@ -1469,32 +1620,36 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
async def _human_click(selector: str, **kwargs: Any) -> None:
|
||||
await _ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = await async_scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = await _async_is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
await async_human_click(raw_mouse, is_input, cfg)
|
||||
await async_human_click(raw_mouse, is_input, call_cfg)
|
||||
|
||||
async def _human_dblclick(selector: str, **kwargs: Any) -> None:
|
||||
await _ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = await async_scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
is_input = await _async_is_input_element(page, selector)
|
||||
target = click_target(box, is_input, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, is_input, call_cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
await raw_mouse.down(click_count=2)
|
||||
@@ -1503,35 +1658,39 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
|
||||
|
||||
async def _human_hover(selector: str, **kwargs: Any) -> None:
|
||||
await _ensure_cursor_init()
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
timeout = kwargs.get("timeout", 2000)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
box, cx, cy = await async_scroll_to_element(
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, cfg
|
||||
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
|
||||
)
|
||||
cursor.x = cx
|
||||
cursor.y = cy
|
||||
target = click_target(box, False, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
target = click_target(box, False, call_cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
|
||||
async def _human_type(selector: str, text: str, **kwargs: Any) -> None:
|
||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
await _human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
await _human_click(selector, **kwargs)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
|
||||
|
||||
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
|
||||
await async_sleep_ms(rand_range(cfg.field_switch_delay))
|
||||
await _human_click(selector)
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
|
||||
await _human_click(selector, **kwargs)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await originals.keyboard_press(_SELECT_ALL)
|
||||
await async_sleep_ms(rand(30, 80))
|
||||
await originals.keyboard_press("Backspace")
|
||||
await async_sleep_ms(rand(50, 150))
|
||||
cdp = await _ensure_cdp()
|
||||
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
|
||||
|
||||
async def _human_check(selector: str, **kwargs: Any) -> None:
|
||||
try:
|
||||
@@ -1637,6 +1796,7 @@ def _patch_single_element_handle_async(
|
||||
_orig_set_checked = getattr(el, 'set_checked', None)
|
||||
_orig_tap = el.tap
|
||||
_orig_focus = el.focus
|
||||
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
|
||||
|
||||
# Nested selectors
|
||||
_orig_qs = el.query_selector
|
||||
@@ -1671,25 +1831,41 @@ def _patch_single_element_handle_async(
|
||||
el.query_selector_all = _patched_qsa
|
||||
el.wait_for_selector = _patched_wfs
|
||||
|
||||
# Helper: move cursor to element (async)
|
||||
async def _move_to_element():
|
||||
# Helper: move cursor to element (async). Accepts optional ``call_cfg`` so
|
||||
# per-call ``human_config`` overrides on type/fill carry through to mouse
|
||||
# timing. Also scrolls into view first so off-screen elements work
|
||||
# (#129, #172 follow-up).
|
||||
async def _move_to_element(call_cfg: HumanConfig = cfg):
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
await originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
|
||||
# Scroll into view first — best-effort.
|
||||
async def _get_box():
|
||||
return await el.bounding_box()
|
||||
try:
|
||||
_, nx, ny = await async_human_scroll_into_view(
|
||||
page, raw_mouse, _get_box,
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
box = await el.bounding_box()
|
||||
if not box:
|
||||
return None
|
||||
|
||||
is_inp = await _async_is_input_element_handle(el)
|
||||
target = click_target(box, is_inp, cfg)
|
||||
target = click_target(box, is_inp, call_cfg)
|
||||
|
||||
if cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
|
||||
if call_cfg.idle_between_actions:
|
||||
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
|
||||
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
|
||||
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
|
||||
cursor.x = target.x
|
||||
cursor.y = target.y
|
||||
return {'box': box, 'is_inp': is_inp}
|
||||
@@ -1704,14 +1880,16 @@ def _patch_single_element_handle_async(
|
||||
|
||||
# --- el.click() ---
|
||||
async def _human_el_click(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_click(**kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
|
||||
# --- el.dblclick() ---
|
||||
async def _human_el_dblclick(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_dblclick(**kwargs)
|
||||
await raw_mouse.down(click_count=2)
|
||||
@@ -1720,33 +1898,62 @@ def _patch_single_element_handle_async(
|
||||
|
||||
# --- el.hover() ---
|
||||
async def _human_el_hover(**kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_hover(**kwargs)
|
||||
|
||||
# --- el.type() ---
|
||||
async def _human_el_type(text: str, **kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_type(text, **kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
cdp = await _get_cdp()
|
||||
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
|
||||
|
||||
# --- el.fill() ---
|
||||
async def _human_el_fill(value: str, **kwargs: Any) -> None:
|
||||
info = await _move_to_element()
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
info = await _move_to_element(call_cfg)
|
||||
if info is None:
|
||||
return await _orig_fill(value, **kwargs)
|
||||
await async_human_click(raw_mouse, info['is_inp'], cfg)
|
||||
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
|
||||
await async_sleep_ms(rand(100, 250))
|
||||
await originals.keyboard_press(_SELECT_ALL)
|
||||
await async_sleep_ms(rand(30, 80))
|
||||
await originals.keyboard_press("Backspace")
|
||||
await async_sleep_ms(rand(50, 150))
|
||||
cdp = await _get_cdp()
|
||||
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
|
||||
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
|
||||
|
||||
# --- el.scroll_into_view_if_needed() ---
|
||||
async def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
|
||||
call_cfg = merge_config(cfg, kwargs.get("human_config"))
|
||||
if not cursor.initialized:
|
||||
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
|
||||
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
|
||||
try:
|
||||
await originals.mouse_move(cursor.x, cursor.y)
|
||||
cursor.initialized = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _get_box():
|
||||
return await el.bounding_box()
|
||||
try:
|
||||
_, nx, ny = await async_human_scroll_into_view(
|
||||
page, raw_mouse, _get_box,
|
||||
cursor.x, cursor.y, call_cfg,
|
||||
)
|
||||
cursor.x = nx
|
||||
cursor.y = ny
|
||||
except Exception:
|
||||
if _orig_scroll_into_view is not None:
|
||||
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
|
||||
await _orig_scroll_into_view(**native_kwargs)
|
||||
|
||||
# --- el.press() ---
|
||||
async def _human_el_press(key: str, **kwargs: Any) -> None:
|
||||
@@ -1830,6 +2037,8 @@ def _patch_single_element_handle_async(
|
||||
el.set_checked = _human_el_set_checked
|
||||
el.tap = _human_el_tap
|
||||
el.focus = _human_el_focus
|
||||
if _orig_scroll_into_view is not None:
|
||||
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
|
||||
|
||||
|
||||
def _patch_page_element_handles_async(
|
||||
|
||||
@@ -201,6 +201,25 @@ def resolve_config(
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
def merge_config(base: HumanConfig, overrides: dict | None) -> HumanConfig:
|
||||
"""Merge ``overrides`` (a dict of HumanConfig field names → values) on top of
|
||||
``base``. Returns a new HumanConfig — ``base`` is never mutated.
|
||||
|
||||
Used by per-call overrides like ``page.type(sel, text, human_config={...})``
|
||||
so the same page can use different timings for different inputs without
|
||||
re-patching.
|
||||
|
||||
Unknown keys are ignored silently to keep this forgiving for callers.
|
||||
"""
|
||||
if not overrides:
|
||||
return base
|
||||
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
|
||||
for k, v in overrides.items():
|
||||
if k in base.__dataclass_fields__:
|
||||
merged[k] = v
|
||||
return HumanConfig(**merged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
|
||||
from .mouse import RawMouse, human_move
|
||||
@@ -18,10 +18,15 @@ def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> boo
|
||||
return top_edge >= zone_top and bottom_edge <= zone_bottom
|
||||
|
||||
|
||||
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
|
||||
def _get_element_box(page: Any, selector: str, timeout: float = 2000) -> Optional[dict]:
|
||||
"""Locate ``selector`` and return its bounding box.
|
||||
|
||||
The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)``
|
||||
so callers can extend it for slow-loading elements (#172).
|
||||
"""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return el.bounding_box(timeout=2000)
|
||||
return el.bounding_box(timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -39,13 +44,21 @@ def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
|
||||
sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
def human_scroll_into_view(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
selector: str,
|
||||
get_box: Callable[[], Optional[dict]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
||||
instead of a CSS selector.
|
||||
|
||||
Used both by ``scroll_to_element`` (selector-based) and by
|
||||
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
|
||||
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
||||
behavior runs everywhere.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
raise RuntimeError("Viewport size not available")
|
||||
@@ -53,12 +66,12 @@ def scroll_to_element(
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box is None:
|
||||
sleep_ms(200)
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element not found: {selector}")
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
@@ -105,7 +118,7 @@ def scroll_to_element(
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
@@ -125,8 +138,29 @@ def scroll_to_element(
|
||||
# Settle
|
||||
sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = _get_element_box(page, selector)
|
||||
box = get_box()
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
page: Any,
|
||||
raw: RawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 2000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Selector-based humanized scroll.
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default stays at 2000ms when not specified.
|
||||
"""
|
||||
return human_scroll_into_view(
|
||||
page, raw,
|
||||
lambda: _get_element_box(page, selector, timeout),
|
||||
cursor_x, cursor_y, cfg,
|
||||
)
|
||||
|
||||
@@ -8,17 +8,22 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, Optional, Tuple
|
||||
|
||||
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
|
||||
from .mouse_async import AsyncRawMouse, async_human_move
|
||||
from .scroll import _is_in_viewport
|
||||
|
||||
|
||||
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
|
||||
async def _get_element_box_async(
|
||||
page: Any, selector: str, timeout: float = 2000,
|
||||
) -> Optional[dict]:
|
||||
"""Async variant. ``timeout`` is forwarded to Playwright's
|
||||
``boundingBox(timeout=...)`` so callers can extend it for slow-loading
|
||||
elements (#172)."""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return await el.bounding_box(timeout=2000)
|
||||
return await el.bounding_box(timeout=timeout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -36,13 +41,20 @@ async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig)
|
||||
await async_sleep_ms(rand(8, 20))
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
async def async_human_scroll_into_view(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
selector: str,
|
||||
get_box: Callable[[], Awaitable[Optional[dict]]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
||||
|
||||
Used by both ``async_scroll_to_element`` (selector-based) and the
|
||||
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
||||
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
||||
\u2192 overshoot behavior.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
raise RuntimeError("Viewport size not available")
|
||||
@@ -50,12 +62,12 @@ async def async_scroll_to_element(
|
||||
viewport_height = viewport["height"]
|
||||
viewport_width = viewport["width"]
|
||||
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
if box is None:
|
||||
await async_sleep_ms(200)
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element not found: {selector}")
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
@@ -102,7 +114,7 @@ async def async_scroll_to_element(
|
||||
|
||||
# Check visibility every 3 steps
|
||||
if i % 3 == 2 or i == total_clicks - 1:
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
if box and _is_in_viewport(box, viewport_height, cfg):
|
||||
break
|
||||
if scrolled >= abs_distance * 1.1:
|
||||
@@ -122,8 +134,29 @@ async def async_scroll_to_element(
|
||||
# Settle
|
||||
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
|
||||
|
||||
box = await _get_element_box_async(page, selector)
|
||||
box = await get_box()
|
||||
if box is None:
|
||||
raise RuntimeError(f"Element lost after scrolling: {selector}")
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
page: Any,
|
||||
raw: AsyncRawMouse,
|
||||
selector: str,
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 2000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
"""Selector-based humanized scroll (async).
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default stays at 2000ms when not specified.
|
||||
"""
|
||||
async def _get():
|
||||
return await _get_element_box_async(page, selector, timeout)
|
||||
return await async_human_scroll_into_view(
|
||||
page, raw, _get, cursor_x, cursor_y, cfg,
|
||||
)
|
||||
|
||||
@@ -48,16 +48,16 @@
|
||||
|
||||
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
|
||||
import type { HumanConfig } from '../human/config.js';
|
||||
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
|
||||
import { resolveConfig, mergeConfig, rand, randRange, sleep } from '../human/config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement, smoothWheel } from './scroll.js';
|
||||
import { scrollToElement, humanScrollIntoView, smoothWheel } from './scroll.js';
|
||||
|
||||
export type { HumanConfig } from '../human/config.js';
|
||||
export { resolveConfig } from '../human/config.js';
|
||||
export { resolveConfig, mergeConfig } from '../human/config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
export { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
@@ -329,52 +329,55 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// ==== click (with clickCount support for dblclick) ====
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await humanClick(raw, isInput, callCfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await humanClick(raw, isInput, callCfg);
|
||||
}
|
||||
};
|
||||
|
||||
// ==== hover ====
|
||||
const humanHoverFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, false, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
};
|
||||
|
||||
// ==== type ====
|
||||
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// ==== select ====
|
||||
@@ -577,6 +580,9 @@ function patchSingleElementHandle(
|
||||
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
|
||||
const origElSelect = (el as any).select?.bind(el);
|
||||
const origElDrop = (el as any).drop?.bind(el);
|
||||
// Puppeteer v22+ adds ElementHandle.scrollIntoView(); earlier versions
|
||||
// expose it implicitly via evaluate(node => node.scrollIntoView()).
|
||||
const origElScrollIntoView = (el as any).scrollIntoView?.bind(el);
|
||||
|
||||
// --- Nested selectors ---
|
||||
const origEl$ = el.$.bind(el);
|
||||
@@ -603,20 +609,34 @@ function patchSingleElementHandle(
|
||||
return child;
|
||||
};
|
||||
|
||||
// --- Helper: get box and move cursor ---
|
||||
const moveToElement = async () => {
|
||||
// --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
|
||||
// so type/fill overrides like ``el.type(text, { human_config: {...} })``
|
||||
// carry through to mouse timing for that single call. Also scrolls into
|
||||
// view first so off-screen elements work (#129, #137 follow-up).
|
||||
const moveToElement = async (callCfg: HumanConfig = cfg) => {
|
||||
await (page as any)._ensureCursorInit();
|
||||
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox().then(b => b ?? null),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch { /* let boundingBox() decide */ }
|
||||
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
const target = clickTarget(box, isInp, callCfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
@@ -624,18 +644,19 @@ function patchSingleElementHandle(
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
|
||||
const clickCount = options?.clickCount ?? options?.count ?? 1;
|
||||
if (clickCount >= 2) {
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(40, 90));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
} else {
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -647,14 +668,39 @@ function patchSingleElementHandle(
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await stealth.getCdpSession().catch(() => null);
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- el.scrollIntoView() ---
|
||||
// Puppeteer-only equivalent of Playwright's scrollIntoViewIfNeeded.
|
||||
// Replaces the native snap-scroll (a strong bot signal) with the same
|
||||
// accelerate → cruise → decelerate → overshoot wheel sequence used by
|
||||
// page.click(). Only patched when the underlying ElementHandle exposes
|
||||
// ``scrollIntoView`` (Puppeteer v22+).
|
||||
if (origElScrollIntoView) {
|
||||
(el as any).scrollIntoView = async (options?: any) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
await (page as any)._ensureCursorInit();
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox().then(b => b ?? null),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch {
|
||||
return origElScrollIntoView(options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- el.press() ---
|
||||
if (origElPress) {
|
||||
(el as any).press = async (key: string, options?: any) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Changes from Playwright version:
|
||||
* - page.viewport() instead of page.viewportSize()
|
||||
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
|
||||
* - No timeout parameter on boundingBox()
|
||||
* - boundingBox() has no timeout param — we poll page.$() up to ``timeout`` ms
|
||||
*/
|
||||
|
||||
import type { Page } from 'puppeteer-core';
|
||||
@@ -55,22 +55,40 @@ export async function smoothWheel(
|
||||
}
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (!el) return null;
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
} catch {
|
||||
return null;
|
||||
/**
|
||||
* Poll ``page.$(selector)`` for up to ``timeout`` ms, returning the element's
|
||||
* bounding box when found. ``timeout`` defaults to 2000ms when not specified.
|
||||
*/
|
||||
async function getElementBox(
|
||||
page: Page,
|
||||
selector: string,
|
||||
timeout: number = 2000,
|
||||
): Promise<ElementBounds | null> {
|
||||
const start = Date.now();
|
||||
const pollInterval = 100;
|
||||
while (true) {
|
||||
try {
|
||||
const el = await page.$(selector);
|
||||
if (el) {
|
||||
const box = await el.boundingBox();
|
||||
if (box) return { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
}
|
||||
} catch { /* keep polling */ }
|
||||
|
||||
if (Date.now() - start >= timeout) return null;
|
||||
await sleep(pollInterval);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
/**
|
||||
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
|
||||
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
|
||||
* ``scrollIntoView`` patch.
|
||||
*/
|
||||
export async function humanScrollIntoView(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
getBox: () => Promise<ElementBounds | null>,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
@@ -78,11 +96,11 @@ export async function scrollToElement(
|
||||
const viewport = page.viewport();
|
||||
if (!viewport) throw new Error('Viewport size not available');
|
||||
|
||||
let box = await getElementBox(page, selector);
|
||||
let box = await getBox();
|
||||
if (!box) {
|
||||
await sleep(200);
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element not found: ${selector}`);
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
}
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
@@ -134,7 +152,7 @@ export async function scrollToElement(
|
||||
await sleep(pause);
|
||||
|
||||
if (i % 3 === 2 || i === totalClicks - 1) {
|
||||
box = await getElementBox(page, selector);
|
||||
box = await getBox();
|
||||
if (box && isInViewport(box, viewport.height, cfg)) {
|
||||
break;
|
||||
}
|
||||
@@ -159,8 +177,31 @@ export async function scrollToElement(
|
||||
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element lost after scrolling into view');
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector-based humanized scroll (Puppeteer).
|
||||
*
|
||||
* ``timeout`` controls how long we poll ``page.$(selector)`` before giving up,
|
||||
* so callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||
* slow-loading elements (#137). Default stays 2000ms when not specified.
|
||||
*/
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
timeout?: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
return humanScrollIntoView(
|
||||
page, raw,
|
||||
() => getElementBox(page, selector, timeout),
|
||||
cursorX, cursorY, cfg,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,6 +201,22 @@ export function resolveConfig(
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a partial overrides object on top of an existing HumanConfig.
|
||||
* Returns a new object — the original ``cfg`` is never mutated.
|
||||
*
|
||||
* Used by per-call overrides such as ``page.type(sel, text, { human_config: { typing_delay: 30 } })``
|
||||
* so the same patched page can type different fields at different speeds
|
||||
* without re-patching.
|
||||
*/
|
||||
export function mergeConfig(
|
||||
cfg: HumanConfig,
|
||||
overrides?: Partial<HumanConfig> | null,
|
||||
): HumanConfig {
|
||||
if (!overrides) return cfg;
|
||||
return { ...cfg, ...overrides };
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: random number in range
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
|
||||
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
||||
import type { HumanConfig } from './config.js';
|
||||
import { rand, randRange, sleep } from './config.js';
|
||||
import { rand, randRange, sleep, mergeConfig } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { humanScrollIntoView } from './scroll.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
@@ -102,6 +103,7 @@ export function patchSingleElementHandle(
|
||||
const origElSetChecked = (el as any).setChecked?.bind(el);
|
||||
const origElTap = el.tap.bind(el);
|
||||
const origElFocus = el.focus.bind(el);
|
||||
const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el);
|
||||
|
||||
// Nested selectors
|
||||
const origEl$ = el.$.bind(el);
|
||||
@@ -130,22 +132,42 @@ export function patchSingleElementHandle(
|
||||
};
|
||||
|
||||
// --- Helper: get bounding box and move cursor to element ---
|
||||
const moveToElement = async () => {
|
||||
// Accepts a per-call ``callCfg`` so type/fill overrides like
|
||||
// ``el.type(text, { human_config: { typing_delay: 30 } })`` carry through to
|
||||
// mouse movement & idle timing for that single call.
|
||||
// Also scrolls the element into view first so off-screen elements work
|
||||
// (#129, #137 follow-up): otherwise boundingBox() returns null and we'd
|
||||
// silently fall back to the unpatched native method.
|
||||
const moveToElement = async (callCfg: HumanConfig = cfg) => {
|
||||
// Ensure cursor is initialized
|
||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||
if (ensureCursorInit) await ensureCursorInit();
|
||||
|
||||
// Scroll into view first so boundingBox() returns coordinates even when
|
||||
// the element starts below the fold. Best-effort — if humanScrollIntoView
|
||||
// throws (e.g. detached element), we let boundingBox() decide whether to
|
||||
// proceed or fall back to the original method.
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox(),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch { /* let boundingBox() decide */ }
|
||||
|
||||
const box = await el.boundingBox();
|
||||
if (!box) return null;
|
||||
|
||||
const isInp = await isInputElementHandle(stealth, el);
|
||||
const target = clickTarget(box, isInp, cfg);
|
||||
const target = clickTarget(box, isInp, callCfg);
|
||||
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
return { box, isInp };
|
||||
@@ -153,14 +175,16 @@ export function patchSingleElementHandle(
|
||||
|
||||
// --- el.click() ---
|
||||
(el as any).click = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
};
|
||||
|
||||
// --- el.dblclick() ---
|
||||
(el as any).dblclick = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElDblclick(options);
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
@@ -169,27 +193,30 @@ export function patchSingleElementHandle(
|
||||
|
||||
// --- el.hover() ---
|
||||
(el as any).hover = async (options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElHover(options);
|
||||
// Just move — no click
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
(el as any).type = async (text: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, text, cfg, cdpSession);
|
||||
await humanType(page, rawKb, text, callCfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.fill() ---
|
||||
(el as any).fill = async (value: string, options?: any) => {
|
||||
const info = await moveToElement();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElFill(value, options);
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
// Clear existing content
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -198,7 +225,7 @@ export function patchSingleElementHandle(
|
||||
await sleep(rand(50, 150));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
try { cdpSession = await stealth?.getCdpSession(); } catch {}
|
||||
await humanType(page, rawKb, value, cfg, cdpSession);
|
||||
await humanType(page, rawKb, value, callCfg, cdpSession);
|
||||
};
|
||||
|
||||
// --- el.press() ---
|
||||
@@ -268,6 +295,30 @@ export function patchSingleElementHandle(
|
||||
await moveToElement(); // human-like Bézier cursor movement
|
||||
await origElFocus(); // programmatic focus, no click
|
||||
};
|
||||
|
||||
// --- el.scrollIntoViewIfNeeded() ---
|
||||
// Playwright's native version snaps the page — a strong bot signal.
|
||||
// Replace with the same accelerate → cruise → decelerate → overshoot
|
||||
// wheel sequence used by page.click() etc. Falls back to the native
|
||||
// method if the element is detached or scrolling fails.
|
||||
if (origElScrollIntoViewIfNeeded) {
|
||||
(el as any).scrollIntoViewIfNeeded = async (options?: any) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||
if (ensureCursorInit) await ensureCursorInit();
|
||||
try {
|
||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||
page, raw,
|
||||
() => el.boundingBox(),
|
||||
cursor.x, cursor.y, callCfg,
|
||||
);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
} catch {
|
||||
return origElScrollIntoViewIfNeeded(options);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+34
-28
@@ -23,16 +23,16 @@
|
||||
*/
|
||||
|
||||
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
||||
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
|
||||
import { HumanConfig, resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement } from './scroll.js';
|
||||
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
export { HumanConfig, resolveConfig } from './config.js';
|
||||
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
export { humanType } from './keyboard.js';
|
||||
export { scrollToElement } from './scroll.js';
|
||||
export { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
export { patchSingleElementHandle } from './elementhandle.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
|
||||
@@ -305,32 +305,34 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- click ---
|
||||
const humanClickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await humanClick(raw, isInput, cfg);
|
||||
await humanClick(raw, isInput, callCfg);
|
||||
};
|
||||
|
||||
// --- dblclick ---
|
||||
const humanDblclickFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
await raw.down({ clickCount: 2 });
|
||||
@@ -341,38 +343,41 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- hover ---
|
||||
const humanHoverFn = async (selector: string, options?: any) => {
|
||||
await ensureCursorInit();
|
||||
if (cfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, cfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
|
||||
const target = clickTarget(box, false, callCfg);
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
};
|
||||
|
||||
// --- type ---
|
||||
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- fill (clears existing content first) ---
|
||||
const humanFillFn = async (selector: string, value: string, options?: any) => {
|
||||
await sleep(randRange(cfg.field_switch_delay));
|
||||
await humanClickFn(selector);
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
await sleep(rand(50, 150));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, value, cfg, cdp);
|
||||
await humanType(page, rawKb, value, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- clear ---
|
||||
@@ -426,12 +431,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector);
|
||||
await humanClickFn(selector, options);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, cfg, cdp);
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
};
|
||||
|
||||
// --- tap ---
|
||||
|
||||
+44
-10
@@ -38,10 +38,17 @@ async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
/**
|
||||
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
|
||||
*
|
||||
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
|
||||
* ``scrollIntoViewIfNeeded`` patch so the same accelerate → cruise →
|
||||
* decelerate → overshoot behavior runs everywhere.
|
||||
*/
|
||||
export async function humanScrollIntoView(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
getBox: () => Promise<ElementBounds | null>,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
@@ -49,11 +56,11 @@ export async function scrollToElement(
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error('Viewport size not available');
|
||||
|
||||
let box = await getElementBox(page, selector);
|
||||
let box = await getBox();
|
||||
if (!box) {
|
||||
await sleep(200);
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element not found: ${selector}`);
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
}
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
@@ -107,7 +114,7 @@ export async function scrollToElement(
|
||||
|
||||
// Check visibility every 3 steps
|
||||
if (i % 3 === 2 || i === totalClicks - 1) {
|
||||
box = await getElementBox(page, selector);
|
||||
box = await getBox();
|
||||
if (box && isInViewport(box, viewport.height, cfg)) {
|
||||
break;
|
||||
}
|
||||
@@ -133,16 +140,43 @@ export async function scrollToElement(
|
||||
// Settle
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
box = await getElementBox(page, selector);
|
||||
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element lost after scrolling into view');
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
|
||||
/**
|
||||
* Selector-based humanized scroll.
|
||||
*
|
||||
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
|
||||
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||
* slow-loading elements (#137). Default stays 2000ms when not specified.
|
||||
*/
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
selector: string,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
timeout?: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
return humanScrollIntoView(
|
||||
page, raw,
|
||||
() => getElementBox(page, selector, timeout),
|
||||
cursorX, cursorY, cfg,
|
||||
);
|
||||
}
|
||||
|
||||
async function getElementBox(
|
||||
page: Page,
|
||||
selector: string,
|
||||
timeout: number = 2000,
|
||||
): Promise<ElementBounds | null> {
|
||||
const el = page.locator(selector).first();
|
||||
try {
|
||||
const box = await el.boundingBox({ timeout: 2000 });
|
||||
const box = await el.boundingBox({ timeout });
|
||||
return box;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1052,3 +1052,326 @@ function buildMockFrame(): any {
|
||||
childFrames: vi.fn(() => []),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// mergeConfig
|
||||
// =========================================================================
|
||||
describe("mergeConfig", () => {
|
||||
it("returns base unchanged when overrides is undefined/null", async () => {
|
||||
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
|
||||
const base = rc("default");
|
||||
expect(mergeConfig(base, undefined)).toBe(base);
|
||||
expect(mergeConfig(base, null)).toBe(base);
|
||||
});
|
||||
|
||||
it("creates a new object — base is never mutated", async () => {
|
||||
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
|
||||
const base = rc("default");
|
||||
const before = base.typing_delay;
|
||||
const merged = mergeConfig(base, { typing_delay: 30 });
|
||||
expect(merged.typing_delay).toBe(30);
|
||||
expect(base.typing_delay).toBe(before);
|
||||
expect(merged).not.toBe(base);
|
||||
});
|
||||
|
||||
it("preserves non-overridden fields", async () => {
|
||||
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
|
||||
const base = rc("default");
|
||||
const merged = mergeConfig(base, { typing_delay: 30 });
|
||||
expect(merged.mouse_min_steps).toBe(base.mouse_min_steps);
|
||||
expect(merged.mistype_chance).toBe(base.mistype_chance);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Per-call timeout forwarding (issue #137)
|
||||
// =========================================================================
|
||||
describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
||||
it("scrollToElement passes timeout to locator.boundingBox()", async () => {
|
||||
const { scrollToElement } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const boundingBox = vi.fn(async () => ({ x: 100, y: 200, width: 50, height: 30 }));
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1280, height: 720 }),
|
||||
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await scrollToElement(page, raw, "#x", 0, 0, cfg, 5000);
|
||||
expect(boundingBox).toHaveBeenCalledWith({ timeout: 5000 });
|
||||
});
|
||||
|
||||
it("default timeout stays 2000ms when not specified", async () => {
|
||||
const { scrollToElement } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const boundingBox = vi.fn(async () => ({ x: 100, y: 200, width: 50, height: 30 }));
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1280, height: 720 }),
|
||||
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
await scrollToElement(page, raw, "#x", 0, 0, cfg);
|
||||
expect(boundingBox).toHaveBeenCalledWith({ timeout: 2000 });
|
||||
});
|
||||
|
||||
it("page.click({ timeout }) reaches scrollToElement", async () => {
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
|
||||
let captured = -1;
|
||||
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
|
||||
captured = timeout ?? -1;
|
||||
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
|
||||
},
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
await (page as any).click("#slow", { timeout: 5000 });
|
||||
|
||||
expect(captured).toBe(5000);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// Per-call human_config override
|
||||
// =========================================================================
|
||||
describe("page.type / page.fill accept per-call human_config override", () => {
|
||||
it("page.type forwards merged config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
// Make field_switch_delay tiny so the test runs fast
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: false,
|
||||
field_switch_delay: [0, 1],
|
||||
});
|
||||
expect(cfg.typing_delay).toBe(70); // baseline
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
}),
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).type("#email", "hi", {
|
||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||
});
|
||||
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
// Global cfg untouched
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
}, 30000);
|
||||
|
||||
it("page.fill forwards merged config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchPage } = await import("../src/human/index.js");
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: false,
|
||||
field_switch_delay: [0, 1],
|
||||
});
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
}),
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).fill("#password", "secret", {
|
||||
human_config: { typing_delay: 150 },
|
||||
});
|
||||
|
||||
expect(captured.typing_delay).toBe(150);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
}, 30000);
|
||||
|
||||
it("el.type forwards human_config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
|
||||
let captured: any = null;
|
||||
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
|
||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
||||
);
|
||||
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
|
||||
await el.type("abc", { human_config: { typing_delay: 25 } });
|
||||
expect(captured.typing_delay).toBe(25);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// scrollIntoViewIfNeeded humanization
|
||||
// =========================================================================
|
||||
describe("humanScrollIntoView", () => {
|
||||
it("skips wheel events when element is already in viewport", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
// Box centered in viewport — squarely in scroll_target_zone
|
||||
const inViewBox = { x: 200, y: 300, width: 50, height: 30 };
|
||||
const result = await humanScrollIntoView(page, raw, async () => inViewBox, 0, 0, cfg);
|
||||
|
||||
expect(result.box).toEqual(inViewBox);
|
||||
expect(raw.wheel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires wheel events when element is below the fold", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default", {
|
||||
scroll_overshoot_chance: 0,
|
||||
scroll_pre_move_delay: [0, 1],
|
||||
scroll_pause_fast: [0, 1],
|
||||
scroll_pause_slow: [0, 1],
|
||||
scroll_settle_delay: [0, 1],
|
||||
});
|
||||
|
||||
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}),
|
||||
down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}),
|
||||
wheel: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const boxes = [
|
||||
{ x: 200, y: 2000, width: 50, height: 30 }, // far below
|
||||
{ x: 200, y: 1500, width: 50, height: 30 },
|
||||
{ x: 200, y: 1000, width: 50, height: 30 },
|
||||
{ x: 200, y: 400, width: 50, height: 30 }, // in view
|
||||
{ x: 200, y: 400, width: 50, height: 30 },
|
||||
{ x: 200, y: 400, width: 50, height: 30 },
|
||||
];
|
||||
let i = 0;
|
||||
const getBox = async () => boxes[Math.min(i++, boxes.length - 1)];
|
||||
|
||||
await humanScrollIntoView(page, raw, getBox, 0, 0, cfg);
|
||||
expect(raw.wheel).toHaveBeenCalled();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("el.scrollIntoViewIfNeeded humanization", () => {
|
||||
it("calls humanScrollIntoView instead of native snap-scroll", async () => {
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
|
||||
let called = 0;
|
||||
const spy = vi.spyOn(scrollMod, "humanScrollIntoView").mockImplementation(
|
||||
async (_p, _raw, _gb, cx, cy) => {
|
||||
called++;
|
||||
return { box: { x: 200, y: 200, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
|
||||
},
|
||||
);
|
||||
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const el = buildMockElementHandle();
|
||||
el.scrollIntoViewIfNeeded = vi.fn(async () => {});
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
await el.scrollIntoViewIfNeeded();
|
||||
|
||||
expect(called).toBeGreaterThan(0);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to native scrollIntoViewIfNeeded if humanized helper throws", async () => {
|
||||
const scrollMod = await import("../src/human/scroll.js");
|
||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||
|
||||
const spy = vi.spyOn(scrollMod, "humanScrollIntoView").mockImplementation(
|
||||
async () => { throw new Error("detached"); },
|
||||
);
|
||||
|
||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: vi.fn(async () => {}) };
|
||||
const rawKb = { down: vi.fn(async () => {}), up: vi.fn(async () => {}), type: vi.fn(async () => {}), insertText: vi.fn(async () => {}) };
|
||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
||||
|
||||
const nativeFallback = vi.fn(async () => {});
|
||||
const el = buildMockElementHandle();
|
||||
el.scrollIntoViewIfNeeded = nativeFallback;
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => {});
|
||||
|
||||
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||
await el.scrollIntoViewIfNeeded();
|
||||
|
||||
expect(nativeFallback).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1296,6 +1296,459 @@ class TestAsyncElementHandle:
|
||||
await browser.close()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 15. Per-call timeout forwarding (issue #137)
|
||||
# =========================================================================
|
||||
|
||||
class TestPerCallTimeoutForwarding:
|
||||
"""page.click('#x', timeout=5000) must forward 5000 to bounding_box(),
|
||||
not silently use the hardcoded 2000ms in scroll."""
|
||||
|
||||
def test_get_element_box_default_timeout(self):
|
||||
"""Default timeout stays 2000ms for backwards compatibility."""
|
||||
from cloakbrowser.human.scroll import _get_element_box
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
page = MagicMock()
|
||||
loc = MagicMock()
|
||||
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
|
||||
page.locator = MagicMock(return_value=MagicMock(first=loc))
|
||||
|
||||
_get_element_box(page, "#x")
|
||||
loc.bounding_box.assert_called_once_with(timeout=2000)
|
||||
|
||||
def test_get_element_box_custom_timeout(self):
|
||||
"""Caller can pass a custom timeout that overrides the default."""
|
||||
from cloakbrowser.human.scroll import _get_element_box
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
page = MagicMock()
|
||||
loc = MagicMock()
|
||||
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
|
||||
page.locator = MagicMock(return_value=MagicMock(first=loc))
|
||||
|
||||
_get_element_box(page, "#x", timeout=5000)
|
||||
loc.bounding_box.assert_called_once_with(timeout=5000)
|
||||
|
||||
def test_scroll_to_element_forwards_timeout(self):
|
||||
"""scroll_to_element passes timeout through to bounding_box()."""
|
||||
from cloakbrowser.human.scroll import scroll_to_element
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
loc = MagicMock()
|
||||
# Already in viewport so we don't actually scroll — just verify
|
||||
# the timeout was forwarded on the first bounding_box() call.
|
||||
loc.bounding_box = MagicMock(return_value={"x": 100, "y": 200, "width": 50, "height": 30})
|
||||
page.locator = MagicMock(return_value=MagicMock(first=loc))
|
||||
|
||||
raw = MagicMock()
|
||||
scroll_to_element(page, raw, "#x", 0, 0, cfg, timeout=7500)
|
||||
loc.bounding_box.assert_called_with(timeout=7500)
|
||||
|
||||
def test_page_click_forwards_timeout_kwarg(self):
|
||||
"""page.click(selector, timeout=...) reaches scroll_to_element.
|
||||
|
||||
Patches scroll_to_element module-side via monkey-patching the
|
||||
cloakbrowser.human module attribute used by patch_page.
|
||||
"""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
# Build a minimal page mock
|
||||
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=False)
|
||||
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 = []
|
||||
|
||||
captured = {}
|
||||
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=2000):
|
||||
captured["timeout"] = timeout
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy)
|
||||
|
||||
with patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.click("#slow-button", timeout=5000)
|
||||
|
||||
assert captured.get("timeout") == 5000, f"expected 5000, got {captured}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 16. Per-call human_config override (typing speed customization)
|
||||
# =========================================================================
|
||||
|
||||
class TestPerCallHumanConfigOverride:
|
||||
"""page.type('#email', text, human_config={'typing_delay': 30}) lets users
|
||||
override typing speed (and any other HumanConfig field) on a per-call
|
||||
basis without re-patching the page."""
|
||||
|
||||
def test_merge_config_creates_new_instance(self):
|
||||
from cloakbrowser.human.config import resolve_config, merge_config
|
||||
|
||||
base = resolve_config("default", None)
|
||||
merged = merge_config(base, {"typing_delay": 30})
|
||||
|
||||
assert merged.typing_delay == 30
|
||||
assert base.typing_delay != 30 # not mutated
|
||||
# Non-overridden fields are preserved
|
||||
assert merged.mouse_min_steps == base.mouse_min_steps
|
||||
|
||||
def test_merge_config_none_returns_base(self):
|
||||
from cloakbrowser.human.config import resolve_config, merge_config
|
||||
|
||||
base = resolve_config("default", None)
|
||||
merged = merge_config(base, None)
|
||||
assert merged is base
|
||||
|
||||
def test_merge_config_ignores_unknown_keys(self):
|
||||
from cloakbrowser.human.config import resolve_config, merge_config
|
||||
|
||||
base = resolve_config("default", None)
|
||||
# ``not_a_real_field`` is silently dropped — callers shouldn't crash
|
||||
# if they pass typos or future field names.
|
||||
merged = merge_config(base, {"typing_delay": 30, "not_a_real_field": 99})
|
||||
assert merged.typing_delay == 30
|
||||
|
||||
def test_page_type_uses_per_call_typing_delay(self):
|
||||
"""page.type(..., human_config={'typing_delay': 30}) reaches human_type
|
||||
with cfg.typing_delay == 30 even when patch was done with default 70."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"idle_between_actions": False,
|
||||
"field_switch_delay": (0, 1),
|
||||
})
|
||||
assert cfg.typing_delay == 70 # baseline
|
||||
|
||||
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=False)
|
||||
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 = []
|
||||
|
||||
captured = {}
|
||||
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
captured["mistype_chance"] = cfg_arg.mistype_chance
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.type(
|
||||
"#email", "hi",
|
||||
human_config={"typing_delay": 30, "mistype_chance": 0},
|
||||
)
|
||||
|
||||
assert captured["typing_delay"] == 30
|
||||
assert captured["mistype_chance"] == 0
|
||||
# Global cfg untouched — per-call override doesn't leak
|
||||
assert cfg.typing_delay == 70
|
||||
|
||||
def test_page_fill_uses_per_call_typing_delay(self):
|
||||
"""Same as type, but for fill (which also clears the field first)."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"idle_between_actions": False,
|
||||
"field_switch_delay": (0, 1),
|
||||
})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
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 = []
|
||||
|
||||
captured = {}
|
||||
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.fill("#password", "secret", human_config={"typing_delay": 150})
|
||||
|
||||
assert captured["typing_delay"] == 150
|
||||
|
||||
def test_element_handle_type_uses_per_call_human_config(self):
|
||||
"""el.type(text, human_config={...}) merges per-call overrides on the
|
||||
ElementHandle path (which doesn't go through page.type)."""
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
import cloakbrowser.human as h
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"idle_between_actions": False,
|
||||
"field_switch_delay": (0, 1),
|
||||
})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page._original = MagicMock()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 100, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
el.wait_for_selector = MagicMock(return_value=None)
|
||||
el.scroll_into_view_if_needed = MagicMock()
|
||||
|
||||
raw_mouse = MagicMock()
|
||||
raw_keyboard = MagicMock()
|
||||
|
||||
captured = {}
|
||||
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type):
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, raw_mouse, raw_keyboard,
|
||||
page._original, None, None,
|
||||
)
|
||||
el.type("abc", human_config={"typing_delay": 25})
|
||||
|
||||
assert captured["typing_delay"] == 25
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 17. scroll_into_view_if_needed humanization
|
||||
# =========================================================================
|
||||
|
||||
class TestScrollIntoViewIfNeeded:
|
||||
"""scroll_into_view_if_needed should run through the same
|
||||
accelerate → cruise → decelerate → overshoot wheel sequence as page.click—
|
||||
not Playwright's instant-snap default."""
|
||||
|
||||
def test_human_scroll_into_view_skips_when_in_viewport(self):
|
||||
"""Already-visible elements: no wheel events, just return."""
|
||||
from cloakbrowser.human.scroll import human_scroll_into_view
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
raw = MagicMock()
|
||||
# Box is dead-center of viewport — squarely in scroll_target_zone
|
||||
in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}
|
||||
|
||||
box, cx, cy = human_scroll_into_view(
|
||||
page, raw, lambda: in_view_box, 0, 0, cfg,
|
||||
)
|
||||
assert box == in_view_box
|
||||
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
|
||||
|
||||
def test_human_scroll_into_view_scrolls_when_below_fold(self):
|
||||
"""Below-fold elements: wheel events fire, eventually box becomes visible."""
|
||||
from cloakbrowser.human.scroll import human_scroll_into_view
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cfg = resolve_config("default", {
|
||||
"scroll_overshoot_chance": 0, # deterministic
|
||||
"scroll_pre_move_delay": (0, 1),
|
||||
"scroll_pause_fast": (0, 1),
|
||||
"scroll_pause_slow": (0, 1),
|
||||
"scroll_settle_delay": (0, 1),
|
||||
})
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
raw = MagicMock()
|
||||
|
||||
# First box is far below the fold; subsequent boxes "come into view"
|
||||
# so the loop terminates after a few wheel bursts.
|
||||
boxes = [
|
||||
{"x": 200, "y": 2000, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 1500, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 1000, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 400, "width": 50, "height": 30}, # in viewport
|
||||
{"x": 200, "y": 400, "width": 50, "height": 30},
|
||||
{"x": 200, "y": 400, "width": 50, "height": 30},
|
||||
]
|
||||
idx = {"i": 0}
|
||||
def get_box():
|
||||
i = min(idx["i"], len(boxes) - 1)
|
||||
idx["i"] += 1
|
||||
return boxes[i]
|
||||
|
||||
human_scroll_into_view(page, raw, get_box, 0, 0, cfg)
|
||||
assert raw.wheel.called, "Below-fold scroll should produce wheel events"
|
||||
|
||||
def test_element_handle_scroll_into_view_if_needed_humanized(self):
|
||||
"""el.scroll_into_view_if_needed() routes through human_scroll_into_view."""
|
||||
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
import cloakbrowser.human as h
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page._original = MagicMock()
|
||||
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 50, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
el.wait_for_selector = MagicMock(return_value=None)
|
||||
# Make sure the original method exists so the patch is wired up
|
||||
el.scroll_into_view_if_needed = MagicMock()
|
||||
|
||||
called = {"count": 0}
|
||||
def fake(*args, **kwargs):
|
||||
called["count"] += 1
|
||||
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(),
|
||||
page._original, None, None,
|
||||
)
|
||||
# Patched method should now invoke our humanized helper
|
||||
el.scroll_into_view_if_needed()
|
||||
|
||||
assert called["count"] >= 1, "humanized scroll helper was never called"
|
||||
|
||||
def test_locator_scroll_into_view_if_needed_humanized(self):
|
||||
"""Locator.scroll_into_view_if_needed() also goes through humanized scroll."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Patch Locator class fresh
|
||||
_ensure_locator_patched()
|
||||
|
||||
from playwright.sync_api._generated import Locator
|
||||
|
||||
cfg = resolve_config("default", None)
|
||||
cursor = _CursorState()
|
||||
cursor.x = 50
|
||||
cursor.y = 50
|
||||
cursor.initialized = True
|
||||
|
||||
page = MagicMock()
|
||||
page._original = MagicMock()
|
||||
page._human_cfg = cfg
|
||||
page._human_cursor = cursor
|
||||
page._human_raw_mouse = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
|
||||
# Build a Locator-like object satisfying the patched method
|
||||
loc = MagicMock(spec=Locator)
|
||||
loc.page = page
|
||||
impl_obj = MagicMock()
|
||||
impl_obj._selector = "#x"
|
||||
loc._impl_obj = impl_obj
|
||||
loc.bounding_box = MagicMock(
|
||||
return_value={"x": 100, "y": 100, "width": 50, "height": 30}
|
||||
)
|
||||
|
||||
called = {"count": 0, "cfg": None}
|
||||
def fake(*args, **kwargs):
|
||||
called["count"] += 1
|
||||
# cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
|
||||
called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg")
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
Locator.scroll_into_view_if_needed(
|
||||
loc, human_config={"scroll_overshoot_chance": 0.5},
|
||||
)
|
||||
|
||||
assert called["count"] == 1
|
||||
# Per-call override merged into the cfg passed downstream
|
||||
assert called["cfg"].scroll_overshoot_chance == 0.5
|
||||
# Cursor was updated from the helper's return value
|
||||
assert cursor.x == 200 and cursor.y == 200
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Direct runner (backwards compat)
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user