""" 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 asyncio import pytest from unittest.mock import MagicMock def _mock_el_evaluate(is_input=False): """Mock evaluate that returns is_input for tagName checks and {hit: True} for pointer events.""" def _eval(js, *args, **kwargs): if isinstance(js, str) and "elementFromPoint" in js: return {"hit": True} return is_input return MagicMock(side_effect=_eval) def _async_mock_el_evaluate(is_input=False): """Async version of _mock_el_evaluate.""" from unittest.mock import AsyncMock async def _eval(js, *args, **kwargs): if isinstance(js, str) and "elementFromPoint" in js: return {"hit": True} return is_input return AsyncMock(side_effect=_eval) # ========================================================================= # 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) def test_patch_page_async_does_not_crash(self): """patch_page_async must not raise NameError for missing definitions.""" import cloakbrowser.human as h from cloakbrowser.human import _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, AsyncMock cfg = resolve_config("default", {"idle_between_actions": False}) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() page.click = AsyncMock() page.dblclick = AsyncMock() page.hover = AsyncMock() page.type = AsyncMock() page.fill = AsyncMock() page.goto = AsyncMock() page.check = AsyncMock() page.uncheck = AsyncMock() page.select_option = AsyncMock() page.press = AsyncMock() page.is_checked = AsyncMock(return_value=False) page.viewport_size = {"width": 1280, "height": 720} page.evaluate = AsyncMock(return_value={"hit": True}) page.context.new_cdp_session = AsyncMock(side_effect=Exception("no cdp")) page.mouse = MagicMock() page.mouse.move = AsyncMock() page.mouse.click = AsyncMock() page.mouse.wheel = AsyncMock() page.mouse.down = AsyncMock() page.mouse.up = AsyncMock() page.keyboard = MagicMock() page.keyboard.type = AsyncMock() page.keyboard.down = AsyncMock() page.keyboard.up = AsyncMock() page.keyboard.press = AsyncMock() page.keyboard.insert_text = AsyncMock() page.query_selector = AsyncMock(return_value=None) page.query_selector_all = AsyncMock(return_value=[]) page.wait_for_selector = AsyncMock(return_value=None) page.main_frame = MagicMock() page.main_frame.return_value = MagicMock() page.main_frame.return_value.child_frames = MagicMock(return_value=[]) page.main_frame.child_frames = MagicMock(return_value=[]) h.patch_page_async(page, cfg, cursor) assert hasattr(page, '_original') assert page.select_option != AsyncMock # ========================================================================= # 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" # ========================================================================= # 6b. iframe humanize routing (#428) # ========================================================================= def _make_frame_for_routing(is_input=False, box=None): """A MagicMock frame whose locator resolves to a real bounding box.""" from cloakbrowser.human import _patch_single_frame_sync, _CursorState from cloakbrowser.human.config import resolve_config cfg = resolve_config("default", None) cursor = _CursorState() page = MagicMock() page._original = MagicMock() page._stealth_world = None # skip CDP path frame = MagicMock() frame._human_patched = False loc_first = frame.locator.return_value.first loc_first.bounding_box.return_value = box if box is not None else { "x": 10.0, "y": 10.0, "width": 40.0, "height": 20.0} loc_first.evaluate.return_value = is_input loc_first.is_checked.return_value = False orig_click = frame.click # capture pre-patch _patch_single_frame_sync(frame, page, cfg, cursor, MagicMock(), MagicMock(), MagicMock()) return frame, page, orig_click class TestFrameHumanizedRouting: def test_frame_click_resolves_in_frame_not_page(self): from unittest.mock import patch as mock_patch frame, page, _ = _make_frame_for_routing() with mock_patch("cloakbrowser.human.human_move") as mv, \ mock_patch("cloakbrowser.human.human_click") as clk: frame.click("#btn") # resolved through the frame's own locator, moved + clicked the real mouse, # and NEVER re-dispatched to page.click (the #428 bug) frame.locator.assert_any_call("#btn") assert mv.called and clk.called page.click.assert_not_called() def test_frame_click_falls_back_to_native_when_no_box(self): from unittest.mock import patch as mock_patch frame, page, orig_click = _make_frame_for_routing(box=False) # bounding_box -> falsy with mock_patch("cloakbrowser.human.human_move") as mv, \ mock_patch("cloakbrowser.human.human_click") as clk: frame.click("#btn") assert not mv.called and not clk.called orig_click.assert_called_once() # native frame.click used page.click.assert_not_called() def _make_locator_for_routing(frame_kind): """Build a MagicMock locator + page.frames for _route_target testing. frame_kind: 'main' | 'patched_sub' | 'unpatched_sub' Returns (loc, page, child_frame). """ _ensure_locator_patched() page = MagicMock() page._original = MagicMock() main = MagicMock() child = MagicMock() child._human_patched = (frame_kind == "patched_sub") page.main_frame = main page.frames = [main, child] loc = MagicMock() loc.page = page loc._impl_obj._selector = "#btn" if frame_kind == "main": loc._impl_obj._frame = main._impl_obj else: loc._impl_obj._frame = child._impl_obj return loc, page, child class TestLocatorSubframeRouting: def test_subframe_locator_routes_to_owning_frame(self): from playwright.sync_api._generated import Locator loc, page, child = _make_locator_for_routing("patched_sub") Locator.click(loc) child.click.assert_called_once() assert child.click.call_args[0][0] == "#btn" page.click.assert_not_called() def test_mainframe_locator_routes_to_page(self): from playwright.sync_api._generated import Locator loc, page, child = _make_locator_for_routing("main") Locator.click(loc) page.click.assert_called_once() assert page.click.call_args[0][0] == "#btn" child.click.assert_not_called() def test_unpatched_subframe_falls_back_to_native(self): from playwright.sync_api._generated import Locator loc, page, child = _make_locator_for_routing("unpatched_sub") # native Locator.click is invoked on the mock; neither humanized path runs Locator.click(loc) child.click.assert_not_called() page.click.assert_not_called() class TestLocatorSubframeRoutingAsync: @pytest.mark.asyncio async def test_async_subframe_locator_routes_to_owning_frame(self): import cloakbrowser.human as h from unittest.mock import AsyncMock h._locator_async_patched = False h._patch_locator_class_async() from playwright.async_api._generated import Locator as AsyncLocator page = MagicMock() page._original = MagicMock() main = MagicMock() child = MagicMock() child._human_patched = True child.click = AsyncMock() page.main_frame = main page.frames = [main, child] loc = MagicMock() loc.page = page loc._impl_obj._selector = "#btn" loc._impl_obj._frame = child._impl_obj await AsyncLocator.click(loc) child.click.assert_awaited_once() assert child.click.call_args[0][0] == "#btn" class TestFrameClickArgContract: """#428 guard: _frame_click must call human_click(raw, is_input, cfg) in order. The routing tests mock human_click, so a swap would slip through.""" def test_frame_click_passes_is_input_bool_and_cfg(self): from unittest.mock import patch as mock_patch from cloakbrowser.human.config import HumanConfig frame, page, _ = _make_frame_for_routing(is_input=False) # button with mock_patch("cloakbrowser.human.human_move"), \ mock_patch("cloakbrowser.human.human_click") as clk: frame.click("#btn") args = clk.call_args[0] assert isinstance(args[1], bool) and args[1] is False # is_input assert isinstance(args[2], HumanConfig) # cfg def test_frame_click_marks_is_input_true_for_input(self): from unittest.mock import patch as mock_patch frame, page, _ = _make_frame_for_routing(is_input=True) with mock_patch("cloakbrowser.human.human_move"), \ mock_patch("cloakbrowser.human.human_click") as clk: frame.click("#inp") assert clk.call_args[0][1] is True class TestFrameFillFocus: def test_frame_fill_clicks_before_typing(self): from unittest.mock import patch as mock_patch frame, page, _ = _make_frame_for_routing(is_input=True) with mock_patch("cloakbrowser.human.human_move"), \ mock_patch("cloakbrowser.human.human_click"), \ mock_patch("cloakbrowser.human.human_type") as htype: frame.fill("#inp", "hello") assert htype.called and htype.call_args[0][2] == "hello" def test_frame_fill_falls_back_when_type_raises(self): from unittest.mock import patch as mock_patch frame, page, _ = _make_frame_for_routing(is_input=True) with mock_patch("cloakbrowser.human.human_move"), \ mock_patch("cloakbrowser.human.human_click"), \ mock_patch("cloakbrowser.human.human_type", side_effect=RuntimeError("boom")): frame.fill("#inp", "hello") # must not raise — native fallback # ========================================================================= # 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" # ========================================================================= # 11. Non-ASCII keyboard input # ========================================================================= class TestNonAsciiKeyboard: def test_cyrillic_uses_insert_text(self): from cloakbrowser.human.keyboard import human_type from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"mistype_chance": 0}) page = MagicMock() raw = MagicMock() down_keys = [] inserted = [] raw.down = MagicMock(side_effect=lambda k: down_keys.append(k)) raw.up = MagicMock() raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t)) human_type(page, raw, "Привет", cfg) assert "".join(inserted) == "Привет" for k in down_keys: assert ord(k[0]) < 128 or k in ("Shift", "Backspace") def test_mixed_ascii_cyrillic(self): from cloakbrowser.human.keyboard import human_type from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"mistype_chance": 0}) page = MagicMock() raw = MagicMock() down_keys = [] inserted = [] raw.down = MagicMock(side_effect=lambda k: down_keys.append(k)) raw.up = MagicMock() raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t)) human_type(page, raw, "Hi Мир", cfg) assert "H" in down_keys assert "i" in down_keys assert "М" in "".join(inserted) def test_cjk_uses_insert_text(self): from cloakbrowser.human.keyboard import human_type from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"mistype_chance": 0}) page = MagicMock() raw = MagicMock() inserted = [] raw.down = MagicMock() raw.up = MagicMock() raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t)) human_type(page, raw, "你好", cfg) assert "".join(inserted) == "你好" def test_mistype_only_ascii(self): from cloakbrowser.human.keyboard import human_type from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"mistype_chance": 1.0}) page = MagicMock() raw = MagicMock() down_keys = [] raw.down = MagicMock(side_effect=lambda k: down_keys.append(k)) raw.up = MagicMock() raw.insert_text = MagicMock() human_type(page, raw, "AБ", cfg) assert "Backspace" in down_keys def test_no_error_on_cyrillic(self): from cloakbrowser.human.keyboard import human_type from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"mistype_chance": 0}) page = MagicMock() raw = MagicMock() raw.down = MagicMock() raw.up = MagicMock() raw.insert_text = MagicMock() # Should not raise human_type(page, raw, "Тест кириллицы", cfg) class TestNonAsciiKeyboardAsync: @pytest.mark.asyncio async def test_async_cyrillic_uses_insert_text(self): from cloakbrowser.human.keyboard_async import async_human_type from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, AsyncMock cfg = resolve_config("default", {"mistype_chance": 0}) page = MagicMock() raw = MagicMock() inserted = [] raw.down = AsyncMock() raw.up = AsyncMock() raw.insert_text = AsyncMock(side_effect=lambda t: inserted.append(t)) await async_human_type(page, raw, "Привет", cfg) assert "".join(inserted) == "Привет" # ========================================================================= # 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=False, 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=False, 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=False, 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() # ========================================================================= # 6c. iframe humanize end-to-end (#428) — real same-origin iframe over HTTP # ========================================================================= import threading import http.server import socketserver import contextlib _IFRAME_PARENT = ( b"

parent

" b"" b"" ) _IFRAME_CHILD = ( b"" b"" b"" b"" ) @contextlib.contextmanager def _iframe_server(): """Serve a parent page + same-origin child page with a button/input.""" class _H(http.server.BaseHTTPRequestHandler): def do_GET(self): body = _IFRAME_CHILD if self.path.startswith("/child") else _IFRAME_PARENT self.send_response(200) self.send_header("Content-Type", "text/html") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *a): pass srv = socketserver.TCPServer(("127.0.0.1", 0), _H) port = srv.server_address[1] threading.Thread(target=srv.serve_forever, daemon=True).start() try: yield f"http://127.0.0.1:{port}/" finally: srv.shutdown() @pytest.mark.slow class TestBrowserIframeHumanize: """Regression for #428: humanize=True must interact with elements inside sub-frames instead of misrouting to the top document.""" def test_humanized_click_inside_iframe(self): from cloakbrowser import launch with _iframe_server() as url: browser = launch(headless=True, humanize=True) try: page = browser.new_page() page.goto(url, wait_until="networkidle") frame = page.frame(name="myframe") assert frame is not None # the #428 case: a Locator obtained from a sub-frame frame.locator("#btn").click(timeout=5000) assert frame.locator("#btn").text_content() == "CLICKED" finally: browser.close() def test_humanized_fill_inside_iframe(self): from cloakbrowser import launch with _iframe_server() as url: browser = launch(headless=True, humanize=True) try: page = browser.new_page() page.goto(url, wait_until="networkidle") frame = page.frame(name="myframe") frame.locator("#inp").fill("hello", timeout=5000) assert frame.locator("#inp").input_value() == "hello" finally: browser.close() def test_native_control_click_inside_iframe(self): """Control: humanize=False must also work (parity).""" from cloakbrowser import launch with _iframe_server() as url: browser = launch(headless=True, humanize=False) try: page = browser.new_page() page.goto(url, wait_until="networkidle") frame = page.frame(name="myframe") frame.locator("#btn").click(timeout=5000) assert frame.locator("#btn").text_content() == "CLICKED" finally: browser.close() @pytest.mark.slow class TestBrowserIframeHumanizeAsync: @pytest.mark.asyncio async def test_async_humanized_click_inside_iframe(self): from cloakbrowser import launch_async with _iframe_server() as url: browser = await launch_async(headless=True, humanize=True) try: page = await browser.new_page() await page.goto(url, wait_until="networkidle") frame = page.frame(name="myframe") assert frame is not None await frame.locator("#btn").click(timeout=5000) assert await frame.locator("#btn").text_content() == "CLICKED" finally: await browser.close() @pytest.mark.slow class TestBrowserPatching: def test_page_has_original(self): from cloakbrowser import launch browser = launch(headless=False, 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=False, 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=False, 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 = None 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('#loginForm 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=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(2) t0 = time.time() page.locator('#email').fill('test@example.com') page.locator('#password').fill('MyPassword!99') page.locator('#loginForm 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: @pytest.mark.asyncio async def test_async_launch_click_fill(self): """launch_async(humanize=True) — async page.click and page.fill work end-to-end.""" from cloakbrowser import launch_async browser = await launch_async(headless=False, humanize=True) page = await browser.new_page() assert hasattr(page, '_original'), "async page not patched" assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" 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() # ========================================================================= # 12. ElementHandle patching — SYNC # ========================================================================= class TestElementHandlePatchingSync: """Test that ElementHandle objects returned by query_selector etc. are humanized.""" def test_patch_single_element_handle_marks_patched(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() page._original = MagicMock() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=True) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) raw_mouse = MagicMock() raw_keyboard = MagicMock() _patch_single_element_handle_sync( el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None ) assert el._human_patched is True def test_element_handle_click_calls_human_move(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"idle_between_actions": False}) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() page._original = MagicMock() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) raw_mouse = MagicMock() raw_mouse.move = MagicMock() raw_mouse.down = MagicMock() raw_mouse.up = MagicMock() raw_mouse.wheel = MagicMock() raw_keyboard = MagicMock() _patch_single_element_handle_sync( el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None ) # Call the patched click el.click() # Should call raw_mouse.move (Bezier path) and then down/up assert raw_mouse.move.called assert raw_mouse.down.called assert raw_mouse.up.called def test_element_handle_hover_moves_cursor_without_click(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"idle_between_actions": False}) cursor = _CursorState() cursor.initialized = True cursor.x = 50 cursor.y = 50 page = MagicMock() page._original = MagicMock() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) raw_mouse = MagicMock() raw_mouse.move = MagicMock() raw_mouse.down = MagicMock() raw_mouse.up = MagicMock() raw_mouse.wheel = MagicMock() raw_keyboard = MagicMock() _patch_single_element_handle_sync( el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None ) el.hover() # Move should be called, but NOT down/up (hover, not click) assert raw_mouse.move.called assert not raw_mouse.down.called def test_element_handle_type_calls_human_type(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) cursor = _CursorState() cursor.initialized = True cursor.x = 50 cursor.y = 50 page = MagicMock() originals = MagicMock() page._original = originals el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=True) # is input el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) raw_mouse = MagicMock() raw_mouse.move = MagicMock() raw_mouse.down = MagicMock() raw_mouse.up = MagicMock() raw_mouse.wheel = MagicMock() raw_keyboard = MagicMock() raw_keyboard.down = MagicMock() raw_keyboard.up = MagicMock() raw_keyboard.insert_text = MagicMock() _patch_single_element_handle_sync( el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None ) el.type("hello") # Mouse moved + clicked (to focus), then keyboard used assert raw_mouse.move.called assert raw_mouse.down.called # click to focus the input # Keyboard events should have fired (down/up for ASCII chars) assert raw_keyboard.down.called or raw_keyboard.insert_text.called def test_element_handle_fill_clears_and_types(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, call cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) cursor = _CursorState() cursor.initialized = True cursor.x = 50 cursor.y = 50 page = MagicMock() originals = MagicMock() page._original = originals pressed_keys = [] originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k)) el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=True) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) raw_mouse = MagicMock() raw_mouse.move = MagicMock() raw_mouse.down = MagicMock() raw_mouse.up = MagicMock() raw_mouse.wheel = MagicMock() raw_keyboard = MagicMock() raw_keyboard.down = MagicMock() raw_keyboard.up = MagicMock() raw_keyboard.insert_text = MagicMock() _patch_single_element_handle_sync( el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None ) el.fill("replaced") # Should have pressed Select-All and Backspace to clear import sys expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a" assert expected_select in pressed_keys assert "Backspace" in pressed_keys def test_element_handle_no_double_patching(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() page = MagicMock() page._original = MagicMock() el = MagicMock() el._human_patched = False el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) _patch_single_element_handle_sync( el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) # Save patched click first_click = el.click # Try to patch again _patch_single_element_handle_sync( el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) # Should be the same — no double wrap assert el.click is first_click def test_nested_query_selector_returns_patched_handle(self): from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() page = MagicMock() page._original = MagicMock() child = MagicMock() child._human_patched = False child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) child.evaluate = MagicMock(return_value=False) child.is_checked = MagicMock(return_value=False) child.query_selector = MagicMock(return_value=None) child.query_selector_all = MagicMock(return_value=[]) child.wait_for_selector = MagicMock(return_value=None) el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=child) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) _patch_single_element_handle_sync( el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) result = el.query_selector("span") assert result._human_patched is True def test_page_query_selector_patched(self): from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) page = MagicMock() page._original = MagicMock() page.query_selector = MagicMock(return_value=el) page.query_selector_all = MagicMock(return_value=[el]) page.wait_for_selector = MagicMock(return_value=el) _patch_page_element_handles_sync( page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) result = page.query_selector("#test") assert result._human_patched is True def test_page_query_selector_all_patches_all(self): from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() def make_el(): e = MagicMock() e._human_patched = False e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) e.evaluate = MagicMock(return_value=False) e.is_checked = MagicMock(return_value=False) e.query_selector = MagicMock(return_value=None) e.query_selector_all = MagicMock(return_value=[]) e.wait_for_selector = MagicMock(return_value=None) return e el1, el2, el3 = make_el(), make_el(), make_el() page = MagicMock() page._original = MagicMock() page.query_selector = MagicMock(return_value=None) page.query_selector_all = MagicMock(return_value=[el1, el2, el3]) page.wait_for_selector = MagicMock(return_value=None) _patch_page_element_handles_sync( page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) results = page.query_selector_all("div") for r in results: assert r._human_patched is True def test_wait_for_selector_patched(self): from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) page = MagicMock() page._original = MagicMock() page.query_selector = MagicMock(return_value=None) page.query_selector_all = MagicMock(return_value=[]) page.wait_for_selector = MagicMock(return_value=el) _patch_page_element_handles_sync( page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) result = page.wait_for_selector("#test") assert result._human_patched is True def test_element_handle_all_methods_patched(self): """Verify all expected interaction methods are replaced.""" from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) cursor = _CursorState() page = MagicMock() page._original = MagicMock() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) el.set_checked = MagicMock() # ensure it exists _patch_single_element_handle_sync( el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None ) expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press', 'select_option', 'check', 'uncheck', 'set_checked', 'tap', 'focus', 'query_selector', 'query_selector_all', 'wait_for_selector'] for method in expected_methods: fn = getattr(el, method) assert not isinstance(fn, MagicMock), f"el.{method} was not patched" # ========================================================================= # 13. ElementHandle patching — ASYNC # ========================================================================= class TestElementHandlePatchingAsync: @pytest.mark.asyncio async def test_async_element_handle_click(self): from cloakbrowser.human import _patch_single_element_handle_async, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, AsyncMock cfg = resolve_config("default", {"idle_between_actions": False}) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() originals = MagicMock() originals.mouse_move = AsyncMock() page._original = originals el = MagicMock() el._human_patched = False el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) el.evaluate = _async_mock_el_evaluate(is_input=False) el.is_checked = AsyncMock(return_value=False) el.wait_for_element_state = AsyncMock() el.query_selector = AsyncMock(return_value=None) el.query_selector_all = AsyncMock(return_value=[]) el.wait_for_selector = AsyncMock(return_value=None) raw_mouse = MagicMock() raw_mouse.move = AsyncMock() raw_mouse.down = AsyncMock() raw_mouse.up = AsyncMock() raw_mouse.wheel = AsyncMock() raw_keyboard = MagicMock() raw_keyboard.down = AsyncMock() raw_keyboard.up = AsyncMock() raw_keyboard.insert_text = AsyncMock() stealth = MagicMock() stealth.get_cdp_session = AsyncMock(return_value=None) _patch_single_element_handle_async( el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None] ) await el.click() assert raw_mouse.move.called assert raw_mouse.down.called assert raw_mouse.up.called @pytest.mark.asyncio async def test_async_page_query_selector_patched(self): from cloakbrowser.human import _patch_page_element_handles_async, _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, AsyncMock cfg = resolve_config("default", None) cursor = _CursorState() el = MagicMock() el._human_patched = False el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) el.evaluate = _async_mock_el_evaluate(is_input=False) el.is_checked = AsyncMock(return_value=False) el.wait_for_element_state = AsyncMock() el.query_selector = AsyncMock(return_value=None) el.query_selector_all = AsyncMock(return_value=[]) el.wait_for_selector = AsyncMock(return_value=None) page = MagicMock() page._original = MagicMock() page.query_selector = AsyncMock(return_value=el) page.query_selector_all = AsyncMock(return_value=[el]) page.wait_for_selector = AsyncMock(return_value=el) stealth = MagicMock() stealth.get_cdp_session = AsyncMock(return_value=None) _patch_page_element_handles_async( page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None] ) result = await page.query_selector("#test") assert result._human_patched is True # ========================================================================= # 14. SLOW: Browser ElementHandle end-to-end # ========================================================================= @pytest.mark.slow class TestBrowserElementHandle: def test_query_selector_click_humanized(self): """page.query_selector() returns a patched handle — el.click() uses human curves.""" from cloakbrowser import launch browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) el = page.query_selector('#searchInput') assert el is not None assert getattr(el, '_human_patched', False), "ElementHandle not patched" t0 = time.time() el.click() click_ms = int((time.time() - t0) * 1000) assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)" browser.close() def test_query_selector_type_humanized(self): """el.type() should type character-by-character with human timing.""" from cloakbrowser import launch browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) el = page.query_selector('#searchInput') assert el is not None t0 = time.time() el.type('ElementHandle test') type_ms = int((time.time() - t0) * 1000) assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms" val = page.locator('#searchInput').input_value() assert val == 'ElementHandle test' browser.close() def test_query_selector_fill_humanized(self): """el.fill() should clear + type with human timing.""" from cloakbrowser import launch browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) el = page.query_selector('#searchInput') el.type('initial') time.sleep(0.3) t0 = time.time() el.fill('replaced') fill_ms = int((time.time() - t0) * 1000) assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms" val = page.locator('#searchInput').input_value() assert val == 'replaced' browser.close() def test_query_selector_all_returns_patched(self): """page.query_selector_all() returns all handles patched.""" from cloakbrowser import launch browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') time.sleep(1) els = page.query_selector_all('input[type="checkbox"]') assert len(els) >= 2 for el in els: assert getattr(el, '_human_patched', False), "ElementHandle not patched" browser.close() def test_query_selector_hover_humanized(self): """el.hover() should move cursor with human Bezier curve.""" from cloakbrowser import launch browser = launch(headless=False, humanize=True) page = browser.new_page() page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') time.sleep(1) el = page.query_selector('#searchInput') t0 = time.time() el.hover() hover_ms = int((time.time() - t0) * 1000) assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms" browser.close() @pytest.mark.slow class TestAsyncElementHandle: @pytest.mark.asyncio async def test_async_query_selector_click(self): from cloakbrowser import launch_async browser = await launch_async(headless=False, humanize=True) page = await browser.new_page() await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') await asyncio.sleep(1) el = await page.query_selector('#searchInput') assert el is not None assert getattr(el, '_human_patched', False), "Async ElementHandle not patched" t0 = time.time() await el.click() click_ms = int((time.time() - t0) * 1000) assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms" await browser.close() # ========================================================================= # 15. Per-call timeout forwarding (issue #137) # ========================================================================= class TestPerCallTimeoutForwarding: """page.click('#x', timeout=5000) must forward 5000 to bounding_box(), not silently use the hardcoded 2000ms in scroll.""" def test_get_element_box_default_timeout(self): """Default timeout matches Playwright's 30000ms.""" from cloakbrowser.human.scroll import _get_element_box from unittest.mock import MagicMock page = MagicMock() loc = MagicMock() loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1}) page.locator = MagicMock(return_value=MagicMock(first=loc)) _get_element_box(page, "#x") loc.bounding_box.assert_called_once_with(timeout=30000) def test_get_element_box_custom_timeout(self): """Caller can pass a custom timeout that overrides the default.""" from cloakbrowser.human.scroll import _get_element_box from unittest.mock import MagicMock page = MagicMock() loc = MagicMock() loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1}) page.locator = MagicMock(return_value=MagicMock(first=loc)) _get_element_box(page, "#x", timeout=5000) loc.bounding_box.assert_called_once_with(timeout=5000) def test_scroll_to_element_forwards_timeout(self): """scroll_to_element passes timeout through to bounding_box().""" from cloakbrowser.human.scroll import scroll_to_element from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) page = MagicMock() page.viewport_size = {"width": 1280, "height": 720} loc = MagicMock() # Already in viewport so we don't actually scroll — just verify # the timeout was forwarded on the first bounding_box() call. loc.bounding_box = MagicMock(return_value={"x": 100, "y": 200, "width": 50, "height": 30}) page.locator = MagicMock(return_value=MagicMock(first=loc)) raw = MagicMock() scroll_to_element(page, raw, "#x", 0, 0, cfg, timeout=7500) loc.bounding_box.assert_called_with(timeout=7500) def test_page_click_forwards_timeout_kwarg(self): """page.click(selector, timeout=...) reaches scroll_to_element. Patches scroll_to_element module-side via monkey-patching the cloakbrowser.human module attribute used by patch_page. """ import cloakbrowser.human as h from cloakbrowser.human import _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, patch cfg = resolve_config("default", {"idle_between_actions": False}) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 # Build a minimal page mock page = MagicMock() page.click = MagicMock() page.dblclick = MagicMock() page.hover = MagicMock() page.type = MagicMock() page.fill = MagicMock() page.goto = MagicMock() page.is_checked = MagicMock(return_value=False) page.viewport_size = {"width": 1280, "height": 720} page.evaluate = MagicMock(return_value={"hit": True}) page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) page.mouse = MagicMock() page.keyboard = MagicMock() page.query_selector = MagicMock(return_value=None) page.query_selector_all = MagicMock(return_value=[]) page.wait_for_selector = MagicMock(return_value=None) page.main_frame = MagicMock() page.main_frame.child_frames = [] captured = {} def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000): captured["timeout"] = timeout return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy, False) with patch.object(h, "scroll_to_element", side_effect=fake_scroll), \ patch.object(h, "ensure_actionable"): h.patch_page(page, cfg, cursor) page.click("#slow-button", timeout=5000) assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}" # ========================================================================= # 16. Per-call human_config override (typing speed customization) # ========================================================================= class TestPerCallHumanConfigOverride: """page.type('#email', text, human_config={'typing_delay': 30}) lets users override typing speed (and any other HumanConfig field) on a per-call basis without re-patching the page.""" def test_merge_config_creates_new_instance(self): from cloakbrowser.human.config import resolve_config, merge_config base = resolve_config("default", None) merged = merge_config(base, {"typing_delay": 30}) assert merged.typing_delay == 30 assert base.typing_delay != 30 # not mutated # Non-overridden fields are preserved assert merged.mouse_min_steps == base.mouse_min_steps def test_merge_config_none_returns_base(self): from cloakbrowser.human.config import resolve_config, merge_config base = resolve_config("default", None) merged = merge_config(base, None) assert merged is base def test_merge_config_ignores_unknown_keys(self): from cloakbrowser.human.config import resolve_config, merge_config base = resolve_config("default", None) # ``not_a_real_field`` is silently dropped — callers shouldn't crash # if they pass typos or future field names. merged = merge_config(base, {"typing_delay": 30, "not_a_real_field": 99}) assert merged.typing_delay == 30 def test_page_type_uses_per_call_typing_delay(self): """page.type(..., human_config={'typing_delay': 30}) reaches human_type with cfg.typing_delay == 30 even when patch was done with default 70.""" import cloakbrowser.human as h from cloakbrowser.human import _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, patch cfg = resolve_config("default", { "idle_between_actions": False, "field_switch_delay": (0, 1), }) assert cfg.typing_delay == 70 # baseline cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() page.click = MagicMock() page.dblclick = MagicMock() page.hover = MagicMock() page.type = MagicMock() page.fill = MagicMock() page.goto = MagicMock() page.is_checked = MagicMock(return_value=False) page.viewport_size = {"width": 1280, "height": 720} page.evaluate = MagicMock(return_value={"hit": True}) page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) page.mouse = MagicMock() page.keyboard = MagicMock() page.query_selector = MagicMock(return_value=None) page.query_selector_all = MagicMock(return_value=[]) page.wait_for_selector = MagicMock(return_value=None) page.main_frame = MagicMock() page.main_frame.child_frames = [] captured = {} def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None): captured["typing_delay"] = cfg_arg.typing_delay captured["mistype_chance"] = cfg_arg.mistype_chance def fake_scroll(*args, **kwargs): return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False) with patch.object(h, "human_type", side_effect=fake_human_type), \ patch.object(h, "scroll_to_element", side_effect=fake_scroll), \ patch.object(h, "ensure_actionable"), \ patch.object(h, "check_pointer_events"): h.patch_page(page, cfg, cursor) page.type( "#email", "hi", human_config={"typing_delay": 30, "mistype_chance": 0}, ) assert captured["typing_delay"] == 30 assert captured["mistype_chance"] == 0 assert cfg.typing_delay == 70 def test_page_fill_uses_per_call_typing_delay(self): """Same as type, but for fill (which also clears the field first).""" import cloakbrowser.human as h from cloakbrowser.human import _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, patch cfg = resolve_config("default", { "idle_between_actions": False, "field_switch_delay": (0, 1), }) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() page.viewport_size = {"width": 1280, "height": 720} page.is_checked = MagicMock(return_value=False) page.evaluate = MagicMock(return_value={"hit": True}) page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) page.mouse = MagicMock() page.keyboard = MagicMock() page.query_selector = MagicMock(return_value=None) page.query_selector_all = MagicMock(return_value=[]) page.wait_for_selector = MagicMock(return_value=None) page.main_frame = MagicMock() page.main_frame.child_frames = [] captured = {} def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None): captured["typing_delay"] = cfg_arg.typing_delay def fake_scroll(*args, **kwargs): return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False) with patch.object(h, "human_type", side_effect=fake_human_type), \ patch.object(h, "scroll_to_element", side_effect=fake_scroll), \ patch.object(h, "ensure_actionable"), \ patch.object(h, "check_pointer_events"): h.patch_page(page, cfg, cursor) page.fill("#password", "secret", human_config={"typing_delay": 150}) assert captured["typing_delay"] == 150 def test_element_handle_type_uses_per_call_human_config(self): """el.type(text, human_config={...}) merges per-call overrides on the ElementHandle path (which doesn't go through page.type).""" from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config import cloakbrowser.human as h from unittest.mock import MagicMock, patch cfg = resolve_config("default", { "idle_between_actions": False, "field_switch_delay": (0, 1), }) cursor = _CursorState() cursor.initialized = True cursor.x = 50 cursor.y = 50 page = MagicMock() page.viewport_size = {"width": 1280, "height": 720} page._original = MagicMock() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock( return_value={"x": 200, "y": 200, "width": 100, "height": 30} ) el.evaluate = _mock_el_evaluate(is_input=True) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) el.scroll_into_view_if_needed = MagicMock() raw_mouse = MagicMock() raw_keyboard = MagicMock() captured = {} def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None): captured["typing_delay"] = cfg_arg.typing_delay with patch.object(h, "human_type", side_effect=fake_human_type): _patch_single_element_handle_sync( el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None, ) el.type("abc", human_config={"typing_delay": 25}) assert captured["typing_delay"] == 25 # ========================================================================= # 17. scroll_into_view_if_needed humanization # ========================================================================= class TestScrollIntoViewIfNeeded: """scroll_into_view_if_needed should run through the same accelerate → cruise → decelerate → overshoot wheel sequence as page.click— not Playwright's instant-snap default.""" def test_human_scroll_into_view_skips_when_in_viewport(self): """Already-visible elements: no wheel events, just return.""" from cloakbrowser.human.scroll import human_scroll_into_view from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", None) page = MagicMock() page.viewport_size = {"width": 1280, "height": 720} raw = MagicMock() # Box is dead-center of viewport — squarely in scroll_target_zone in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30} box, cx, cy, did_scroll = human_scroll_into_view( page, raw, lambda: in_view_box, 0, 0, cfg, ) assert not did_scroll, "In-viewport elements shouldn't report scrolling" assert box == in_view_box assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events" def test_human_scroll_into_view_scrolls_when_below_fold(self): """Below-fold elements: wheel events fire, eventually box becomes visible.""" from cloakbrowser.human.scroll import human_scroll_into_view from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock cfg = resolve_config("default", { "scroll_overshoot_chance": 0, # deterministic "scroll_pre_move_delay": (0, 1), "scroll_pause_fast": (0, 1), "scroll_pause_slow": (0, 1), "scroll_settle_delay": (0, 1), }) page = MagicMock() page.viewport_size = {"width": 1280, "height": 720} raw = MagicMock() # First box is far below the fold; subsequent boxes "come into view" # so the loop terminates after a few wheel bursts. boxes = [ {"x": 200, "y": 2000, "width": 50, "height": 30}, {"x": 200, "y": 1500, "width": 50, "height": 30}, {"x": 200, "y": 1000, "width": 50, "height": 30}, {"x": 200, "y": 400, "width": 50, "height": 30}, # in viewport {"x": 200, "y": 400, "width": 50, "height": 30}, {"x": 200, "y": 400, "width": 50, "height": 30}, ] idx = {"i": 0} def get_box(): i = min(idx["i"], len(boxes) - 1) idx["i"] += 1 return boxes[i] human_scroll_into_view(page, raw, get_box, 0, 0, cfg) assert raw.wheel.called, "Below-fold scroll should produce wheel events" def test_element_handle_scroll_into_view_if_needed_humanized(self): """el.scroll_into_view_if_needed() routes through human_scroll_into_view.""" from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState from cloakbrowser.human.config import resolve_config import cloakbrowser.human as h from unittest.mock import MagicMock, patch cfg = resolve_config("default", None) cursor = _CursorState() cursor.initialized = True cursor.x = 50 cursor.y = 50 page = MagicMock() page.viewport_size = {"width": 1280, "height": 720} page._original = MagicMock() el = MagicMock() el._human_patched = False el.bounding_box = MagicMock( return_value={"x": 200, "y": 200, "width": 50, "height": 30} ) el.evaluate = _mock_el_evaluate(is_input=False) el.is_checked = MagicMock(return_value=False) el.query_selector = MagicMock(return_value=None) el.query_selector_all = MagicMock(return_value=[]) el.wait_for_selector = MagicMock(return_value=None) # Make sure the original method exists so the patch is wired up el.scroll_into_view_if_needed = MagicMock() called = {"count": 0} def fake(*args, **kwargs): called["count"] += 1 return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100, False) with patch.object(h, "human_scroll_into_view", side_effect=fake): _patch_single_element_handle_sync( el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None, ) el.scroll_into_view_if_needed() assert called["count"] >= 1, "humanized scroll helper was never called" def test_locator_scroll_into_view_if_needed_humanized(self): """Locator.scroll_into_view_if_needed() also goes through humanized scroll.""" import cloakbrowser.human as h from cloakbrowser.human import _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, patch # Patch Locator class fresh _ensure_locator_patched() from playwright.sync_api._generated import Locator cfg = resolve_config("default", None) cursor = _CursorState() cursor.x = 50 cursor.y = 50 cursor.initialized = True page = MagicMock() page._original = MagicMock() page._human_cfg = cfg page._human_cursor = cursor page._human_raw_mouse = MagicMock() page.viewport_size = {"width": 1280, "height": 720} # Build a Locator-like object satisfying the patched method loc = MagicMock(spec=Locator) loc.page = page impl_obj = MagicMock() impl_obj._selector = "#x" loc._impl_obj = impl_obj loc.bounding_box = MagicMock( return_value={"x": 100, "y": 100, "width": 50, "height": 30} ) called = {"count": 0, "cfg": None} def fake(*args, **kwargs): called["count"] += 1 # cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg) called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg") return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200, False) with patch.object(h, "human_scroll_into_view", side_effect=fake): Locator.scroll_into_view_if_needed( loc, human_config={"scroll_overshoot_chance": 0.5}, ) assert called["count"] == 1 # Per-call override merged into the cfg passed downstream assert called["cfg"].scroll_overshoot_chance == 0.5 # Cursor was updated from the helper's return value assert cursor.x == 200 and cursor.y == 200 # ========================================================================= # Issue #307: frame/page click timeout should not multiply # ========================================================================= class TestTimeoutBudget307: """Verify timeout budget is shared across sequential operations.""" def test_page_click_total_time_within_budget(self): """page.click on a missing element should not exceed ~1x the timeout.""" import cloakbrowser.human as h from cloakbrowser.human import _CursorState from cloakbrowser.human.config import resolve_config from unittest.mock import MagicMock, patch TIMEOUT_MS = 500 cfg = resolve_config("default", {"idle_between_actions": False}) cursor = _CursorState() cursor.initialized = True cursor.x = 100 cursor.y = 100 page = MagicMock() page.click = MagicMock() page.dblclick = MagicMock() page.hover = MagicMock() page.type = MagicMock() page.fill = MagicMock() page.goto = MagicMock() page.is_checked = MagicMock(return_value=False) page.viewport_size = {"width": 1280, "height": 720} page.evaluate = MagicMock(return_value={"hit": True}) page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) page.mouse = MagicMock() page.keyboard = MagicMock() page.query_selector = MagicMock(return_value=None) page.query_selector_all = MagicMock(return_value=[]) page.wait_for_selector = MagicMock(return_value=None) page.main_frame = MagicMock() page.main_frame.child_frames = [] loc = MagicMock() loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0)) loc.is_visible = MagicMock(return_value=False) loc.first = loc page.locator = MagicMock(return_value=loc) h.patch_page(page, cfg, cursor) start = time.monotonic() try: page.click("#does-not-exist", timeout=TIMEOUT_MS) except Exception: pass elapsed_ms = (time.monotonic() - start) * 1000 assert elapsed_ms < TIMEOUT_MS * 1.8, ( f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms" ) class TestPointerEventsFailOpen: """The pointer-events check must fail open: when it cannot run (evaluate / bounding_box throws -> result None), proceed with the click instead of blocking it until the timeout expires.""" def test_handle_failopen_returns_on_evaluate_error(self): from cloakbrowser.human.actionability import check_pointer_events_handle el = MagicMock() el.bounding_box = MagicMock(side_effect=Exception("stale handle")) el.evaluate = MagicMock(side_effect=Exception("execution context destroyed")) start = time.monotonic() check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000) # must not raise elapsed_ms = (time.monotonic() - start) * 1000 assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms" def test_locator_failopen_returns_on_evaluate_error(self): from cloakbrowser.human.actionability import check_pointer_events page = MagicMock() loc = MagicMock() loc.first = loc loc.bounding_box = MagicMock(side_effect=Exception("no element")) loc.evaluate = MagicMock(side_effect=Exception("no element")) page.locator = MagicMock(return_value=loc) start = time.monotonic() check_pointer_events(page, "#x", 100, 100, timeout=2000) # must not raise elapsed_ms = (time.monotonic() - start) * 1000 assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms" def test_handle_still_raises_when_covered(self): """A genuine 'covered' result (not None) must still raise — fail-open only applies when the check could not be determined.""" from cloakbrowser.human.actionability import ( check_pointer_events_handle, ElementNotReceivingEventsError, ) el = MagicMock() el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10}) el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"}) with pytest.raises(ElementNotReceivingEventsError): check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200) def test_async_handle_failopen_returns_on_evaluate_error(self): from cloakbrowser.human.actionability_async import async_check_pointer_events_handle from unittest.mock import AsyncMock el = MagicMock() el.bounding_box = AsyncMock(side_effect=Exception("stale handle")) el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed")) start = time.monotonic() asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000)) elapsed_ms = (time.monotonic() - start) * 1000 assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms" # ========================================================================= # Direct runner (backwards compat) # ========================================================================= if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))