mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
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>
1136 lines
43 KiB
Python
1136 lines
43 KiB
Python
"""
|
||
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"))
|