Files
CloakBrowser/tests/test_humanize_unit.py
lilosandevelaa123 1966ff861d fix(humanize): human-like key timing, Fitts-law mouse, notch wheels; containing-block scroller (#593) (#595)
Typing (Python, JS Playwright/Puppeteer, .NET)
- Absolute-time Typist: log-normal hold (~100 ms), key-pair dependent
  inter-key gaps (space, same/other hand, repeated letter), rollover via
  queued releases, Shift often released before the letter, no key pressed
  twice, all held keys released on error. A lower-case press of a key is
  released before Shift + the same key goes down ("xX", "1!"), so no
  keydown with repeat=true.
- Defaults moved to Aalto 136M Keystrokes: typing_delay 150, key_hold
  (60, 175), mistype_chance 0.04; careful preset updated.

Mouse
- Fitts-law stroke duration (mouse_time_base / per_bit / spread),
  log-normal run-to-run spread, frame-rate events, time-warped
  minimum-jerk profile with an early velocity peak, gentle curvature and
  wobble, no zero-movement events, notch-free overshoot correction.

Scroll
- Whole wheel notches of the persona's platform (Win 100, mac 40,
  Linux 120; scroll_notch_px override) in short flicks, so deltaY agrees
  with CDP's one-notch wheelDeltaY; overshoot measured in notches.

- scroller() in the isolated-world helpers walks the containing-block
  chain: fixed boxes skip ancestors that do not establish a containing
  block for them, absolute boxes skip static ancestors. A fixed modal in
  a scrolling panel is clicked instead of timing out with "element is
  outside of the viewport". All three helper copies stay byte-identical.

Removed
- Settings with no effect after the change: mouse_steps_divisor,
  mouse_burst_size, mouse_burst_pause, scroll_delta_base,
  scroll_delta_variance (and the .NET properties). Python drops them from
  human_config with a FutureWarning that points at the caller; JS and the .NET override
  dictionary ignore them. The standalone .NET HumanScroll wheels in whole
  notches too. READMEs updated to the new defaults.

- .NET: a "\r\n" text element is inserted as text again (it threw
  "Unknown key" from KeyDownAsync).

Tests: timing/rollover/notch/zero-move tests; five #593 cases in Python,
JS and .NET; puppeteer/stealth tests updated to the new semantics.

Co-authored-by: evelaa123 <189992797+evelaa123@users.noreply.github.com>
2026-10-14 18:30:49 +02:00

1136 lines
43 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
# =========================================================================
# =========================================================================
# 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
class TestHumanTiming:
"""Distributions the engine draws from, checked against human data
(136M Keystrokes, Dhakal et al. CHI 2018; Fitts' law; SapiMouse)."""
def test_removed_settings_are_dropped_with_a_warning(self):
import pytest
from cloakbrowser.human.config import HumanConfig, resolve_config
for k in ("mouse_steps_divisor", "mouse_burst_size", "mouse_burst_pause",
"scroll_delta_base", "scroll_delta_variance"):
assert k not in HumanConfig.__dataclass_fields__
with pytest.warns(FutureWarning, match="mouse_steps_divisor") as rec:
cfg = resolve_config("default", {"mouse_steps_divisor": 4, "typing_delay": 90})
assert cfg.typing_delay == 90
# Shown by default (not a DeprecationWarning) and points at the caller.
assert rec[0].filename == __file__
def test_key_hold_is_human_length(self):
import statistics
from cloakbrowser.human.config import resolve_config
from cloakbrowser.human.keyboard import key_hold
cfg = resolve_config("default", None)
holds = [key_hold(cfg) for _ in range(5000)]
# People hold a key ~100 ms; under 40 ms is under 1% of human presses.
assert 85 <= statistics.median(holds) <= 120
assert sum(h < 40 for h in holds) / len(holds) < 0.01
def test_key_gap_depends_on_the_letters(self):
import statistics
from cloakbrowser.human.config import resolve_config
from cloakbrowser.human.keyboard import key_gap
cfg = resolve_config("default", {"typing_pause_chance": 0})
med = lambda a, b: statistics.median(key_gap(cfg, a, b) for _ in range(3000))
other_hand, same_hand, after_space = med("a", "j"), med("a", "s"), med(" ", "t")
assert other_hand < same_hand < after_space
def test_mouse_never_reports_a_move_without_movement(self):
from cloakbrowser.human.config import resolve_config
from cloakbrowser.human.mouse import plan_move
cfg = resolve_config("default", None)
for d in (2, 30, 300, 1500):
for _ in range(100):
pts = plan_move(10, 10, 10 + d, 10 + d / 2, cfg)
prev = (10, 10)
for x, y, dt in pts:
assert abs(x - prev[0]) >= 1 or abs(y - prev[1]) >= 1
assert dt >= 0
prev = (x, y)
assert pts[-1][:2] == (10 + d, 10 + d / 2)
def test_mouse_duration_follows_fitts_law_with_spread(self):
import statistics
from cloakbrowser.human.config import resolve_config
from cloakbrowser.human.mouse import plan_stroke
cfg = resolve_config("default", None)
def times(d):
return [sum(p[2] for p in plan_stroke(0, 0, d, 0, cfg)) for _ in range(400)]
short, long = times(100), times(1200)
assert statistics.median(short) < statistics.median(long) < statistics.median(short) * 2
# Same distance, very different durations (human CV is ~0.5).
assert statistics.pstdev(long) / statistics.mean(long) > 0.2
def test_mouse_speed_peaks_early(self):
import math, statistics
from cloakbrowser.human.config import resolve_config
from cloakbrowser.human.mouse import plan_stroke
cfg = resolve_config("default", None)
peaks = []
for _ in range(200):
pts = plan_stroke(0, 0, 800, 0, cfg)
t, prev, best, at = 0.0, (0, 0), 0.0, 0.0
for x, y, dt in pts:
t += dt
v = math.hypot(x - prev[0], y - prev[1]) / max(dt, 1e-6)
if v > best:
best, at = v, t
prev = (x, y)
peaks.append(at / t)
assert statistics.median(peaks) < 0.4 # people: ~0.3; a symmetric ease: 0.5
# =========================================================================
# 3. Async compatibility
# =========================================================================
# =========================================================================
# 4. Focus check — press / clear / pressSequentially
# =========================================================================
# =========================================================================
# 5. check/uncheck idle
# =========================================================================
# =========================================================================
# 6. Frame patching completeness
# =========================================================================
# =========================================================================
# 6b. iframe humanize routing (#428)
# =========================================================================
# =========================================================================
# 7. drag_to safety
# =========================================================================
# =========================================================================
# 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
def test_digit_neighbors_are_digits(self):
# #573: a letter typo is rejected by <input type=number>, so the
# correcting Backspace would delete the previous real digit.
from cloakbrowser.human.keyboard import NEARBY_KEYS
for d in "0123456789":
assert NEARBY_KEYS[d].isdigit(), (d, NEARBY_KEYS[d])
# =========================================================================
# 10. Select-all platform detection
# =========================================================================
# =========================================================================
# 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)
# =========================================================================
# 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
from urllib.parse import quote
_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, format, *args):
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_humanized_click_inside_dynamically_attached_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")
with page.expect_event("frameattached") as event:
page.evaluate("""() => {
const iframe = document.createElement('iframe');
iframe.src = '/child.html';
document.body.appendChild(iframe);
}""")
frame = event.value
frame.wait_for_load_state("domcontentloaded")
frame.evaluate("window.__moves = 0; addEventListener('mousemove', () => __moves++)")
frame.click("#btn", timeout=5000)
assert frame.evaluate("__moves") > 0, "frame click was not preceded by mouse movement"
assert frame.locator("#btn").text_content() == "CLICKED"
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.asyncio
async def test_async_humanized_click_inside_dynamically_attached_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")
async with page.expect_event("frameattached") as event:
await page.evaluate("""() => {
const iframe = document.createElement('iframe');
iframe.src = '/child.html';
document.body.appendChild(iframe);
}""")
frame = await event.value
await frame.wait_for_load_state("domcontentloaded")
await frame.evaluate("window.__moves = 0; addEventListener('mousemove', () => __moves++)")
await frame.click("#btn", timeout=5000)
assert await frame.evaluate("__moves") > 0, "frame click was not preceded by mouse movement"
assert await frame.locator("#btn").text_content() == "CLICKED"
finally:
await browser.close()
# page.set_content timed out in local runs of this test, so the page is loaded as a data: URL.
_WIDE_ROW_HTML = """
<body style="margin:0">
<div style="display:flex">
<div style="min-width:800px">left</div>
<button id="target" style="min-width:800px"
onclick="this.textContent = 'CLICKED'">right</button>
</div>
</body>
"""
@pytest.mark.slow
class TestBrowserHorizontalScroll:
"""Regression for #521: a target off the right edge of a horizontally
overflowing page must be scrolled into view on the x axis, not rejected
as "covered by <none>"."""
def test_humanized_click_scrolls_x_axis(self):
from cloakbrowser import launch
browser = launch(headless=True, humanize=True, geoip=False)
try:
page = browser.new_page(viewport={"width": 1000, "height": 700})
page.goto("data:text/html," + quote(_WIDE_ROW_HTML))
page.click("#target", timeout=5000)
assert page.locator("#target").text_content() == "CLICKED"
finally:
browser.close()
@pytest.mark.asyncio
async def test_async_humanized_click_scrolls_x_axis(self):
from cloakbrowser import launch_async
browser = await launch_async(headless=True, humanize=True, geoip=False)
try:
page = await browser.new_page(viewport={"width": 1000, "height": 700})
await page.goto("data:text/html," + quote(_WIDE_ROW_HTML))
await page.click("#target", timeout=5000)
assert await page.locator("#target").text_content() == "CLICKED"
finally:
await 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
# =========================================================================
# =========================================================================
# 13. ElementHandle patching — ASYNC
# =========================================================================
# =========================================================================
# 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
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() handles are humanized: clicks move the mouse first."""
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
try:
page = browser.new_page()
page.evaluate("""() => {
document.body.innerHTML = '<input type="checkbox" id="a"><input type="checkbox" id="b">';
window.__moves = 0; addEventListener('mousemove', () => __moves++);
}""")
els = page.query_selector_all('input[type="checkbox"]')
assert len(els) == 2
for el in els:
el.click()
assert page.evaluate("__moves") > 0, "handle clicks were not preceded by mouse movement"
assert page.evaluate("[a.checked, b.checked]") == [True, True]
finally:
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
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)
# =========================================================================
# =========================================================================
# 16. Per-call human_config override (typing speed customization)
# =========================================================================
# =========================================================================
# 17. scroll_into_view_if_needed humanization
# =========================================================================
# =========================================================================
# Issue #307: frame/page click timeout should not multiply
# =========================================================================
# =========================================================================
# framenavigated -> isolated-world invalidation (#507)
# =========================================================================
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"]))
def test_error_classes_stay_importable():
"""Same compatibility set as JS errors.ts / .NET Actionability.cs."""
from cloakbrowser import human as h
for name in ("ElementNotAttachedError", "ElementNotVisibleError", "ElementNotStableError",
"ElementNotEnabledError", "ElementNotEditableError",
"ElementNotReceivingEventsError", "ElementTargetChangedError"):
cls = getattr(h, name)
assert issubclass(cls, h.ActionabilityError) and issubclass(cls, RuntimeError)
e = h.ElementNotReceivingEventsError("#b", "div")
assert (e.selector, e.check, e.covering_tag) == ("#b", "pointer_events", "div")
assert str(e) == "Element '#b' failed pointer_events check: element is covered by <div>"
for name in ("UnsupportedHumanizeSelectorError", "StealthWorldUnavailableError",
"StealthEvaluationError"):
assert issubclass(getattr(h, name), h.StealthDomError)
def test_check_playwright_accepts_a_compiled_playwright(monkeypatch):
"""A Nuitka build swaps function bytecode for a placeholder, so the
internals probe must not report a supported Playwright as broken."""
from playwright._impl._element_handle import ElementHandle
from cloakbrowser.human import patch
class _CompiledFunction: # stands in for Nuitka's compiled_function
__code__ = (lambda: None).__code__
monkeypatch.setattr(ElementHandle, "__init__", _CompiledFunction())
patch._check_playwright()
def test_check_playwright_still_flags_missing_internals(monkeypatch):
from playwright._impl._element_handle import ElementHandle
from cloakbrowser.human import patch
def __init__(self, parent, type, guid, initializer):
pass
monkeypatch.setattr(ElementHandle, "__init__", __init__)
with pytest.raises(RuntimeError, match="missing internals: ElementHandle._frame"):
patch._check_playwright()
# =========================================================================
# Reads: resolved and read in the isolated world, never Playwright's own
# read path (which a humanized flow otherwise never touches)
# =========================================================================
READ_OPS = {
"input_value": "inputValue",
"text_content": "textContent",
"inner_text": "innerText",
"inner_html": "innerHTML",
"get_attribute": "getAttribute",
}
class _StubWorlds:
"""Stands in for human.world.Worlds: records ops, answers resolve/states."""
def __init__(self, result):
self.result = result
self.calls = []
async def call(self, frame, op, *args):
self.calls.append((op, args))
if op == "resolve":
return {"status": "ok", "id": 7, "count": 1}
if op == "states":
return None
return self.result
class _StubFrame:
def _timeout(self, timeout):
return 5000.0 if timeout is None else float(timeout)
def _human_with(result):
from cloakbrowser.human.engine import Human, Target
human = Human.__new__(Human) # read() needs only worlds + the target frame
human.worlds = _StubWorlds(result)
return human, Target(_StubFrame(), "input[name='q']")
def test_read_returns_the_world_value_without_playwright():
from cloakbrowser.human.engine import Human
human, target = _human_with({"value": "laptop"})
got = asyncio.run(human.read(target, "inputValue", {}, "page.input_value"))
assert got == "laptop"
ops = [op for op, _ in human.worlds.calls]
assert ops == ["resolve", "states", "inputValue"], ops
assert human.worlds.calls[-1][1] == (7,)
def test_read_passes_extra_args_to_the_world_op():
from cloakbrowser.human.engine import Human
human, target = _human_with({"value": "data-x"})
got = asyncio.run(human.read(target, "getAttribute", {}, "page.get_attribute", "data-x"))
assert got == "data-x"
assert human.worlds.calls[-1] == ("getAttribute", (7, "data-x"))
def test_read_maps_null_and_missing_values():
for result in ({"value": None}, {}):
human, target = _human_with(result)
assert asyncio.run(human.read(target, "textContent", {}, "page.text_content")) is None
def test_read_raises_playwright_error_on_world_error():
from playwright.sync_api import Error
from cloakbrowser.human.engine import Human
human, target = _human_with(
{"error": "Node is not an <input>, <textarea> or <select> element"}
)
with pytest.raises(Error, match="page.input_value: Error: Node is not an <input>"):
asyncio.run(human.read(target, "inputValue", {}, "page.input_value"))
def test_read_api_label_follows_the_kind():
"""Handlers pass ``<Kind>.<method>`` (same as click/type), e.g. Page.input_value."""
from cloakbrowser.human import patch
class _StubHuman:
def __init__(self):
self.api = None
async def read(self, target, op, opts, api, *extra):
self.api = api
return None
impl = MagicMock()
human = _StubHuman()
asyncio.run(patch._handlers("Locator")["input_value"](human, impl, {"timeout": None, "human_config": None}))
assert human.api == "Locator.input_value"
def test_read_handlers_registered_for_every_class():
from cloakbrowser.human import patch
for kind in ("Page", "Frame", "Locator", "ElementHandle"):
table = patch._handlers(kind)
for name in READ_OPS:
assert name in table, f"{kind}.{name} not patched"
def test_read_handlers_use_the_matching_world_op():
from cloakbrowser.human import patch
class _StubHuman:
def __init__(self):
self.seen = []
async def read(self, target, op, opts, api, *extra):
self.seen.append((op, api, extra))
return "ok"
impl = MagicMock()
for name, op in READ_OPS.items():
human = _StubHuman()
args = {"selector": "#x", "name": "data-x", "timeout": None, "strict": None,
"human_config": None}
asyncio.run(patch._handlers("Page")[name](human, impl, dict(args)))
extra = ("data-x",) if name == "get_attribute" else ()
assert human.seen == [(op, f"Page.{name}", extra)], (name, human.seen)
def test_originals_expose_the_raw_reads():
from cloakbrowser.human import patch
for name in READ_OPS:
assert name in patch._Originals._PAGE, f"page._original.{name} missing"
def test_dispatch_event_handlers_pass_type_and_init():
from cloakbrowser.human import patch
class _StubHuman:
def __init__(self):
self.seen = []
async def dispatch(self, target, opts, api, event_type, event_init=None):
self.seen.append((api, event_type, event_init))
return None
impl = MagicMock()
human = _StubHuman()
asyncio.run(patch._handlers("Page")["dispatch_event"](
human, impl, {"selector": "#x", "type": "click", "event_init": {"bubbles": False},
"timeout": None, "strict": None, "human_config": None}))
assert human.seen == [("Page.dispatch_event", "click", {"bubbles": False})]
human = _StubHuman()
asyncio.run(patch._handlers("ElementHandle")["dispatch_event"](
human, impl, {"type": "input", "event_init": None, "human_config": None}))
assert human.seen == [("ElementHandle.dispatch_event", "input", None)]
def test_dispatch_event_uses_the_world_op():
from cloakbrowser.human.engine import Human
human, target = _human_with({"value": True})
asyncio.run(human.dispatch(target, {}, "page.dispatch_event", "click", {"clientX": 7}))
assert human.worlds.calls[-1] == ("dispatchEvent", (7, "click", {"clientX": 7}))
def test_dispatch_event_raises_on_world_error():
from playwright.sync_api import Error
from cloakbrowser.human.engine import Human
human, target = _human_with({"error": "boom"})
with pytest.raises(Error, match="page.dispatch_event: Error: boom"):
asyncio.run(human.dispatch(target, {}, "page.dispatch_event", "click"))