Files
CloakBrowser/tests/test_humanize_engine.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

965 lines
38 KiB
Python

"""Behaviour tests for the unified humanize engine (real browser, fast config).
These replace the old mock-based tests that asserted on internals of the
previous implementation (private helpers, call forwarding into Playwright).
Every test here drives the real CloakBrowser binary headless against a small
local page (served below; it records every input event the page observes)
with near-zero delays, so the whole file runs in well under a minute.
"""
from __future__ import annotations
import http.server
import socketserver
import sys
import threading
import time
import urllib.parse
from typing import Any
import pytest
from playwright.sync_api import Error, TimeoutError
# Fast, deterministic humanize settings: same code paths, no long sleeps, no
# random typos. Tests that need specific behaviour override per context/call.
FAST: dict[str, Any] = {
"mistype_chance": 0.0,
"field_switch_delay": (0, 0),
"typing_delay": 5,
"typing_delay_spread": 0,
"typing_pause_chance": 0.0,
"key_hold": (5, 5),
"shift_down_delay": (5, 5),
"shift_up_delay": (5, 5),
"mouse_min_steps": 4,
"mouse_max_steps": 4,
"mouse_overshoot_chance": 0.0,
"click_aim_delay_input": (5, 5),
"click_aim_delay_button": (5, 5),
"click_hold_input": (20, 20),
"click_hold_button": (20, 20),
"idle_between_actions": False,
"scroll_pause_fast": (5, 5),
"scroll_pause_slow": (5, 5),
"scroll_settle_delay": (50, 50),
"scroll_pre_move_delay": (5, 5),
"scroll_overshoot_chance": 0.0,
}
# Test page: recorder (window.__log), main-world probes (window.__mainWorldHits),
# inputs of every kind, a covered button, an off-screen input and an iframe.
INDEX_HTML = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>humanize fixture</title>
<script>
// Records DOM reads that only a main-world evaluate makes into window.__mainWorldHits.
(function () {
const hits = [];
window.__mainWorldHits = hits;
const note = (probe, detail) => hits.push({ probe, detail: String(detail ?? '') });
const origGetAttribute = Element.prototype.getAttribute;
Element.prototype.getAttribute = function (name) {
if (name === 'contenteditable') note('getAttribute(contenteditable)', this.id || this.tagName);
return origGetAttribute.call(this, name);
};
const activeDesc = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement');
Object.defineProperty(Document.prototype, 'activeElement', {
configurable: true,
get() {
note('document.activeElement', '');
return activeDesc.get.call(this);
},
});
const origFromPoint = Document.prototype.elementFromPoint;
Document.prototype.elementFromPoint = function (x, y) {
note('elementFromPoint', x + ',' + y);
return origFromPoint.call(this, x, y);
};
const origQS = Document.prototype.querySelector;
Document.prototype.querySelector = function (sel) {
note('document.querySelector', sel);
return origQS.call(this, sel);
};
})();
</script>
<script>
// Records every input event (capture phase) into window.__log.
(function () {
const log = [];
window.__log = log;
window.__reset = () => { log.length = 0; };
const TYPES = [
'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick',
'contextmenu', 'auxclick', 'mousemove', 'keydown', 'keyup', 'beforeinput',
'input', 'wheel', 'focus',
];
const idOf = (el) => {
if (el === window) return 'window';
if (!el || el === document) return 'document';
return el.id || (el.tagName ? el.tagName.toLowerCase() : '?');
};
for (const type of TYPES) {
document.addEventListener(type, (e) => {
const rec = { t: type, ts: e.timeStamp, trusted: e.isTrusted, target: idOf(e.target) };
if ('clientX' in e) { rec.x = e.clientX; rec.y = e.clientY; }
if ('button' in e) rec.button = e.button;
if ('detail' in e && type !== 'focus') rec.detail = e.detail;
if ('key' in e) { rec.key = e.key; rec.code = e.code; }
if ('shiftKey' in e) {
rec.shift = e.shiftKey; rec.ctrl = e.ctrlKey; rec.meta = e.metaKey; rec.alt = e.altKey;
}
if ('inputType' in e) { rec.inputType = e.inputType; rec.data = e.data; }
if (type === 'wheel') { rec.dx = e.deltaX; rec.dy = e.deltaY; }
log.push(rec);
}, true);
}
window.addEventListener('scroll', () => {
log.push({ t: 'scroll', ts: performance.now(), sx: scrollX, sy: scrollY });
}, true);
})();
</script>
<style>
body { margin: 0; font: 14px sans-serif; }
section { padding: 8px 12px; border-bottom: 1px solid #ddd; }
input, select, button, textarea { font: inherit; margin: 2px 6px 2px 0; }
button { min-width: 140px; min-height: 36px; }
#neg { position: absolute; top: -600px; left: -600px; }
.wrap { position: relative; display: inline-block; }
.over { position: absolute; inset: 0; background: rgba(255,0,0,.3); }
</style>
</head>
<body>
<section id="text">
<h4>Text inputs</h4>
<input id="name" value="firstname">
<!-- Masked field: the page strips anything that is not hex (card/IBAN/serial-style mask). -->
<input id="hex" oninput="this.value=this.value.replace(/[^0-9a-f]/gi,'')">
</section>
<section id="special">
<h4>Non-text inputs</h4>
<input id="date" type="date">
<input id="range" type="range" min="0" max="100" value="10">
<input id="color" type="color" value="#000000">
</section>
<section id="focus">
<h4>Focus targets</h4>
<input id="fa" placeholder="A (focused first)">
<input id="fb" placeholder="B (handle target)">
</section>
<section id="buttons">
<h4>Buttons</h4>
<button id="btn">Press me</button>
<button class="dup" id="dup1">Duplicate</button>
<button class="dup" id="dup2">Duplicate</button>
<input id="chk" type="checkbox">
<select id="sel"><option value="a">a</option><option value="b">b</option></select>
</section>
<section id="covered-sec">
<h4>Covered button (transparent overlay on top)</h4>
<span class="wrap"><button id="covered">covered</button><div class="over" id="over"></div></span>
</section>
<input id="neg" name="neg">
<canvas id="cv" width="200" height="40" style="border:1px solid #999"></canvas>
<section id="frames">
<h4>Frame</h4>
<iframe id="frame" name="f" src="frame.html" style="width:420px;height:200px"></iframe>
</section>
<div style="height:200px"></div>
</body>
</html>
"""
FRAME_HTML = """<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script>
// Records DOM reads that only a main-world evaluate makes into window.__mainWorldHits.
(function () {
const hits = [];
window.__mainWorldHits = hits;
const note = (probe, detail) => hits.push({ probe, detail: String(detail ?? '') });
const origGetAttribute = Element.prototype.getAttribute;
Element.prototype.getAttribute = function (name) {
if (name === 'contenteditable') note('getAttribute(contenteditable)', this.id || this.tagName);
return origGetAttribute.call(this, name);
};
const activeDesc = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement');
Object.defineProperty(Document.prototype, 'activeElement', {
configurable: true,
get() {
note('document.activeElement', '');
return activeDesc.get.call(this);
},
});
const origFromPoint = Document.prototype.elementFromPoint;
Document.prototype.elementFromPoint = function (x, y) {
note('elementFromPoint', x + ',' + y);
return origFromPoint.call(this, x, y);
};
const origQS = Document.prototype.querySelector;
Document.prototype.querySelector = function (sel) {
note('document.querySelector', sel);
return origQS.call(this, sel);
};
})();
</script>
<script>
// Records every input event (capture phase) into window.__log.
(function () {
const log = [];
window.__log = log;
window.__reset = () => { log.length = 0; };
const TYPES = [
'pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick',
'contextmenu', 'auxclick', 'mousemove', 'keydown', 'keyup', 'beforeinput',
'input', 'wheel', 'focus',
];
const idOf = (el) => {
if (el === window) return 'window';
if (!el || el === document) return 'document';
return el.id || (el.tagName ? el.tagName.toLowerCase() : '?');
};
for (const type of TYPES) {
document.addEventListener(type, (e) => {
const rec = { t: type, ts: e.timeStamp, trusted: e.isTrusted, target: idOf(e.target) };
if ('clientX' in e) { rec.x = e.clientX; rec.y = e.clientY; }
if ('button' in e) rec.button = e.button;
if ('detail' in e && type !== 'focus') rec.detail = e.detail;
if ('key' in e) { rec.key = e.key; rec.code = e.code; }
if ('shiftKey' in e) {
rec.shift = e.shiftKey; rec.ctrl = e.ctrlKey; rec.meta = e.metaKey; rec.alt = e.altKey;
}
if ('inputType' in e) { rec.inputType = e.inputType; rec.data = e.data; }
if (type === 'wheel') { rec.dx = e.deltaX; rec.dy = e.deltaY; }
log.push(rec);
}, true);
}
window.addEventListener('scroll', () => {
log.push({ t: 'scroll', ts: performance.now(), sx: scrollX, sy: scrollY });
}, true);
})();
</script>
<style>
body { margin: 0; font: 14px sans-serif; }
.wrap { position: relative; display: inline-block; }
.over { position: absolute; inset: 0; background: rgba(255,0,0,.3); }
</style>
</head>
<body>
<input id="finput" value="old">
<span class="wrap"><button id="fcovered">covered</button><div class="over" id="fover"></div></span>
<div style="height:1200px"></div>
<button id="fbottom">bottom</button>
</body>
</html>
"""
_PAGES = {"/index.html": INDEX_HTML, "/frame.html": FRAME_HTML}
class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
body = _PAGES.get(self.path.split("?")[0])
self.send_response(200 if body else 404)
self.send_header("content-type", "text/html; charset=utf-8")
self.end_headers()
if body:
self.wfile.write(body.encode())
def log_message(self, *args: Any) -> None: # pragma: no cover
pass
class SiteServer:
"""Serve the test pages on 127.0.0.1 with a random port (the iframe must be same-origin)."""
def __init__(self) -> None:
self._srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), _Handler)
self._srv.daemon_threads = True
self.url = f"http://127.0.0.1:{self._srv.server_address[1]}/"
threading.Thread(target=self._srv.serve_forever, daemon=True).start()
def close(self) -> None:
self._srv.shutdown()
self._srv.server_close()
def events(target: Any, *types: str) -> list[dict]:
"""Recorded input events, optionally filtered by type."""
log = target.evaluate("window.__log.slice()")
return [e for e in log if not types or e["t"] in types]
def reset(target: Any) -> None:
target.evaluate("window.__reset(); window.__mainWorldHits.length = 0")
def value(target: Any, selector: str) -> str:
return target.evaluate(
"s => { const e = document.querySelector(s);"
" return e.isContentEditable ? e.textContent : e.value; }",
selector,
)
pytestmark = [pytest.mark.timeout(120), pytest.mark.real_browser]
@pytest.fixture(scope="module")
def site():
srv = SiteServer()
yield srv
srv.close()
@pytest.fixture(scope="module")
def browser(site):
from cloakbrowser import launch
b = launch(headless=True, humanize=True, human_config=FAST)
yield b
b.close()
@pytest.fixture
def page(browser, site):
p = browser.new_page()
p.goto(site.url + "index.html")
yield p
p.close()
def frame(p):
f = p.frame(name="f")
f.wait_for_load_state()
return f
# --- wiring ---------------------------------------------------------------
def test_page_is_humanized_and_keeps_compat_attributes(page):
assert page._human_cfg.mistype_chance == 0.0
assert page._human_cursor.initialized
assert page._stealth_world.evaluate("1 + 1") == 2
page._original.click("#btn") # raw Playwright still reachable
def test_unknown_keyword_still_raises_type_error(page):
with pytest.raises(TypeError):
page.click("#btn", no_such_option=True)
# --- pointer actions --------------------------------------------------------
def test_click_moves_along_a_curve_then_presses(page):
reset(page)
page.click("#btn")
moves = events(page, "mousemove")
clicks = [e["target"] for e in events(page, "click")]
assert len(moves) >= 3 and clicks == ["btn"]
def test_click_options(page):
reset(page)
page.click("#btn", button="right", modifiers=["Shift"])
down = events(page, "mousedown")[-1]
assert down["button"] == 2 and down["shift"] is True
reset(page)
page.click("#btn", click_count=2)
assert len(events(page, "dblclick")) == 1
reset(page)
page.click("#btn", trial=True)
assert events(page, "click") == []
def test_hover_does_not_press(page):
reset(page)
page.hover("#btn")
assert events(page, "mousedown") == [] and events(page, "mousemove")
def test_hover_holds_modifiers_during_the_move(page):
page.hover("#chk")
reset(page)
page.hover("#btn", modifiers=["Shift"])
seq = events(page, "mousemove", "keydown", "keyup")
moves = [i for i, e in enumerate(seq) if e["t"] == "mousemove"]
assert moves and all(seq[i]["shift"] for i in moves)
assert seq[0]["t"] == "keydown" and seq[-1]["t"] == "keyup"
assert events(page, "mousedown") == []
def test_dblclick_matches_a_real_double_click(page):
reset(page)
page.dblclick("#btn")
seq = [(e["t"], e["detail"]) for e in events(page, "mousedown", "click", "dblclick")]
assert seq == [("mousedown", 1), ("click", 1), ("mousedown", 2), ("click", 2), ("dblclick", 2)]
def test_covered_element_times_out_with_reason(page):
with pytest.raises(TimeoutError, match="intercepts pointer events"):
page.click("#covered", timeout=1000)
def test_strict_mode_violation(page):
with pytest.raises(Error, match="strict mode violation"):
page.locator(".dup").click(timeout=1000)
page.locator(".dup").first.click() # explicit choice is fine
def test_page_default_timeout_and_zero(page):
page.set_default_timeout(500)
t0 = time.monotonic()
with pytest.raises(TimeoutError):
page.click("#missing")
assert time.monotonic() - t0 < 1.5
page.click("#btn", timeout=0)
def test_standard_locators(page):
page.get_by_role("button", name="Press me").click()
page.locator("#buttons").locator("#btn").click()
page.locator("button").filter(has_text="Press me").click()
page.click("#btn:visible")
# --- keyboard ---------------------------------------------------------------
def test_fill_replaces_and_type_appends(page):
page.fill("#name", "Shaho")
assert value(page, "#name") == "Shaho"
page.locator("#name").press_sequentially("!")
assert value(page, "#name") == "Shaho!"
page.locator("#name").clear()
assert value(page, "#name") == ""
def test_value_inputs_are_operated_like_a_person(page):
"""date/time editors are typed segment by segment, sliders clicked and
nudged with arrows, dropdowns opened and moved with arrow keys. Every
input/change event the page sees is trusted (no programmatic value)."""
page.evaluate("""() => { window.__untrusted = 0;
for (const t of ['input', 'change'])
document.addEventListener(t, e => { if (!e.isTrusted) __untrusted++; }, true); }""")
page.fill("#date", "2024-01-31")
page.fill("#date", "1999-12-05")
page.fill("#range", "80")
assert page.select_option("#sel", "b") == ["b"]
assert (value(page, "#date"), value(page, "#range"), value(page, "#sel")) == ("1999-12-05", "80", "b")
assert page.evaluate("__untrusted") == 0
with pytest.raises(Error, match="native colour picker"):
page.fill("#color", "#ff0000")
with pytest.raises(Error, match="cannot be filled"):
page.fill("#chk", "x")
def test_unreachable_element_is_not_focused_programmatically(page):
"""#neg sits at (-600, -600): no scroll can reach it, so a person could
not click it. No hidden focus() -- a clear timeout instead."""
with pytest.raises(TimeoutError, match="outside of the viewport"):
page.fill("#neg", "x", timeout=1500)
assert value(page, "#neg") == ""
def test_press_focuses_target_first(page):
page.focus("#fa")
page.query_selector("#fb").press("x")
assert (value(page, "#fa"), value(page, "#fb")) == ("", "x")
def test_handle_sharing_its_box_with_a_child_acts_on_the_focusable_one(page):
"""A wrapper and its same-size child form one nested chain. The handle acts on
the element a real click would focus (the focusable wrapper), not the child."""
page.evaluate("""() => document.body.insertAdjacentHTML('beforeend',
'<div id="wrap" tabindex="0" style="display:inline-block"><span id="inner"'
+ ' style="display:block;width:80px;height:20px">x</span></div>')""")
page.evaluate("() => { window.__kd = []; document.addEventListener('keydown',"
" e => __kd.push(e.target.id + ':' + e.key)); }")
page.query_selector("#wrap").press("a")
assert page.evaluate("() => __kd") == ["wrap:a"]
def test_stale_handle_after_navigation_fails_fast(page, site):
"""A handle from a previous document raises "not attached" at once, like
Playwright, instead of waiting out the timeout. A same-document navigation
keeps the element, so its handle keeps working."""
h = page.query_selector("#btn")
h.click()
page.evaluate("() => history.pushState({}, '', '#spa')")
h.click()
page.goto(site.url + "index.html?second")
page.click("#chk")
t = time.monotonic()
with pytest.raises(Error, match="not attached"):
h.click(timeout=10000)
assert time.monotonic() - t < 5
def test_handle_click_on_same_box_nesting(page):
"""Common markup where parent and child share a border box: a block link
wrapping a same-size image, and a list item filled by its link."""
page.evaluate("""() => { document.body.insertAdjacentHTML('beforeend',
'<a id="card" href="#c" style="display:block;width:60px"><img id="thumb" width="60" height="60"'
+ ' style="display:block" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></a>'
+ '<ul style="width:200px;padding:0;margin:0;list-style:none"><li id="li"><a id="nav" href="#n"'
+ ' style="display:block">nav</a></li></ul>');
window.__clicks = []; document.addEventListener('click', e => { __clicks.push(e.target.id); e.preventDefault(); }); }""")
page.query_selector("#card").click()
page.query_selector("#nav").click()
page.query_selector("#li").click()
assert page.evaluate("() => __clicks") == ["thumb", "nav", "nav"]
def test_handle_sharing_its_box_with_an_unrelated_element_raises(page):
"""Two unrelated elements stacked on the same box cannot be told apart by the
box alone: the handle action raises instead of guessing."""
page.evaluate("""() => document.body.insertAdjacentHTML('beforeend',
'<div style="position:relative;width:80px;height:20px">'
+ '<span id="s1" style="position:absolute;inset:0"></span>'
+ '<span id="s2" style="position:absolute;inset:0"></span></div>')""")
with pytest.raises(Error, match="shares its box"):
page.query_selector("#s1").click()
def test_handle_reads_of_hidden_or_same_box_elements(page):
"""A handle read needs the handle's own element: hidden (no box) and same-box
handles run Playwright's own read at once; a unique visible one reads in the
isolated world."""
page.evaluate("""() => {
document.head.insertAdjacentHTML('beforeend', '<meta name="csrf" content="tok123">');
document.body.insertAdjacentHTML('beforeend', '<input id="hid" type="hidden" value="secret">'
+ '<div id="outer" style="width:90px;height:30px"><div id="inner" style="width:90px;height:30px"></div></div>'
+ '<p id="uniq" data-x="shown">u</p>'); }""")
t = time.monotonic()
assert page.query_selector('meta[name="csrf"]').get_attribute("content") == "tok123"
assert page.query_selector("#hid").input_value() == "secret"
assert time.monotonic() - t < 5
assert page.query_selector("#inner").get_attribute("id") == "inner"
assert page.query_selector("#outer").get_attribute("id") == "outer"
assert page.query_selector("#uniq").get_attribute("data-x") == "shown"
def test_dispatch_event_with_a_js_handle_in_event_init(page):
"""Playwright's documented drag pattern: the DataTransfer handle lives in the
page's main world, so the dispatch runs Playwright's own call."""
page.evaluate("""() => { window.__dt = [];
document.addEventListener('dragstart', e => __dt.push(e.dataTransfer instanceof DataTransfer)); }""")
dt = page.evaluate_handle("() => new DataTransfer()")
page.dispatch_event("#btn", "dragstart", {"dataTransfer": dt})
page.locator("#btn").dispatch_event("dragstart", {"dataTransfer": dt})
assert page.evaluate("() => __dt") == [True, True]
def test_press_and_type_reach_non_focusable_targets(page):
"""A <canvas> (or a game area with document-level key handlers) does not take
focus. press()/type() click it and send the keys anyway, like Playwright."""
page.evaluate("() => { window.__keys = []; document.addEventListener('keydown', e => __keys.push(e.key)); }")
page.press("#cv", "ArrowUp")
page.type("#cv", "ab")
assert page.evaluate("__keys") == ["ArrowUp", "a", "b"]
def test_press_delay_is_forwarded(page):
reset(page)
page.press("#name", "a", delay=150)
ev = events(page, "keydown", "keyup")
assert ev[-1]["ts"] - ev[-2]["ts"] >= 120
def test_mistypes_are_corrected_and_skip_masked_fields(browser, site):
p = browser.new_page()
p.goto(site.url + "index.html")
try:
p.fill("#name", "Hello", human_config={"mistype_chance": 1.0})
assert value(p, "#name") == "Hello"
p.fill("#hex", "ab", human_config={"mistype_chance": 1.0})
assert value(p, "#hex") == "ab"
reset(p)
p.locator("#name").press_sequentially("AB", human_config={"mistype_chance": 1.0})
assert all(e["shift"] for e in events(p, "keydown")
if len(e["key"]) == 1 and e["key"].isupper())
finally:
p.close()
def test_scroll_overshoot_wheels_back(page):
page.set_viewport_size({"width": 800, "height": 300})
reset(page)
page.click("#chk", human_config={"scroll_overshoot_chance": 1.0, "scroll_overshoot_px": (120, 120)})
dys = [e["dy"] for e in events(page, "wheel")]
assert any(d > 0 for d in dys) and any(d < 0 for d in dys), dys
assert [e["target"] for e in events(page, "click")] == ["chk"]
reset(page)
page.evaluate("scrollTo(0, 0)")
page.click("#chk", human_config={"scroll_overshoot_chance": 0.0})
assert all(e["dy"] > 0 for e in events(page, "wheel"))
def test_wheel_events_are_whole_notches(page):
"""CDP reports every wheel event as one notch (wheelDeltaY = 120), so the
page-visible deltaY must be a whole notch of the persona's platform too;
a 20-40 px chunk with wheelDeltaY 120 is something no wheel produces."""
page.set_viewport_size({"width": 800, "height": 300})
page.evaluate("""() => { window.__wh = []; document.addEventListener('wheel',
e => __wh.push([e.deltaY, e.wheelDeltaY]), { capture: true, passive: true }); }""")
page.click("#chk", human_config={"scroll_notch_px": 0})
wheels = page.evaluate("__wh")
assert wheels
plat = page.evaluate("navigator.platform").lower()
notch = 40 if plat.startswith("mac") else 100 if plat.startswith("win") else 120
assert {abs(dy) for dy, _ in wheels} == {notch}, wheels
assert all(dy * wdy < 0 for dy, wdy in wheels)
def test_typing_uses_human_key_timing(browser, site):
"""Default timings: keys held ~100 ms, the next key often down before the
previous one is up, Shift usually let go first; never a key down twice."""
p = browser.new_page()
p.goto(site.url + "index.html")
try:
text = "The Quick brown fox jumps over the lazy dog"
p.fill("#name", text, human_config={"key_hold": (60, 175), "typing_delay": 150,
"typing_delay_spread": 70, "typing_pause_chance": 0,
"shift_down_delay": (60, 220), "shift_up_delay": (-60, 30)})
assert value(p, "#name") == text
down: dict = {}
holds, rolled, pairs, last = [], 0, 0, None
for e in events(p, "keydown", "keyup"):
if e["t"] == "keydown":
assert e["code"] not in down, e
down[e["code"]] = e["ts"]
if last is not None and e["key"] != "Shift":
pairs += 1
rolled += last in down
if e["key"] != "Shift":
last = e["code"]
else:
holds.append(e["ts"] - down.pop(e["code"]))
assert not down
holds.sort()
assert 70 <= holds[len(holds) // 2] <= 140, holds
assert rolled > 0, "no rollover at all"
finally:
p.close()
def test_same_key_lower_then_upper_never_repeats(browser, site):
""""xX": the capital's key must not go down while the lower-case press of
the same physical key is still held (a keydown with repeat=true). Same for
a digit and its shift symbol ("1!")."""
p = browser.new_page()
p.goto(site.url + "index.html")
try:
p.evaluate("""() => { window.__kd = []; for (const t of ['keydown', 'keyup'])
document.addEventListener(t, e => __kd.push([t, e.code, e.repeat]), true); }""")
text = "xX aA zZ 1! qQ"
cfg = {"key_hold": (60, 175), "typing_delay": 150, "typing_delay_spread": 70,
"typing_pause_chance": 0, "shift_down_delay": (60, 220), "shift_up_delay": (-60, 30)}
for _ in range(3):
p.fill("#name", text, human_config=cfg)
assert value(p, "#name") == text
log = p.evaluate("__kd")
assert not [e for e in log if e[2]], [e for e in log if e[2]]
held = set()
for t, code, _ in log:
if t == "keydown":
assert code not in held, (code, log)
held.add(code)
else:
held.discard(code)
finally:
p.close()
def test_moves_never_report_zero_movement(page):
page.evaluate("""() => { window.__mm = []; document.addEventListener('mousemove',
e => __mm.push([e.movementX, e.movementY]), true); }""")
page.hover("#btn")
page.hover("#chk")
mm = page.evaluate("__mm")
assert mm and not [m for m in mm[1:] if m == [0, 0]], mm
# --- containing-block-aware scroller (#593) ----------------------------------
_CB_CSS = """<!doctype html><style>
body { margin: 0 } header { height: 215px }
.content { height: 300px; overflow: auto } .tall { height: 2000px }
button { width: 44px; height: 44px }
</style><header>header</header>"""
_CB_CASES = {
# renders on screen, outside the scroller's scrollport: no scrolling
"fixed_modal": ("""<div class="content"><div class="tall">
<div style="position: fixed; inset: 0; background: rgba(0,0,0,.4)">
<button id="t" style="position: absolute; top: 146px; right: 36px">X</button></div></div></div>""", False),
"absolute_escapes_static_scroller": ("""<div style="position: relative">
<div class="content"><div class="tall">
<button id="t" style="position: absolute; top: 320px; left: 10px">X</button></div></div></div>""", False),
# still clipped by the scroller: must be wheeled into view first
"normal_child_below_fold": ("""<div class="content"><div class="tall">
<div style="height: 1500px"></div><button id="t">X</button></div></div>""", True),
"absolute_in_positioned_scroller": ("""<div class="content" style="position: relative"><div class="tall">
<button id="t" style="position: absolute; top: 1500px; left: 10px">X</button></div></div>""", True),
"fixed_in_transformed_scroller": ("""<div class="content" style="transform: translateZ(0)"><div class="tall">
<button id="t" style="position: fixed; top: 1500px; left: 10px">X</button></div></div>""", True),
}
@pytest.mark.parametrize("name", list(_CB_CASES))
def test_scroller_follows_the_containing_block_chain(page, name):
"""overflow clips only boxes whose containing block sits inside the
scroller; a fixed modal that is a DOM child of a scrolling panel is on
screen and must be clicked, not retried as 'outside of the viewport'."""
body, clipped = _CB_CASES[name]
page.set_viewport_size({"width": 390, "height": 844})
page.goto("data:text/html," + urllib.parse.quote(_CB_CSS + body))
page.evaluate("""() => { window.__clicks = 0;
document.getElementById('t').addEventListener('click', () => __clicks++); }""")
page.click("#t", timeout=5000)
assert page.evaluate("__clicks") == 1
scrolled = page.evaluate("document.querySelector('.content').scrollTop")
assert (scrolled > 0) == clipped, scrolled
def test_per_call_human_config(page):
reset(page)
page.click("#btn", human_config={"click_hold_button": (300, 300)})
assert events(page, "mouseup")[-1]["ts"] - events(page, "mousedown")[-1]["ts"] >= 250
# --- checkable / select -----------------------------------------------------
def test_check_uncheck_and_select(page):
page.check("#chk")
assert page.is_checked("#chk")
page.locator("#chk").uncheck()
assert not page.is_checked("#chk")
page.query_selector("#chk").set_checked(True)
assert page.is_checked("#chk")
assert page.select_option("#sel", index=1) == ["b"]
COUNTRIES = ["Afghanistan", "Albania", "Chad", "Chile", "China", "Dominica", "Dominican Republic",
"Guinea", "Guinea-Bissau", "Niger", "Nigeria", "\u00c5land Islands", "Aaland", "United Arab Emirates",
"United Kingdom", "United States", "United States Minor Outlying Islands", "Zimbabwe"]
def _blink_typeahead(labels, enabled, start, typed):
"""Blink's TypeAhead::HandleEvent (kMatchPrefix | kCycleFirstChar) for one search."""
import unicodedata
def fold(t):
return "".join(c for c in unicodedata.normalize("NFD", t) if not unicodedata.combining(c)).lower()
keys = [fold(l.lstrip()) if ok else "" for l, ok in zip(labels, enabled)]
buf = rep = ""
sel = start
for ch in fold(typed):
buf += ch
if ch == rep:
prefix, off = ch, 1
else:
prefix = buf
off = 0 if len(buf) > 1 else 1
rep = "" if len(buf) > 1 else ch
i = (max(sel, 0) + off) % len(keys)
for _ in keys:
if keys[i] and keys[i].startswith(prefix):
sel = i
break
i = (i + 1) % len(keys)
return sel
def test_type_ahead_plan_lands_on_the_option_from_any_highlighted_row(page):
"""The plan must not depend on which row the open popup highlights (#580): it
can sit under the OS pointer, and the page cannot read it."""
page.evaluate("""(labels) => { const s = document.createElement('select'); s.id = 'ta';
for (const l of labels) s.add(new Option(l));
s.options[1].disabled = true; document.body.append(s); }""", COUNTRIES)
enabled = [i != 1 for i in range(len(COUNTRIES))]
human = page._impl_obj._cloak_human
frame = page.main_frame._impl_obj
async def plans():
from cloakbrowser.human.engine import Target, _Deadline
r = await human.resolve(Target(frame, "#ta"), _Deadline(5000))
return [await human.worlds.call(frame, "typeAheadPlan", r.id, t, 3) for t in range(len(COUNTRIES))]
for target, plan in enumerate(page._sync(plans())):
if target == 1:
assert plan is None # disabled: not reachable
continue
assert plan and not plan.get("home"), (COUNTRIES[target], plan)
for start in range(len(COUNTRIES)):
row = _blink_typeahead(COUNTRIES, enabled, start, plan["typed"])
step = 1 if plan["arrows"] > 0 else -1
for _ in range(abs(plan["arrows"])):
row += step
while not enabled[row]:
row += step
assert row == target, (COUNTRIES[target], plan, COUNTRIES[start])
got = {COUNTRIES[t]: p for t, p in enumerate(page._sync(plans()))}
assert got["Chile"] == {"typed": "chil", "arrows": 0} # its own name, not "cha" + 1 arrow
assert got["\u00c5land Islands"]["typed"] == "al" # the browser folds accents: plain "al" finds it
assert got["Aaland"]["typed"] == "aal" # "aa" would cycle through the a's instead
assert all(abs(p["arrows"]) <= 3 for p in got.values() if p)
def test_select_option_on_a_long_dropdown_types_ahead(page):
"""#581: no walk over every row, and the timeout bounds it."""
page.evaluate("""() => { const s = document.createElement('select'); s.id = 'long';
for (let i = 0; i < 205; i++) s.add(new Option('Country ' + String(i).padStart(3, '0'), 'v' + i));
window.__sel = []; for (const t of ['input', 'change']) s.addEventListener(t, () => __sel.push(t));
document.body.append(s); }""")
reset(page)
assert page.select_option("#long", "v150") == ["v150"]
keys = [e["key"] for e in events(page, "keydown")]
assert "ArrowDown" not in keys and len(keys) <= 13, keys
sel = page.evaluate("__sel")
# A macOS dropdown moves on every typed letter (one input/change each); elsewhere only Enter commits.
assert sel[-2:] == ["input", "change"] and (sys.platform == "darwin" or len(sel) == 2), sel
page.evaluate("document.querySelector('#long').selectedIndex = 0")
with pytest.raises(TimeoutError):
page.select_option("#long", "v200", timeout=200)
def test_select_option_on_emoji_labels(page):
"""Blink's type-ahead drops a typed emoji, so these options are reached with
arrows; a macOS popup ignores arrows, so there it refuses."""
page.evaluate("""() => { const s = document.createElement('select'); s.id = 'emoji';
for (const [l, v] of [['Pick one', ''], ['\U0001F34E Apple', 'a'], ['\U0001F34C Banana', 'b'],
['\U0001F352 Cherry', 'c']]) s.add(new Option(l, v));
document.body.append(s); }""")
if sys.platform == "darwin":
with pytest.raises(Error, match="cannot be picked with human input"):
page.select_option("#emoji", "c")
else:
assert page.select_option("#emoji", "c") == ["c"]
def test_select_option_that_navigates_the_page(page):
"""#597: a dropdown that submits its form on change; the pick still returns."""
page.evaluate("""() => { const f = document.createElement('form'); f.method = 'get';
const s = document.createElement('select'); s.id = 'nav'; s.name = 'city';
for (const [l, v] of [['Amsterdam', 'a'], ['Berlin', 'b']]) s.add(new Option(l, v));
s.onchange = () => f.submit(); f.append(s); document.body.prepend(f); }""")
assert page.select_option("#nav", "b") == ["b"]
page.wait_for_url("**city=b")
# --- frames and handles ------------------------------------------------------
def test_frame_actions(page):
f = frame(page)
f.fill("#finput", "in frame")
assert value(f, "#finput") == "in frame"
page.frame_locator("#frame").locator("#finput").press_sequentially("!")
assert value(f, "#finput") == "in frame!"
reset(f)
f.click("#fbottom", timeout=5000)
assert [e["target"] for e in events(f, "click")] == ["fbottom"]
# The frame was scrolled to its bottom above; wheeling back up takes time.
with pytest.raises(TimeoutError, match="intercepts pointer events"):
f.click("#fcovered", timeout=4000)
def test_element_handle_actions(page):
h = page.query_selector("#name")
h.fill("handle")
assert value(page, "#name") == "handle"
reset(page)
page.query_selector("#btn").click()
assert [e["target"] for e in events(page, "click")] == ["btn"]
def test_mouse_api(page):
reset(page)
page.mouse.move(50, 50, steps=1)
assert len(events(page, "mousemove")) == 1
box = page.locator("#btn").bounding_box()
reset(page)
page.mouse.click(box["x"] + 5, box["y"] + 5, button="right")
assert [e["button"] for e in events(page, "mousedown")] == [2]
# --- separate browser processes -----------------------------------------------
# Sync Playwright allows one driver per thread, and the module browser above
# owns this one; these scenarios each run in a fresh interpreter.
_PRELUDE = """
import asyncio, sys
sys.path.insert(0, {root!r})
from tests.test_humanize_engine import FAST
URL = {url!r}
"""
_SCENARIOS = {
"non_humanized_untouched": """
from cloakbrowser import launch
b = launch(headless=True)
p = b.new_page(); p.goto(URL + "index.html")
assert not hasattr(p, "_original")
p.click("#btn"); p.fill("#name", "plain")
assert p.evaluate("document.querySelector('#name').value") == "plain"
b.close()
""",
"select_all_follows_persona": """
from cloakbrowser import launch
b = launch(headless=True, humanize=True, human_config=FAST, args=["--fingerprint-platform=macos"])
p = b.new_page(); p.goto(URL + "index.html")
assert p.evaluate("navigator.platform") == "MacIntel"
p.fill("#name", "Mac")
assert p.evaluate("document.querySelector('#name').value") == "Mac"
b.close()
""",
"async_api": """
from cloakbrowser import launch_async
from playwright.async_api import Error
async def main():
b = await launch_async(headless=True, humanize=True, human_config=FAST)
try:
p = await b.new_page(); await p.goto(URL + "index.html")
await p.get_by_role("button", name="Press me").click()
await p.fill("#name", "async")
await p.frame_locator("#frame").locator("#finput").fill("af")
await (await p.query_selector("#chk")).check()
try:
await p.locator(".dup").click(timeout=1000)
raise AssertionError("strict mode not enforced")
except Error as e:
assert "strict mode violation" in str(e)
assert await p.evaluate("document.querySelector('#name').value") == "async"
assert await p.frame(name="f").evaluate("document.querySelector('#finput').value") == "af"
assert await p.is_checked("#chk")
finally:
await b.close()
asyncio.run(main())
""",
}
@pytest.mark.parametrize("name", list(_SCENARIOS))
def test_in_fresh_process(site, name):
import os
import subprocess
import sys
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
code = _PRELUDE.format(root=root, url=site.url) + _SCENARIOS[name]
res = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=100)
assert res.returncode == 0, res.stderr[-2000:]