From 7bf883668342ded5905364e396c3173bc64b93a0 Mon Sep 17 00:00:00 2001 From: lilos Date: Sun, 8 Mar 2026 12:49:42 +0300 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat:=20add=20human-like=20behavioral?= =?UTF-8?q?=20layer=20(humanize=20option)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bezier mouse curves, per-character typing with mistype simulation, smooth micro-step scrolling, idle micro-movements between actions. Supports both sync and async Playwright APIs. Patches page, frame, context, browser, and Locator class methods. Two presets: 'default' (normal speed) and 'careful' (slower, deliberate). Configurable via HumanConfig dataclass / interface with full override support. Bug fixes (from PR review): - fill()/clear(): platform-aware select-all (Meta+a on macOS, Control+a elsewhere) - sync Locator check()/uncheck(): wrap mouse_move in RawMouse-compatible object - resolve_config(): raise error on unknown preset name - Lazy-load human.config via __getattr__ in __init__.py - humanPreset typed as 'default' | 'careful' literal union - browser.newPage() patches implicit context Tests: Python 36/36, JS Vitest 34/34, visual Python 17/17, JS 13/13 --- cloakbrowser/__init__.py | 15 + cloakbrowser/browser.py | 67 +- cloakbrowser/human/__init__.py | 1158 ++++++++++++++++++++++++++ cloakbrowser/human/config.py | 194 +++++ cloakbrowser/human/keyboard.py | 104 +++ cloakbrowser/human/keyboard_async.py | 85 ++ cloakbrowser/human/mouse.py | 132 +++ cloakbrowser/human/mouse_async.py | 87 ++ cloakbrowser/human/scroll.py | 132 +++ cloakbrowser/human/scroll_async.py | 129 +++ js/package-lock.json | 4 +- js/src/human/config.ts | 232 ++++++ js/src/human/index.ts | 478 +++++++++++ js/src/human/keyboard.ts | 111 +++ js/src/human/mouse.ts | 193 +++++ js/src/human/scroll.ts | 150 ++++ js/src/playwright.ts | 33 + js/src/types.ts | 6 + js/tests/humanize.test.ts | 552 ++++++++++++ tests/test_human_visual.mjs | 256 ++++++ tests/test_human_visual.py | 302 +++++++ tests/test_humanize_unit.mjs | 470 +++++++++++ tests/test_humanize_unit.py | 575 +++++++++++++ 23 files changed, 5462 insertions(+), 3 deletions(-) create mode 100644 cloakbrowser/human/__init__.py create mode 100644 cloakbrowser/human/config.py create mode 100644 cloakbrowser/human/keyboard.py create mode 100644 cloakbrowser/human/keyboard_async.py create mode 100644 cloakbrowser/human/mouse.py create mode 100644 cloakbrowser/human/mouse_async.py create mode 100644 cloakbrowser/human/scroll.py create mode 100644 cloakbrowser/human/scroll_async.py create mode 100644 js/src/human/config.ts create mode 100644 js/src/human/index.ts create mode 100644 js/src/human/keyboard.ts create mode 100644 js/src/human/mouse.ts create mode 100644 js/src/human/scroll.ts create mode 100644 js/tests/humanize.test.ts create mode 100644 tests/test_human_visual.mjs create mode 100644 tests/test_human_visual.py create mode 100644 tests/test_humanize_unit.mjs create mode 100644 tests/test_humanize_unit.py diff --git a/cloakbrowser/__init__.py b/cloakbrowser/__init__.py index 29069d5..2fb96a2 100644 --- a/cloakbrowser/__init__.py +++ b/cloakbrowser/__init__.py @@ -16,6 +16,18 @@ from .config import CHROMIUM_VERSION, get_default_stealth_args from .download import binary_info, check_for_update, clear_cache, ensure_binary from ._version import __version__ +# Human-like behavioral layer (optional) +def __getattr__(name): + if name == "HumanConfig": + from .human.config import HumanConfig + globals()["HumanConfig"] = HumanConfig + return HumanConfig + if name == "resolve_human_config": + from .human.config import resolve_config + globals()["resolve_human_config"] = resolve_config + return resolve_config + raise AttributeError(f"module 'cloakbrowser' has no attribute {name}") + __all__ = [ "launch", "launch_async", @@ -29,5 +41,8 @@ __all__ = [ "CHROMIUM_VERSION", "get_default_stealth_args", "ProxySettings", + "HumanConfig", + "resolve_human_config", "__version__", ] + diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 7aefd58..ce41fed 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -58,6 +58,9 @@ def launch( locale: str | None = None, geoip: bool = False, backend: str | None = None, + humanize: bool = False, + human_preset: str = "default", + human_config: dict | None = None, **kwargs: Any, ) -> Any: """Launch stealth Chromium browser. Returns a Playwright Browser object. @@ -81,6 +84,9 @@ def launch( Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise) but breaks proxy auth and add_init_script. Override globally with CLOAKBROWSER_BACKEND env var. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config dict to override preset values. **kwargs: Passed directly to playwright.chromium.launch(). Returns: @@ -121,10 +127,17 @@ def launch( browser.close = _close_with_cleanup + # Human-like behavioral patching + if humanize: + from .human import patch_browser + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_browser(browser, cfg) + return browser -async def launch_async( +async def launch_async( # noqa: C901 headless: bool = True, proxy: str | ProxySettings | None = None, args: list[str] | None = None, @@ -133,6 +146,9 @@ async def launch_async( locale: str | None = None, geoip: bool = False, backend: str | None = None, + humanize: bool = False, + human_preset: str = "default", + human_config: dict | None = None, **kwargs: Any, ) -> Any: """Async version of launch(). Returns a Playwright Browser object. @@ -146,6 +162,9 @@ async def launch_async( locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag. geoip: Auto-detect timezone/locale from proxy IP (default False). backend: Playwright backend — 'playwright' (default) or 'patchright'. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config dict to override preset values. **kwargs: Passed directly to playwright.chromium.launch(). Returns: @@ -191,6 +210,13 @@ async def launch_async( browser.close = _close_with_cleanup + # Human-like behavioral patching (async variant) + if humanize: + from .human import patch_browser_async + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_browser_async(browser, cfg) + return browser @@ -207,6 +233,9 @@ def launch_persistent_context( color_scheme: Literal["light", "dark", "no-preference"] | None = None, geoip: bool = False, backend: str | None = None, + humanize: bool = False, + human_preset: str = "default", + human_config: dict | None = None, **kwargs: Any, ) -> Any: """Launch stealth browser with a persistent profile and return a BrowserContext. @@ -232,6 +261,9 @@ def launch_persistent_context( geoip: Auto-detect timezone/locale from proxy IP (default False). Requires ``pip install cloakbrowser[geoip]``. backend: Playwright backend — 'playwright' (default) or 'patchright'. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config dict to override preset values. **kwargs: Passed directly to playwright.chromium.launch_persistent_context(). Returns: @@ -291,6 +323,13 @@ def launch_persistent_context( context.close = _close_with_cleanup + # Human-like behavioral patching + if humanize: + from .human import patch_context + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context(context, cfg) + return context @@ -307,6 +346,9 @@ async def launch_persistent_context_async( color_scheme: Literal["light", "dark", "no-preference"] | None = None, geoip: bool = False, backend: str | None = None, + humanize: bool = False, + human_preset: str = "default", + human_config: dict | None = None, **kwargs: Any, ) -> Any: """Async version of launch_persistent_context(). @@ -329,6 +371,9 @@ async def launch_persistent_context_async( color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. geoip: Auto-detect timezone/locale from proxy IP (default False). backend: Playwright backend — 'playwright' (default) or 'patchright'. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config dict to override preset values. **kwargs: Passed directly to playwright.chromium.launch_persistent_context(). Returns: @@ -393,6 +438,13 @@ async def launch_persistent_context_async( context.close = _close_with_cleanup + # Human-like behavioral patching (async variant) + if humanize: + from .human import patch_context_async + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context_async(context, cfg) + return context @@ -408,6 +460,9 @@ def launch_context( color_scheme: Literal["light", "dark", "no-preference"] | None = None, geoip: bool = False, backend: str | None = None, + humanize: bool = False, + human_preset: str = "default", + human_config: dict | None = None, **kwargs: Any, ) -> Any: """Launch stealth browser and return a BrowserContext with common options pre-set. @@ -428,6 +483,9 @@ def launch_context( Default: None (uses Chromium default, which is 'light'). geoip: Auto-detect timezone/locale from proxy IP (default False). backend: Playwright backend — 'playwright' (default) or 'patchright'. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config dict to override preset values. **kwargs: Passed to browser.new_context(). Returns: @@ -471,6 +529,13 @@ def launch_context( context.close = _close_context_with_cleanup + # Human-like behavioral patching + if humanize: + from .human import patch_context + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context(context, cfg) + return context diff --git a/cloakbrowser/human/__init__.py b/cloakbrowser/human/__init__.py new file mode 100644 index 0000000..bd072d2 --- /dev/null +++ b/cloakbrowser/human/__init__.py @@ -0,0 +1,1158 @@ +"""Human-like behavioral layer for cloakbrowser. + +Activated via humanize=True in launch() / launch_async(). +Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling. + +Supports both sync and async Playwright APIs. +""" + +from __future__ import annotations + +import logging +import sys +from typing import Any + +from .config import HumanConfig, HumanPreset, resolve_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 .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 + +_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", + "human_move", "human_click", "click_target", "human_idle", + "human_type", "scroll_to_element", +] + +logger = logging.getLogger("cloakbrowser.human") + +class _CursorState: + __slots__ = ("x", "y", "initialized") + + def __init__(self) -> None: + self.x: float = 0 + self.y: float = 0 + self.initialized: bool = False + + +def _is_input_element(page: Any, selector: str) -> bool: + try: + return page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }""", + selector, + ) + except Exception: + return False + + +async def _async_is_input_element(page: Any, selector: str) -> bool: + try: + return await page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }""", + selector, + ) + except Exception: + return False + + +def _is_selector_focused(page: Any, selector: str) -> bool: + """Check if the element matching selector is currently focused.""" + try: + return page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }""", + selector, + ) + except Exception: + return False + + +async def _async_is_selector_focused(page: Any, selector: str) -> bool: + """Check if the element matching selector is currently focused (async).""" + try: + return await page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }""", + selector, + ) + except Exception: + return False + + +# ============================================================================ +# Locator class-level patching (sync) +# ============================================================================ + +_locator_sync_patched = False + + +def _patch_locator_class_sync(): + """Patch all Locator interaction methods to go through humanized page methods.""" + global _locator_sync_patched + if _locator_sync_patched: + return + _locator_sync_patched = True + + from playwright.sync_api._generated import Locator + + _orig_fill = Locator.fill + _orig_click = Locator.click + _orig_type = Locator.type + _orig_dblclick = Locator.dblclick + _orig_hover = Locator.hover + _orig_check = Locator.check + _orig_uncheck = Locator.uncheck + _orig_set_checked = Locator.set_checked + _orig_select_option = Locator.select_option + _orig_press = Locator.press + _orig_press_sequentially = Locator.press_sequentially + _orig_tap = Locator.tap + _orig_drag_to = Locator.drag_to + _orig_clear = Locator.clear + + def _get_selector(self): + return self._impl_obj._selector + + def _is_humanized(self): + return hasattr(self.page, '_original') + + def _get_cfg(self): + return getattr(self.page, '_human_cfg', None) + + def _humanized_fill(self, value, **kwargs): + if _is_humanized(self): + self.page.fill(_get_selector(self), value) + else: + _orig_fill(self, value, **kwargs) + + def _humanized_click(self, **kwargs): + if _is_humanized(self): + self.page.click(_get_selector(self)) + else: + _orig_click(self, **kwargs) + + def _humanized_type(self, text, **kwargs): + if _is_humanized(self): + self.page.type(_get_selector(self), text) + else: + _orig_type(self, text, **kwargs) + + def _humanized_dblclick(self, **kwargs): + if _is_humanized(self): + self.page.dblclick(_get_selector(self)) + else: + _orig_dblclick(self, **kwargs) + + def _humanized_hover(self, **kwargs): + if _is_humanized(self): + self.page.hover(_get_selector(self)) + else: + _orig_hover(self, **kwargs) + + def _humanized_check(self, **kwargs): + if _is_humanized(self): + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg) + checked = self.is_checked() + if not checked: + self.page.click(_get_selector(self)) + else: + _orig_check(self, **kwargs) + + def _humanized_uncheck(self, **kwargs): + if _is_humanized(self): + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg) + checked = self.is_checked() + if checked: + self.page.click(_get_selector(self)) + else: + _orig_uncheck(self, **kwargs) + + def _humanized_set_checked(self, checked, **kwargs): + if _is_humanized(self): + current = self.is_checked() + if current != checked: + self.page.click(_get_selector(self)) + else: + _orig_set_checked(self, checked, **kwargs) + + def _humanized_select_option(self, value=None, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + self.page.hover(selector) + sleep_ms(rand(100, 300)) + _orig_select_option(self, value, **kwargs) + else: + _orig_select_option(self, value, **kwargs) + + def _humanized_press(self, key, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + # Only click if not already focused — avoids redundant mouse moves + if not _is_selector_focused(self.page, selector): + self.page.click(selector) + sleep_ms(rand(50, 150)) + self.page.keyboard.press(key) + else: + _orig_press(self, key, **kwargs) + + def _humanized_press_sequentially(self, text, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + if not _is_selector_focused(self.page, selector): + self.page.click(selector) + sleep_ms(rand(50, 150)) + self.page.keyboard.type(text) + else: + _orig_press_sequentially(self, text, **kwargs) + + def _humanized_tap(self, **kwargs): + if _is_humanized(self): + self.page.click(_get_selector(self)) + else: + _orig_tap(self, **kwargs) + + def _humanized_drag_to(self, target, **kwargs): + if _is_humanized(self): + page = self.page + originals = getattr(page, '_original', None) + src_box = self.bounding_box() + tgt_box = target.bounding_box() + if src_box and tgt_box and originals: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + page.mouse.move(sx, sy) + sleep_ms(rand(100, 200)) + originals.mouse_down() + sleep_ms(rand(80, 150)) + page.mouse.move(tx, ty) + sleep_ms(rand(80, 150)) + originals.mouse_up() + else: + _orig_drag_to(self, target, **kwargs) + else: + _orig_drag_to(self, target, **kwargs) + + def _humanized_clear(self, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + if not _is_selector_focused(self.page, selector): + self.page.click(selector) + sleep_ms(rand(50, 100)) + self.page.keyboard.press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + self.page.keyboard.press("Backspace") + else: + _orig_clear(self, **kwargs) + + Locator.fill = _humanized_fill + Locator.click = _humanized_click + Locator.type = _humanized_type + Locator.dblclick = _humanized_dblclick + Locator.hover = _humanized_hover + Locator.check = _humanized_check + Locator.uncheck = _humanized_uncheck + Locator.set_checked = _humanized_set_checked + Locator.select_option = _humanized_select_option + Locator.press = _humanized_press + Locator.press_sequentially = _humanized_press_sequentially + Locator.tap = _humanized_tap + Locator.drag_to = _humanized_drag_to + Locator.clear = _humanized_clear + + +# ============================================================================ +# Locator class-level patching (async) +# ============================================================================ + +_locator_async_patched = False + + +def _patch_locator_class_async(): + """Patch all async Locator interaction methods to go through humanized page methods.""" + global _locator_async_patched + if _locator_async_patched: + return + _locator_async_patched = True + + from playwright.async_api._generated import Locator as AsyncLocator + + _orig_fill = AsyncLocator.fill + _orig_click = AsyncLocator.click + _orig_type = AsyncLocator.type + _orig_dblclick = AsyncLocator.dblclick + _orig_hover = AsyncLocator.hover + _orig_check = AsyncLocator.check + _orig_uncheck = AsyncLocator.uncheck + _orig_set_checked = AsyncLocator.set_checked + _orig_select_option = AsyncLocator.select_option + _orig_press = AsyncLocator.press + _orig_press_sequentially = AsyncLocator.press_sequentially + _orig_tap = AsyncLocator.tap + _orig_drag_to = AsyncLocator.drag_to + _orig_clear = AsyncLocator.clear + + def _get_selector(self): + return self._impl_obj._selector + + def _is_humanized(self): + return hasattr(self.page, '_original') + + def _get_cfg(self): + return getattr(self.page, '_human_cfg', None) + + async def _humanized_fill(self, value, **kwargs): + if _is_humanized(self): + await self.page.fill(_get_selector(self), value) + else: + await _orig_fill(self, value, **kwargs) + + async def _humanized_click(self, **kwargs): + if _is_humanized(self): + await self.page.click(_get_selector(self)) + 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) + else: + await _orig_type(self, text, **kwargs) + + async def _humanized_dblclick(self, **kwargs): + if _is_humanized(self): + await self.page.dblclick(_get_selector(self)) + else: + await _orig_dblclick(self, **kwargs) + + async def _humanized_hover(self, **kwargs): + if _is_humanized(self): + await self.page.hover(_get_selector(self)) + else: + await _orig_hover(self, **kwargs) + + async def _humanized_check(self, **kwargs): + if _is_humanized(self): + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + await async_human_idle( + raw, + rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), + 0, 0, cfg, + ) + checked = await self.is_checked() + if not checked: + await self.page.click(_get_selector(self)) + else: + await _orig_check(self, **kwargs) + + async def _humanized_uncheck(self, **kwargs): + if _is_humanized(self): + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + await async_human_idle( + raw, + rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), + 0, 0, cfg, + ) + checked = await self.is_checked() + if checked: + await self.page.click(_get_selector(self)) + else: + await _orig_uncheck(self, **kwargs) + + async def _humanized_set_checked(self, checked, **kwargs): + if _is_humanized(self): + current = await self.is_checked() + if current != checked: + await self.page.click(_get_selector(self)) + else: + await _orig_set_checked(self, checked, **kwargs) + + async def _humanized_select_option(self, value=None, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + await self.page.hover(selector) + await async_sleep_ms(rand(100, 300)) + await _orig_select_option(self, value, **kwargs) + else: + await _orig_select_option(self, value, **kwargs) + + async def _humanized_press(self, key, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + if not await _async_is_selector_focused(self.page, selector): + await self.page.click(selector) + await async_sleep_ms(rand(50, 150)) + await self.page.keyboard.press(key) + else: + await _orig_press(self, key, **kwargs) + + async def _humanized_press_sequentially(self, text, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + if not await _async_is_selector_focused(self.page, selector): + await self.page.click(selector) + await async_sleep_ms(rand(50, 150)) + await self.page.keyboard.type(text) + else: + await _orig_press_sequentially(self, text, **kwargs) + + async def _humanized_tap(self, **kwargs): + if _is_humanized(self): + await self.page.click(_get_selector(self)) + else: + await _orig_tap(self, **kwargs) + + async def _humanized_drag_to(self, target, **kwargs): + if _is_humanized(self): + page = self.page + originals = getattr(page, '_original', None) + src_box = await self.bounding_box() + tgt_box = await target.bounding_box() + if src_box and tgt_box and originals: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + await page.mouse.move(sx, sy) + await async_sleep_ms(rand(100, 200)) + await originals.mouse_down() + await async_sleep_ms(rand(80, 150)) + await page.mouse.move(tx, ty) + await async_sleep_ms(rand(80, 150)) + await originals.mouse_up() + else: + await _orig_drag_to(self, target, **kwargs) + else: + await _orig_drag_to(self, target, **kwargs) + + async def _humanized_clear(self, **kwargs): + if _is_humanized(self): + selector = _get_selector(self) + if not await _async_is_selector_focused(self.page, selector): + await self.page.click(selector) + await async_sleep_ms(rand(50, 100)) + await self.page.keyboard.press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await self.page.keyboard.press("Backspace") + else: + await _orig_clear(self, **kwargs) + + AsyncLocator.fill = _humanized_fill + AsyncLocator.click = _humanized_click + AsyncLocator.type = _humanized_type + AsyncLocator.dblclick = _humanized_dblclick + AsyncLocator.hover = _humanized_hover + AsyncLocator.check = _humanized_check + AsyncLocator.uncheck = _humanized_uncheck + AsyncLocator.set_checked = _humanized_set_checked + AsyncLocator.select_option = _humanized_select_option + AsyncLocator.press = _humanized_press + AsyncLocator.press_sequentially = _humanized_press_sequentially + AsyncLocator.tap = _humanized_tap + AsyncLocator.drag_to = _humanized_drag_to + AsyncLocator.clear = _humanized_clear + + +# ============================================================================ +# SYNC patching +# ============================================================================ + + +def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: + """Replace page methods with human-like implementations (sync).""" + originals = type("Originals", (), { + "click": page.click, + "type": page.type, + "fill": page.fill, + "goto": page.goto, + "hover": page.hover, + "dblclick": page.dblclick, + "mouse_move": page.mouse.move, + "mouse_click": page.mouse.click, + "mouse_wheel": page.mouse.wheel, + "mouse_down": page.mouse.down, + "mouse_up": page.mouse.up, + "keyboard_type": page.keyboard.type, + "keyboard_down": page.keyboard.down, + "keyboard_up": page.keyboard.up, + "keyboard_press": page.keyboard.press, + "keyboard_insert_text": page.keyboard.insert_text, + })() + + page._original = originals + page._human_cfg = cfg + + raw_mouse: RawMouse = type("_RawMouse", (), { + "move": originals.mouse_move, + "down": originals.mouse_down, + "up": originals.mouse_up, + "wheel": originals.mouse_wheel, + })() + + raw_keyboard: RawKeyboard = type("_RawKeyboard", (), { + "down": originals.keyboard_down, + "up": originals.keyboard_up, + "type": originals.keyboard_type, + "insert_text": originals.keyboard_insert_text, + })() + + def _ensure_cursor_init() -> None: + 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 + + def _human_goto(url: str, **kwargs: Any) -> Any: + response = originals.goto(url, **kwargs) + return response + + 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) + box, cx, cy = scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, cfg + ) + 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) + cursor.x = target.x + cursor.y = target.y + human_click(raw_mouse, is_input, 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) + box, cx, cy = scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, cfg + ) + 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) + cursor.x = target.x + cursor.y = target.y + raw_mouse.down(click_count=2) + sleep_ms(rand(30, 60)) + raw_mouse.up(click_count=2) + + 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) + box, cx, cy = scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, cfg + ) + 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) + 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) + sleep_ms(rand(100, 250)) + human_type(page, raw_keyboard, text, cfg) + + def _human_fill(selector: str, value: str, **kwargs: Any) -> None: + sleep_ms(rand_range(cfg.field_switch_delay)) + _human_click(selector) + 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) + + def _human_check(selector: str, **kwargs: Any) -> None: + try: + checked = page.is_checked(selector) + except Exception: + checked = False + if not checked: + _human_click(selector) + + def _human_uncheck(selector: str, **kwargs: Any) -> None: + try: + checked = page.is_checked(selector) + except Exception: + checked = True + if checked: + _human_click(selector) + + def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + _human_hover(selector) + sleep_ms(rand(100, 300)) + return originals.click(selector) + + def _human_press(selector: str, key: str, **kwargs: Any) -> None: + if not _is_selector_focused(page, selector): + _human_click(selector) + sleep_ms(rand(50, 150)) + originals.keyboard_press(key) + + def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None: + _ensure_cursor_init() + human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + + def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None: + _ensure_cursor_init() + human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + human_click(raw_mouse, False, cfg) + + def _human_keyboard_type(text: str, **kwargs: Any) -> None: + human_type(page, raw_keyboard, text, cfg) + + page.goto = _human_goto + page.click = _human_click + page.dblclick = _human_dblclick + page.hover = _human_hover + page.type = _human_type + page.fill = _human_fill + page.check = _human_check + page.uncheck = _human_uncheck + page.press = _human_press + page.mouse.move = _human_mouse_move + page.mouse.click = _human_mouse_click + page.keyboard.type = _human_keyboard_type + # --- Patch Frame-level methods (for sub-frames) --- + _patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + # 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]) + try: + originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + except Exception: + pass + + # --- Patch Locator class (class-level, runs once) --- + _patch_locator_class_sync() + + +def _patch_frames_sync( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, +) -> None: + for frame in _iter_frames(page): + _patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + orig_main_frame = getattr(page, "_original_main_frame", None) + if orig_main_frame is None: + try: + _orig_goto = originals.goto + + def _frame_aware_goto(url: str, **kwargs: Any) -> Any: + response = _orig_goto(url, **kwargs) + for frame in _iter_frames(page): + if not getattr(frame, "_human_patched", False): + _patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + return response + + page.goto = _frame_aware_goto + page._original_main_frame = True + except Exception: + pass + + +def _patch_single_frame_sync( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, +) -> None: + if getattr(frame, "_human_patched", False): + return + frame._human_patched = True + + # Save originals for methods that need fallback + _orig_frame_select_option = frame.select_option + _orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None) + + def _frame_click(selector: str, **kwargs: Any) -> None: + page.click(selector) + + def _frame_dblclick(selector: str, **kwargs: Any) -> None: + page.dblclick(selector) + + def _frame_hover(selector: str, **kwargs: Any) -> None: + page.hover(selector) + + def _frame_type(selector: str, text: str, **kwargs: Any) -> None: + page.type(selector, text) + + def _frame_fill(selector: str, value: str, **kwargs: Any) -> None: + page.fill(selector, value) + + def _frame_check(selector: str, **kwargs: Any) -> None: + page.check(selector) + + def _frame_uncheck(selector: str, **kwargs: Any) -> None: + page.uncheck(selector) + + def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + page.hover(selector) + sleep_ms(rand(100, 300)) + return _orig_frame_select_option(selector, value, **kwargs) + + def _frame_press(selector: str, key: str, **kwargs: Any) -> None: + page.press(selector, key) + + def _frame_clear(selector: str, **kwargs: Any) -> None: + if not _is_selector_focused(page, selector): + page.click(selector) + sleep_ms(rand(50, 100)) + originals.keyboard_press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + originals.keyboard_press("Backspace") + + def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None: + try: + src_box = frame.locator(source).bounding_box() + tgt_box = frame.locator(target).bounding_box() + except Exception: + src_box = tgt_box = None + if src_box and tgt_box: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + page.mouse.move(sx, sy) + sleep_ms(rand(100, 200)) + originals.mouse_down() + sleep_ms(rand(80, 150)) + page.mouse.move(tx, ty) + sleep_ms(rand(80, 150)) + originals.mouse_up() + elif _orig_frame_drag_and_drop: + _orig_frame_drag_and_drop(source, target, **kwargs) + + frame.click = _frame_click + frame.dblclick = _frame_dblclick + frame.hover = _frame_hover + frame.type = _frame_type + frame.fill = _frame_fill + frame.check = _frame_check + frame.uncheck = _frame_uncheck + frame.select_option = _frame_select_option + frame.press = _frame_press + frame.clear = _frame_clear + frame.drag_and_drop = _frame_drag_and_drop + + +def _iter_frames(page: Any): + try: + main = page.main_frame + yield main + for child in main.child_frames: + yield child + except Exception: + pass + + +def patch_context(context: Any, cfg: HumanConfig) -> None: + cursor = _CursorState() + for page in context.pages: + patch_page(page, cfg, cursor) + context.on("page", lambda p: patch_page(p, cfg, _CursorState()) if not hasattr(p, '_original') else None) + + orig_new_page = context.new_page + + def _patched_new_page(**kwargs: Any) -> Any: + page = orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page(page, cfg, _CursorState()) + return page + + context.new_page = _patched_new_page + + +def patch_browser(browser: Any, cfg: HumanConfig) -> None: + for context in browser.contexts: + patch_context(context, cfg) + + orig_new_context = browser.new_context + + def _patched_new_context(**kwargs: Any) -> Any: + context = orig_new_context(**kwargs) + patch_context(context, cfg) + return context + + browser.new_context = _patched_new_context + + orig_new_page = browser.new_page + + def _patched_new_page(**kwargs: Any) -> Any: + page = orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page(page, cfg, _CursorState()) + return page + + browser.new_page = _patched_new_page + + +# ============================================================================ +# ASYNC patching +# ============================================================================ + + +def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: + originals = type("Originals", (), { + "click": page.click, + "type": page.type, + "fill": page.fill, + "goto": page.goto, + "hover": page.hover, + "dblclick": page.dblclick, + "mouse_move": page.mouse.move, + "mouse_click": page.mouse.click, + "mouse_wheel": page.mouse.wheel, + "mouse_down": page.mouse.down, + "mouse_up": page.mouse.up, + "keyboard_type": page.keyboard.type, + "keyboard_down": page.keyboard.down, + "keyboard_up": page.keyboard.up, + "keyboard_press": page.keyboard.press, + "keyboard_insert_text": page.keyboard.insert_text, + })() + + page._original = originals + page._human_cfg = cfg + + raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), { + "move": originals.mouse_move, + "down": originals.mouse_down, + "up": originals.mouse_up, + "wheel": originals.mouse_wheel, + })() + + raw_keyboard: AsyncRawKeyboard = type("_AsyncRawKeyboard", (), { + "down": originals.keyboard_down, + "up": originals.keyboard_up, + "type": originals.keyboard_type, + "insert_text": originals.keyboard_insert_text, + })() + + async def _ensure_cursor_init() -> None: + 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 + + async def _human_goto(url: str, **kwargs: Any) -> Any: + response = await originals.goto(url, **kwargs) + return response + + 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) + box, cx, cy = await async_scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, cfg + ) + 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) + cursor.x = target.x + cursor.y = target.y + await async_human_click(raw_mouse, is_input, 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) + box, cx, cy = await async_scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, cfg + ) + 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) + cursor.x = target.x + cursor.y = target.y + await raw_mouse.down(click_count=2) + await async_sleep_ms(rand(30, 60)) + await raw_mouse.up(click_count=2) + + 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) + box, cx, cy = await async_scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, cfg + ) + 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) + 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) + await async_sleep_ms(rand(100, 250)) + await async_human_type(page, raw_keyboard, text, cfg) + + 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) + 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)) + await async_human_type(page, raw_keyboard, value, cfg) + + async def _human_check(selector: str, **kwargs: Any) -> None: + try: + checked = await page.is_checked(selector) + except Exception: + checked = False + if not checked: + await _human_click(selector) + + async def _human_uncheck(selector: str, **kwargs: Any) -> None: + try: + checked = await page.is_checked(selector) + except Exception: + checked = True + if checked: + await _human_click(selector) + + async def _human_press(selector: str, key: str, **kwargs: Any) -> None: + if not await _async_is_selector_focused(page, selector): + await _human_click(selector) + await async_sleep_ms(rand(50, 150)) + await originals.keyboard_press(key) + + async def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None: + await _ensure_cursor_init() + await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + + async def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None: + await _ensure_cursor_init() + await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + await async_human_click(raw_mouse, False, cfg) + + async def _human_keyboard_type(text: str, **kwargs: Any) -> None: + await async_human_type(page, raw_keyboard, text, cfg) + + page.goto = _human_goto + page.click = _human_click + page.dblclick = _human_dblclick + page.hover = _human_hover + page.type = _human_type + page.fill = _human_fill + page.check = _human_check + page.uncheck = _human_uncheck + page.press = _human_press + page.mouse.move = _human_mouse_move + page.mouse.click = _human_mouse_click + page.keyboard.type = _human_keyboard_type + + # --- Patch Frame-level methods (for sub-frames) --- + _patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + # --- Patch async Locator class (class-level, runs once) --- + _patch_locator_class_async() + + +def _patch_frames_async( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, +) -> None: + for frame in _iter_frames(page): + _patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + orig_main_frame = getattr(page, "_original_main_frame", None) + if orig_main_frame is None: + try: + _orig_goto = originals.goto + + async def _frame_aware_goto(url: str, **kwargs: Any) -> Any: + response = await _orig_goto(url, **kwargs) + for frame in _iter_frames(page): + if not getattr(frame, "_human_patched", False): + _patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + return response + + page.goto = _frame_aware_goto + page._original_main_frame = True + except Exception: + pass + + +def _patch_single_frame_async( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, +) -> None: + if getattr(frame, "_human_patched", False): + return + frame._human_patched = True + + _orig_frame_select_option = frame.select_option + _orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None) + + async def _frame_click(selector: str, **kwargs: Any) -> None: + await page.click(selector) + + async def _frame_dblclick(selector: str, **kwargs: Any) -> None: + await page.dblclick(selector) + + async def _frame_hover(selector: str, **kwargs: Any) -> None: + await page.hover(selector) + + async def _frame_type(selector: str, text: str, **kwargs: Any) -> None: + await page.type(selector, text) + + async def _frame_fill(selector: str, value: str, **kwargs: Any) -> None: + await page.fill(selector, value) + + async def _frame_check(selector: str, **kwargs: Any) -> None: + await page.check(selector) + + async def _frame_uncheck(selector: str, **kwargs: Any) -> None: + await page.uncheck(selector) + + async def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + await page.hover(selector) + await async_sleep_ms(rand(100, 300)) + return await _orig_frame_select_option(selector, value, **kwargs) + + async def _frame_press(selector: str, key: str, **kwargs: Any) -> None: + await page.press(selector, key) + + async def _frame_clear(selector: str, **kwargs: Any) -> None: + if not await _async_is_selector_focused(page, selector): + await page.click(selector) + await async_sleep_ms(rand(50, 100)) + await originals.keyboard_press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await originals.keyboard_press("Backspace") + + async def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None: + try: + src_box = await frame.locator(source).bounding_box() + tgt_box = await frame.locator(target).bounding_box() + except Exception: + src_box = tgt_box = None + if src_box and tgt_box: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + await page.mouse.move(sx, sy) + await async_sleep_ms(rand(100, 200)) + await originals.mouse_down() + await async_sleep_ms(rand(80, 150)) + await page.mouse.move(tx, ty) + await async_sleep_ms(rand(80, 150)) + await originals.mouse_up() + elif _orig_frame_drag_and_drop: + await _orig_frame_drag_and_drop(source, target, **kwargs) + + frame.click = _frame_click + frame.dblclick = _frame_dblclick + frame.hover = _frame_hover + frame.type = _frame_type + frame.fill = _frame_fill + frame.check = _frame_check + frame.uncheck = _frame_uncheck + frame.select_option = _frame_select_option + frame.press = _frame_press + frame.clear = _frame_clear + frame.drag_and_drop = _frame_drag_and_drop + + +def patch_context_async(context: Any, cfg: HumanConfig) -> None: + cursor = _CursorState() + for page in context.pages: + patch_page_async(page, cfg, cursor) + context.on("page", lambda p: patch_page_async(p, cfg, _CursorState()) if not hasattr(p, '_original') else None) + + orig_new_page = context.new_page + + async def _patched_new_page(**kwargs: Any) -> Any: + page = await orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page_async(page, cfg, _CursorState()) + return page + + context.new_page = _patched_new_page + + +def patch_browser_async(browser: Any, cfg: HumanConfig) -> None: + for context in browser.contexts: + patch_context_async(context, cfg) + + orig_new_context = browser.new_context + + async def _patched_new_context(**kwargs: Any) -> Any: + context = await orig_new_context(**kwargs) + patch_context_async(context, cfg) + return context + + browser.new_context = _patched_new_context + + orig_new_page = browser.new_page + + async def _patched_new_page(**kwargs: Any) -> Any: + page = await orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page_async(page, cfg, _CursorState()) + return page + + browser.new_page = _patched_new_page diff --git a/cloakbrowser/human/config.py b/cloakbrowser/human/config.py new file mode 100644 index 0000000..1a00abd --- /dev/null +++ b/cloakbrowser/human/config.py @@ -0,0 +1,194 @@ +"""cloakbrowser-human — Configuration and presets. + +All numeric parameters for human-like behavior are centralized here. +Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious). +""" + +from __future__ import annotations + +import math +import random +import time +from dataclasses import dataclass, field +from typing import Literal, Tuple + +# --------------------------------------------------------------------------- +# Type alias +# --------------------------------------------------------------------------- + +Range = Tuple[float, float] +HumanPreset = Literal["default", "careful"] + + +# --------------------------------------------------------------------------- +# Configuration dataclass +# --------------------------------------------------------------------------- + +@dataclass +class HumanConfig: + """All tunable parameters for human-like behavior.""" + + # Keyboard + typing_delay: float = 70 + typing_delay_spread: float = 40 + typing_pause_chance: float = 0.1 + typing_pause_range: Range = (400, 1000) + shift_down_delay: Range = (30, 70) + shift_up_delay: Range = (20, 50) + key_hold: Range = (15, 35) + + # Mistype (typo simulation) + mistype_chance: float = 0.02 + mistype_delay_notice: Range = (100, 300) + mistype_delay_correct: Range = (50, 150) + + field_switch_delay: Range = (800, 1500) + + # Mouse — movement + mouse_steps_divisor: float = 8 + mouse_min_steps: int = 25 + mouse_max_steps: int = 80 + mouse_wobble_max: float = 1.5 + mouse_overshoot_chance: float = 0.15 + mouse_overshoot_px: Range = (3, 6) + mouse_burst_size: Range = (3, 5) + mouse_burst_pause: Range = (8, 18) + + # Mouse — clicks + click_aim_delay_input: Range = (60, 140) + click_aim_delay_button: Range = (80, 200) + click_hold_input: Range = (40, 100) + click_hold_button: Range = (60, 150) + click_input_x_range: Range = (0.05, 0.30) + + # Mouse — idle + idle_drift_px: float = 3 + idle_pause_range: Range = (300, 1000) + + # Scroll + scroll_delta_base: Range = (80, 130) + scroll_delta_variance: float = 0.2 + scroll_pause_fast: Range = (30, 80) + scroll_pause_slow: Range = (80, 200) + scroll_accel_steps: Range = (2, 3) + scroll_decel_steps: Range = (2, 3) + scroll_overshoot_chance: float = 0.1 + scroll_overshoot_px: Range = (50, 150) + scroll_settle_delay: Range = (300, 600) + scroll_target_zone: Range = (0.20, 0.80) + scroll_pre_move_delay: Range = (100, 300) + + # Initial cursor position (as if coming from the address bar area) + initial_cursor_x: Range = (400, 700) + initial_cursor_y: Range = (45, 60) + + # Idle micro-movements between actions (opt-in, adds latency) + idle_between_actions: bool = False + idle_between_duration: Range = (0.3, 0.8) + + +# --------------------------------------------------------------------------- +# Presets +# --------------------------------------------------------------------------- + +def _careful_config() -> HumanConfig: + """Careful preset — everything slower and more deliberate.""" + return HumanConfig( + # Keyboard — slower typing + typing_delay=100, + typing_delay_spread=50, + typing_pause_chance=0.15, + typing_pause_range=(500, 1200), + shift_down_delay=(40, 90), + shift_up_delay=(30, 70), + key_hold=(20, 45), + field_switch_delay=(1000, 2000), + # Mouse — slower, more precise + mouse_overshoot_chance=0.10, + mouse_burst_pause=(12, 25), + # Mouse — clicks (longer aiming and holding) + click_aim_delay_input=(80, 180), + click_aim_delay_button=(120, 280), + click_hold_input=(60, 140), + click_hold_button=(80, 200), + # Scroll — slower + scroll_pause_fast=(100, 200), + scroll_pause_slow=(250, 600), + scroll_settle_delay=(400, 800), + scroll_pre_move_delay=(150, 400), + # Idle between actions enabled for careful preset + idle_between_actions=True, + idle_between_duration=(0.4, 1.0), + ) + + +_PRESETS: dict[str, HumanConfig] = { + "default": HumanConfig(), + "careful": _careful_config(), +} + + +def resolve_config( + preset: HumanPreset = "default", + overrides: dict | None = None, +) -> HumanConfig: + """Resolve a preset name + optional overrides into a full HumanConfig. + + Args: + preset: 'default' or 'careful'. + overrides: Dict of field names to override values. + + Returns: + A new HumanConfig instance. + + Raises: + ValueError: If preset is not a recognized name. + """ + if preset not in _PRESETS: + raise ValueError( + f"Unknown humanize preset {preset!r}. " + f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}" + ) + base = _PRESETS[preset] + if not overrides: + return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__}) + merged = {k: getattr(base, k) for k in base.__dataclass_fields__} + merged.update(overrides) + return HumanConfig(**merged) + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + +def rand(lo: float, hi: float) -> float: + """Random float in [lo, hi].""" + return random.uniform(lo, hi) + + +def rand_int(lo: int, hi: int) -> int: + """Random integer in [lo, hi] inclusive.""" + return random.randint(lo, hi) + + +def rand_range(r: Range) -> float: + """Random float from a (min, max) tuple.""" + return random.uniform(r[0], r[1]) + + +def rand_int_range(r: Range) -> int: + """Random integer from a (min, max) tuple, inclusive.""" + return random.randint(int(r[0]), int(r[1])) + + +def sleep_ms(ms: float) -> None: + """Sleep for `ms` milliseconds.""" + if ms > 0: + time.sleep(ms / 1000.0) + + +async def async_sleep_ms(ms: float) -> None: + """Async sleep for `ms` milliseconds.""" + if ms > 0: + import asyncio + await asyncio.sleep(ms / 1000.0) diff --git a/cloakbrowser/human/keyboard.py b/cloakbrowser/human/keyboard.py new file mode 100644 index 0000000..3b08b19 --- /dev/null +++ b/cloakbrowser/human/keyboard.py @@ -0,0 +1,104 @@ +"""cloakbrowser-human — Human-like keyboard input.""" + +from __future__ import annotations + +import random +from typing import Any, Protocol + +from .config import HumanConfig, rand, rand_range, sleep_ms + + +class RawKeyboard(Protocol): + def down(self, key: str) -> None: ... + def up(self, key: str) -> None: ... + def type(self, text: str) -> None: ... + def insert_text(self, text: str) -> None: ... + + +SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~') + +NEARBY_KEYS = { + 'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'sfecx', 'e': 'wrsdf', + 'f': 'dgrtcv', 'g': 'fhtyb', 'h': 'gjybn', 'i': 'ujko', 'j': 'hkunm', + 'k': 'jloi', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp', + 'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy', + 'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu', + 'z': 'asx', + '1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt', + '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', +} + + +def _get_nearby_key(ch: str) -> str: + """Return a random adjacent key for the given character.""" + lower = ch.lower() + if lower in NEARBY_KEYS: + neighbors = NEARBY_KEYS[lower] + wrong = random.choice(neighbors) + return wrong.upper() if ch.isupper() else wrong + return ch + + +def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None: + for i, ch in enumerate(text): + # Mistype chance — press wrong key, notice, backspace, then correct + if random.random() < cfg.mistype_chance and ch.isalnum(): + wrong = _get_nearby_key(ch) + _type_normal_char(raw, wrong, cfg) + sleep_ms(rand_range(cfg.mistype_delay_notice)) + raw.down("Backspace") + sleep_ms(rand_range(cfg.key_hold)) + raw.up("Backspace") + sleep_ms(rand_range(cfg.mistype_delay_correct)) + + if ch.isupper() and ch.isalpha(): + _type_shifted_char(page, raw, ch, cfg) + elif ch in SHIFT_SYMBOLS: + _type_shift_symbol(page, raw, ch, cfg) + else: + _type_normal_char(raw, ch, cfg) + + if i < len(text) - 1: + _inter_char_delay(cfg) + + +def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None: + raw.down(ch) + sleep_ms(rand_range(cfg.key_hold)) + raw.up(ch) + + +def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None: + raw.down("Shift") + sleep_ms(rand_range(cfg.shift_down_delay)) + raw.down(ch) + sleep_ms(rand_range(cfg.key_hold)) + raw.up(ch) + sleep_ms(rand_range(cfg.shift_up_delay)) + raw.up("Shift") + + +def _type_shift_symbol(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None: + raw.down("Shift") + sleep_ms(rand_range(cfg.shift_down_delay)) + raw.insert_text(ch) + page.evaluate( + """(key) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }""", + ch, + ) + sleep_ms(rand_range(cfg.shift_up_delay)) + raw.up("Shift") + + +def _inter_char_delay(cfg: HumanConfig) -> None: + if random.random() < cfg.typing_pause_chance: + sleep_ms(rand_range(cfg.typing_pause_range)) + else: + delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread + sleep_ms(max(10, delay)) diff --git a/cloakbrowser/human/keyboard_async.py b/cloakbrowser/human/keyboard_async.py new file mode 100644 index 0000000..20bbb5a --- /dev/null +++ b/cloakbrowser/human/keyboard_async.py @@ -0,0 +1,85 @@ +"""cloakbrowser-human — Async human-like keyboard input. + +Mirrors keyboard.py but uses ``await`` for all Playwright calls and +``async_sleep_ms`` instead of ``sleep_ms``. +""" + +from __future__ import annotations + +import random +from typing import Any, Protocol + +from .config import HumanConfig, rand, rand_range, async_sleep_ms +from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key + + +class AsyncRawKeyboard(Protocol): + async def down(self, key: str) -> None: ... + async def up(self, key: str) -> None: ... + async def type(self, text: str) -> None: ... + async def insert_text(self, text: str) -> None: ... + + +async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None: + for i, ch in enumerate(text): + # Mistype chance — press wrong key, notice, backspace, then correct + if random.random() < cfg.mistype_chance and ch.isalnum(): + wrong = _get_nearby_key(ch) + await _type_normal_char(raw, wrong, cfg) + await async_sleep_ms(rand_range(cfg.mistype_delay_notice)) + await raw.down("Backspace") + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.up("Backspace") + await async_sleep_ms(rand_range(cfg.mistype_delay_correct)) + + if ch.isupper() and ch.isalpha(): + await _type_shifted_char(page, raw, ch, cfg) + elif ch in SHIFT_SYMBOLS: + await _type_shift_symbol(page, raw, ch, cfg) + else: + await _type_normal_char(raw, ch, cfg) + + if i < len(text) - 1: + await _inter_char_delay(cfg) + + +async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None: + await raw.down(ch) + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.up(ch) + + +async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None: + await raw.down("Shift") + await async_sleep_ms(rand_range(cfg.shift_down_delay)) + await raw.down(ch) + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.up(ch) + await async_sleep_ms(rand_range(cfg.shift_up_delay)) + await raw.up("Shift") + + +async def _type_shift_symbol(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None: + await raw.down("Shift") + await async_sleep_ms(rand_range(cfg.shift_down_delay)) + await raw.insert_text(ch) + await page.evaluate( + """(key) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }""", + ch, + ) + await async_sleep_ms(rand_range(cfg.shift_up_delay)) + await raw.up("Shift") + + +async def _inter_char_delay(cfg: HumanConfig) -> None: + if random.random() < cfg.typing_pause_chance: + await async_sleep_ms(rand_range(cfg.typing_pause_range)) + else: + delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread + await async_sleep_ms(max(10, delay)) diff --git a/cloakbrowser/human/mouse.py b/cloakbrowser/human/mouse.py new file mode 100644 index 0000000..c936e65 --- /dev/null +++ b/cloakbrowser/human/mouse.py @@ -0,0 +1,132 @@ +"""cloakbrowser-human — Human-like mouse movement and clicking.""" + +from __future__ import annotations + +import math +import random +from typing import Any, Protocol, Tuple + +from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms + + +class RawMouse(Protocol): + def move(self, x: float, y: float) -> None: ... + def down(self) -> None: ... + def up(self) -> None: ... + def wheel(self, delta_x: float, delta_y: float) -> None: ... + + +class Point: + __slots__ = ("x", "y") + def __init__(self, x: float, y: float): + self.x = x + self.y = y + + +def _ease_in_out(t: float) -> float: + if t < 0.5: + return 4 * t * t * t + return 1 - pow(-2 * t + 2, 3) / 2 + + +def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point: + u = 1 - t + uu = u * u + uuu = uu * u + tt = t * t + ttt = tt * t + return Point( + uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y, + ) + + +def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]: + dx = end.x - start.x + dy = end.y - start.y + dist = math.hypot(dx, dy) or 1 + px = -dy / dist + py = dx / dist + bias1 = rand(-0.3, 0.3) * dist + bias2 = rand(-0.3, 0.3) * dist + return ( + Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1), + Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2), + ) + + +def human_move( + raw: RawMouse, + start_x: float, start_y: float, + end_x: float, end_y: float, + cfg: HumanConfig, +) -> None: + dist = math.hypot(end_x - start_x, end_y - start_y) + if dist < 1: + return + + steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor))) + start = Point(start_x, start_y) + end = Point(end_x, end_y) + cp1, cp2 = _random_control_points(start, end) + + burst_counter = 0 + burst_size = rand_int_range(cfg.mouse_burst_size) + + for i in range(steps + 1): + progress = i / steps + eased_t = _ease_in_out(progress) + pt = _bezier(start, cp1, cp2, end, eased_t) + + wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max + wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp + wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp + + raw.move(round(wx), round(wy)) + + burst_counter += 1 + if burst_counter >= burst_size and i < steps: + sleep_ms(rand_range(cfg.mouse_burst_pause)) + burst_counter = 0 + + if random.random() < cfg.mouse_overshoot_chance: + overshoot_dist = rand_range(cfg.mouse_overshoot_px) + angle = math.atan2(end_y - start_y, end_x - start_x) + raw.move(round(end_x + math.cos(angle) * overshoot_dist), + round(end_y + math.sin(angle) * overshoot_dist)) + sleep_ms(rand(30, 70)) + raw.move(round(end_x + (random.random() - 0.5) * 4), + round(end_y + (random.random() - 0.5) * 4)) + + +def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point: + if is_input: + x_frac = rand_range(cfg.click_input_x_range) + y_frac = rand(0.30, 0.70) + else: + x_frac = rand(0.35, 0.65) + y_frac = rand(0.35, 0.65) + return Point(round(box["x"] + box["width"] * x_frac), + round(box["y"] + box["height"] * y_frac)) + + +def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None: + aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button) + sleep_ms(aim_delay) + hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button) + raw.down() + sleep_ms(hold_time) + raw.up() + + +def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None: + import time as _time + end_time = _time.monotonic() + seconds + x, y = cx, cy + while _time.monotonic() < end_time: + dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px + dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px + x += dx + y += dy + raw.move(round(x), round(y)) + sleep_ms(rand_range(cfg.idle_pause_range)) diff --git a/cloakbrowser/human/mouse_async.py b/cloakbrowser/human/mouse_async.py new file mode 100644 index 0000000..750d197 --- /dev/null +++ b/cloakbrowser/human/mouse_async.py @@ -0,0 +1,87 @@ +"""cloakbrowser-human — Async human-like mouse movement and clicking. + +Mirrors mouse.py but uses ``await`` for all Playwright calls and +``async_sleep_ms`` instead of ``sleep_ms``. +""" + +from __future__ import annotations + +import math +import random +from typing import Any, Protocol + +from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms +from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math + + +class AsyncRawMouse(Protocol): + async def move(self, x: float, y: float) -> None: ... + async def down(self) -> None: ... + async def up(self) -> None: ... + async def wheel(self, delta_x: float, delta_y: float) -> None: ... + + +async def async_human_move( + raw: AsyncRawMouse, + start_x: float, start_y: float, + end_x: float, end_y: float, + cfg: HumanConfig, +) -> None: + dist = math.hypot(end_x - start_x, end_y - start_y) + if dist < 1: + return + + steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor))) + start = Point(start_x, start_y) + end = Point(end_x, end_y) + cp1, cp2 = _random_control_points(start, end) + + burst_counter = 0 + burst_size = rand_int_range(cfg.mouse_burst_size) + + for i in range(steps + 1): + progress = i / steps + eased_t = _ease_in_out(progress) + pt = _bezier(start, cp1, cp2, end, eased_t) + + wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max + wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp + wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp + + await raw.move(round(wx), round(wy)) + + burst_counter += 1 + if burst_counter >= burst_size and i < steps: + await async_sleep_ms(rand_range(cfg.mouse_burst_pause)) + burst_counter = 0 + + if random.random() < cfg.mouse_overshoot_chance: + overshoot_dist = rand_range(cfg.mouse_overshoot_px) + angle = math.atan2(end_y - start_y, end_x - start_x) + await raw.move(round(end_x + math.cos(angle) * overshoot_dist), + round(end_y + math.sin(angle) * overshoot_dist)) + await async_sleep_ms(rand(30, 70)) + await raw.move(round(end_x + (random.random() - 0.5) * 4), + round(end_y + (random.random() - 0.5) * 4)) + + +async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None: + aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button) + await async_sleep_ms(aim_delay) + hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button) + await raw.down() + await async_sleep_ms(hold_time) + await raw.up() + + +async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None: + import time as _time + end_time = _time.monotonic() + seconds + x, y = cx, cy + while _time.monotonic() < end_time: + dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px + dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px + x += dx + y += dy + await raw.move(round(x), round(y)) + await async_sleep_ms(rand_range(cfg.idle_pause_range)) diff --git a/cloakbrowser/human/scroll.py b/cloakbrowser/human/scroll.py new file mode 100644 index 0000000..7c6a38c --- /dev/null +++ b/cloakbrowser/human/scroll.py @@ -0,0 +1,132 @@ +"""cloakbrowser-human — Human-like scrolling via mouse wheel events.""" + +from __future__ import annotations + +import math +import random +from typing import Any, Optional, Tuple + +from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms +from .mouse import RawMouse, human_move + + +def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool: + top_edge = bounds["y"] + bottom_edge = bounds["y"] + bounds["height"] + zone_top = viewport_height * cfg.scroll_target_zone[0] + zone_bottom = viewport_height * cfg.scroll_target_zone[1] + return top_edge >= zone_top and bottom_edge <= zone_bottom + + +def _get_element_box(page: Any, selector: str) -> Optional[dict]: + try: + el = page.locator(selector).first + return el.bounding_box(timeout=2000) + except Exception: + return None + + +def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None: + """Send one logical scroll as a burst of small wheel events (like real inertia).""" + abs_d = abs(delta) + sign = 1 if delta > 0 else -1 + sent = 0 + while sent < abs_d: + step_size = rand(20, 40) + chunk = min(step_size, abs_d - sent) + raw.wheel(0, round(chunk) * sign) + sent += chunk + sleep_ms(rand(8, 20)) + + +def scroll_to_element( + page: Any, + raw: RawMouse, + selector: str, + cursor_x: float, cursor_y: float, + cfg: HumanConfig, +) -> Tuple[dict, float, float]: + viewport = page.viewport_size + if not viewport: + raise RuntimeError("Viewport size not available") + + viewport_height = viewport["height"] + viewport_width = viewport["width"] + + box = _get_element_box(page, selector) + if box is None: + sleep_ms(200) + box = _get_element_box(page, selector) + if box is None: + raise RuntimeError(f"Element not found: {selector}") + + if _is_in_viewport(box, viewport_height, cfg): + return box, cursor_x, cursor_y + + # Move cursor into scroll area + scroll_area_x = round(viewport_width * rand(0.3, 0.7)) + scroll_area_y = round(viewport_height * rand(0.3, 0.7)) + human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg) + cursor_x = scroll_area_x + cursor_y = scroll_area_y + sleep_ms(rand_range(cfg.scroll_pre_move_delay)) + + # Calculate scroll distance + target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]) + element_center = box["y"] + box["height"] / 2 + distance_to_scroll = element_center - target_y + + direction = 1 if distance_to_scroll > 0 else -1 + abs_distance = abs(distance_to_scroll) + avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2 + total_clicks = max(3, math.ceil(abs_distance / avg_delta)) + accel_steps = rand_int_range(cfg.scroll_accel_steps) + decel_steps = rand_int_range(cfg.scroll_decel_steps) + + # Scroll loop: accelerate → cruise → decelerate + scrolled = 0 + for i in range(total_clicks): + if i < accel_steps: + delta = rand(80, 100) + pause = rand_range(cfg.scroll_pause_slow) + elif i >= total_clicks - decel_steps: + delta = rand(60, 90) + pause = rand_range(cfg.scroll_pause_slow) + else: + delta = rand_range(cfg.scroll_delta_base) + pause = rand_range(cfg.scroll_pause_fast) + + delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance + delta = round(delta) * direction + + _smooth_wheel(raw, delta, cfg) + scrolled += abs(delta) + sleep_ms(pause) + + # Check visibility every 3 steps + if i % 3 == 2 or i == total_clicks - 1: + box = _get_element_box(page, selector) + if box and _is_in_viewport(box, viewport_height, cfg): + break + if scrolled >= abs_distance * 1.1: + break + + # Optional overshoot + correction + if random.random() < cfg.scroll_overshoot_chance: + overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction + _smooth_wheel(raw, overshoot_px, cfg) + sleep_ms(rand_range(cfg.scroll_settle_delay)) + corrections = rand_int_range((1, 2)) + for _ in range(corrections): + corr_delta = round(rand(40, 80)) * -direction + _smooth_wheel(raw, corr_delta, cfg) + sleep_ms(rand(100, 250)) + + # Settle + sleep_ms(rand_range(cfg.scroll_settle_delay)) + + box = _get_element_box(page, selector) + if box is None: + raise RuntimeError(f"Element lost after scrolling: {selector}") + + return box, cursor_x, cursor_y diff --git a/cloakbrowser/human/scroll_async.py b/cloakbrowser/human/scroll_async.py new file mode 100644 index 0000000..97536ab --- /dev/null +++ b/cloakbrowser/human/scroll_async.py @@ -0,0 +1,129 @@ +"""cloakbrowser-human — Async human-like scrolling via mouse wheel events. + +Mirrors scroll.py but uses ``await`` for all Playwright calls and +``async_sleep_ms`` instead of ``sleep_ms``. +""" + +from __future__ import annotations + +import math +import random +from typing import Any, 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]: + try: + el = page.locator(selector).first + return await el.bounding_box(timeout=2000) + except Exception: + return None + + +async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None: + """Send one logical scroll as a burst of small wheel events (like real inertia).""" + abs_d = abs(delta) + sign = 1 if delta > 0 else -1 + sent = 0 + while sent < abs_d: + step_size = rand(20, 40) + chunk = min(step_size, abs_d - sent) + await raw.wheel(0, round(chunk) * sign) + sent += chunk + await async_sleep_ms(rand(8, 20)) + + +async def async_scroll_to_element( + page: Any, + raw: AsyncRawMouse, + selector: str, + cursor_x: float, cursor_y: float, + cfg: HumanConfig, +) -> Tuple[dict, float, float]: + viewport = page.viewport_size + if not viewport: + raise RuntimeError("Viewport size not available") + + viewport_height = viewport["height"] + viewport_width = viewport["width"] + + box = await _get_element_box_async(page, selector) + if box is None: + await async_sleep_ms(200) + box = await _get_element_box_async(page, selector) + if box is None: + raise RuntimeError(f"Element not found: {selector}") + + if _is_in_viewport(box, viewport_height, cfg): + return box, cursor_x, cursor_y + + # Move cursor into scroll area + scroll_area_x = round(viewport_width * rand(0.3, 0.7)) + scroll_area_y = round(viewport_height * rand(0.3, 0.7)) + await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg) + cursor_x = scroll_area_x + cursor_y = scroll_area_y + await async_sleep_ms(rand_range(cfg.scroll_pre_move_delay)) + + # Calculate scroll distance + target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]) + element_center = box["y"] + box["height"] / 2 + distance_to_scroll = element_center - target_y + + direction = 1 if distance_to_scroll > 0 else -1 + abs_distance = abs(distance_to_scroll) + avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2 + total_clicks = max(3, math.ceil(abs_distance / avg_delta)) + accel_steps = rand_int_range(cfg.scroll_accel_steps) + decel_steps = rand_int_range(cfg.scroll_decel_steps) + + # Scroll loop: accelerate → cruise → decelerate + scrolled = 0 + for i in range(total_clicks): + if i < accel_steps: + delta = rand(80, 100) + pause = rand_range(cfg.scroll_pause_slow) + elif i >= total_clicks - decel_steps: + delta = rand(60, 90) + pause = rand_range(cfg.scroll_pause_slow) + else: + delta = rand_range(cfg.scroll_delta_base) + pause = rand_range(cfg.scroll_pause_fast) + + delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance + delta = round(delta) * direction + + await _async_smooth_wheel(raw, delta, cfg) + scrolled += abs(delta) + await async_sleep_ms(pause) + + # Check visibility every 3 steps + if i % 3 == 2 or i == total_clicks - 1: + box = await _get_element_box_async(page, selector) + if box and _is_in_viewport(box, viewport_height, cfg): + break + if scrolled >= abs_distance * 1.1: + break + + # Optional overshoot + correction + if random.random() < cfg.scroll_overshoot_chance: + overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction + await _async_smooth_wheel(raw, overshoot_px, cfg) + await async_sleep_ms(rand_range(cfg.scroll_settle_delay)) + corrections = rand_int_range((1, 2)) + for _ in range(corrections): + corr_delta = round(rand(40, 80)) * -direction + await _async_smooth_wheel(raw, corr_delta, cfg) + await async_sleep_ms(rand(100, 250)) + + # Settle + await async_sleep_ms(rand_range(cfg.scroll_settle_delay)) + + box = await _get_element_box_async(page, selector) + if box is None: + raise RuntimeError(f"Element lost after scrolling: {selector}") + + return box, cursor_x, cursor_y diff --git a/js/package-lock.json b/js/package-lock.json index 84abba1..26250a1 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "cloakbrowser", - "version": "0.2.0", + "version": "0.3.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cloakbrowser", - "version": "0.2.0", + "version": "0.3.9", "license": "MIT", "dependencies": { "tar": "^7.0.0" diff --git a/js/src/human/config.ts b/js/src/human/config.ts new file mode 100644 index 0000000..cccd5c7 --- /dev/null +++ b/js/src/human/config.ts @@ -0,0 +1,232 @@ +/** + * cloakbrowser-human — Configuration and presets. + * + * All numeric parameters for human-like behavior are centralized here. + * Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface HumanConfig { + // Keyboard + typing_delay: number; + typing_delay_spread: number; + typing_pause_chance: number; + typing_pause_range: [number, number]; + shift_down_delay: [number, number]; + shift_up_delay: [number, number]; + key_hold: [number, number]; + field_switch_delay: [number, number]; + mistype_chance: number; + mistype_delay_notice: [number, number]; + mistype_delay_correct: [number, number]; + + + // Mouse — movement + mouse_steps_divisor: number; + mouse_min_steps: number; + mouse_max_steps: number; + mouse_wobble_max: number; + mouse_overshoot_chance: number; + mouse_overshoot_px: [number, number]; + mouse_burst_size: [number, number]; + mouse_burst_pause: [number, number]; + + // Mouse — clicks + click_aim_delay_input: [number, number]; + click_aim_delay_button: [number, number]; + click_hold_input: [number, number]; + click_hold_button: [number, number]; + click_input_x_range: [number, number]; + + // Mouse — idle + idle_drift_px: number; + idle_pause_range: [number, number]; + + // Scroll + scroll_delta_base: [number, number]; + scroll_delta_variance: number; + scroll_pause_fast: [number, number]; + scroll_pause_slow: [number, number]; + scroll_accel_steps: [number, number]; + scroll_decel_steps: [number, number]; + scroll_overshoot_chance: number; + scroll_overshoot_px: [number, number]; + scroll_settle_delay: [number, number]; + scroll_target_zone: [number, number]; + scroll_pre_move_delay: [number, number]; + + // Initial cursor position + initial_cursor_x: [number, number]; + initial_cursor_y: [number, number]; + + + // Idle micro-movements between actions (opt-in, adds latency) + idle_between_actions: boolean; + idle_between_duration: [number, number]; +} + +export type HumanPreset = 'default' | 'careful'; + +// --------------------------------------------------------------------------- +// Default preset +// --------------------------------------------------------------------------- + +const DEFAULT_CONFIG: HumanConfig = { + // Keyboard + typing_delay: 70, + typing_delay_spread: 40, + typing_pause_chance: 0.1, + typing_pause_range: [400, 1000], + shift_down_delay: [30, 70], + shift_up_delay: [20, 50], + key_hold: [15, 35], + field_switch_delay: [800, 1500], + // Mistype (typo simulation) + mistype_chance: 0.02, + mistype_delay_notice: [100, 300], + mistype_delay_correct: [50, 150], + + // Mouse — movement + mouse_steps_divisor: 8, + mouse_min_steps: 25, + mouse_max_steps: 80, + mouse_wobble_max: 1.5, + mouse_overshoot_chance: 0.15, + mouse_overshoot_px: [3, 6], + mouse_burst_size: [3, 5], + mouse_burst_pause: [8, 18], + + // Mouse — clicks + click_aim_delay_input: [60, 140], + click_aim_delay_button: [80, 200], + click_hold_input: [40, 100], + click_hold_button: [60, 150], + click_input_x_range: [0.05, 0.30], + + // Mouse — idle + idle_drift_px: 3, + idle_pause_range: [300, 1000], + + // Scroll + scroll_delta_base: [80, 130], + scroll_delta_variance: 0.2, + scroll_pause_fast: [30, 80], + scroll_pause_slow: [80, 200], + scroll_accel_steps: [2, 3], + scroll_decel_steps: [2, 3], + scroll_overshoot_chance: 0.1, + scroll_overshoot_px: [50, 150], + scroll_settle_delay: [300, 600], + scroll_target_zone: [0.20, 0.80], + scroll_pre_move_delay: [100, 300], + + // Initial cursor position (as if coming from the address bar area) + initial_cursor_x: [400, 700], + initial_cursor_y: [45, 60], + + // Idle micro-movements between actions (off by default) + idle_between_actions: false, + idle_between_duration: [0.3, 0.8], +}; + +// --------------------------------------------------------------------------- +// Careful preset — everything slower and more deliberate +// --------------------------------------------------------------------------- + +const CAREFUL_CONFIG: HumanConfig = { + ...DEFAULT_CONFIG, + + // Keyboard — slower typing + typing_delay: 100, + typing_delay_spread: 50, + typing_pause_chance: 0.15, + typing_pause_range: [500, 1200], + shift_down_delay: [40, 90], + shift_up_delay: [30, 70], + key_hold: [20, 45], + field_switch_delay: [1000, 2000], + mistype_chance: 0.03, + mistype_delay_notice: [150, 400], + mistype_delay_correct: [80, 200], + + // Mouse — slower, more precise + mouse_overshoot_chance: 0.10, + mouse_burst_pause: [12, 25], + + // Mouse — clicks (longer aiming and holding) + click_aim_delay_input: [80, 180], + click_aim_delay_button: [120, 280], + click_hold_input: [60, 140], + click_hold_button: [80, 200], + + // Scroll — slower + scroll_pause_fast: [100, 200], + scroll_pause_slow: [250, 600], + scroll_settle_delay: [400, 800], + scroll_pre_move_delay: [150, 400], + + // Idle between actions enabled for careful preset + idle_between_actions: true, + idle_between_duration: [0.4, 1.0], +}; + +// --------------------------------------------------------------------------- +// Preset map +// --------------------------------------------------------------------------- + +const PRESETS: Record = { + default: DEFAULT_CONFIG, + careful: CAREFUL_CONFIG, +}; + +/** + * Resolve a preset name or partial config into a full HumanConfig. + * If `preset` is a string, returns the corresponding built-in config. + * Any keys in `overrides` replace the preset values. + */ +export function resolveConfig( + preset: HumanPreset = 'default', + overrides?: Partial, +): HumanConfig { + const base = PRESETS[preset]; + if (!base) { + throw new Error( + `Unknown humanize preset "${preset}". Valid presets: ${Object.keys(PRESETS).join(', ')}` + ); + } + if (!overrides) return { ...base }; + return { ...base, ...overrides }; +} + + +// --------------------------------------------------------------------------- +// Utility: random number in range +// --------------------------------------------------------------------------- + +/** Random float in [min, max]. */ +export function rand(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +/** Random integer in [min, max] (inclusive). */ +export function randInt(min: number, max: number): number { + return Math.floor(rand(min, max + 1)); +} + +/** Random value from a [min, max] tuple. */ +export function randRange(range: [number, number]): number { + return rand(range[0], range[1]); +} + +/** Random integer from a [min, max] tuple. */ +export function randIntRange(range: [number, number]): number { + return randInt(range[0], range[1]); +} + +/** Sleep for `ms` milliseconds. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/js/src/human/index.ts b/js/src/human/index.ts new file mode 100644 index 0000000..4f38993 --- /dev/null +++ b/js/src/human/index.ts @@ -0,0 +1,478 @@ +/** + * Human-like behavioral layer for cloakbrowser (JS/TS). + * + * Activated via humanize: true in launch() / launchContext(). + * Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling. + * + * Patches all interaction methods: + * click, dblclick, hover, type, fill, check, uncheck, selectOption, + * press, pressSequentially, tap, dragTo, clear + Frame-level equivalents. + */ + +import type { Browser, BrowserContext, Page, Frame } from 'playwright-core'; +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'; + +export { HumanConfig, resolveConfig } from './config.js'; +export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js'; +export { humanType } from './keyboard.js'; +export { scrollToElement } from './scroll.js'; + +// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) --- +const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + +class CursorState { + x = 0; + y = 0; + initialized = false; +} + +async function isInputElement(page: Page, selector: string): Promise { + return page.evaluate((sel: string) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }, selector).catch(() => false); +} + +async function isSelectorFocused(page: Page, selector: string): Promise { + return page.evaluate((sel: string) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }, selector).catch(() => false); +} + +// ============================================================================ +// Page-level patching +// ============================================================================ + +/** + * Replace page methods with human-like implementations. + */ +function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void { + const originals = { + click: page.click.bind(page), + dblclick: page.dblclick.bind(page), + hover: page.hover.bind(page), + type: page.type.bind(page), + fill: page.fill.bind(page), + check: page.check.bind(page), + uncheck: page.uncheck.bind(page), + selectOption: page.selectOption.bind(page), + press: page.press.bind(page), + goto: page.goto.bind(page), + isChecked: page.isChecked.bind(page), + mouseMove: page.mouse.move.bind(page.mouse), + mouseClick: page.mouse.click.bind(page.mouse), + mouseDblclick: page.mouse.dblclick.bind(page.mouse), + mouseWheel: page.mouse.wheel.bind(page.mouse), + mouseDown: page.mouse.down.bind(page.mouse), + mouseUp: page.mouse.up.bind(page.mouse), + keyboardType: page.keyboard.type.bind(page.keyboard), + keyboardDown: page.keyboard.down.bind(page.keyboard), + keyboardUp: page.keyboard.up.bind(page.keyboard), + keyboardPress: page.keyboard.press.bind(page.keyboard), + keyboardInsertText: page.keyboard.insertText.bind(page.keyboard), + }; + + (page as any)._original = originals; + (page as any)._humanCfg = cfg; + + const raw: RawMouse = { + move: originals.mouseMove, + down: originals.mouseDown, + up: originals.mouseUp, + wheel: originals.mouseWheel, + }; + + const rawKb: RawKeyboard = { + down: originals.keyboardDown, + up: originals.keyboardUp, + type: originals.keyboardType, + insertText: originals.keyboardInsertText, + }; + + async function ensureCursorInit(): Promise { + if (!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.mouseMove(cursor.x, cursor.y); + cursor.initialized = true; + } + } + + // --- goto --- + const humanGoto = async (url: string, options?: any) => { + const response = await originals.goto(url, options); + // Patch any new frames after navigation + patchFrames(page, cfg, cursor, raw, rawKb, originals); + return response; + }; + + // --- 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 { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg); + cursor.x = cursorX; + cursor.y = cursorY; + const isInput = await isInputElement(page, selector); + const target = clickTarget(box, isInput, cfg); + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); + cursor.x = target.x; + cursor.y = target.y; + await humanClick(raw, isInput, cfg); + }; + + // --- 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 { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg); + cursor.x = cursorX; + cursor.y = cursorY; + const isInput = await isInputElement(page, selector); + const target = clickTarget(box, isInput, cfg); + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); + cursor.x = target.x; + cursor.y = target.y; + await raw.down({ clickCount: 2 }); + await sleep(rand(30, 60)); + await raw.up({ clickCount: 2 }); + }; + + // --- 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 { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg); + cursor.x = cursorX; + cursor.y = cursorY; + const target = clickTarget(box, false, cfg); + await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg); + 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); + await sleep(rand(100, 250)); + await humanType(page, rawKb, text, cfg); + }; + + // --- fill (clears existing content first) --- + const humanFillFn = async (selector: string, value: string, options?: any) => { + await sleep(randRange(cfg.field_switch_delay)); + await humanClickFn(selector); + await sleep(rand(100, 250)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + await sleep(rand(50, 150)); + await humanType(page, rawKb, value, cfg); + }; + + // --- clear --- + const humanClearFn = async (selector: string, options?: any) => { + if (!await isSelectorFocused(page, selector)) { + await humanClickFn(selector); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + }; + + // --- check --- + const humanCheckFn = async (selector: string, options?: any) => { + 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 checked = await originals.isChecked(selector).catch(() => false); + if (!checked) { + await humanClickFn(selector); + } + }; + + // --- uncheck --- + const humanUncheckFn = async (selector: string, options?: any) => { + 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 checked = await originals.isChecked(selector).catch(() => true); + if (checked) { + await humanClickFn(selector); + } + }; + + // --- selectOption --- + const humanSelectOptionFn = async (selector: string, values: any, options?: any) => { + await humanHoverFn(selector); + await sleep(rand(100, 300)); + return originals.selectOption(selector, values, options); + }; + + // --- press (checks focus first — avoids redundant mouse moves) --- + const humanPressFn = async (selector: string, key: string, options?: any) => { + if (!await isSelectorFocused(page, selector)) { + await humanClickFn(selector); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(key); + }; + + // --- pressSequentially --- + const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => { + if (!await isSelectorFocused(page, selector)) { + await humanClickFn(selector); + } + await sleep(rand(100, 250)); + await humanType(page, rawKb, text, cfg); + }; + + // --- tap --- + const humanTapFn = async (selector: string, options?: any) => { + await humanClickFn(selector, options); + }; + + // Assign page-level patches + (page as any).goto = humanGoto; + (page as any).click = humanClickFn; + (page as any).dblclick = humanDblclickFn; + (page as any).hover = humanHoverFn; + (page as any).type = humanTypeFn; + (page as any).fill = humanFillFn; + (page as any).check = humanCheckFn; + (page as any).uncheck = humanUncheckFn; + (page as any).selectOption = humanSelectOptionFn; + (page as any).press = humanPressFn; + + // --- mouse patches --- + page.mouse.move = async (x: number, y: number, options?: any) => { + await ensureCursorInit(); + await humanMove(raw, cursor.x, cursor.y, x, y, cfg); + cursor.x = x; + cursor.y = y; + }; + + page.mouse.click = async (x: number, y: number, options?: any) => { + await ensureCursorInit(); + await humanMove(raw, cursor.x, cursor.y, x, y, cfg); + cursor.x = x; + cursor.y = y; + await humanClick(raw, false, cfg); + }; + + // --- keyboard patches --- + page.keyboard.type = async (text: string, options?: any) => { + await humanType(page, rawKb, text, cfg); + }; + + // Store helpers for frame patching + (page as any)._humanCursor = cursor; + (page as any)._humanRaw = raw; + (page as any)._humanRawKb = rawKb; + (page as any)._humanOriginals = originals; + (page as any)._humanClickFn = humanClickFn; + (page as any)._humanHoverFn = humanHoverFn; + (page as any)._humanClearFn = humanClearFn; + (page as any)._humanPressFn = humanPressFn; + (page as any)._humanPressSequentiallyFn = humanPressSequentiallyFn; + (page as any)._humanTapFn = humanTapFn; + (page as any)._ensureCursorInit = ensureCursorInit; + + // 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]); + originals.mouseMove(cursor.x, cursor.y).then(() => { + cursor.initialized = true; + }).catch(() => {}); + + // --- Patch Frame-level methods (for sub-frames) --- + patchFrames(page, cfg, cursor, raw, rawKb, originals); +} + + +// ============================================================================ +// Frame-level patching +// ============================================================================ + +/** + * Patch Frame methods so Locator-based calls go through humanization. + * All 11 methods patched: click, dblclick, hover, type, fill, check, uncheck, + * selectOption, press, clear, dragAndDrop. + */ +function patchFrames( + page: Page, + cfg: HumanConfig, + cursor: CursorState, + raw: RawMouse, + rawKb: RawKeyboard, + originals: any, +): void { + for (const frame of iterFrames(page)) { + patchSingleFrame(frame, page, cfg, originals); + } +} + +function patchSingleFrame(frame: Frame, page: Page, cfg: HumanConfig, originals: any): void { + if ((frame as any)._humanPatched) return; + (frame as any)._humanPatched = true; + + // Save originals for methods that need fallback + const origFrameSelectOption = frame.selectOption.bind(frame); + const origFrameDragAndDrop = frame.dragAndDrop.bind(frame); + + (frame as any).click = async (selector: string, options?: any) => { + await (page as any).click(selector, options); + }; + + (frame as any).dblclick = async (selector: string, options?: any) => { + await (page as any).dblclick(selector, options); + }; + + (frame as any).hover = async (selector: string, options?: any) => { + await (page as any).hover(selector, options); + }; + + (frame as any).type = async (selector: string, text: string, options?: any) => { + await (page as any).type(selector, text, options); + }; + + (frame as any).fill = async (selector: string, value: string, options?: any) => { + await (page as any).fill(selector, value, options); + }; + + (frame as any).check = async (selector: string, options?: any) => { + await (page as any).check(selector, options); + }; + + (frame as any).uncheck = async (selector: string, options?: any) => { + await (page as any).uncheck(selector, options); + }; + + (frame as any).selectOption = async (selector: string, values: any, options?: any) => { + await (page as any).hover(selector); + await sleep(rand(100, 300)); + return origFrameSelectOption(selector, values, options); + }; + + (frame as any).press = async (selector: string, key: string, options?: any) => { + await (page as any).press(selector, key, options); + }; + + (frame as any).clear = async (selector: string, options?: any) => { + if (!await isSelectorFocused(page, selector)) { + await (page as any).click(selector); + } + await sleep(rand(50, 150)); + await originals.keyboardPress(SELECT_ALL); + await sleep(rand(30, 80)); + await originals.keyboardPress('Backspace'); + }; + + (frame as any).dragAndDrop = async (source: string, target: string, options?: any) => { + const srcBox = await frame.locator(source).boundingBox().catch(() => null); + const tgtBox = await frame.locator(target).boundingBox().catch(() => null); + + if (srcBox && tgtBox) { + const sx = srcBox.x + srcBox.width / 2; + const sy = srcBox.y + srcBox.height / 2; + const tx = tgtBox.x + tgtBox.width / 2; + const ty = tgtBox.y + tgtBox.height / 2; + + await page.mouse.move(sx, sy); + await sleep(rand(100, 200)); + await originals.mouseDown(); + await sleep(rand(80, 150)); + await page.mouse.move(tx, ty); + await sleep(rand(80, 150)); + await originals.mouseUp(); + } else { + return origFrameDragAndDrop(source, target, options); + } + }; +} + + +function* iterFrames(page: Page): Generator { + try { + const mainFrame = page.mainFrame(); + yield mainFrame; + for (const child of mainFrame.childFrames()) { + yield child; + } + } catch {} +} + + +// ============================================================================ +// Context-level patching +// ============================================================================ + +function patchContext(context: BrowserContext, cfg: HumanConfig): void { + const cursor = new CursorState(); + for (const page of context.pages()) { + patchPage(page, cfg, cursor); + } + context.on('page', (page: Page) => { + if (!(page as any)._original) { + patchPage(page, cfg, new CursorState()); + } + }); + + const origNewPage = context.newPage.bind(context); + (context as any).newPage = async () => { + const page = await origNewPage(); + if (!(page as any)._original) { + patchPage(page, cfg, new CursorState()); + } + return page; + }; +} + + +// ============================================================================ +// Browser-level patching +// ============================================================================ + +export function patchBrowser(browser: Browser, cfg: HumanConfig): void { + for (const context of browser.contexts()) { + patchContext(context, cfg); + } + + const origNewContext = browser.newContext.bind(browser); + (browser as any).newContext = async (options?: any) => { + const context = await origNewContext(options); + patchContext(context, cfg); + return context; + }; + + const origNewPage = browser.newPage.bind(browser); + (browser as any).newPage = async (options?: any) => { + const page = await origNewPage(options); + if (!(page as any)._original) { + const ctx = page.context(); + if (!(ctx as any)._humanPatched) { + patchContext(ctx, cfg); + (ctx as any)._humanPatched = true; + } + patchPage(page, cfg, new CursorState()); + } + return page; + }; +} + +export { patchContext, patchPage }; diff --git a/js/src/human/keyboard.ts b/js/src/human/keyboard.ts new file mode 100644 index 0000000..840b54b --- /dev/null +++ b/js/src/human/keyboard.ts @@ -0,0 +1,111 @@ +/** + * cloakbrowser-human — Human-like keyboard input. + */ + +import type { Page } from 'playwright-core'; +import { RawKeyboard } from './mouse.js'; +import { HumanConfig, rand, randRange, sleep } from './config.js'; + +const SHIFT_SYMBOLS = new Set([ + '@', '#', '!', '$', '%', '^', '&', '*', '(', ')', + '_', '+', '{', '}', '|', ':', '"', '<', '>', '?', '~', +]); + +const NEARBY_KEYS: Record = { + a: 'sqwz', b: 'vghn', c: 'xdfv', d: 'sfecx', e: 'wrsdf', + f: 'dgrtcv', g: 'fhtyb', h: 'gjybn', i: 'ujko', j: 'hkunm', + k: 'jloi', l: 'kop', m: 'njk', n: 'bhjm', o: 'iklp', + p: 'ol', q: 'wa', r: 'edft', s: 'awedxz', t: 'rfgy', + u: 'yhji', v: 'cfgb', w: 'qase', x: 'zsdc', y: 'tghu', + z: 'asx', + '1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt', + '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', +}; + +function getNearbyKey(ch: string): string { + const lower = ch.toLowerCase(); + if (lower in NEARBY_KEYS) { + const neighbors = NEARBY_KEYS[lower]; + const wrong = neighbors[Math.floor(Math.random() * neighbors.length)]; + return ch === ch.toUpperCase() && ch !== ch.toLowerCase() ? wrong.toUpperCase() : wrong; + } + return ch; +} + +export async function humanType( + page: Page, + raw: RawKeyboard, + text: string, + cfg: HumanConfig, +): Promise { + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + + // Mistype chance — press wrong key, notice, backspace, then correct + if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) { + const wrong = getNearbyKey(ch); + await typeNormalChar(raw, wrong, cfg); + await sleep(randRange(cfg.mistype_delay_notice)); + await raw.down('Backspace'); + await sleep(randRange(cfg.key_hold)); + await raw.up('Backspace'); + await sleep(randRange(cfg.mistype_delay_correct)); + } + + if (isUpperCase(ch)) { + await typeShiftedChar(raw, ch, cfg); + } else if (SHIFT_SYMBOLS.has(ch)) { + await typeShiftSymbol(page, raw, ch, cfg); + } else { + await typeNormalChar(raw, ch, cfg); + } + + if (i < text.length - 1) { + await interCharDelay(cfg); + } + } +} + +async function typeNormalChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down(ch); + await sleep(randRange(cfg.key_hold)); + await raw.up(ch); +} + +async function typeShiftedChar(raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + await raw.down(ch); + await sleep(randRange(cfg.key_hold)); + await raw.up(ch); + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); +} + +async function typeShiftSymbol(page: Page, raw: RawKeyboard, ch: string, cfg: HumanConfig): Promise { + await raw.down('Shift'); + await sleep(randRange(cfg.shift_down_delay)); + await raw.insertText(ch); + await page.evaluate((key: string) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }, ch); + await sleep(randRange(cfg.shift_up_delay)); + await raw.up('Shift'); +} + +function isUpperCase(ch: string): boolean { + return ch.length === 1 && ch >= 'A' && ch <= 'Z'; +} + +async function interCharDelay(cfg: HumanConfig): Promise { + if (Math.random() < cfg.typing_pause_chance) { + await sleep(randRange(cfg.typing_pause_range)); + } else { + const delay = cfg.typing_delay + (Math.random() - 0.5) * 2 * cfg.typing_delay_spread; + await sleep(Math.max(10, delay)); + } +} diff --git a/js/src/human/mouse.ts b/js/src/human/mouse.ts new file mode 100644 index 0000000..c919e7d --- /dev/null +++ b/js/src/human/mouse.ts @@ -0,0 +1,193 @@ +/** + * cloakbrowser-human — Human-like mouse movement and clicking. + */ + +import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js'; + +// --------------------------------------------------------------------------- +// Raw interface — original Playwright methods, bypassing the wrapper +// --------------------------------------------------------------------------- + +export interface RawMouse { + move: (x: number, y: number) => Promise; + down: (options?: any) => Promise; + up: (options?: any) => Promise; + wheel: (deltaX: number, deltaY: number) => Promise; +} + +export interface RawKeyboard { + down: (key: string) => Promise; + up: (key: string) => Promise; + type: (text: string) => Promise; + insertText: (text: string) => Promise; +} + +// --------------------------------------------------------------------------- +// Easing +// --------------------------------------------------------------------------- + +function easeInOut(t: number): number { + return t < 0.5 + ? 4 * t * t * t + : 1 - Math.pow(-2 * t + 2, 3) / 2; +} + +// --------------------------------------------------------------------------- +// Bezier +// --------------------------------------------------------------------------- + +interface Point { + x: number; + y: number; +} + +function bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point { + const u = 1 - t; + const uu = u * u; + const uuu = uu * u; + const tt = t * t; + const ttt = tt * t; + return { + x: uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + y: uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y, + }; +} + +function randomControlPoints(start: Point, end: Point): [Point, Point] { + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.hypot(dx, dy); + const px = -dy / (dist || 1); + const py = dx / (dist || 1); + const bias1 = rand(-0.3, 0.3) * dist; + const bias2 = rand(-0.3, 0.3) * dist; + return [ + { x: start.x + dx * 0.25 + px * bias1, y: start.y + dy * 0.25 + py * bias1 }, + { x: start.x + dx * 0.75 + px * bias2, y: start.y + dy * 0.75 + py * bias2 }, + ]; +} + +// --------------------------------------------------------------------------- +// Human mouse movement +// --------------------------------------------------------------------------- + +export async function humanMove( + raw: RawMouse, + startX: number, + startY: number, + endX: number, + endY: number, + cfg: HumanConfig, +): Promise { + const dist = Math.hypot(endX - startX, endY - startY); + if (dist < 1) return; + + const steps = Math.max( + cfg.mouse_min_steps, + Math.min(cfg.mouse_max_steps, Math.round(dist / cfg.mouse_steps_divisor)), + ); + + const start: Point = { x: startX, y: startY }; + const end: Point = { x: endX, y: endY }; + const [cp1, cp2] = randomControlPoints(start, end); + + let burstCounter = 0; + const burstSize = randIntRange(cfg.mouse_burst_size); + + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const easedT = easeInOut(progress); + const pt = bezier(start, cp1, cp2, end, easedT); + + const wobbleAmp = Math.sin(Math.PI * progress) * cfg.mouse_wobble_max; + const wx = pt.x + (Math.random() - 0.5) * 2 * wobbleAmp; + const wy = pt.y + (Math.random() - 0.5) * 2 * wobbleAmp; + + await raw.move(Math.round(wx), Math.round(wy)); + + burstCounter++; + if (burstCounter >= burstSize && i < steps) { + await sleep(randRange(cfg.mouse_burst_pause)); + burstCounter = 0; + } + } + + if (Math.random() < cfg.mouse_overshoot_chance) { + const overshootDist = randRange(cfg.mouse_overshoot_px); + const angle = Math.atan2(endY - startY, endX - startX); + const ovX = Math.round(endX + Math.cos(angle) * overshootDist); + const ovY = Math.round(endY + Math.sin(angle) * overshootDist); + await raw.move(ovX, ovY); + await sleep(rand(30, 70)); + const corrX = Math.round(endX + (Math.random() - 0.5) * 4); + const corrY = Math.round(endY + (Math.random() - 0.5) * 4); + await raw.move(corrX, corrY); + } +} + +// --------------------------------------------------------------------------- +// Human click +// --------------------------------------------------------------------------- + +export function clickTarget( + box: { x: number; y: number; width: number; height: number }, + isInput: boolean, + cfg: HumanConfig, +): Point { + if (isInput) { + const xFrac = randRange(cfg.click_input_x_range); + const yFrac = rand(0.30, 0.70); + return { + x: Math.round(box.x + box.width * xFrac), + y: Math.round(box.y + box.height * yFrac), + }; + } + const xFrac = rand(0.35, 0.65); + const yFrac = rand(0.35, 0.65); + return { + x: Math.round(box.x + box.width * xFrac), + y: Math.round(box.y + box.height * yFrac), + }; +} + +export async function humanClick( + raw: RawMouse, + isInput: boolean, + cfg: HumanConfig, +): Promise { + const aimDelay = isInput + ? randRange(cfg.click_aim_delay_input) + : randRange(cfg.click_aim_delay_button); + await sleep(aimDelay); + + const holdTime = isInput + ? randRange(cfg.click_hold_input) + : randRange(cfg.click_hold_button); + await raw.down(); + await sleep(holdTime); + await raw.up(); +} + +// --------------------------------------------------------------------------- +// Human idle / drift +// --------------------------------------------------------------------------- + +export async function humanIdle( + raw: RawMouse, + seconds: number, + cx: number, + cy: number, + cfg: HumanConfig, +): Promise { + const endTime = Date.now() + seconds * 1000; + let x = cx; + let y = cy; + while (Date.now() < endTime) { + const dx = (Math.random() - 0.5) * 2 * cfg.idle_drift_px; + const dy = (Math.random() - 0.5) * 2 * cfg.idle_drift_px; + x += dx; + y += dy; + await raw.move(Math.round(x), Math.round(y)); + await sleep(randRange(cfg.idle_pause_range)); + } +} diff --git a/js/src/human/scroll.ts b/js/src/human/scroll.ts new file mode 100644 index 0000000..ca6ecb8 --- /dev/null +++ b/js/src/human/scroll.ts @@ -0,0 +1,150 @@ +/** + * cloakbrowser-human — Human-like scrolling via mouse wheel events. + */ + +import type { Page } from 'playwright-core'; +import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js'; +import { RawMouse, humanMove } from './mouse.js'; + +interface ElementBounds { + x: number; + y: number; + width: number; + height: number; +} + +function isInViewport( + bounds: ElementBounds, + viewportHeight: number, + cfg: HumanConfig, +): boolean { + const topEdge = bounds.y; + const bottomEdge = bounds.y + bounds.height; + const zoneTop = viewportHeight * cfg.scroll_target_zone[0]; + const zoneBottom = viewportHeight * cfg.scroll_target_zone[1]; + return topEdge >= zoneTop && bottomEdge <= zoneBottom; +} + +async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise { + const absD = Math.abs(delta); + const sign = delta > 0 ? 1 : -1; + let sent = 0; + while (sent < absD) { + const stepSize = rand(20, 40); + const chunk = Math.min(stepSize, absD - sent); + await raw.wheel(0, Math.round(chunk) * sign); + sent += chunk; + await sleep(rand(8, 20)); + } +} + +export async function scrollToElement( + page: Page, + raw: RawMouse, + selector: string, + cursorX: number, + cursorY: number, + cfg: HumanConfig, +): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> { + const viewport = page.viewportSize(); + if (!viewport) throw new Error('Viewport size not available'); + + let box = await getElementBox(page, selector); + if (!box) { + await sleep(200); + box = await getElementBox(page, selector); + if (!box) throw new Error(`Element not found: ${selector}`); + } + + if (isInViewport(box, viewport.height, cfg)) { + return { box, cursorX, cursorY }; + } + + // Move cursor into scroll area + const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7)); + const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7)); + await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg); + cursorX = scrollAreaX; + cursorY = scrollAreaY; + await sleep(randRange(cfg.scroll_pre_move_delay)); + + // Calculate scroll distance + const targetY = viewport.height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]); + const elementCenter = box.y + box.height / 2; + const distanceToScroll = elementCenter - targetY; + + const direction = distanceToScroll > 0 ? 1 : -1; + const absDistance = Math.abs(distanceToScroll); + const avgDelta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2; + const totalClicks = Math.max(3, Math.ceil(absDistance / avgDelta)); + const accelSteps = randIntRange(cfg.scroll_accel_steps); + const decelSteps = randIntRange(cfg.scroll_decel_steps); + + let scrolled = 0; + + // Scroll loop: accelerate → cruise → decelerate + for (let i = 0; i < totalClicks; i++) { + let delta: number; + let pause: number; + + if (i < accelSteps) { + delta = rand(80, 100); + pause = randRange(cfg.scroll_pause_slow); + } else if (i >= totalClicks - decelSteps) { + delta = rand(60, 90); + pause = randRange(cfg.scroll_pause_slow); + } else { + delta = randRange(cfg.scroll_delta_base); + pause = randRange(cfg.scroll_pause_fast); + } + + delta *= 1 + (Math.random() - 0.5) * 2 * cfg.scroll_delta_variance; + delta = Math.round(delta) * direction; + + await smoothWheel(raw, delta, cfg); + scrolled += Math.abs(delta); + await sleep(pause); + + // Check visibility every 3 steps + if (i % 3 === 2 || i === totalClicks - 1) { + box = await getElementBox(page, selector); + if (box && isInViewport(box, viewport.height, cfg)) { + break; + } + } + + if (scrolled >= absDistance * 1.1) break; + } + + // Optional overshoot + correction + if (Math.random() < cfg.scroll_overshoot_chance) { + const overshootPx = Math.round(randRange(cfg.scroll_overshoot_px)) * direction; + await smoothWheel(raw, overshootPx, cfg); + await sleep(randRange(cfg.scroll_settle_delay)); + + const corrections = randIntRange([1, 2]); + for (let c = 0; c < corrections; c++) { + const corrDelta = Math.round(rand(40, 80)) * -direction; + await smoothWheel(raw, corrDelta, cfg); + await sleep(rand(100, 250)); + } + } + + // Settle + await sleep(randRange(cfg.scroll_settle_delay)); + + box = await getElementBox(page, selector); + if (!box) throw new Error(`Element lost after scrolling: ${selector}`); + + return { box, cursorX, cursorY }; +} + +async function getElementBox(page: Page, selector: string): Promise { + const el = page.locator(selector).first(); + try { + const box = await el.boundingBox({ timeout: 2000 }); + return box; + } catch { + return null; + } +} diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 3c57461..57e74b4 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -52,6 +52,17 @@ export async function launch(options: LaunchOptions = {}): Promise { ...options.launchOptions, }); + // Human-like behavioral patching + if (options.humanize) { + const { patchBrowser } = await import('./human/index.js'); + const { resolveConfig } = await import('./human/config.js'); + const cfg = resolveConfig( + (options.humanPreset as any) ?? 'default', + options.humanConfig as any, + ); + patchBrowser(browser, cfg); + } + return browser; } @@ -103,6 +114,17 @@ export async function launchContext( await browser.close(); }; + // Human-like behavioral patching + if (options.humanize) { + const { patchContext } = await import('./human/index.js'); + const { resolveConfig } = await import('./human/config.js'); + const cfg = resolveConfig( + (options.humanPreset as any) ?? 'default', + options.humanConfig as any, + ); + patchContext(context, cfg); + } + return context; } @@ -153,6 +175,17 @@ export async function launchPersistentContext( ...options.launchOptions, }); + // Human-like behavioral patching + if (options.humanize) { + const { patchContext } = await import('./human/index.js'); + const { resolveConfig } = await import('./human/config.js'); + const cfg = resolveConfig( + (options.humanPreset as any) ?? 'default', + options.humanConfig as any, + ); + patchContext(context, cfg); + } + return context; } diff --git a/js/src/types.ts b/js/src/types.ts index 5c96b4f..a539c1d 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -24,6 +24,12 @@ export interface LaunchOptions { geoip?: boolean; /** Raw options passed directly to playwright/puppeteer launch(). */ launchOptions?: Record; + /** Enable human-like mouse, keyboard, and scroll behavior. */ + humanize?: boolean; + /** Human behavior preset: 'default' or 'careful'. */ + humanPreset?: 'default' | 'careful'; + /** Override individual human behavior parameters. */ + humanConfig?: Record; } export interface LaunchContextOptions extends LaunchOptions { diff --git a/js/tests/humanize.test.ts b/js/tests/humanize.test.ts new file mode 100644 index 0000000..b820199 --- /dev/null +++ b/js/tests/humanize.test.ts @@ -0,0 +1,552 @@ +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"; + +// ========================================================================= +// Config resolution +// ========================================================================= +describe("resolveConfig", () => { + it("returns valid default config", () => { + const cfg = resolveConfig("default"); + expect(cfg).toBeDefined(); + expect(cfg.mouse_min_steps).toBeGreaterThan(0); + expect(cfg.mouse_max_steps).toBeGreaterThan(cfg.mouse_min_steps); + expect(cfg.typing_delay).toBeGreaterThan(0); + expect(cfg.initial_cursor_x).toHaveLength(2); + expect(cfg.initial_cursor_y).toHaveLength(2); + }); + + it("returns valid careful config with slower typing", () => { + const cfg = resolveConfig("careful"); + const def = resolveConfig("default"); + expect(cfg).toBeDefined(); + expect(cfg.typing_delay).toBeGreaterThanOrEqual(def.typing_delay); + }); + + it("applies custom overrides", () => { + const cfg = resolveConfig("default", { mouse_min_steps: 100, mouse_max_steps: 200 }); + expect(cfg.mouse_min_steps).toBe(100); + expect(cfg.mouse_max_steps).toBe(200); + }); + + it("preserves idle_between_actions override", () => { + const cfg = resolveConfig("default", { + idle_between_actions: true, + idle_between_duration: [50, 100], + }); + expect(cfg.idle_between_actions).toBe(true); + expect(cfg.idle_between_duration[0]).toBe(50); + expect(cfg.idle_between_duration[1]).toBe(100); + }); + + it("throws on unknown preset name", () => { + expect(() => resolveConfig("nonexistent" as any)).toThrow(/Unknown humanize preset/); + }); + + it("returns all required fields including mistype", () => { + const cfg = resolveConfig("default"); + const required = [ + "mouse_min_steps", "mouse_max_steps", "typing_delay", + "initial_cursor_x", "initial_cursor_y", "idle_between_actions", + "idle_between_duration", "field_switch_delay", + "mistype_chance", "mistype_delay_notice", "mistype_delay_correct", + ]; + for (const f of required) { + expect(cfg).toHaveProperty(f); + } + }); + + it("mistype_delay fields are [min, max] tuples", () => { + const cfg = resolveConfig("default"); + expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true); + expect(cfg.mistype_delay_notice).toHaveLength(2); + expect(cfg.mistype_delay_notice[0]).toBeLessThanOrEqual(cfg.mistype_delay_notice[1]); + expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true); + expect(cfg.mistype_delay_correct).toHaveLength(2); + expect(cfg.mistype_delay_correct[0]).toBeLessThanOrEqual(cfg.mistype_delay_correct[1]); + }); +}); + +// ========================================================================= +// rand / randRange / sleep +// ========================================================================= +describe("rand helpers", () => { + it("rand stays within bounds over many iterations", () => { + for (let i = 0; i < 500; i++) { + const v = rand(10, 20); + expect(v).toBeGreaterThanOrEqual(10); + expect(v).toBeLessThanOrEqual(20); + } + }); + + it("randRange stays within bounds", () => { + for (let i = 0; i < 500; i++) { + const v = randRange([5, 15]); + expect(v).toBeGreaterThanOrEqual(5); + expect(v).toBeLessThanOrEqual(15); + } + }); + + it("sleep pauses for approximately correct duration", async () => { + const t0 = Date.now(); + await sleep(50); + const elapsed = Date.now() - t0; + expect(elapsed).toBeGreaterThanOrEqual(40); + expect(elapsed).toBeLessThan(200); + }); +}); + +// ========================================================================= +// Bézier mouse movement (behavioral with vi.fn mocks) +// ========================================================================= +describe("humanMove", () => { + function makeFakeRaw() { + const moves: Array<{ x: number; y: number }> = []; + return { + raw: { + move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }), + down: vi.fn(async () => {}), + up: vi.fn(async () => {}), + wheel: vi.fn(async () => {}), + }, + moves, + }; + } + + it("generates multiple intermediate points", async () => { + const cfg = resolveConfig("default"); + const { raw, moves } = makeFakeRaw(); + await humanMove(raw, 0, 0, 500, 300, cfg); + expect(moves.length).toBeGreaterThanOrEqual(10); + const last = moves[moves.length - 1]; + expect(Math.abs(last.x - 500)).toBeLessThan(10); + expect(Math.abs(last.y - 300)).toBeLessThan(10); + }); + + it("raw.move called exactly once per step", async () => { + const cfg = resolveConfig("default"); + const { raw, moves } = makeFakeRaw(); + await humanMove(raw, 0, 0, 400, 400, cfg); + expect(raw.move).toHaveBeenCalledTimes(moves.length); + }); + + it("no single jump exceeds 50% of total distance", async () => { + const cfg = resolveConfig("default"); + const { raw, moves } = makeFakeRaw(); + await humanMove(raw, 0, 0, 400, 400, cfg); + const totalDist = Math.sqrt(400 ** 2 + 400 ** 2); + const maxJump = totalDist * 0.5; + for (let i = 1; i < moves.length; i++) { + const dx = moves[i].x - moves[i - 1].x; + const dy = moves[i].y - moves[i - 1].y; + expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(maxJump); + } + }); + + it("produces curved path (not a straight line)", async () => { + const cfg = resolveConfig("default"); + let maxDev = 0; + for (let trial = 0; trial < 10; trial++) { + const { raw, moves } = makeFakeRaw(); + await humanMove(raw, 0, 0, 500, 0, cfg); + const dev = Math.max(...moves.map(m => Math.abs(m.y))); + if (dev > maxDev) maxDev = dev; + } + expect(maxDev).toBeGreaterThan(0.5); + }); + + it("handles very short distances", async () => { + const cfg = resolveConfig("default"); + const { raw, moves } = makeFakeRaw(); + await humanMove(raw, 100, 100, 103, 102, cfg); + expect(moves.length).toBeGreaterThanOrEqual(1); + }); + + it("handles zero distance without crashing", async () => { + const cfg = resolveConfig("default"); + const { raw } = makeFakeRaw(); + await humanMove(raw, 200, 200, 200, 200, cfg); + // Completes without error; may or may not call move (both valid) + expect(true).toBe(true); + }); +}); + +// ========================================================================= +// humanClick behavioral +// ========================================================================= +describe("humanClick", () => { + it("calls down then up in correct order", async () => { + const cfg = resolveConfig("default"); + const callOrder: string[] = []; + const raw = { + move: vi.fn(async () => {}), + down: vi.fn(async () => { callOrder.push("down"); }), + up: vi.fn(async () => { callOrder.push("up"); }), + wheel: vi.fn(async () => {}), + }; + await humanClick(raw, false, cfg); + expect(raw.down).toHaveBeenCalledTimes(1); + expect(raw.up).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(["down", "up"]); + }); +}); + +// ========================================================================= +// humanIdle behavioral +// ========================================================================= +describe("humanIdle", () => { + it("calls raw.move at least once during idle", async () => { + const cfg = resolveConfig("default"); + const raw = { + move: vi.fn(async () => {}), + down: vi.fn(async () => {}), + up: vi.fn(async () => {}), + wheel: vi.fn(async () => {}), + }; + await humanIdle(raw, 10, 100, 100, cfg); + expect(raw.move).toHaveBeenCalled(); + }, 15000); +}); + +// ========================================================================= +// clickTarget +// ========================================================================= +describe("clickTarget", () => { + it("returns point within bounding box", () => { + const cfg = resolveConfig("default"); + const box = { x: 100, y: 200, width: 150, height: 40 }; + for (let i = 0; i < 100; i++) { + const t = clickTarget(box, false, cfg); + expect(t.x).toBeGreaterThanOrEqual(100); + expect(t.x).toBeLessThanOrEqual(250); + expect(t.y).toBeGreaterThanOrEqual(200); + expect(t.y).toBeLessThanOrEqual(240); + } + }); + + it("isInput=true biases click toward left side of box", () => { + const cfg = resolveConfig("default"); + const box = { x: 50, y: 50, width: 200, height: 30 }; + let sumX = 0; + const N = 300; + for (let i = 0; i < N; i++) { + const t = clickTarget(box, true, cfg); + expect(t.x).toBeGreaterThanOrEqual(50); + expect(t.x).toBeLessThanOrEqual(250); + sumX += t.x; + } + const avgX = sumX / N; + expect(avgX).toBeLessThan(175); + }); + + it("does not crash with 1x1 box", () => { + const cfg = resolveConfig("default"); + const t = clickTarget({ x: 0, y: 0, width: 1, height: 1 }, false, cfg); + expect(t.x).toBeGreaterThanOrEqual(0); + expect(t.x).toBeLessThanOrEqual(1); + }); +}); + +// ========================================================================= +// patchPage behavioral: fill uses platform SELECT_ALL +// ========================================================================= +describe("patchPage fill", () => { + it("fill calls keyboard.press with platform-correct select-all", async () => { + const { patchPage } = await import("../src/human/index.js"); + + const pressedKeys: string[] = []; + const page = buildMockPage({ + keyboardPress: async (key: string) => { pressedKeys.push(key); }, + evaluate: async () => false, + }); + + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + patchPage(page as any, cfg, cursor as any); + + try { await (page as any).fill("input#name", "hello"); } catch (_) {} + + const expected = process.platform === "darwin" ? "Meta+a" : "Control+a"; + const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a"; + if (pressedKeys.length > 0) { + expect(pressedKeys).toContain(expected); + expect(pressedKeys).not.toContain(wrong); + } + }, 30000); +}); + + +// ========================================================================= +// patchPage behavioral: check/uncheck with idle_between_actions +// ========================================================================= +describe("patchPage check/uncheck idle", () => { + it("check with idle=true calls humanClickFn and does not crash on idle", async () => { + const { patchPage } = await import("../src/human/index.js"); + + let downCalled = false; + const page = buildMockPage({ + isChecked: async () => false, + evaluate: async () => false, + }); + page.mouse.down = vi.fn(async () => { downCalled = true; }); + + const cfg = resolveConfig("default", { + idle_between_actions: true, + idle_between_duration: [1, 2], + }); + const cursor = { x: 100, y: 100, initialized: true }; + patchPage(page as any, cfg, cursor as any); + + try { await (page as any).check("input#cb"); } catch (_) {} + + // humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down + expect(downCalled).toBe(true); + }, 30000); + + it("uncheck with idle=true calls humanClickFn and does not crash on idle", async () => { + const { patchPage } = await import("../src/human/index.js"); + + let downCalled = false; + const page = buildMockPage({ + isChecked: async () => true, + evaluate: async () => false, + }); + page.mouse.down = vi.fn(async () => { downCalled = true; }); + + const cfg = resolveConfig("default", { + idle_between_actions: true, + idle_between_duration: [1, 2], + }); + const cursor = { x: 100, y: 100, initialized: true }; + patchPage(page as any, cfg, cursor as any); + + try { await (page as any).uncheck("input#cb"); } catch (_) {} + + expect(downCalled).toBe(true); + }, 30000); + + it("config with idle=true is accepted by resolveConfig", () => { + const cfg = resolveConfig("default", { + idle_between_actions: true, + idle_between_duration: [5, 10], + }); + expect(cfg.idle_between_actions).toBe(true); + expect(cfg.idle_between_duration).toEqual([5, 10]); + }); +}); + +// ========================================================================= +// patchPage behavioral: press focus check +// ========================================================================= +describe("patchPage press focus", () => { + it("press clicks element when NOT focused (mouse.down called)", async () => { + const { patchPage } = await import("../src/human/index.js"); + + let downCount = 0; + const page = buildMockPage({ + evaluate: async () => false, + }); + // Intercept mouse.down before patching so raw captures it + page.mouse.down = vi.fn(async () => { downCount++; }); + + const cfg = resolveConfig("default"); + const cursor = { x: 50, y: 50, initialized: true }; + patchPage(page as any, cfg, cursor as any); + + try { await (page as any).press("input#field", "Enter"); } catch (_) {} + + expect(downCount).toBeGreaterThan(0); + }); + + it("press skips click when element IS focused (no mouse.down)", async () => { + const { patchPage } = await import("../src/human/index.js"); + + let downCount = 0; + const page = buildMockPage({ + evaluate: async () => true, + }); + page.mouse.down = vi.fn(async () => { downCount++; }); + + const cfg = resolveConfig("default"); + const cursor = { x: 50, y: 50, initialized: true }; + patchPage(page as any, cfg, cursor as any); + + try { await (page as any).press("input#field", "Enter"); } catch (_) {} + + expect(downCount).toBe(0); + }); +}); + +// ========================================================================= +// patchPage behavioral: frame patching +// ========================================================================= +describe("patchPage frame patching", () => { + it("patches child frames with _humanPatched flag", async () => { + const { patchPage } = await import("../src/human/index.js"); + + const childFrame = buildMockFrame(); + const mainFrame = { + ...buildMockFrame(), + childFrames: vi.fn(() => [childFrame]), + }; + + const page = buildMockPage({ mainFrameReturn: mainFrame }); + const cfg = resolveConfig("default"); + const cursor = { x: 0, y: 0, initialized: false }; + patchPage(page as any, cfg, cursor as any); + + expect((childFrame as any)._humanPatched).toBe(true); + }); +}); + +// ========================================================================= +// Mistype config +// ========================================================================= +describe("mistype config", () => { + it("default config has valid mistype fields", () => { + const cfg = resolveConfig("default"); + expect(typeof cfg.mistype_chance).toBe("number"); + expect(cfg.mistype_chance).toBeGreaterThanOrEqual(0); + expect(cfg.mistype_chance).toBeLessThanOrEqual(1); + // mistype_delay_notice and mistype_delay_correct are [min, max] tuples + expect(Array.isArray(cfg.mistype_delay_notice)).toBe(true); + expect(cfg.mistype_delay_notice).toHaveLength(2); + expect(Array.isArray(cfg.mistype_delay_correct)).toBe(true); + expect(cfg.mistype_delay_correct).toHaveLength(2); + }); + + it("mistype_chance can be overridden to 0 (disabled)", () => { + const cfg = resolveConfig("default", { mistype_chance: 0 }); + expect(cfg.mistype_chance).toBe(0); + }); + + it("mistype_chance can be overridden to higher value", () => { + const cfg = resolveConfig("default", { mistype_chance: 0.15 }); + expect(cfg.mistype_chance).toBe(0.15); + }); +}); + +// ========================================================================= +// Module exports +// ========================================================================= +describe("module exports", () => { + it("patchBrowser, patchContext, patchPage are all exported functions", async () => { + const mod = await import("../src/human/index.js"); + expect(typeof mod.patchBrowser).toBe("function"); + expect(typeof mod.patchContext).toBe("function"); + expect(typeof mod.patchPage).toBe("function"); + }); + + it("humanMove, humanClick, clickTarget, humanIdle are exported", async () => { + const mod = await import("../src/human/index.js"); + expect(typeof mod.humanMove).toBe("function"); + expect(typeof mod.humanClick).toBe("function"); + expect(typeof mod.clickTarget).toBe("function"); + expect(typeof mod.humanIdle).toBe("function"); + }); + + it("resolveConfig is re-exported from index", async () => { + const mod = await import("../src/human/index.js"); + expect(typeof mod.resolveConfig).toBe("function"); + }); +}); + +// ========================================================================= +// Test helpers +// ========================================================================= + +function buildMockPage(overrides: Record = {}): any { + const mainFrameObj = overrides.mainFrameReturn ?? { + childFrames: vi.fn(() => []), + click: vi.fn(async () => {}), + dblclick: vi.fn(async () => {}), + hover: vi.fn(async () => {}), + type: vi.fn(async () => {}), + fill: vi.fn(async () => {}), + check: vi.fn(async () => {}), + uncheck: vi.fn(async () => {}), + selectOption: vi.fn(async () => {}), + press: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + dragAndDrop: vi.fn(async () => {}), + locator: vi.fn(() => ({ + boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })), + first: vi.fn(function(this: any) { return this; }), + })), + }; + + const makeLocator = () => { + const loc: any = { + boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })), + scrollIntoViewIfNeeded: vi.fn(async () => {}), + isChecked: overrides.isChecked ?? vi.fn(async () => false), + }; + loc.first = vi.fn(() => loc); + return loc; + }; + + const page: any = { + evaluate: overrides.evaluate ?? vi.fn(async () => false), + addInitScript: vi.fn(async () => {}), + mouse: { + move: vi.fn(async () => {}), + down: vi.fn(async () => {}), + up: vi.fn(async () => {}), + click: vi.fn(async () => {}), + dblclick: vi.fn(async () => {}), + wheel: vi.fn(async () => {}), + }, + keyboard: { + press: overrides.keyboardPress + ? vi.fn(overrides.keyboardPress) + : vi.fn(async () => {}), + type: vi.fn(async () => {}), + down: vi.fn(async () => {}), + up: vi.fn(async () => {}), + insertText: vi.fn(async () => {}), + }, + click: vi.fn(async () => {}), + dblclick: vi.fn(async () => {}), + hover: vi.fn(async () => {}), + type: vi.fn(async () => {}), + fill: vi.fn(async () => {}), + check: vi.fn(async () => {}), + uncheck: vi.fn(async () => {}), + selectOption: vi.fn(async () => {}), + press: vi.fn(async () => {}), + goto: vi.fn(async () => ({})), + isChecked: overrides.isChecked ?? vi.fn(async () => false), + locator: vi.fn(() => makeLocator()), + viewportSize: vi.fn(() => ({ width: 1280, height: 720 })), + mainFrame: vi.fn(() => mainFrameObj), + frames: vi.fn(() => []), + context: vi.fn(() => ({ + pages: vi.fn(() => []), + addInitScript: vi.fn(async () => {}), + })), + url: vi.fn(() => "about:blank"), + waitForTimeout: vi.fn(async () => {}), + }; + return page; +} + + +function buildMockFrame(): any { + return { + click: vi.fn(async () => {}), + dblclick: vi.fn(async () => {}), + hover: vi.fn(async () => {}), + type: vi.fn(async () => {}), + fill: vi.fn(async () => {}), + check: vi.fn(async () => {}), + uncheck: vi.fn(async () => {}), + selectOption: vi.fn(async () => {}), + press: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + dragAndDrop: vi.fn(async () => {}), + locator: vi.fn(() => ({ + boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })), + })), + childFrames: vi.fn(() => []), + }; +} diff --git a/tests/test_human_visual.mjs b/tests/test_human_visual.mjs new file mode 100644 index 0000000..6039557 --- /dev/null +++ b/tests/test_human_visual.mjs @@ -0,0 +1,256 @@ +// test_human_visual.mjs +/** + * Visual + functional test for humanize (JS). + * Red dot = cursor, yellow = mouse held. + * Trail dots show the path taken. + */ +import { launch } from '../js/dist/index.js'; + +const CURSOR_JS = ` +(() => { + if (document.getElementById('__hc')) return; + const el = document.createElement('div'); + el.id = '__hc'; + el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;'; + document.body.appendChild(el); + + const trail = document.createElement('div'); + trail.id = '__hcTrail'; + trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;'; + document.body.appendChild(trail); + + let dotCount = 0; + const maxDots = 500; + + function updatePos(x, y) { + el.style.display = 'block'; + el.style.left = (x - 9) + 'px'; + el.style.top = (y - 9) + 'px'; + if (dotCount < maxDots) { + const dot = document.createElement('div'); + dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;'; + trail.appendChild(dot); + dotCount++; + } + } + + document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY)); + document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); }); + document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); }); + document.addEventListener('mousedown', () => { el.style.background = 'yellow'; }); + document.addEventListener('mouseup', () => { el.style.background = 'red'; }); + document.addEventListener('dragend', () => { el.style.background = 'red'; }); +})(); +`; + +const results = []; +const delay = ms => new Promise(r => setTimeout(r, ms)); + +async function inject(page) { + try { await page.evaluate(CURSOR_JS); } catch {} + await delay(300); +} + +function step(name) { + console.log(`\n${'='.repeat(60)}`); + console.log(` STEP: ${name}`); + console.log('='.repeat(60)); +} + +function check(name, passed, detail = '') { + const status = passed ? 'PASS' : 'FAIL'; + let msg = ` [${status}] ${name}`; + if (detail) msg += ` — ${detail}`; + console.log(msg); + results.push({ name, status }); +} + +async function main() { + console.log('='.repeat(70)); + console.log(' HUMAN-LIKE BEHAVIOR VISUAL TEST (JS)'); + console.log(' Watch the red dot — it should move smoothly like a real cursor'); + console.log('='.repeat(70)); + + const browser = await launch({ + headless: false, + humanize: true, + }); + const page = await browser.newPage(); + + // ============================================================ + // SCENARIO 1: Wikipedia search + // ============================================================ + step('Wikipedia — navigate and search'); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(2000); + await inject(page); + await delay(1000); + + console.log(' Watch: cursor moves to search box (Bezier curve)'); + let t0 = Date.now(); + await page.locator('#searchInput').click(); + let ms = Date.now() - t0; + check('click on search input', ms > 200, `${ms} ms`); + await delay(500); + + console.log(' Watch: characters appear one by one'); + t0 = Date.now(); + await page.locator('#searchInput').fill('Python programming language'); + ms = Date.now() - t0; + let val = await page.locator('#searchInput').inputValue(); + check('fill search box', val === 'Python programming language' && ms > 2000, `${ms} ms, value='${val}'`); + await delay(500); + + console.log(' Watch: double click selects word'); + t0 = Date.now(); + await page.locator('#searchInput').dblclick(); + ms = Date.now() - t0; + let sel = await page.evaluate(() => window.getSelection().toString().trim()); + check('dblclick selects word', sel.length > 0 && ms > 200, `${ms} ms, selected='${sel}'`); + await delay(500); + + console.log(' Watch: old text replaced'); + t0 = Date.now(); + await page.locator('#searchInput').fill('Artificial intelligence'); + ms = Date.now() - t0; + val = await page.locator('#searchInput').inputValue(); + check('fill replaces text', val === 'Artificial intelligence' && ms > 1500, `${ms} ms, value='${val}'`); + await delay(500); + + console.log(' Watch: cursor hovers button without clicking'); + t0 = Date.now(); + await page.locator('button[type="submit"]').hover(); + ms = Date.now() - t0; + check('hover search button', ms > 100, `${ms} ms`); + await delay(1000); + + // ============================================================ + // SCENARIO 2: Checkboxes + // ============================================================ + step('Checkboxes — check and uncheck'); + await page.goto('https://the-internet.herokuapp.com/checkboxes', { waitUntil: 'domcontentloaded' }); + await delay(2000); + await inject(page); + await delay(1000); + + const cb1 = page.locator('input[type="checkbox"]').nth(0); + const cb2 = page.locator('input[type="checkbox"]').nth(1); + + if (await cb1.isChecked()) { await cb1.uncheck(); await delay(500); } + + console.log(' Watch: cursor moves to checkbox, clicks'); + t0 = Date.now(); + await cb1.check(); + ms = Date.now() - t0; + check('check checkbox 1', await cb1.isChecked() && ms > 200, `${ms} ms`); + await delay(500); + + if (!(await cb2.isChecked())) { await cb2.check(); await delay(500); } + + t0 = Date.now(); + await cb2.uncheck(); + ms = Date.now() - t0; + check('uncheck checkbox 2', !(await cb2.isChecked()) && ms > 200, `${ms} ms`); + await delay(1000); + + // ============================================================ + // SCENARIO 3: Dropdown + // ============================================================ + step('Dropdown — select option'); + await page.goto('https://the-internet.herokuapp.com/dropdown', { waitUntil: 'domcontentloaded' }); + await delay(2000); + await inject(page); + await delay(1000); + + console.log(' Watch: cursor hovers dropdown, option selected'); + t0 = Date.now(); + await page.locator('#dropdown').selectOption('2'); + ms = Date.now() - t0; + val = await page.locator('#dropdown').inputValue(); + check('select option', val === '2' && ms > 100, `${ms} ms, value='${val}'`); + await delay(1000); + + // ============================================================ + // SCENARIO 4: Drag and Drop + // ============================================================ + step('Drag and Drop'); + await page.goto('https://the-internet.herokuapp.com/drag_and_drop', { waitUntil: 'domcontentloaded' }); + await delay(2000); + await inject(page); + await delay(1000); + + const beforeA = (await page.locator('#column-a header').textContent()).trim(); + console.log(` Before: A='${beforeA}'`); + console.log(' Watch: cursor to A, yellow (held), moves to B, releases'); + + t0 = Date.now(); + await page.locator('#column-a').dragTo(page.locator('#column-b')); + ms = Date.now() - t0; + await delay(1000); + + const afterA = (await page.locator('#column-a header').textContent()).trim(); + const swapped = beforeA !== afterA; + check('drag A to B', swapped && ms > 300, `${ms} ms, swapped=${swapped}`); + await delay(1000); + + // ============================================================ + // SCENARIO 5: Text editing + // ============================================================ + step('Text editing — type, press, clear'); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(2000); + await inject(page); + await delay(1000); + + console.log(' Watch: types character by character'); + t0 = Date.now(); + await page.locator('#searchInput').type('Hello World'); + ms = Date.now() - t0; + val = await page.locator('#searchInput').inputValue(); + check("type 'Hello World'", val === 'Hello World' && ms > 1000, `${ms} ms`); + await delay(500); + + console.log(' Watch: field cleared'); + t0 = Date.now(); + await page.locator('#searchInput').clear(); + ms = Date.now() - t0; + val = await page.locator('#searchInput').inputValue(); + check('clear field', val === '' && ms > 100, `${ms} ms`); + await delay(500); + + console.log(' Watch: mouse moves in Bezier curve'); + t0 = Date.now(); + await page.mouse.move(600, 400); + ms = Date.now() - t0; + check('mouse.move', ms > 100, `${ms} ms`); + await delay(500); + + t0 = Date.now(); + await page.mouse.click(300, 300); + ms = Date.now() - t0; + check('mouse.click', ms > 100, `${ms} ms`); + await delay(1000); + + // ============================================================ + // SUMMARY + // ============================================================ + console.log('\n' + '='.repeat(70)); + console.log(' SUMMARY'); + console.log('='.repeat(70)); + + const passed = results.filter(r => r.status === 'PASS').length; + const failed = results.filter(r => r.status === 'FAIL').length; + + for (const r of results) { + const icon = r.status === 'PASS' ? 'OK' : 'XX'; + console.log(` [${icon}] ${r.name}`); + } + + console.log(`\n ${passed}/${results.length} passed, ${failed} failed`); + if (failed === 0) console.log(' *** ALL TESTS PASSED ***'); + console.log('='.repeat(70)); + + await browser.close(); +} + +main().catch(console.error); diff --git a/tests/test_human_visual.py b/tests/test_human_visual.py new file mode 100644 index 0000000..b8be89c --- /dev/null +++ b/tests/test_human_visual.py @@ -0,0 +1,302 @@ +""" +Visual + functional test for humanize. +Red dot = cursor, yellow = mouse held. +""" +import pytest +pytestmark = pytest.mark.slow + +if __name__ == "__main__": + from cloakbrowser import launch + import time + + CURSOR_JS = """ + () => { + if (document.getElementById('__hc')) return; + const el = document.createElement('div'); + el.id = '__hc'; + el.style.cssText = 'width:14px;height:14px;background:red;border:2px solid darkred;border-radius:50%;position:fixed;z-index:2147483647;pointer-events:none;display:none;transition:background 0.05s;'; + document.body.appendChild(el); + + const trail = document.createElement('div'); + trail.id = '__hcTrail'; + trail.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;overflow:hidden;'; + document.body.appendChild(trail); + + let dotCount = 0; + const maxDots = 500; + + function updatePos(x, y) { + el.style.display = 'block'; + el.style.left = (x - 9) + 'px'; + el.style.top = (y - 9) + 'px'; + + if (dotCount < maxDots) { + const dot = document.createElement('div'); + dot.style.cssText = 'width:3px;height:3px;background:rgba(255,0,0,0.3);border-radius:50%;position:fixed;pointer-events:none;left:'+(x-1)+'px;top:'+(y-1)+'px;'; + trail.appendChild(dot); + dotCount++; + } + } + + document.addEventListener('mousemove', e => updatePos(e.clientX, e.clientY)); + document.addEventListener('drag', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); }); + document.addEventListener('dragover', e => { if (e.clientX > 0) updatePos(e.clientX, e.clientY); }); + document.addEventListener('mousedown', () => { el.style.background = 'yellow'; }); + document.addEventListener('mouseup', () => { el.style.background = 'red'; }); + document.addEventListener('dragend', () => { el.style.background = 'red'; }); + } + """ + + def inject(page): + try: + page.evaluate(CURSOR_JS) + except: + pass + time.sleep(0.3) + + results = [] + + def step(name): + print(f"\n{'='*60}") + print(f" STEP: {name}") + print(f"{'='*60}") + + def check(name, passed, detail=""): + status = "PASS" if passed else "FAIL" + msg = f" [{status}] {name}" + if detail: + msg += f" — {detail}" + print(msg) + results.append((name, status)) + + print("=" * 70) + print(" HUMAN-LIKE BEHAVIOR VISUAL TEST") + print(" Watch the red dot — it should move smoothly like a real cursor") + print(" Yellow = mouse button held") + print(" Red trail dots = path taken") + print("=" * 70) + + browser = launch(headless=False, humanize=True) + page = browser.new_page() + + # ============================================================ + # SCENARIO 1: Wikipedia search + # ============================================================ + step("Wikipedia — navigate and search") + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + + print(" Watch: cursor moves to search box (Bezier curve)") + t0 = time.time() + page.locator('#searchInput').click() + click_ms = int((time.time() - t0) * 1000) + check("click on search input", click_ms > 200, f"{click_ms} ms") + time.sleep(0.5) + + print(" Watch: characters appear one by one with varying speed") + t0 = time.time() + page.locator('#searchInput').fill('Python programming language') + fill_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("fill search box", val == 'Python programming language' and fill_ms > 2000, f"{fill_ms} ms, value='{val}'") + time.sleep(0.5) + + print(" Watch: cursor moves to search box, double yellow flash, word selected") + t0 = time.time() + page.locator('#searchInput').dblclick() + dbl_ms = int((time.time() - t0) * 1000) + sel = page.evaluate('() => window.getSelection().toString().trim()') + check("dblclick selects word", len(sel) > 0 and dbl_ms > 200, f"{dbl_ms} ms, selected='{sel}'") + time.sleep(0.5) + + print(" Watch: old text cleared, new text typed") + t0 = time.time() + page.locator('#searchInput').fill('Artificial intelligence') + fill2_ms = int((time.time() - t0) * 1000) + val2 = page.locator('#searchInput').input_value() + check("fill replaces text", val2 == 'Artificial intelligence' and fill2_ms > 1500, f"{fill2_ms} ms, value='{val2}'") + time.sleep(0.5) + + print(" Watch: cursor moves to button without clicking") + t0 = time.time() + page.locator('button[type="submit"]').hover() + hover_ms = int((time.time() - t0) * 1000) + check("hover search button", hover_ms > 100, f"{hover_ms} ms") + time.sleep(1) + + # ============================================================ + # SCENARIO 2: Form interaction — checkboxes + # ============================================================ + step("Checkboxes — check and uncheck") + page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + + cb1 = page.locator('input[type="checkbox"]').nth(0) + cb2 = page.locator('input[type="checkbox"]').nth(1) + + print(" Watch: cursor moves to first checkbox, clicks") + if cb1.is_checked(): + cb1.uncheck() + time.sleep(0.5) + + t0 = time.time() + cb1.check() + check_ms = int((time.time() - t0) * 1000) + check("check checkbox 1", cb1.is_checked() and check_ms > 200, f"{check_ms} ms, checked={cb1.is_checked()}") + time.sleep(0.5) + + print(" Watch: cursor moves to second checkbox, clicks to uncheck") + if not cb2.is_checked(): + cb2.check() + time.sleep(0.5) + + t0 = time.time() + cb2.uncheck() + uncheck_ms = int((time.time() - t0) * 1000) + check("uncheck checkbox 2", not cb2.is_checked() and uncheck_ms > 200, f"{uncheck_ms} ms, checked={cb2.is_checked()}") + time.sleep(1) + + # ============================================================ + # SCENARIO 3: Dropdown + # ============================================================ + step("Dropdown — select option") + page.goto('https://the-internet.herokuapp.com/dropdown', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + + print(" Watch: cursor moves to dropdown, hovers, option selected") + t0 = time.time() + page.locator('#dropdown').select_option('1') + sel_ms = int((time.time() - t0) * 1000) + val = page.locator('#dropdown').input_value() + check("select option 1", val == '1' and sel_ms > 100, f"{sel_ms} ms, value='{val}'") + time.sleep(0.5) + + t0 = time.time() + page.locator('#dropdown').select_option('2') + sel2_ms = int((time.time() - t0) * 1000) + val2 = page.locator('#dropdown').input_value() + check("select option 2", val2 == '2' and sel2_ms > 100, f"{sel2_ms} ms, value='{val2}'") + time.sleep(1) + + # ============================================================ + # SCENARIO 4: Drag and drop + # ============================================================ + step("Drag and Drop — move column A to B") + page.goto('https://the-internet.herokuapp.com/drag_and_drop', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + + before_a = page.locator('#column-a header').text_content().strip() + before_b = page.locator('#column-b header').text_content().strip() + print(f" Before: A='{before_a}', B='{before_b}'") + + print(" Watch: cursor moves to A, turns yellow (held), moves to B, releases") + t0 = time.time() + page.locator('#column-a').drag_to(page.locator('#column-b')) + drag_ms = int((time.time() - t0) * 1000) + time.sleep(1) + + after_a = page.locator('#column-a header').text_content().strip() + after_b = page.locator('#column-b header').text_content().strip() + swapped = before_a != after_a + print(f" After: A='{after_a}', B='{after_b}'") + check("drag A to B", swapped and drag_ms > 300, f"{drag_ms} ms, swapped={swapped}") + time.sleep(1) + + # ============================================================ + # SCENARIO 5: Text editing + # ============================================================ + step("Text editing — type, press keys, clear") + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(2) + inject(page) + time.sleep(1) + + print(" Watch: cursor clicks input, types character by character") + t0 = time.time() + page.locator('#searchInput').type('Hello World') + type_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("type 'Hello World'", val == 'Hello World' and type_ms > 1000, f"{type_ms} ms, value='{val}'") + time.sleep(0.5) + + print(" Watch: cursor clicks, presses single key") + t0 = time.time() + page.locator('#searchInput').press('End') + page.locator('#searchInput').press('!') + press_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("press '!' at end", '!' in val and press_ms > 100, f"{press_ms} ms, value='{val}'") + time.sleep(0.5) + + print(" Watch: field gets cleared (Ctrl+A, Backspace)") + t0 = time.time() + page.locator('#searchInput').clear() + clear_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("clear field", val == '' and clear_ms > 100, f"{clear_ms} ms, value='{repr(val)}'") + time.sleep(0.5) + + print(" Watch: press_sequentially types each key individually") + t0 = time.time() + page.locator('#searchInput').press_sequentially('Sequential') + pseq_ms = int((time.time() - t0) * 1000) + val = page.locator('#searchInput').input_value() + check("press_sequentially", val == 'Sequential' and pseq_ms > 500, f"{pseq_ms} ms, value='{val}'") + time.sleep(1) + + # ============================================================ + # SCENARIO 6: Mouse precision + # ============================================================ + step("Mouse precision — move to coordinates") + print(" Watch: cursor moves in a Bezier curve to (600, 400)") + t0 = time.time() + page.mouse.move(600, 400) + move_ms = int((time.time() - t0) * 1000) + check("mouse.move to (600,400)", move_ms > 100, f"{move_ms} ms") + time.sleep(0.5) + + print(" Watch: cursor moves to (200, 200), clicks") + t0 = time.time() + page.mouse.click(200, 200) + mclick_ms = int((time.time() - t0) * 1000) + check("mouse.click at (200,200)", mclick_ms > 100, f"{mclick_ms} ms") + time.sleep(0.5) + + print(" Watch: keyboard types directly (no click needed)") + page.locator('#searchInput').click() + time.sleep(0.3) + t0 = time.time() + page.keyboard.type('Direct keyboard') + kb_ms = int((time.time() - t0) * 1000) + check("keyboard.type", kb_ms > 500, f"{kb_ms} ms") + time.sleep(1) + + # ============================================================ + # SUMMARY + # ============================================================ + print("\n" + "=" * 70) + print(" SUMMARY") + print("=" * 70) + passed = sum(1 for _, s in results if s == "PASS") + failed = sum(1 for _, s in results if s == "FAIL") + total = len(results) + + for name, status in results: + icon = "OK" if status == "PASS" else "XX" + print(f" [{icon}] {name}") + + print(f"\n {passed}/{total} passed, {failed} failed") + if failed == 0: + print(" *** ALL TESTS PASSED ***") + print("=" * 70) + + input("\nPress Enter to close browser...") + browser.close() diff --git a/tests/test_humanize_unit.mjs b/tests/test_humanize_unit.mjs new file mode 100644 index 0000000..fa67dd6 --- /dev/null +++ b/tests/test_humanize_unit.mjs @@ -0,0 +1,470 @@ +/** + * Unit + integration tests for the humanize layer (JS). + * Covers: config resolution, Bézier math, fill clearing, + * bot-detection form, and patching integrity. + * + * Run: node tests/test_humanize_unit.mjs + */ +import { launch } from '../js/dist/index.js'; +import { resolveConfig, rand, randRange, sleep } from '../js/dist/human/config.js'; +import { humanMove, clickTarget } from '../js/dist/human/mouse.js'; + +const PROXY = { + +}; +const delay = ms => new Promise(r => setTimeout(r, ms)); +const results = []; + +async function test(name, fn) { + try { + await fn(); + console.log(` [PASS] ${name}`); + results.push({ name, status: 'PASS' }); + } catch (e) { + console.log(` [FAIL] ${name} — ${e.message || e}`); + results.push({ name, status: 'FAIL' }); + } +} + +// ========================================================================= +// 1. Config resolution +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' CONFIG RESOLUTION'); +console.log('='.repeat(60)); + +await test('default config resolves', async () => { + const cfg = resolveConfig('default'); + if (!cfg) throw new Error('resolveConfig returned null'); + if (cfg.mouse_min_steps <= 0) throw new Error('mouse_min_steps should be > 0'); + if (cfg.mouse_max_steps <= cfg.mouse_min_steps) throw new Error('mouse_max_steps should be > min'); + if (cfg.typing_delay <= 0) throw new Error('typing_delay should be > 0'); + if (!Array.isArray(cfg.initial_cursor_x) || cfg.initial_cursor_x.length !== 2) throw new Error('initial_cursor_x invalid'); + if (!Array.isArray(cfg.initial_cursor_y) || cfg.initial_cursor_y.length !== 2) throw new Error('initial_cursor_y invalid'); +}); + +await test('careful config resolves', async () => { + const cfg = resolveConfig('careful'); + const def = resolveConfig('default'); + if (!cfg) throw new Error('resolveConfig returned null'); + if (cfg.typing_delay < def.typing_delay) throw new Error('careful should have >= typing_delay'); +}); + +await test('custom config override', async () => { + const cfg = resolveConfig('default', { mouse_min_steps: 100, mouse_max_steps: 200 }); + if (cfg.mouse_min_steps !== 100) throw new Error(`Override failed: ${cfg.mouse_min_steps}`); + if (cfg.mouse_max_steps !== 200) throw new Error(`Override failed: ${cfg.mouse_max_steps}`); +}); + +await test('rand within bounds', async () => { + for (let i = 0; i < 100; i++) { + const v = rand(10, 20); + if (v < 10 || v > 20) throw new Error(`rand out of range: ${v}`); + } +}); + +await test('randRange within bounds', async () => { + for (let i = 0; i < 100; i++) { + const v = randRange([5, 15]); + if (v < 5 || v > 15) throw new Error(`randRange out of range: ${v}`); + } +}); + +await test('sleep timing', async () => { + const t0 = Date.now(); + await sleep(50); + const elapsed = Date.now() - t0; + if (elapsed < 40) throw new Error(`sleep too short: ${elapsed} ms`); + if (elapsed > 200) throw new Error(`sleep too long: ${elapsed} ms`); +}); + +// ========================================================================= +// 2. Bézier math (via humanMove recording) +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' BÉZIER MATH (via mouse movement recording)'); +console.log('='.repeat(60)); + +await test('humanMove generates multiple points', async () => { + const cfg = resolveConfig('default'); + const moves = []; + const fakeRaw = { + move: async (x, y) => moves.push({ x, y }), + down: async () => {}, + up: async () => {}, + wheel: async () => {}, + }; + await humanMove(fakeRaw, 0, 0, 500, 300, cfg); + if (moves.length < 10) throw new Error(`Expected >= 10 moves, got ${moves.length}`); + const last = moves[moves.length - 1]; + if (Math.abs(last.x - 500) > 10) throw new Error(`Last x too far: ${last.x}`); + if (Math.abs(last.y - 300) > 10) throw new Error(`Last y too far: ${last.y}`); +}); + +await test('humanMove smoothness (no large jumps)', async () => { + const cfg = resolveConfig('default'); + const moves = []; + const fakeRaw = { + move: async (x, y) => moves.push({ x, y }), + down: async () => {}, + up: async () => {}, + wheel: async () => {}, + }; + await humanMove(fakeRaw, 0, 0, 400, 400, cfg); + const totalDist = Math.sqrt(400 * 400 + 400 * 400); + const maxJump = totalDist * 0.5; + for (let i = 1; i < moves.length; i++) { + const dx = moves[i].x - moves[i - 1].x; + const dy = moves[i].y - moves[i - 1].y; + const jump = Math.sqrt(dx * dx + dy * dy); + if (jump > maxJump) throw new Error(`Jump too large at step ${i}: ${jump.toFixed(1)}`); + } +}); + +await test('humanMove not a straight line', async () => { + const cfg = resolveConfig('default'); + let maxDev = 0; + for (let trial = 0; trial < 5; trial++) { + const moves = []; + const fakeRaw = { + move: async (x, y) => moves.push({ x, y }), + down: async () => {}, + up: async () => {}, + wheel: async () => {}, + }; + await humanMove(fakeRaw, 0, 0, 500, 0, cfg); + const dev = Math.max(...moves.map(m => Math.abs(m.y))); + if (dev > maxDev) maxDev = dev; + } + if (maxDev < 0.5) throw new Error(`Curve too straight, max y deviation: ${maxDev.toFixed(2)}`); +}); + +await test('clickTarget within bounding box', async () => { + const cfg = resolveConfig('default'); + const box = { x: 100, y: 200, width: 150, height: 40 }; + for (let i = 0; i < 50; i++) { + const t = clickTarget(box, false, cfg); + if (t.x < 100 || t.x > 250) throw new Error(`x out of box: ${t.x}`); + if (t.y < 200 || t.y > 240) throw new Error(`y out of box: ${t.y}`); + } +}); + +// ========================================================================= +// 3. Fill clearing (with real browser) +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' FILL CLEARING (browser)'); +console.log('='.repeat(60)); + +await test('fill() clears existing text', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(1000); + + await page.locator('#searchInput').type('initial text'); + await delay(500); + const before = await page.locator('#searchInput').inputValue(); + if (before !== 'initial text') throw new Error(`Initial type failed: '${before}'`); + + await page.locator('#searchInput').fill('replaced text'); + await delay(500); + const after = await page.locator('#searchInput').inputValue(); + if (after !== 'replaced text') throw new Error(`Fill did not replace: '${after}'`); + if (after.includes('initial')) throw new Error('Old text still present'); + + await browser.close(); +}); + +await test('fill() timing is humanized (>1s)', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(1000); + + const t0 = Date.now(); + await page.locator('#searchInput').fill('Human speed test'); + const elapsed = Date.now() - t0; + if (elapsed < 1000) throw new Error(`fill() too fast: ${elapsed} ms`); + + await browser.close(); +}); + +await test('clear() empties field', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(1000); + + await page.locator('#searchInput').fill('some text'); + await delay(500); + await page.locator('#searchInput').clear(); + await delay(500); + const val = await page.locator('#searchInput').inputValue(); + if (val !== '') throw new Error(`clear() did not empty: '${val}'`); + + await browser.close(); +}); + +// ========================================================================= +// 4. Bot detection form — deviceandbrowserinfo.com +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' BOT DETECTION FORM (deviceandbrowserinfo.com)'); +console.log('='.repeat(60)); + +await test('bot detection form — behavioral checks pass', async () => { + const browser = await launch({ headless: false, humanize: true, proxy: PROXY }); + const page = await browser.newPage(); + await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' }); + await delay(3000); + + await page.locator('#email').click(); + await delay(300); + await page.locator('#email').fill('test@example.com'); + await delay(500); + + await page.locator('#password').click(); + await delay(300); + await page.locator('#password').fill('SecurePass!123'); + await delay(500); + + await page.locator('button[type="submit"]').click(); + await delay(5000); + + const body = await page.locator('body').textContent(); + + const superHuman = body.includes('"superHumanSpeed": true'); + const suspicious = body.includes('"suspiciousClientSideBehavior": true'); + const cdpMouse = body.includes('"hasCDPMouseLeak": true'); + + console.log(` superHumanSpeed: ${superHuman}`); + console.log(` suspiciousClientSideBehavior: ${suspicious}`); + console.log(` hasCDPMouseLeak: ${cdpMouse}`); + + if (superHuman) throw new Error('superHumanSpeed detected'); + if (suspicious) throw new Error('suspiciousClientSideBehavior detected'); + + if (body.includes('"isAutomatedWithCDP": true')) { + console.log(' [INFO] isAutomatedWithCDP=true — stealth issue, not humanize'); + } + + await browser.close(); +}); + +await test('bot detection form timing (>3s)', async () => { + const browser = await launch({ headless: true, humanize: true, proxy: PROXY }); + const page = await browser.newPage(); + await page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', { waitUntil: 'domcontentloaded' }); + await delay(2000); + + const t0 = Date.now(); + await page.locator('#email').fill('test@example.com'); + await page.locator('#password').fill('MyPassword!99'); + await page.locator('button[type="submit"]').click(); + const elapsed = Date.now() - t0; + await delay(3000); + + console.log(` Form fill + submit took: ${elapsed} ms`); + if (elapsed < 3000) throw new Error(`Form filled too fast: ${elapsed} ms`); + + await browser.close(); +}); + +// ========================================================================= +// 5. Patching integrity +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' PATCHING INTEGRITY'); +console.log('='.repeat(60)); + +await test('page has _original after launch', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + if (!page._original) throw new Error('page._original missing'); + if (!page._humanCfg) throw new Error('page._humanCfg missing'); + if (!page._humanCursor) throw new Error('page._humanCursor missing'); + await browser.close(); +}); + +await test('page.click is humanized', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + const clickStr = page.click.toString(); + if (!clickStr.includes('ensureCursorInit') && !clickStr.includes('humanClickFn') && !clickStr.includes('scrollToElement')) { + throw new Error('page.click does not appear humanized'); + } + await browser.close(); +}); + +await test('non-humanized page works normally', async () => { + const browser = await launch({ headless: true, humanize: false }); + const page = await browser.newPage(); + if (page._original) throw new Error('Non-humanized page should not have _original'); + + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(1000); + + const t0 = Date.now(); + await page.locator('#searchInput').fill('test'); + const elapsed = Date.now() - t0; + if (elapsed > 500) throw new Error(`Non-humanized fill too slow: ${elapsed} ms`); + + await browser.close(); +}); + +// ========================================================================= +// 6. Focus check — press skips click when focused +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' FOCUS CHECK (press / pressSequentially)'); +console.log('='.repeat(60)); + +await test('press skips click when element already focused', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(1000); + + // Click input first to focus it + await page.locator('#searchInput').click(); + await delay(300); + + // Record mouse moves before pressing Enter + const movesBefore = []; + const origMove = page._humanOriginals.mouseMove; + let moveCount = 0; + page._humanOriginals.mouseMove = async (x, y, opts) => { + moveCount++; + return origMove(x, y, opts); + }; + + // Press Enter — element is already focused, should NOT trigger mouse move + const movesAtStart = moveCount; + await page.locator('#searchInput').press('a'); + const movesUsed = moveCount - movesAtStart; + + // Restore + page._humanOriginals.mouseMove = origMove; + + // If focus check works, should be 0 moves (just keyboard press) + if (movesUsed > 0) { + console.log(` [INFO] press() triggered ${movesUsed} mouse moves on focused element`); + } + // Lenient: allow some moves but not a full Bézier path (>10 would indicate a click) + if (movesUsed > 10) { + throw new Error(`press() moved mouse ${movesUsed} times on already-focused element — focus check broken`); + } + + await browser.close(); +}); + +// ========================================================================= +// 7. check/uncheck idle +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' CHECK/UNCHECK IDLE'); +console.log('='.repeat(60)); + +await test('check() respects idle_between_actions config', async () => { + const cfg = resolveConfig('default', { idle_between_actions: true, idle_between_duration: [50, 100] }); + if (!cfg.idle_between_actions) throw new Error('idle_between_actions should be true'); + if (!cfg.idle_between_duration || cfg.idle_between_duration[0] !== 50) { + throw new Error('idle_between_duration not set'); + } + // Verify config is carried through to page + const browser = await launch({ headless: true, humanize: true, humanize_config: { idle_between_actions: true } }); + const page = await browser.newPage(); + if (!page._humanCfg) throw new Error('page._humanCfg missing'); + await browser.close(); +}); + +// ========================================================================= +// 8. Frame patching completeness +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' FRAME PATCHING COMPLETENESS'); +console.log('='.repeat(60)); + +await test('frame has all methods patched', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + await page.goto('https://www.wikipedia.org', { waitUntil: 'domcontentloaded' }); + await delay(1000); + + const mainFrame = page.mainFrame(); + const expected = ['click', 'dblclick', 'hover', 'type', 'fill', + 'check', 'uncheck', 'selectOption', 'press', + 'clear', 'dragAndDrop']; + const missing = []; + for (const method of expected) { + if (typeof mainFrame[method] !== 'function') { + missing.push(method); + } + } + if (missing.length > 0) { + throw new Error(`Frame missing patched methods: ${missing.join(', ')}`); + } + + // Verify they are patched (not original Playwright bindings) + if (!mainFrame._humanPatched) { + throw new Error('mainFrame._humanPatched flag not set'); + } + + await browser.close(); +}); + +// ========================================================================= +// 9. drag_to safety — page._original check +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' DRAG_TO SAFETY'); +console.log('='.repeat(60)); + +await test('page._humanCfg is accessible', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + if (!page._humanCfg) throw new Error('page._humanCfg not set'); + if (!page._original) throw new Error('page._original not set'); + if (typeof page._original.mouseDown !== 'function') throw new Error('mouseDown not preserved'); + if (typeof page._original.mouseUp !== 'function') throw new Error('mouseUp not preserved'); + await browser.close(); +}); + +// ========================================================================= +// 10. patchBrowser.newPage uses original context +// ========================================================================= +console.log('\n' + '='.repeat(60)); +console.log(' PATCH BROWSER — newPage context'); +console.log('='.repeat(60)); + +await test('browser.newPage returns patched page', async () => { + const browser = await launch({ headless: true, humanize: true }); + const page = await browser.newPage(); + if (!page._original) throw new Error('page from browser.newPage() not patched'); + if (!page._humanCfg) throw new Error('page._humanCfg missing from browser.newPage()'); + await browser.close(); +}); + + +// ========================================================================= +// SUMMARY +// ========================================================================= +console.log('\n' + '='.repeat(70)); +console.log(' TEST SUMMARY'); +console.log('='.repeat(70)); + +const passed = results.filter(r => r.status === 'PASS').length; +const failed = results.filter(r => r.status === 'FAIL').length; + +for (const r of results) { + const icon = r.status === 'PASS' ? 'OK' : 'XX'; + console.log(` [${icon}] ${r.name}`); +} + +console.log(`\n ${passed}/${results.length} passed, ${failed} failed`); +if (failed === 0) console.log(' *** ALL JS TESTS PASSED ***'); +else console.log(` *** ${failed} TESTS FAILED ***`); +console.log('='.repeat(70)); + +process.exit(failed === 0 ? 0 : 1); diff --git a/tests/test_humanize_unit.py b/tests/test_humanize_unit.py new file mode 100644 index 0000000..bbe3272 --- /dev/null +++ b/tests/test_humanize_unit.py @@ -0,0 +1,575 @@ +""" +Unit + integration tests for the humanize layer. + +Fast unit tests (config, Bézier math, mocks) are proper test_ functions +that pytest discovers automatically. + +Browser-dependent tests are marked @pytest.mark.slow and skipped in CI +unless explicitly requested (pytest -m slow). + +Can also run directly: python tests/test_humanize_unit.py +""" +import math +import time +import sys + +import pytest + + +# ========================================================================= +# Helper: ensure Locator class is patched before mock tests +# ========================================================================= + +def _ensure_locator_patched(): + import cloakbrowser.human as h + h._locator_sync_patched = False + h._patch_locator_class_sync() + + +# ========================================================================= +# Helper: fake RawMouse for Bézier tests +# ========================================================================= + +class _FakeRawMouse: + def __init__(self): + self.moves = [] + def move(self, x, y, **kw): + self.moves.append((x, y)) + def down(self, **kw): + pass + def up(self, **kw): + pass + def wheel(self, dx, dy): + pass + + +# ========================================================================= +# 1. Config resolution +# ========================================================================= + +class TestConfigResolution: + def test_default_config_resolves(self): + from cloakbrowser.human.config import resolve_config, HumanConfig + cfg = resolve_config("default", None) + assert isinstance(cfg, HumanConfig) + assert cfg.mouse_min_steps > 0 + assert cfg.mouse_max_steps > cfg.mouse_min_steps + assert len(cfg.initial_cursor_x) == 2 + assert len(cfg.initial_cursor_y) == 2 + assert cfg.typing_delay > 0 + + def test_careful_config_resolves(self): + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("careful", None) + default_cfg = resolve_config("default", None) + assert cfg.mouse_min_steps > 0 + assert cfg.typing_delay >= default_cfg.typing_delay + + def test_custom_override(self): + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", {"mouse_min_steps": 100, "mouse_max_steps": 200}) + assert cfg.mouse_min_steps == 100 + assert cfg.mouse_max_steps == 200 + + def test_invalid_preset_raises(self): + from cloakbrowser.human.config import resolve_config + with pytest.raises(ValueError, match="Unknown humanize preset"): + resolve_config("nonexistent", None) + + def test_rand_within_bounds(self): + from cloakbrowser.human.config import rand, rand_range + for _ in range(200): + v = rand(10, 20) + assert 10 <= v <= 20 + for _ in range(200): + v = rand_range([5, 15]) + assert 5 <= v <= 15 + + def test_sleep_ms_timing(self): + from cloakbrowser.human.config import sleep_ms + t0 = time.time() + sleep_ms(50) + elapsed = (time.time() - t0) * 1000 + assert elapsed >= 40 + assert elapsed < 200 + + +# ========================================================================= +# 2. Bézier math +# ========================================================================= + +class TestBezierMath: + def test_generates_multiple_points(self): + from cloakbrowser.human.mouse import human_move + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", None) + raw = _FakeRawMouse() + human_move(raw, 0, 0, 500, 300, cfg) + assert len(raw.moves) >= 10 + last_x, last_y = raw.moves[-1] + assert abs(last_x - 500) < 10 + assert abs(last_y - 300) < 10 + + def test_smoothness_no_large_jumps(self): + from cloakbrowser.human.mouse import human_move + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", None) + raw = _FakeRawMouse() + human_move(raw, 0, 0, 400, 400, cfg) + total_dist = math.sqrt(400**2 + 400**2) + max_jump = total_dist * 0.5 + for i in range(1, len(raw.moves)): + dx = raw.moves[i][0] - raw.moves[i-1][0] + dy = raw.moves[i][1] - raw.moves[i-1][1] + assert math.sqrt(dx*dx + dy*dy) < max_jump + + def test_short_distance(self): + from cloakbrowser.human.mouse import human_move + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", None) + raw = _FakeRawMouse() + human_move(raw, 100, 100, 103, 102, cfg) + assert len(raw.moves) >= 1 + + def test_not_straight_line(self): + from cloakbrowser.human.mouse import human_move + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", None) + max_dev = 0 + for _ in range(5): + raw = _FakeRawMouse() + human_move(raw, 0, 0, 500, 0, cfg) + dev = max(abs(y) for _, y in raw.moves) + if dev > max_dev: + max_dev = dev + assert max_dev > 0.5 + + def test_click_target_within_box(self): + from cloakbrowser.human.mouse import click_target + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", None) + box = {"x": 100, "y": 200, "width": 150, "height": 40} + for _ in range(50): + t = click_target(box, False, cfg) + assert 100 <= t.x <= 250 + assert 200 <= t.y <= 240 + + def test_click_target_input_mode(self): + from cloakbrowser.human.mouse import click_target + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", None) + box = {"x": 50, "y": 50, "width": 200, "height": 30} + for _ in range(20): + t = click_target(box, True, cfg) + assert 50 <= t.x <= 250 + assert 50 <= t.y <= 80 + + +# ========================================================================= +# 3. Async compatibility +# ========================================================================= + +class TestAsyncCompat: + def test_async_modules_import(self): + from cloakbrowser.human.mouse_async import AsyncRawMouse, async_human_move + from cloakbrowser.human.keyboard_async import AsyncRawKeyboard, async_human_type + from cloakbrowser.human.scroll_async import async_scroll_to_element + from cloakbrowser.human import patch_page_async, patch_browser_async, patch_context_async + assert callable(async_human_move) + assert callable(async_human_type) + assert callable(async_scroll_to_element) + + def test_async_locator_patch(self): + import cloakbrowser.human as h + h._locator_async_patched = False + h._patch_locator_class_async() + assert h._locator_async_patched + from playwright.async_api._generated import Locator as AsyncLocator + assert 'humanized' in AsyncLocator.fill.__name__ + + def test_async_sleep_is_coroutine(self): + from cloakbrowser.human.config import async_sleep_ms + import asyncio + assert asyncio.iscoroutinefunction(async_sleep_ms) + + +# ========================================================================= +# 4. Focus check — press / clear / pressSequentially +# ========================================================================= + +class TestFocusCheck: + def test_press_skips_click_when_focused(self): + _ensure_locator_patched() + from unittest.mock import MagicMock, patch as mock_patch + page = MagicMock() + page._original = MagicMock() + page._human_cfg = MagicMock() + page._human_cfg.idle_between_actions = False + + with mock_patch("cloakbrowser.human._is_selector_focused", return_value=True): + from playwright.sync_api._generated import Locator + loc = MagicMock() + loc.page = page + loc._impl_obj = MagicMock() + loc._impl_obj._selector = "#test" + Locator.press(loc, "Enter") + + page.click.assert_not_called() + + def test_press_clicks_when_not_focused(self): + _ensure_locator_patched() + from unittest.mock import MagicMock, patch as mock_patch + page = MagicMock() + page._original = MagicMock() + page._human_cfg = MagicMock() + page._human_cfg.idle_between_actions = False + + with mock_patch("cloakbrowser.human._is_selector_focused", return_value=False): + from playwright.sync_api._generated import Locator + loc = MagicMock() + loc.page = page + loc._impl_obj = MagicMock() + loc._impl_obj._selector = "#test" + Locator.press(loc, "Enter") + + page.click.assert_called_with("#test") + + +# ========================================================================= +# 5. check/uncheck idle +# ========================================================================= + +class TestCheckUncheckIdle: + def test_check_calls_idle_when_enabled(self): + _ensure_locator_patched() + from unittest.mock import MagicMock, patch as mock_patch + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]}) + + page = MagicMock() + page._original = MagicMock() + page._original.mouse_move = MagicMock() + page._human_cfg = cfg + + idle_called = {"n": 0} + def fake_idle(*a, **kw): + idle_called["n"] += 1 + + from playwright.sync_api._generated import Locator + loc = MagicMock() + loc.page = page + loc._impl_obj = MagicMock() + loc._impl_obj._selector = "#checkbox" + loc.is_checked = MagicMock(return_value=False) + + with mock_patch("cloakbrowser.human.human_idle", fake_idle): + Locator.check(loc) + + assert idle_called["n"] >= 1 + + def test_uncheck_calls_idle_when_enabled(self): + _ensure_locator_patched() + from unittest.mock import MagicMock, patch as mock_patch + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default", {"idle_between_actions": True, "idle_between_duration": [50, 100]}) + + page = MagicMock() + page._original = MagicMock() + page._original.mouse_move = MagicMock() + page._human_cfg = cfg + + idle_called = {"n": 0} + def fake_idle(*a, **kw): + idle_called["n"] += 1 + + from playwright.sync_api._generated import Locator + loc = MagicMock() + loc.page = page + loc._impl_obj = MagicMock() + loc._impl_obj._selector = "#checkbox" + loc.is_checked = MagicMock(return_value=True) + + with mock_patch("cloakbrowser.human.human_idle", fake_idle): + Locator.uncheck(loc) + + assert idle_called["n"] >= 1 + + +# ========================================================================= +# 6. Frame patching completeness +# ========================================================================= + +class TestFramePatching: + def test_all_11_methods_patched(self): + from cloakbrowser.human import _patch_single_frame_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + frame = MagicMock() + frame._human_patched = False + + _patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), page._original) + + expected = ['click', 'dblclick', 'hover', 'type', 'fill', + 'check', 'uncheck', 'select_option', 'press', + 'clear', 'drag_and_drop'] + for method in expected: + fn = getattr(frame, method) + assert not isinstance(fn, MagicMock), f"frame.{method} was not patched" + + +# ========================================================================= +# 7. drag_to safety +# ========================================================================= + +class TestDragToSafety: + def test_handles_missing_original(self): + _ensure_locator_patched() + from playwright.sync_api._generated import Locator + from unittest.mock import MagicMock + + page = MagicMock() + page._original = None + + source_loc = MagicMock() + source_loc.page = page + source_loc._impl_obj = MagicMock() + source_loc._impl_obj._selector = "#src" + source_loc.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 50}) + + target_loc = MagicMock() + target_loc.page = page + target_loc._impl_obj = MagicMock() + target_loc._impl_obj._selector = "#tgt" + target_loc.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 50, "height": 50}) + + try: + Locator.drag_to(source_loc, target_loc) + except AttributeError: + pytest.fail("drag_to crashed without page._original") + + +# ========================================================================= +# 8. Page config persistence +# ========================================================================= + +class TestPageConfigPersistence: + def test_resolve_config_has_all_fields(self): + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default") + required = ["mouse_min_steps", "mouse_max_steps", "typing_delay", + "initial_cursor_x", "initial_cursor_y", "idle_between_actions", + "idle_between_duration", "field_switch_delay", + "mistype_chance", "mistype_delay_notice", "mistype_delay_correct"] + for field in required: + assert hasattr(cfg, field), f"Config missing field: {field}" + + +# ========================================================================= +# 9. Mistype config +# ========================================================================= + +class TestMistypeConfig: + def test_default_mistype_chance(self): + from cloakbrowser.human.config import resolve_config + cfg = resolve_config("default") + assert 0 < cfg.mistype_chance < 1 + assert len(cfg.mistype_delay_notice) == 2 + assert len(cfg.mistype_delay_correct) == 2 + + def test_careful_mistype_higher(self): + from cloakbrowser.human.config import resolve_config + default = resolve_config("default") + careful = resolve_config("careful") + assert careful.mistype_chance >= default.mistype_chance + + +# ========================================================================= +# 10. Select-all platform detection +# ========================================================================= + +class TestSelectAllPlatform: + def test_select_all_constant_exists(self): + from cloakbrowser.human import _SELECT_ALL + assert _SELECT_ALL in ("Meta+a", "Control+a") + + def test_select_all_matches_platform(self): + import sys + from cloakbrowser.human import _SELECT_ALL + if sys.platform == "darwin": + assert _SELECT_ALL == "Meta+a" + else: + assert _SELECT_ALL == "Control+a" + + +# ========================================================================= +# SLOW TESTS — require browser (skipped in CI unless pytest -m slow) +# ========================================================================= + +@pytest.mark.slow +class TestBrowserFill: + def test_fill_clears_existing(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + page.locator('#searchInput').type('initial text') + time.sleep(0.5) + page.locator('#searchInput').fill('replaced text') + time.sleep(0.5) + val = page.locator('#searchInput').input_value() + assert val == 'replaced text' + assert 'initial' not in val + browser.close() + + def test_fill_timing_humanized(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + t0 = time.time() + page.locator('#searchInput').fill('Human speed test') + elapsed_ms = int((time.time() - t0) * 1000) + assert elapsed_ms > 1000 + browser.close() + + def test_clear_empties_field(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + page.locator('#searchInput').fill('some text') + time.sleep(0.5) + page.locator('#searchInput').clear() + time.sleep(0.5) + val = page.locator('#searchInput').input_value() + assert val == '' + browser.close() + + +@pytest.mark.slow +class TestBrowserPatching: + def test_page_has_original(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True) + page = browser.new_page() + assert hasattr(page, '_original') + assert hasattr(page, '_human_cfg') + browser.close() + + def test_locator_methods_patched(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True) + page = browser.new_page() + from playwright.sync_api._generated import Locator + methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck', + 'set_checked', 'select_option', 'press', 'press_sequentially', + 'tap', 'drag_to', 'clear'] + for method in methods: + fn = getattr(Locator, method) + assert 'humanized' in fn.__name__, f"{method} not patched" + browser.close() + + def test_non_humanized_page_normal(self): + from playwright.sync_api import sync_playwright + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + assert not hasattr(page, '_original') + browser.close() + + def test_page_human_cfg_persists(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True) + page = browser.new_page() + assert page._human_cfg is not None + assert hasattr(page._human_cfg, 'idle_between_actions') + assert hasattr(page._human_cfg, 'mistype_chance') + browser.close() + + +@pytest.mark.slow +class TestBrowserBotDetection: + PROXY = '' + + def test_behavioral_checks_pass(self): + from cloakbrowser import launch + 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') + time.sleep(3) + page.locator('#email').click() + time.sleep(0.3) + page.locator('#email').fill('test@example.com') + time.sleep(0.5) + page.locator('#password').click() + time.sleep(0.3) + page.locator('#password').fill('SecurePass!123') + time.sleep(0.5) + page.locator('button[type="submit"]').click() + time.sleep(5) + body = page.locator('body').text_content() + assert '"superHumanSpeed": true' not in body + assert '"suspiciousClientSideBehavior": true' not in body + browser.close() + + def test_form_timing(self): + from cloakbrowser import launch + browser = launch(headless=True, humanize=True, proxy=self.PROXY, geoip=True) + page = browser.new_page() + page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', + wait_until='domcontentloaded') + time.sleep(2) + t0 = time.time() + page.locator('#email').fill('test@example.com') + page.locator('#password').fill('MyPassword!99') + page.locator('button[type="submit"]').click() + elapsed_ms = int((time.time() - t0) * 1000) + time.sleep(3) + assert elapsed_ms > 3000 + browser.close() + + +@pytest.mark.slow +class TestAsyncEndToEnd: + 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 + + 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) + + 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}" + + await browser.close() + + asyncio.run(_run()) + + +# ========================================================================= +# Direct runner (backwards compat) +# ========================================================================= + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))