Files
wehub-resource-sync 0af812490d
CI / python (push) Failing after 0s
CI / javascript (push) Failing after 1s
CI / dotnet (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:12:36 +08:00

2230 lines
87 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"<html><body><h1>parent</h1>"
b"<iframe name='myframe' src='/child.html' width=400 height=250></iframe>"
b"</body></html>"
)
_IFRAME_CHILD = (
b"<html><body>"
b"<button id='btn' onclick=\"this.textContent='CLICKED'\">Click Me</button>"
b"<input id='inp'>"
b"</body></html>"
)
@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"]))