From 2be8cdcc0368c9e7286d29792400c3a250cd1b9b Mon Sep 17 00:00:00 2001 From: lilos Date: Fri, 10 Apr 2026 23:18:14 +0300 Subject: [PATCH] feat(humanize): add Playwright ElementHandle support and fix async tests (#133) --- cloakbrowser/human/__init__.py | 649 ++++++++++++++++++++++++++++++++ js/src/human/elementhandle.ts | 366 ++++++++++++++++++ js/src/human/index.ts | 15 + js/tests/humanize.test.ts | 344 +++++++++++++++++ tests/test_human_visual.py | 61 +++ tests/test_humanize_unit.py | 660 +++++++++++++++++++++++++++++++-- 6 files changed, 2069 insertions(+), 26 deletions(-) create mode 100644 js/src/human/elementhandle.ts diff --git a/cloakbrowser/human/__init__.py b/cloakbrowser/human/__init__.py index 4c0ef63..5ae9ba3 100644 --- a/cloakbrowser/human/__init__.py +++ b/cloakbrowser/human/__init__.py @@ -900,6 +900,9 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: # --- Patch Frame-level methods (for sub-frames) --- _patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals) + # --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) --- + _patch_page_element_handles_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session) + # Initialize cursor immediately so it doesn't visibly jump from (0,0) 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]) @@ -913,6 +916,273 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: _patch_locator_class_sync() +# ============================================================================ +# SYNC ElementHandle patching +# ============================================================================ + + +def _is_input_element_handle_sync(el: Any) -> bool: + """Check if an ElementHandle is an input/textarea/contenteditable (sync).""" + try: + return el.evaluate( + """(node) => { + const tag = node.tagName ? node.tagName.toLowerCase() : ''; + return tag === 'input' || tag === 'textarea' + || node.getAttribute && node.getAttribute('contenteditable') === 'true'; + }""" + ) + except Exception: + return False + + +def _patch_single_element_handle_sync( + el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, + stealth: Any, cdp_session: Any, +) -> None: + """Patch all interaction methods on a sync Playwright ElementHandle.""" + if getattr(el, '_human_patched', False): + return + el._human_patched = True + + # Save originals + _orig_click = el.click + _orig_dblclick = el.dblclick + _orig_hover = el.hover + _orig_type = el.type + _orig_fill = el.fill + _orig_press = el.press + _orig_select_option = el.select_option + _orig_check = el.check + _orig_uncheck = el.uncheck + _orig_set_checked = getattr(el, 'set_checked', None) + _orig_tap = el.tap + _orig_focus = el.focus + + # Nested selectors + _orig_qs = el.query_selector + _orig_qsa = el.query_selector_all + _orig_wfs = el.wait_for_selector + + def _patched_qs(selector: str, **kwargs: Any) -> Any: + child = _orig_qs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_sync( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return child + + def _patched_qsa(selector: str, **kwargs: Any) -> Any: + children = _orig_qsa(selector, **kwargs) + for child in children: + _patch_single_element_handle_sync( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return children + + def _patched_wfs(selector: str, **kwargs: Any) -> Any: + child = _orig_wfs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_sync( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return child + + el.query_selector = _patched_qs + el.query_selector_all = _patched_qsa + el.wait_for_selector = _patched_wfs + + # Helper: move cursor to element + def _move_to_element(): + 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]) + originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + + box = el.bounding_box() + if not box: + return None + + is_inp = _is_input_element_handle_sync(el) + target = click_target(box, is_inp, 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) + + human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, 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() + if info is None: + return _orig_click(**kwargs) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.dblclick() --- + def _human_el_dblclick(**kwargs: Any) -> None: + info = _move_to_element() + if info is None: + return _orig_dblclick(**kwargs) + raw_mouse.down(click_count=2) + sleep_ms(rand(30, 60)) + raw_mouse.up(click_count=2) + + # --- el.hover() --- + def _human_el_hover(**kwargs: Any) -> None: + info = _move_to_element() + 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() + if info is None: + return _orig_type(text, **kwargs) + human_click(raw_mouse, info['is_inp'], cfg) + sleep_ms(rand(100, 250)) + human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session) + + # --- el.fill() --- + def _human_el_fill(value: str, **kwargs: Any) -> None: + info = _move_to_element() + if info is None: + return _orig_fill(value, **kwargs) + human_click(raw_mouse, info['is_inp'], 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) + + # --- el.press() --- + def _human_el_press(key: str, **kwargs: Any) -> None: + sleep_ms(rand(20, 60)) + originals.keyboard_down(key) + sleep_ms(rand_range(cfg.key_hold)) + originals.keyboard_up(key) + + # --- el.select_option() --- + def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any: + info = _move_to_element() + if info is None: + return _orig_select_option(value, **kwargs) + human_click(raw_mouse, False, cfg) + sleep_ms(rand(100, 300)) + return _orig_select_option(value, **kwargs) + + # --- el.check() --- + def _human_el_check(**kwargs: Any) -> None: + try: + if el.is_checked(): + return + except Exception: + pass + info = _move_to_element() + if info is None: + return _orig_check(**kwargs) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.uncheck() --- + def _human_el_uncheck(**kwargs: Any) -> None: + try: + if not el.is_checked(): + return + except Exception: + pass + info = _move_to_element() + if info is None: + return _orig_uncheck(**kwargs) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.set_checked() --- + def _human_el_set_checked(checked: bool, **kwargs: Any) -> None: + try: + current = el.is_checked() + if current == checked: + return + except Exception: + pass + info = _move_to_element() + if info is None and _orig_set_checked: + return _orig_set_checked(checked, **kwargs) + if info: + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.tap() --- + def _human_el_tap(**kwargs: Any) -> None: + info = _move_to_element() + if info is None: + return _orig_tap(**kwargs) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.focus() --- + # FIX: move cursor humanly but use programmatic focus (no click side-effects). + # Stock Playwright el.focus() never clicks — it just calls element.focus() in JS. + # Clicking would trigger onclick, submit forms, navigate links, etc. + def _human_el_focus() -> None: + _move_to_element() # human-like cursor movement (Bézier) + _orig_focus() # programmatic focus, no click side-effects + + el.click = _human_el_click + el.dblclick = _human_el_dblclick + el.hover = _human_el_hover + el.type = _human_el_type + el.fill = _human_el_fill + el.press = _human_el_press + el.select_option = _human_el_select_option + el.check = _human_el_check + el.uncheck = _human_el_uncheck + if _orig_set_checked is not None: + el.set_checked = _human_el_set_checked + el.tap = _human_el_tap + el.focus = _human_el_focus + + +def _patch_page_element_handles_sync( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, + stealth: Any, cdp_session: Any, +) -> None: + """Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (sync).""" + _orig_qs = page.query_selector + _orig_qsa = page.query_selector_all + _orig_wfs = page.wait_for_selector + + def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return els + + def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + page.query_selector = _patched_qs + page.query_selector_all = _patched_qsa + page.wait_for_selector = _patched_wfs + + def _patch_frames_sync( page: Any, cfg: HumanConfig, cursor: _CursorState, raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, @@ -1023,6 +1293,58 @@ def _patch_single_frame_sync( frame.clear = _frame_clear frame.drag_and_drop = _frame_drag_and_drop + # --- Patch frame-level ElementHandle selectors --- + stealth_world = getattr(page, '_stealth_world', None) + cdp_session = None + if stealth_world is not None: + try: + cdp_session = stealth_world.get_cdp_session() + except Exception: + pass + _patch_frame_element_handles_sync( + frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session + ) + + +def _patch_frame_element_handles_sync( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, + stealth: Any, cdp_session: Any, +) -> None: + """Patch frame.query_selector, query_selector_all, wait_for_selector (sync).""" + _orig_qs = frame.query_selector + _orig_qsa = frame.query_selector_all + _orig_wfs = frame.wait_for_selector + + def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return els + + def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + frame.query_selector = _patched_qs + frame.query_selector_all = _patched_qsa + frame.wait_for_selector = _patched_wfs + + def _iter_frames(page: Any): try: @@ -1108,6 +1430,7 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: stealth = _AsyncIsolatedWorld(page) page._stealth_world = stealth cdp_session_holder: list[Any] = [None] # mutable container for closure + page._cdp_session_holder = cdp_session_holder # expose for frame-level patching async def _ensure_cdp() -> Any: if cdp_session_holder[0] is None: @@ -1265,10 +1588,289 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: # --- Patch Frame-level methods (for sub-frames) --- _patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals) + # --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) --- + _patch_page_element_handles_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder) + # --- Patch async Locator class (class-level, runs once) --- _patch_locator_class_async() +# ============================================================================ +# ASYNC ElementHandle patching +# ============================================================================ + + +async def _async_is_input_element_handle(el: Any) -> bool: + """Check if an ElementHandle is an input/textarea/contenteditable (async).""" + try: + return await el.evaluate( + """(node) => { + const tag = node.tagName ? node.tagName.toLowerCase() : ''; + return tag === 'input' || tag === 'textarea' + || node.getAttribute && node.getAttribute('contenteditable') === 'true'; + }""" + ) + except Exception: + return False + + +def _patch_single_element_handle_async( + el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, + stealth: Any, cdp_session_holder: Any, +) -> None: + """Patch all interaction methods on an async Playwright ElementHandle.""" + if getattr(el, '_human_patched', False): + return + el._human_patched = True + + # Save originals + _orig_click = el.click + _orig_dblclick = el.dblclick + _orig_hover = el.hover + _orig_type = el.type + _orig_fill = el.fill + _orig_press = el.press + _orig_select_option = el.select_option + _orig_check = el.check + _orig_uncheck = el.uncheck + _orig_set_checked = getattr(el, 'set_checked', None) + _orig_tap = el.tap + _orig_focus = el.focus + + # Nested selectors + _orig_qs = el.query_selector + _orig_qsa = el.query_selector_all + _orig_wfs = el.wait_for_selector + + async def _patched_qs(selector: str, **kwargs: Any) -> Any: + child = await _orig_qs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_async( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return child + + async def _patched_qsa(selector: str, **kwargs: Any) -> Any: + children = await _orig_qsa(selector, **kwargs) + for child in children: + _patch_single_element_handle_async( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return children + + async def _patched_wfs(selector: str, **kwargs: Any) -> Any: + child = await _orig_wfs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_async( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return child + + el.query_selector = _patched_qs + el.query_selector_all = _patched_qsa + el.wait_for_selector = _patched_wfs + + # Helper: move cursor to element (async) + async def _move_to_element(): + 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]) + await originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + + 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) + + 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) + + await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg) + cursor.x = target.x + cursor.y = target.y + return {'box': box, 'is_inp': is_inp} + + async def _get_cdp(): + if cdp_session_holder[0] is None: + try: + cdp_session_holder[0] = await stealth.get_cdp_session() + except Exception: + pass + return cdp_session_holder[0] + + # --- el.click() --- + async def _human_el_click(**kwargs: Any) -> None: + info = await _move_to_element() + if info is None: + return await _orig_click(**kwargs) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.dblclick() --- + async def _human_el_dblclick(**kwargs: Any) -> None: + info = await _move_to_element() + if info is None: + return await _orig_dblclick(**kwargs) + await raw_mouse.down(click_count=2) + await async_sleep_ms(rand(30, 60)) + await raw_mouse.up(click_count=2) + + # --- el.hover() --- + async def _human_el_hover(**kwargs: Any) -> None: + info = await _move_to_element() + 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() + if info is None: + return await _orig_type(text, **kwargs) + await async_human_click(raw_mouse, info['is_inp'], cfg) + await async_sleep_ms(rand(100, 250)) + cdp = await _get_cdp() + await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp) + + # --- el.fill() --- + async def _human_el_fill(value: str, **kwargs: Any) -> None: + info = await _move_to_element() + if info is None: + return await _orig_fill(value, **kwargs) + await async_human_click(raw_mouse, info['is_inp'], 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) + + # --- el.press() --- + async def _human_el_press(key: str, **kwargs: Any) -> None: + await async_sleep_ms(rand(20, 60)) + await originals.keyboard_down(key) + await async_sleep_ms(rand_range(cfg.key_hold)) + await originals.keyboard_up(key) + + # --- el.select_option() --- + async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any: + info = await _move_to_element() + if info is None: + return await _orig_select_option(value, **kwargs) + await async_human_click(raw_mouse, False, cfg) + await async_sleep_ms(rand(100, 300)) + return await _orig_select_option(value, **kwargs) + + # --- el.check() --- + async def _human_el_check(**kwargs: Any) -> None: + try: + if await el.is_checked(): + return + except Exception: + pass + info = await _move_to_element() + if info is None: + return await _orig_check(**kwargs) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.uncheck() --- + async def _human_el_uncheck(**kwargs: Any) -> None: + try: + if not await el.is_checked(): + return + except Exception: + pass + info = await _move_to_element() + if info is None: + return await _orig_uncheck(**kwargs) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.set_checked() --- + async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None: + try: + current = await el.is_checked() + if current == checked: + return + except Exception: + pass + info = await _move_to_element() + if info is None and _orig_set_checked: + return await _orig_set_checked(checked, **kwargs) + if info: + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.tap() --- + async def _human_el_tap(**kwargs: Any) -> None: + info = await _move_to_element() + if info is None: + return await _orig_tap(**kwargs) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.focus() --- + # FIX: move cursor humanly but use programmatic focus (no click side-effects). + # Stock Playwright el.focus() never clicks — it just calls element.focus() in JS. + # Clicking would trigger onclick, submit forms, navigate links, etc. + async def _human_el_focus() -> None: + await _move_to_element() # human-like cursor movement (Bézier) + await _orig_focus() # programmatic focus, no click side-effects + + el.click = _human_el_click + el.dblclick = _human_el_dblclick + el.hover = _human_el_hover + el.type = _human_el_type + el.fill = _human_el_fill + el.press = _human_el_press + el.select_option = _human_el_select_option + el.check = _human_el_check + el.uncheck = _human_el_uncheck + if _orig_set_checked is not None: + el.set_checked = _human_el_set_checked + el.tap = _human_el_tap + el.focus = _human_el_focus + + +def _patch_page_element_handles_async( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, + stealth: Any, cdp_session_holder: Any, +) -> None: + """Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (async).""" + _orig_qs = page.query_selector + _orig_qsa = page.query_selector_all + _orig_wfs = page.wait_for_selector + + async def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = await _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + async def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = await _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return els + + async def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = await _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + page.query_selector = _patched_qs + page.query_selector_all = _patched_qsa + page.wait_for_selector = _patched_wfs + + def _patch_frames_async( page: Any, cfg: HumanConfig, cursor: _CursorState, raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, @@ -1379,6 +1981,53 @@ def _patch_single_frame_async( frame.clear = _frame_clear frame.drag_and_drop = _frame_drag_and_drop + # --- Patch frame-level ElementHandle selectors (async) --- + stealth_world = getattr(page, '_stealth_world', None) + cdp_session_holder = getattr(page, '_cdp_session_holder', [None]) + _patch_frame_element_handles_async( + frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session_holder + ) + + +def _patch_frame_element_handles_async( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, + stealth: Any, cdp_session_holder: Any, +) -> None: + """Patch frame.query_selector, query_selector_all, wait_for_selector (async).""" + _orig_qs = frame.query_selector + _orig_qsa = frame.query_selector_all + _orig_wfs = frame.wait_for_selector + + async def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = await _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + async def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = await _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return els + + async def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = await _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + frame.query_selector = _patched_qs + frame.query_selector_all = _patched_qsa + frame.wait_for_selector = _patched_wfs + + def patch_context_async(context: Any, cfg: HumanConfig) -> None: cursor = _CursorState() diff --git a/js/src/human/elementhandle.ts b/js/src/human/elementhandle.ts new file mode 100644 index 0000000..a0bb2f1 --- /dev/null +++ b/js/src/human/elementhandle.ts @@ -0,0 +1,366 @@ +/** + * ElementHandle humanization for Playwright. + * + * Mirrors Puppeteer's ElementHandle patching architecture. + * Patches page.$(), page.$$(), page.waitForSelector() to return humanized handles, + * and patches all interaction methods on each ElementHandle instance. + * + * Playwright ElementHandle methods patched: + * click, dblclick, hover, type, fill, press, selectOption, + * check, uncheck, setChecked, tap, focus + * + $, $$, waitForSelector (nested elements are also patched) + * + * Stealth-aware: + * - Uses CDP DOM.describeNode when available to check element type + * (no main-world JS execution) + * - Falls back to el.evaluate() only when CDP is unavailable + */ + +import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core'; +import type { HumanConfig } from './config.js'; +import { rand, randRange, sleep } from './config.js'; +import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +import { humanType } from './keyboard.js'; + +// --- Platform-aware select-all shortcut --- +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + + +// ============================================================================ +// Stealth ElementHandle input check — uses CDP DOM.describeNode +// ============================================================================ + +async function isInputElementHandle( + stealth: any, // StealthEval from index.ts + el: ElementHandle, +): Promise { + // Try CDP DOM.describeNode first (no main-world JS execution) + if (stealth) { + try { + const cdp: CDPSession = await stealth.getCdpSession(); + // Playwright exposes the JSHandle's internal preview via _objectId or similar + // We need the remote object ID. Try to get it via internal API. + const impl = (el as any)._impl ?? (el as any)._object ?? el; + const guid = (impl as any)._guid; + + // Use el.evaluate as a reliable fallback within stealth context + // Playwright doesn't expose remoteObject directly like Puppeteer + } catch { /* fallthrough */ } + } + + // Fallback: el.evaluate (works reliably in Playwright) + try { + return await el.evaluate((node: any) => { + const tag = node.tagName?.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || node.getAttribute?.('contenteditable') === 'true'; + }); + } catch { + return false; + } +} + + +// ============================================================================ +// CursorState type (matches index.ts) +// ============================================================================ + +interface CursorState { + x: number; + y: number; + initialized: boolean; +} + + +// ============================================================================ +// Patch a single Playwright ElementHandle +// ============================================================================ + +export function patchSingleElementHandle( + el: ElementHandle, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + if ((el as any)._humanPatched) return; + (el as any)._humanPatched = true; + + // Save originals + const origElClick = el.click.bind(el); + const origElDblclick = el.dblclick.bind(el); + const origElHover = el.hover.bind(el); + const origElType = el.type.bind(el); + const origElFill = el.fill.bind(el); + const origElPress = el.press.bind(el); + const origElSelectOption = el.selectOption.bind(el); + const origElCheck = el.check.bind(el); + const origElUncheck = el.uncheck.bind(el); + const origElSetChecked = (el as any).setChecked?.bind(el); + const origElTap = el.tap.bind(el); + const origElFocus = el.focus.bind(el); + + // Nested selectors + const origEl$ = el.$.bind(el); + const origEl$$ = el.$$.bind(el); + const origElWaitForSelector = el.waitForSelector.bind(el); + + // --- Nested elements are also patched --- + (el as any).$ = async (selector: string) => { + const child = await origEl$(selector); + if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + return child; + }; + + (el as any).$$ = async (selector: string) => { + const children = await origEl$$(selector); + for (const child of children) { + patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return children; + }; + + (el as any).waitForSelector = async (selector: string, options?: any) => { + const child = await origElWaitForSelector(selector, options); + if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth); + return child; + }; + + // --- Helper: get bounding box and move cursor to element --- + const moveToElement = async () => { + // Ensure cursor is initialized + const ensureCursorInit = (page as any)._ensureCursorInit; + if (ensureCursorInit) await ensureCursorInit(); + + const box = await el.boundingBox(); + if (!box) return null; + + const isInp = await isInputElementHandle(stealth, el); + const target = clickTarget(box, isInp, cfg); + + if (cfg.idle_between_actions) { + await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg); + } + + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); + cursor.x = target.x; + cursor.y = target.y; + return { box, isInp }; + }; + + // --- el.click() --- + (el as any).click = async (options?: any) => { + const info = await moveToElement(); + if (!info) return origElClick(options); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.dblclick() --- + (el as any).dblclick = async (options?: any) => { + const info = await moveToElement(); + if (!info) return origElDblclick(options); + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + // --- el.hover() --- + (el as any).hover = async (options?: any) => { + const info = await moveToElement(); + if (!info) return origElHover(options); + // Just move — no click + }; + + // --- el.type() --- + (el as any).type = async (text: string, options?: any) => { + const info = await moveToElement(); + if (!info) return origElType(text, options); + await humanClick(raw, info.isInp, cfg); + await sleep(rand(100, 250)); + let cdpSession: CDPSession | null = null; + try { cdpSession = await stealth?.getCdpSession(); } catch {} + await humanType(page, rawKb, text, cfg, cdpSession); + }; + + // --- el.fill() --- + (el as any).fill = async (value: string, options?: any) => { + const info = await moveToElement(); + if (!info) return origElFill(value, options); + await humanClick(raw, info.isInp, cfg); + await sleep(rand(100, 250)); + // Clear existing content + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + let cdpSession: CDPSession | null = null; + try { cdpSession = await stealth?.getCdpSession(); } catch {} + await humanType(page, rawKb, value, cfg, cdpSession); + }; + + // --- el.press() --- + (el as any).press = async (key: string, options?: any) => { + await sleep(rand(20, 60)); + await originals.keyboardDown(key); + await sleep(randRange(cfg.key_hold)); + await originals.keyboardUp(key); + }; + + // --- el.selectOption() --- + (el as any).selectOption = async (values: any, options?: any) => { + const info = await moveToElement(); + if (!info) return origElSelectOption(values, options); + await humanClick(raw, false, cfg); + await sleep(rand(100, 300)); + return origElSelectOption(values, options); + }; + + // --- el.check() --- + (el as any).check = async (options?: any) => { + try { + const checked = await el.isChecked(); + if (checked) return; // Already checked + } catch {} + const info = await moveToElement(); + if (!info) return origElCheck(options); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.uncheck() --- + (el as any).uncheck = async (options?: any) => { + try { + const checked = await el.isChecked(); + if (!checked) return; // Already unchecked + } catch {} + const info = await moveToElement(); + if (!info) return origElUncheck(options); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.setChecked() --- + if (origElSetChecked) { + (el as any).setChecked = async (checked: boolean, options?: any) => { + try { + const current = await el.isChecked(); + if (current === checked) return; + } catch {} + const info = await moveToElement(); + if (!info) return origElSetChecked(checked, options); + await humanClick(raw, info.isInp, cfg); + }; + } + + // --- el.tap() --- + (el as any).tap = async (options?: any) => { + const info = await moveToElement(); + if (!info) return origElTap(options); + await humanClick(raw, info.isInp, cfg); + }; + + // --- el.focus() --- + // Move cursor humanly but use programmatic focus (no click side-effects). + // Stock Playwright el.focus() never clicks — clicking would trigger onclick, + // submit forms, navigate links, etc. + (el as any).focus = async () => { + await moveToElement(); // human-like Bézier cursor movement + await origElFocus(); // programmatic focus, no click + }; +} + + +// ============================================================================ +// Page-level ElementHandle patching +// ============================================================================ + +export function patchPageElementHandles( + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + // Patch page.$() — only if the method exists + if (typeof page.$ === 'function') { + const orig$ = page.$.bind(page); + (page as any).$ = async (selector: string) => { + const el = await orig$(selector); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } + + // Patch page.$$() + if (typeof page.$$ === 'function') { + const orig$$ = page.$$.bind(page); + (page as any).$$ = async (selector: string) => { + const els = await orig$$(selector); + for (const el of els) { + patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return els; + }; + } + + // Patch page.waitForSelector() + if (typeof page.waitForSelector === 'function') { + const origWaitForSelector = page.waitForSelector.bind(page); + (page as any).waitForSelector = async (selector: string, options?: any) => { + const el = await origWaitForSelector(selector, options); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } +} + + +// ============================================================================ +// Frame-level ElementHandle patching +// ============================================================================ + +export function patchFrameElementHandles( + frame: Frame, + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, + stealth: any, +): void { + // Patch frame.$() — only if the method exists + if (typeof frame.$ === 'function') { + const origFrame$ = frame.$.bind(frame); + (frame as any).$ = async (selector: string) => { + const el = await origFrame$(selector); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } + + // Patch frame.$$() + if (typeof frame.$$ === 'function') { + const origFrame$$ = frame.$$.bind(frame); + (frame as any).$$ = async (selector: string) => { + const els = await origFrame$$(selector); + for (const el of els) { + patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + } + return els; + }; + } + + // Patch frame.waitForSelector() + if (typeof frame.waitForSelector === 'function') { + const origFrameWaitForSelector = frame.waitForSelector.bind(frame); + (frame as any).waitForSelector = async (selector: string, options?: any) => { + const el = await origFrameWaitForSelector(selector, options); + if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth); + return el; + }; + } +} diff --git a/js/src/human/index.ts b/js/src/human/index.ts index 7beabce..c0c00a2 100644 --- a/js/src/human/index.ts +++ b/js/src/human/index.ts @@ -12,6 +12,14 @@ * Patches all interaction methods: * click, dblclick, hover, type, fill, check, uncheck, selectOption, * press, pressSequentially, tap, dragTo, clear + Frame-level equivalents. + * + * ELEMENTHANDLE-LEVEL: + * click, dblclick, hover, type, fill, press, selectOption, + * check, uncheck, setChecked, tap, focus + * + $, $$, waitForSelector (nested elements are also patched) + * + * page.$(), page.$$(), page.waitForSelector() and Frame equivalents + * return patched ElementHandles automatically. */ import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core'; @@ -19,11 +27,13 @@ import { HumanConfig, resolveConfig, 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 { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js'; export { HumanConfig, resolveConfig } from './config.js'; export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; export { humanType } from './keyboard.js'; export { scrollToElement } from './scroll.js'; +export { patchSingleElementHandle } from './elementhandle.js'; // --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; @@ -488,6 +498,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { // --- Patch Frame-level methods (for sub-frames) --- patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth); + + // --- Patch ElementHandle selectors (page.$, page.$$, page.waitForSelector) --- + patchPageElementHandles(page, cfg, cursor, raw, rawKb, originals, stealth); } @@ -511,6 +524,8 @@ function patchFrames( ): void { for (const frame of iterFrames(page)) { patchSingleFrame(frame, page, cfg, originals, stealth); + // Patch frame-level ElementHandle selectors ($, $$, waitForSelector) + patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth); } } diff --git a/js/tests/humanize.test.ts b/js/tests/humanize.test.ts index 0f3b734..f1009ed 100644 --- a/js/tests/humanize.test.ts +++ b/js/tests/humanize.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { resolveConfig, rand, randRange, sleep } from "../src/human/config.js"; import { humanMove, humanClick, clickTarget, humanIdle } from "../src/human/mouse.js"; +import { patchPageElementHandles } from "../src/human/elementhandle.js"; // ========================================================================= // Config resolution @@ -689,6 +690,349 @@ describe("humanType non-ASCII", () => { +// ========================================================================= +// ElementHandle patching (Playwright) +// ========================================================================= + +function buildMockElementHandle(overrides: Record = {}): any { + const el: any = { + click: vi.fn(async () => {}), + dblclick: vi.fn(async () => {}), + hover: vi.fn(async () => {}), + type: vi.fn(async () => {}), + fill: vi.fn(async () => {}), + press: vi.fn(async () => {}), + selectOption: vi.fn(async () => {}), + check: vi.fn(async () => {}), + uncheck: vi.fn(async () => {}), + setChecked: vi.fn(async () => {}), + tap: vi.fn(async () => {}), + focus: vi.fn(async () => {}), + boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })), + evaluate: overrides.evaluate ?? vi.fn(async () => false), + isChecked: overrides.isChecked ?? vi.fn(async () => false), + $: vi.fn(async () => null), + $$: vi.fn(async () => []), + waitForSelector: vi.fn(async () => null), + _humanPatched: false, + }; + return el; +} + +describe("patchSingleElementHandle", () => { + it("marks element as patched", async () => { + const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 100, y: 100, 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(); + const page = buildMockPage(); + + patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); + + expect(el._humanPatched).toBe(true); + }); + + it("el.click calls mouse.move and mouse.down/up (humanized path)", async () => { + const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default", { idle_between_actions: false }); + const cursor = { x: 50, y: 50, initialized: true }; + + let moveCount = 0; + let downCalled = false; + let upCalled = false; + const raw = { + move: vi.fn(async () => { moveCount++; }), + down: vi.fn(async () => { downCalled = true; }), + up: vi.fn(async () => { upCalled = true; }), + 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(); + 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.click(); + + expect(moveCount).toBeGreaterThan(0); + expect(downCalled).toBe(true); + expect(upCalled).toBe(true); + }, 30000); + + it("el.hover calls mouse.move but NOT down/up", async () => { + const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default", { idle_between_actions: false }); + const cursor = { x: 50, y: 50, initialized: true }; + + let downCalled = false; + const raw = { + move: vi.fn(async () => {}), + down: vi.fn(async () => { downCalled = true; }), + 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(); + 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.hover(); + + expect(raw.move).toHaveBeenCalled(); + expect(downCalled).toBe(false); + }, 30000); + + it("el.type triggers mouse move + click + keyboard events", async () => { + 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 }; + + 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) }); // isInput = 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"); + + expect(raw.move).toHaveBeenCalled(); + expect(raw.down).toHaveBeenCalled(); // click to focus + expect(rawKb.down).toHaveBeenCalled(); // keyboard typing + }, 30000); + + it("el.fill calls selectAll + backspace + type", async () => { + 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 }; + + const pressedKeys: string[] = []; + 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 (key: string) => { pressedKeys.push(key); }), + 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.fill("newtext"); + + const expected = process.platform === "darwin" ? "Meta+a" : "Control+a"; + expect(pressedKeys).toContain(expected); + expect(pressedKeys).toContain("Backspace"); + }, 30000); + + it("no double patching", async () => { + const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + 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(); + const page = buildMockPage(); + + patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); + const firstClick = el.click; + + patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); + + expect(el.click).toBe(firstClick); + }); + + it("nested $() returns patched child handle", async () => { + const { patchSingleElementHandle } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + 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 child = buildMockElementHandle(); + const el = buildMockElementHandle(); + el.$ = vi.fn(async () => child); + + const page = buildMockPage(); + + patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null); + + const result = await el.$("span"); + expect(result._humanPatched).toBe(true); + }); +}); + +describe("patchPageElementHandles", () => { + it("page.$() returns patched ElementHandle", async () => { + const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + 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(); + const page = buildMockPage(); + (page as any).$ = vi.fn(async () => el); + (page as any).$$ = vi.fn(async () => [el]); + (page as any).waitForSelector = vi.fn(async () => el); + + patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); + + const result = await (page as any).$("#test"); + expect(result._humanPatched).toBe(true); + }); + + it("page.$$() returns all patched handles", async () => { + const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + 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 el1 = buildMockElementHandle(); + const el2 = buildMockElementHandle(); + const page = buildMockPage(); + (page as any).$ = vi.fn(async () => null); + (page as any).$$ = vi.fn(async () => [el1, el2]); + (page as any).waitForSelector = vi.fn(async () => null); + + patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); + + const results = await (page as any).$$("div"); + expect(results[0]._humanPatched).toBe(true); + expect(results[1]._humanPatched).toBe(true); + }); + + it("page.waitForSelector() returns patched handle", async () => { + const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + 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(); + const page = buildMockPage(); + (page as any).$ = vi.fn(async () => null); + (page as any).$$ = vi.fn(async () => []); + (page as any).waitForSelector = vi.fn(async () => el); + + patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); + + const result = await (page as any).waitForSelector("#test"); + expect(result._humanPatched).toBe(true); + }); + + it("page.$() returns null when no element found (no crash)", async () => { + const { patchPageElementHandles } = await import("../src/human/elementhandle.js"); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + 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 page = buildMockPage(); + (page as any).$ = vi.fn(async () => null); + (page as any).$$ = vi.fn(async () => []); + (page as any).waitForSelector = vi.fn(async () => null); + + patchPageElementHandles(page as any, cfg, cursor as any, raw, rawKb, originals, null); + + const result = await (page as any).$("#nonexistent"); + expect(result).toBeNull(); + }); +}); + +describe("patchPage integrates ElementHandle patching", () => { + it("patchPage patches page.$ automatically", async () => { + const { patchPage } = await import("../src/human/index.js"); + + const el = buildMockElementHandle(); + const page = buildMockPage(); + (page as any).$ = vi.fn(async () => el); + (page as any).$$ = vi.fn(async () => []); + (page as any).waitForSelector = vi.fn(async () => null); + + const cfg = resolveConfig("default"); + const cursor = { x: 100, y: 100, initialized: true }; + patchPage(page as any, cfg, cursor as any); + + const result = await (page as any).$("#test"); + expect(result._humanPatched).toBe(true); + }); +}); + + function buildMockFrame(): any { return { click: vi.fn(async () => {}), diff --git a/tests/test_human_visual.py b/tests/test_human_visual.py index b8be89c..f84f327 100644 --- a/tests/test_human_visual.py +++ b/tests/test_human_visual.py @@ -279,6 +279,67 @@ if __name__ == "__main__": check("keyboard.type", kb_ms > 500, f"{kb_ms} ms") time.sleep(1) + # ============================================================ + # SCENARIO 7: ElementHandle — query_selector interactions + # ============================================================ + step("ElementHandle — query_selector click, type, fill, hover") + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + + print(" Watch: get element via query_selector, cursor moves smoothly") + el = page.query_selector('#searchInput') + assert el is not None, "query_selector returned None" + assert getattr(el, '_human_patched', False), "ElementHandle not patched!" + + t0 = time.time() + el.click() + eh_click_ms = int((time.time() - t0) * 1000) + check("ElementHandle click", eh_click_ms > 100, f"{eh_click_ms} ms") + time.sleep(0.5) + + print(" Watch: ElementHandle type — characters appear one by one") + t0 = time.time() + el.type('ElementHandle typing') + eh_type_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("ElementHandle type", val == 'ElementHandle typing' and eh_type_ms > 1500, f"{eh_type_ms} ms, value='{val}'") + time.sleep(0.5) + + print(" Watch: ElementHandle fill — clears then types") + t0 = time.time() + el.fill('Filled via EH') + eh_fill_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("ElementHandle fill", val == 'Filled via EH' and eh_fill_ms > 1000, f"{eh_fill_ms} ms, value='{val}'") + time.sleep(0.5) + + print(" Watch: ElementHandle hover — cursor moves without clicking") + btn_el = page.query_selector('button[type="submit"]') + t0 = time.time() + btn_el.hover() + eh_hover_ms = int((time.time() - t0) * 1000) + check("ElementHandle hover", eh_hover_ms > 50, f"{eh_hover_ms} ms") + time.sleep(0.5) + + print(" Watch: query_selector_all returns patched handles") + page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + els = page.query_selector_all('input[type="checkbox"]') + all_patched = all(getattr(e, '_human_patched', False) for e in els) + check("query_selector_all all patched", all_patched and len(els) >= 2, f"{len(els)} elements, all_patched={all_patched}") + + if els: + print(" Watch: click checkbox via ElementHandle") + t0 = time.time() + els[0].click() + cb_click_ms = int((time.time() - t0) * 1000) + check("ElementHandle checkbox click", cb_click_ms > 100, f"{cb_click_ms} ms") + time.sleep(1) + # ============================================================ # SUMMARY # ============================================================ diff --git a/tests/test_humanize_unit.py b/tests/test_humanize_unit.py index a3feb95..9961728 100644 --- a/tests/test_humanize_unit.py +++ b/tests/test_humanize_unit.py @@ -12,7 +12,7 @@ Can also run directly: python tests/test_humanize_unit.py import math import time import sys - +import asyncio import pytest @@ -535,7 +535,7 @@ class TestNonAsciiKeyboardAsync: class TestBrowserFill: def test_fill_clears_existing(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True) + browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) @@ -550,7 +550,7 @@ class TestBrowserFill: def test_fill_timing_humanized(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True) + browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) @@ -562,7 +562,7 @@ class TestBrowserFill: def test_clear_empties_field(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True) + browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) @@ -579,7 +579,7 @@ class TestBrowserFill: class TestBrowserPatching: def test_page_has_original(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True) + browser = launch(headless=False, humanize=True) page = browser.new_page() assert hasattr(page, '_original') assert hasattr(page, '_human_cfg') @@ -587,7 +587,7 @@ class TestBrowserPatching: def test_locator_methods_patched(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True) + browser = launch(headless=False, humanize=True) page = browser.new_page() from playwright.sync_api._generated import Locator methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck', @@ -608,7 +608,7 @@ class TestBrowserPatching: def test_page_human_cfg_persists(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True) + browser = launch(headless=False, humanize=True) page = browser.new_page() assert page._human_cfg is not None assert hasattr(page._human_cfg, 'idle_between_actions') @@ -618,7 +618,7 @@ class TestBrowserPatching: @pytest.mark.slow class TestBrowserBotDetection: - PROXY = '' + PROXY = None def test_behavioral_checks_pass(self): from cloakbrowser import launch @@ -644,7 +644,7 @@ class TestBrowserBotDetection: def test_form_timing(self): from cloakbrowser import launch - browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True) + browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True) page = browser.new_page() page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', wait_until='domcontentloaded') @@ -661,31 +661,639 @@ class TestBrowserBotDetection: @pytest.mark.slow class TestAsyncEndToEnd: - def test_async_launch_click_fill(self): + @pytest.mark.asyncio + async def test_async_launch_click_fill(self): """launch_async(humanize=True) — async page.click and page.fill work end-to-end.""" - import asyncio from cloakbrowser import launch_async + + browser = await launch_async(headless=False, humanize=True) + page = await browser.new_page() + assert hasattr(page, '_original'), "async page not patched" + assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" - async def _run(): - browser = await launch_async(headless=True, humanize=True) - page = await browser.new_page() - assert hasattr(page, '_original'), "async page not patched" - assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) - await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') - await asyncio.sleep(1) + t0 = time.time() + await page.locator('#searchInput').fill('async test') + elapsed_ms = int((time.time() - t0) * 1000) + assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms" - t0 = time.time() - await page.locator('#searchInput').fill('async test') - elapsed_ms = int((time.time() - t0) * 1000) - assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms" + val = await page.locator('#searchInput').input_value() + assert val == 'async test', f"async fill wrong value: {val}" - val = await page.locator('#searchInput').input_value() - assert val == 'async test', f"async fill wrong value: {val}" + await browser.close() - await browser.close() - asyncio.run(_run()) +# ========================================================================= +# 12. ElementHandle patching — SYNC +# ========================================================================= + +class TestElementHandlePatchingSync: + """Test that ElementHandle objects returned by query_selector etc. are humanized.""" + + def test_patch_single_element_handle_marks_patched(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + page = MagicMock() + page._original = MagicMock() + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = MagicMock(return_value=True) # is_input + 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) + + raw_mouse = MagicMock() + raw_keyboard = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None + ) + + assert el._human_patched is True + + def test_element_handle_click_calls_human_move(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + page = MagicMock() + 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=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) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None + ) + + # Call the patched click + el.click() + + # Should call raw_mouse.move (Bezier path) and then down/up + assert raw_mouse.move.called + assert raw_mouse.down.called + assert raw_mouse.up.called + + def test_element_handle_hover_moves_cursor_without_click(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + page = MagicMock() + 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=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) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None + ) + + el.hover() + + # Move should be called, but NOT down/up (hover, not click) + assert raw_mouse.move.called + assert not raw_mouse.down.called + + def test_element_handle_type_calls_human_type(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + page = MagicMock() + originals = MagicMock() + page._original = originals + + 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) # is input + 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) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + raw_keyboard.down = MagicMock() + raw_keyboard.up = MagicMock() + raw_keyboard.insert_text = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None + ) + + el.type("hello") + + # Mouse moved + clicked (to focus), then keyboard used + assert raw_mouse.move.called + assert raw_mouse.down.called # click to focus the input + # Keyboard events should have fired (down/up for ASCII chars) + assert raw_keyboard.down.called or raw_keyboard.insert_text.called + + def test_element_handle_fill_clears_and_types(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, call + + cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + page = MagicMock() + originals = MagicMock() + page._original = originals + + pressed_keys = [] + originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k)) + + 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) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + raw_keyboard.down = MagicMock() + raw_keyboard.up = MagicMock() + raw_keyboard.insert_text = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None + ) + + el.fill("replaced") + + # Should have pressed Select-All and Backspace to clear + import sys + expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a" + assert expected_select in pressed_keys + assert "Backspace" in pressed_keys + + def test_element_handle_no_double_patching(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + el = MagicMock() + el._human_patched = False + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + # Save patched click + first_click = el.click + + # Try to patch again + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + # Should be the same — no double wrap + assert el.click is first_click + + def test_nested_query_selector_returns_patched_handle(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + + child = MagicMock() + child._human_patched = False + child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) + child.evaluate = MagicMock(return_value=False) + child.is_checked = MagicMock(return_value=False) + child.query_selector = MagicMock(return_value=None) + child.query_selector_all = MagicMock(return_value=[]) + child.wait_for_selector = MagicMock(return_value=None) + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = MagicMock(return_value=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=child) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + result = el.query_selector("span") + assert result._human_patched is True + + def test_page_query_selector_patched(self): + from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "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) + + page = MagicMock() + page._original = MagicMock() + page.query_selector = MagicMock(return_value=el) + page.query_selector_all = MagicMock(return_value=[el]) + page.wait_for_selector = MagicMock(return_value=el) + + _patch_page_element_handles_sync( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + result = page.query_selector("#test") + assert result._human_patched is True + + def test_page_query_selector_all_patches_all(self): + from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + def make_el(): + e = MagicMock() + e._human_patched = False + e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) + e.evaluate = MagicMock(return_value=False) + e.is_checked = MagicMock(return_value=False) + e.query_selector = MagicMock(return_value=None) + e.query_selector_all = MagicMock(return_value=[]) + e.wait_for_selector = MagicMock(return_value=None) + return e + + el1, el2, el3 = make_el(), make_el(), make_el() + + page = MagicMock() + page._original = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[el1, el2, el3]) + page.wait_for_selector = MagicMock(return_value=None) + + _patch_page_element_handles_sync( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + results = page.query_selector_all("div") + for r in results: + assert r._human_patched is True + + def test_wait_for_selector_patched(self): + from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "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) + + page = MagicMock() + page._original = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=el) + + _patch_page_element_handles_sync( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + result = page.wait_for_selector("#test") + assert result._human_patched is True + + def test_element_handle_all_methods_patched(self): + """Verify all expected interaction methods are replaced.""" + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "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) + el.set_checked = MagicMock() # ensure it exists + + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press', + 'select_option', 'check', 'uncheck', 'set_checked', + 'tap', 'focus', 'query_selector', 'query_selector_all', + 'wait_for_selector'] + for method in expected_methods: + fn = getattr(el, method) + assert not isinstance(fn, MagicMock), f"el.{method} was not patched" + + +# ========================================================================= +# 13. ElementHandle patching — ASYNC +# ========================================================================= + +class TestElementHandlePatchingAsync: + @pytest.mark.asyncio + async def test_async_element_handle_click(self): + from cloakbrowser.human import _patch_single_element_handle_async, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, AsyncMock + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + page = MagicMock() + originals = MagicMock() + originals.mouse_move = AsyncMock() + page._original = originals + + el = MagicMock() + el._human_patched = False + el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) + el.evaluate = AsyncMock(return_value=False) + el.is_checked = AsyncMock(return_value=False) + el.query_selector = AsyncMock(return_value=None) + el.query_selector_all = AsyncMock(return_value=[]) + el.wait_for_selector = AsyncMock(return_value=None) + + raw_mouse = MagicMock() + raw_mouse.move = AsyncMock() + raw_mouse.down = AsyncMock() + raw_mouse.up = AsyncMock() + raw_mouse.wheel = AsyncMock() + raw_keyboard = MagicMock() + raw_keyboard.down = AsyncMock() + raw_keyboard.up = AsyncMock() + raw_keyboard.insert_text = AsyncMock() + + stealth = MagicMock() + stealth.get_cdp_session = AsyncMock(return_value=None) + + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None] + ) + + await el.click() + + assert raw_mouse.move.called + assert raw_mouse.down.called + assert raw_mouse.up.called + + @pytest.mark.asyncio + async def test_async_page_query_selector_patched(self): + from cloakbrowser.human import _patch_page_element_handles_async, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, AsyncMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + el = MagicMock() + el._human_patched = False + el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = AsyncMock(return_value=False) + el.is_checked = AsyncMock(return_value=False) + el.query_selector = AsyncMock(return_value=None) + el.query_selector_all = AsyncMock(return_value=[]) + el.wait_for_selector = AsyncMock(return_value=None) + + page = MagicMock() + page._original = MagicMock() + page.query_selector = AsyncMock(return_value=el) + page.query_selector_all = AsyncMock(return_value=[el]) + page.wait_for_selector = AsyncMock(return_value=el) + + stealth = MagicMock() + stealth.get_cdp_session = AsyncMock(return_value=None) + + _patch_page_element_handles_async( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None] + ) + + result = await page.query_selector("#test") + assert result._human_patched is True + + +# ========================================================================= +# 14. SLOW: Browser ElementHandle end-to-end +# ========================================================================= + +@pytest.mark.slow +class TestBrowserElementHandle: + def test_query_selector_click_humanized(self): + """page.query_selector() returns a patched handle — el.click() uses human curves.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + assert el is not None + assert getattr(el, '_human_patched', False), "ElementHandle not patched" + + t0 = time.time() + el.click() + click_ms = int((time.time() - t0) * 1000) + assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)" + browser.close() + + def test_query_selector_type_humanized(self): + """el.type() should type character-by-character with human timing.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + assert el is not None + + t0 = time.time() + el.type('ElementHandle test') + type_ms = int((time.time() - t0) * 1000) + assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms" + + val = page.locator('#searchInput').input_value() + assert val == 'ElementHandle test' + browser.close() + + def test_query_selector_fill_humanized(self): + """el.fill() should clear + type with human timing.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + el.type('initial') + time.sleep(0.3) + + t0 = time.time() + el.fill('replaced') + fill_ms = int((time.time() - t0) * 1000) + assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms" + + val = page.locator('#searchInput').input_value() + assert val == 'replaced' + browser.close() + + def test_query_selector_all_returns_patched(self): + """page.query_selector_all() returns all handles patched.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') + time.sleep(1) + + els = page.query_selector_all('input[type="checkbox"]') + assert len(els) >= 2 + for el in els: + assert getattr(el, '_human_patched', False), "ElementHandle not patched" + browser.close() + + def test_query_selector_hover_humanized(self): + """el.hover() should move cursor with human Bezier curve.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + t0 = time.time() + el.hover() + hover_ms = int((time.time() - t0) * 1000) + assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms" + browser.close() + + +@pytest.mark.slow +class TestAsyncElementHandle: + @pytest.mark.asyncio + async def test_async_query_selector_click(self): + from cloakbrowser import launch_async + + browser = await launch_async(headless=False, humanize=True) + page = await browser.new_page() + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + el = await page.query_selector('#searchInput') + assert el is not None + assert getattr(el, '_human_patched', False), "Async ElementHandle not patched" + + t0 = time.time() + await el.click() + click_ms = int((time.time() - t0) * 1000) + assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms" + + await browser.close() # =========================================================================