refactor(humanize): route pre-click element reads through a shared DOM helper

Python + JS wrappers. Add a stealth_dom / stealthDom module: reimplements the
common Playwright selector grammar (css, :has-text, text=, xpath=, trailing
>> nth=N) for direct DOM resolution in the isolated execution context, with a
fallback to the regular Playwright read for grammar it can't resolve. World
reads are wrapped so a world/CDP failure falls back rather than propagating out
of the humanized action.

ensure_actionable, ensure_stable, scroll geometry (_get_element_box + the
no_viewport window-size read), and check_pointer_events now read through it,
sync and async. Selector-based main-page actions (click/dblclick/hover/type/
fill/focus/press) are covered; ElementHandle and sub-frame paths unchanged.

JS routes Locator actions through frame methods, so main-frame locator clicks
now delegate to the humanized page methods (which use the shared helpers)
instead of the frame-scoped Playwright reads; sub-frames unchanged.

Tests: builders + parse, the rewired helpers' branching via a mock isolated
world, a Node-driven check of the shipped resolver JS selector semantics, and a
guard that main-frame locator clicks delegate to the humanized page path.
This commit is contained in:
CloakHQ
2026-08-07 22:31:43 +02:00
parent 7f19b2fc0e
commit 9fa247231b
13 changed files with 1333 additions and 89 deletions
+94 -25
View File
@@ -11,6 +11,11 @@ import logging
import time
from typing import Any, FrozenSet, Optional, Tuple
from .stealth_dom import (
build_actionable_js, build_box_js, build_pointer_js, eval_parsed,
OK, NOT_FOUND, UNSUPPORTED,
)
logger = logging.getLogger(__name__)
@@ -83,6 +88,54 @@ def _backoff_sleep(attempt: int) -> None:
# Pre-scroll actionability: attached, visible, enabled, editable
# ---------------------------------------------------------------------------
def _stealth_actionable(page: Any, selector: str, checks: FrozenSet[str]) -> bool:
"""Run the actionability checks through the isolated world.
Returns True when handled (raising the specific ``ActionabilityError`` on a
failed check), or False when the selector/world is unsupported so the caller
falls back to the regular Playwright read.
"""
world = getattr(page, "_stealth_world", None)
if world is None:
return False
status, data = eval_parsed(world, build_actionable_js(selector))
if status == UNSUPPORTED:
return False
if status == NOT_FOUND:
# Every check-set includes 'attached'; not present yet -> raise so the
# retry loop backs off and re-reads in-world (mirrors wait_for(attached)).
raise ElementNotAttachedError(selector)
if "visible" in checks and not data.get("visible"):
raise ElementNotVisibleError(selector)
if "enabled" in checks and not data.get("enabled"):
raise ElementNotEnabledError(selector)
if "editable" in checks and not data.get("editable"):
raise ElementNotEditableError(selector)
return True
def _read_box(page: Any, selector: str, remaining_ms: float) -> Optional[dict]:
"""Bounding box via the isolated world, falling back to Playwright.
Returns the box dict, or None when the element is not present. Only an
*unsupported* selector (or missing world) reaches Playwright's
``bounding_box``; a genuine not-found stays in-world and returns None.
"""
world = getattr(page, "_stealth_world", None)
if world is not None:
status, data = eval_parsed(world, build_box_js(selector))
if status == OK:
return data["box"]
if status == NOT_FOUND:
return None
# UNSUPPORTED -> Playwright below
try:
loc = page.locator(selector).first
return loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
except Exception:
return None
def ensure_actionable(
page: Any,
selector: str,
@@ -111,25 +164,28 @@ def ensure_actionable(
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
try:
loc = page.locator(selector).first
# Prefer the isolated-world read; fall back to Playwright's locator
# predicates only for selector grammar the isolated world can't resolve.
if not _stealth_actionable(page, selector, checks):
loc = page.locator(selector).first
if "attached" in checks:
try:
loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotAttachedError(selector)
if "attached" in checks:
try:
loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotAttachedError(selector)
if "visible" in checks:
if not loc.is_visible():
raise ElementNotVisibleError(selector)
if "visible" in checks:
if not loc.is_visible():
raise ElementNotVisibleError(selector)
if "enabled" in checks:
if not loc.is_enabled():
raise ElementNotEnabledError(selector)
if "enabled" in checks:
if not loc.is_enabled():
raise ElementNotEnabledError(selector)
if "editable" in checks:
if not loc.is_editable():
raise ElementNotEditableError(selector)
if "editable" in checks:
if not loc.is_editable():
raise ElementNotEditableError(selector)
return
@@ -171,14 +227,13 @@ def ensure_stable(
if remaining_ms <= 0:
raise ElementNotStableError(selector)
loc = page.locator(selector).first
box1 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
box1 = _read_box(page, selector, remaining_ms)
if box1 is None:
raise ElementNotAttachedError(selector)
time.sleep(0.1)
box2 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
box2 = _read_box(page, selector, remaining_ms)
if box2 is None:
raise ElementNotAttachedError(selector)
@@ -242,13 +297,27 @@ def check_pointer_events(
last_miss: Optional[str] = None
while True:
try:
loc = page.locator(selector).first
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Isolated-world hit test; the passed-in ``stealth`` world is reused,
# falling back to Playwright only for unsupported selectors.
world = stealth if stealth is not None else getattr(page, "_stealth_world", None)
result: Optional[dict] = None
handled = False
if world is not None:
status, data = eval_parsed(world, build_pointer_js(selector, x, y))
if status == OK:
result = {"hit": data.get("hit", False), "covering": data.get("covering", "unknown")}
handled = True
elif status == NOT_FOUND:
result = None # indeterminate -> proceed (fail-open)
handled = True
if not handled:
try:
loc = page.locator(selector).first
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks. But once a miss
+81 -25
View File
@@ -25,6 +25,10 @@ from .actionability import (
_POINTER_EVENTS_LOCATOR_JS,
_POINTER_EVENTS_HANDLE_JS,
)
from .stealth_dom import (
build_actionable_js, build_box_js, build_pointer_js, async_eval_parsed,
OK, NOT_FOUND, UNSUPPORTED,
)
async def _async_backoff_sleep(attempt: int) -> None:
@@ -36,6 +40,46 @@ async def _async_backoff_sleep(attempt: int) -> None:
# Pre-scroll actionability
# ---------------------------------------------------------------------------
async def _async_stealth_actionable(page: Any, selector: str, checks: FrozenSet[str]) -> bool:
"""Async mirror of ``_stealth_actionable`` — isolated-world actionability read.
Returns True when handled (raising on a failed check), False when the
selector/world is unsupported (caller falls back to Playwright).
"""
world = getattr(page, "_stealth_world", None)
if world is None:
return False
status, data = await async_eval_parsed(world, build_actionable_js(selector))
if status == UNSUPPORTED:
return False
if status == NOT_FOUND:
raise ElementNotAttachedError(selector)
if "visible" in checks and not data.get("visible"):
raise ElementNotVisibleError(selector)
if "enabled" in checks and not data.get("enabled"):
raise ElementNotEnabledError(selector)
if "editable" in checks and not data.get("editable"):
raise ElementNotEditableError(selector)
return True
async def _async_read_box(page: Any, selector: str, remaining_ms: float) -> Optional[dict]:
"""Async mirror of ``_read_box`` — isolated-world box with Playwright fallback."""
world = getattr(page, "_stealth_world", None)
if world is not None:
status, data = await async_eval_parsed(world, build_box_js(selector))
if status == OK:
return data["box"]
if status == NOT_FOUND:
return None
# UNSUPPORTED -> Playwright below
try:
loc = page.locator(selector).first
return await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
except Exception:
return None
async def async_ensure_actionable(
page: Any,
selector: str,
@@ -58,25 +102,26 @@ async def async_ensure_actionable(
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
try:
loc = page.locator(selector).first
if not await _async_stealth_actionable(page, selector, checks):
loc = page.locator(selector).first
if "attached" in checks:
try:
await loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotAttachedError(selector)
if "attached" in checks:
try:
await loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000)))
except Exception:
raise ElementNotAttachedError(selector)
if "visible" in checks:
if not await loc.is_visible():
raise ElementNotVisibleError(selector)
if "visible" in checks:
if not await loc.is_visible():
raise ElementNotVisibleError(selector)
if "enabled" in checks:
if not await loc.is_enabled():
raise ElementNotEnabledError(selector)
if "enabled" in checks:
if not await loc.is_enabled():
raise ElementNotEnabledError(selector)
if "editable" in checks:
if not await loc.is_editable():
raise ElementNotEditableError(selector)
if "editable" in checks:
if not await loc.is_editable():
raise ElementNotEditableError(selector)
return
@@ -105,14 +150,13 @@ async def async_ensure_stable(
if remaining_ms <= 0:
raise ElementNotStableError(selector)
loc = page.locator(selector).first
box1 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
box1 = await _async_read_box(page, selector, remaining_ms)
if box1 is None:
raise ElementNotAttachedError(selector)
await asyncio.sleep(0.1)
box2 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
box2 = await _async_read_box(page, selector, remaining_ms)
if box2 is None:
raise ElementNotAttachedError(selector)
@@ -143,13 +187,25 @@ async def async_check_pointer_events(
last_miss: Optional[str] = None
while True:
try:
loc = page.locator(selector).first
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
world = stealth if stealth is not None else getattr(page, "_stealth_world", None)
result: Optional[dict] = None
handled = False
if world is not None:
status, data = await async_eval_parsed(world, build_pointer_js(selector, x, y))
if status == OK:
result = {"hit": data.get("hit", False), "covering": data.get("covering", "unknown")}
handled = True
elif status == NOT_FOUND:
result = None
handled = True
if not handled:
try:
loc = page.locator(selector).first
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks. But once a miss
+35 -5
View File
@@ -4,10 +4,12 @@ from __future__ import annotations
import math
import random
import time
from typing import Any, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
from .mouse import RawMouse, human_move
from .stealth_dom import build_box_js, eval_parsed, OK, NOT_FOUND, UNSUPPORTED, _VIEWPORT_JS
def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool:
@@ -23,7 +25,28 @@ def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Option
The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)``
so callers can extend it for slow-loading elements (#172).
Reads geometry through the isolated world when available; a not-found is
retried briefly in-world (SPA re-renders) and only an *unsupported* selector
reaches Playwright's ``bounding_box``.
"""
world = getattr(page, "_stealth_world", None)
if world is not None:
status, data = eval_parsed(world, build_box_js(selector))
if status == OK:
return data["box"]
if status == NOT_FOUND:
deadline = time.monotonic() + min(timeout, 2000) / 1000.0
while time.monotonic() < deadline:
time.sleep(0.05)
status, data = eval_parsed(world, build_box_js(selector))
if status == OK:
return data["box"]
if status == UNSUPPORTED:
break
if status != UNSUPPORTED:
return None
# UNSUPPORTED -> Playwright fallback below
try:
el = page.locator(selector).first
return el.bounding_box(timeout=max(1, timeout))
@@ -65,11 +88,18 @@ def human_scroll_into_view(
viewport = page.viewport_size
if not viewport:
# Headed launches default to no_viewport so the page tracks the real OS
# window; page.viewport_size is then None. Fall back to the live window
# dimensions so humanize works headed (the stealth-relevant mode).
viewport = page.evaluate(
"() => ({ width: window.innerWidth, height: window.innerHeight })"
)
# window; page.viewport_size is then None. Read the live window dimensions
# through the isolated world, consistent with the other geometry reads here.
world = getattr(page, "_stealth_world", None)
if world is not None:
try:
viewport = world.evaluate(_VIEWPORT_JS)
except Exception:
viewport = None
if not viewport:
viewport = page.evaluate(
"() => ({ width: window.innerWidth, height: window.innerHeight })"
)
if not viewport or not viewport.get("height"):
raise RuntimeError("Viewport size not available")
+37 -6
View File
@@ -6,13 +6,16 @@ Mirrors scroll.py but uses ``await`` for all Playwright calls and
from __future__ import annotations
import asyncio
import math
import random
import time
from typing import Any, Awaitable, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport
from .stealth_dom import build_box_js, async_eval_parsed, OK, NOT_FOUND, UNSUPPORTED, _VIEWPORT_JS
async def _get_element_box_async(
@@ -20,7 +23,28 @@ async def _get_element_box_async(
) -> Optional[dict]:
"""Async variant. ``timeout`` is forwarded to Playwright's
``boundingBox(timeout=...)`` so callers can extend it for slow-loading
elements (#172)."""
elements (#172).
Reads geometry through the isolated world when available; a not-found is
retried briefly in-world (SPA re-renders) and only an *unsupported* selector
reaches Playwright's ``bounding_box``."""
world = getattr(page, "_stealth_world", None)
if world is not None:
status, data = await async_eval_parsed(world, build_box_js(selector))
if status == OK:
return data["box"]
if status == NOT_FOUND:
deadline = time.monotonic() + min(timeout, 2000) / 1000.0
while time.monotonic() < deadline:
await asyncio.sleep(0.05)
status, data = await async_eval_parsed(world, build_box_js(selector))
if status == OK:
return data["box"]
if status == UNSUPPORTED:
break
if status != UNSUPPORTED:
return None
# UNSUPPORTED -> Playwright fallback below
try:
el = page.locator(selector).first
return await el.bounding_box(timeout=max(1, timeout))
@@ -61,11 +85,18 @@ async def async_human_scroll_into_view(
viewport = page.viewport_size
if not viewport:
# Headed launches default to no_viewport so the page tracks the real OS
# window; page.viewport_size is then None. Fall back to the live window
# dimensions so humanize works headed (the stealth-relevant mode).
viewport = await page.evaluate(
"() => ({ width: window.innerWidth, height: window.innerHeight })"
)
# window; page.viewport_size is then None. Read the live window dimensions
# through the isolated world, consistent with the other geometry reads here.
world = getattr(page, "_stealth_world", None)
if world is not None:
try:
viewport = await world.evaluate(_VIEWPORT_JS)
except Exception:
viewport = None
if not viewport:
viewport = await page.evaluate(
"() => ({ width: window.innerWidth, height: window.innerHeight })"
)
if not viewport or not viewport.get("height"):
raise RuntimeError("Viewport size not available")
+219
View File
@@ -0,0 +1,219 @@
"""Isolated-world DOM reads for the humanize layer.
The pre-click helpers (``ensure_actionable``, ``scroll_to_element`` geometry,
``check_pointer_events``) read element state and geometry. This module performs
those reads inside the CDP isolated execution context (``page._stealth_world``,
created via ``Page.createIsolatedWorld``) rather than through Playwright's
selector/evaluate machinery.
The isolated world resolves elements with plain DOM APIs (``document.querySelector``
etc.), so this module reimplements the subset of Playwright's selector grammar the
humanize layer commonly receives:
* plain CSS / ``css=`` — including the ``:has-text("...")`` pseudo
* ``text=`` engine (quoted = exact, unquoted = case-insensitive substring)
* ``xpath=`` / leading ``//``
* a trailing ``>> nth=N`` (what ``.first`` / ``.nth(k)`` / ``.last`` append)
Anything richer (``>>`` chaining, ``internal:*`` engines from ``get_by_*``,
``:visible``, ``:nth-match``, layout pseudos, …) returns ``unsupported`` so the
caller keeps using the regular Playwright read for that call. Correctness wins on
tie: a mis-resolved element yields wrong coordinates, so uncertain grammar always
defers to Playwright rather than guessing.
The JS builders and :func:`parse_result` are pure and shared by the sync and
async call sites; only the ``world.evaluate`` await differs.
"""
from __future__ import annotations
import json
from typing import Any, Optional, Tuple
# Status returned to callers.
OK = "ok"
NOT_FOUND = "not_found"
UNSUPPORTED = "unsupported"
# ---------------------------------------------------------------------------
# JS: selector resolution inside the isolated world
# ---------------------------------------------------------------------------
# Defines ``__resolve(sel)`` returning the matched Element, ``null`` (no match),
# or the string ``'UNSUPPORTED'`` (grammar we don't reimplement). ``__SEL`` is
# inlined by the Python builders below.
_RESOLVER_BODY = r"""
const __UNS = 'UNSUPPORTED';
function __normWS(s){ return (s || '').replace(/\s+/g, ' ').trim(); }
function __byXPath(xp){
try {
const r = document.evaluate(xp, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const out = [];
for (let i = 0; i < r.snapshotLength; i++) { out.push(r.snapshotItem(i)); }
return out;
} catch (e) { return __UNS; }
}
function __matchText(el, needle, exact){
const t = __normWS(el.textContent);
if (exact) return t === needle;
return t.toLowerCase().includes(String(needle).toLowerCase());
}
function __byText(arg){
arg = arg.trim();
let exact = false, needle = arg;
const q = arg.charAt(0);
if ((q === '"' || q === "'") && arg.charAt(arg.length - 1) === q) { exact = true; needle = arg.slice(1, -1); }
needle = __normWS(needle);
const matches = [];
const all = document.querySelectorAll('*');
for (const el of all) { if (__matchText(el, needle, exact)) matches.push(el); }
// Playwright's text engine targets the smallest matching element: drop any
// element that has a descendant which also matches.
return matches.filter(el => !matches.some(o => o !== el && el.contains(o)));
}
function __extractHasText(css){
// Only quoted string args are supported; a regex arg (/re/) or bare arg is not.
if (/:has-text\(\s*[^'"]/.test(css)) return __UNS;
const texts = [];
const out = css.replace(/:has-text\(\s*(['"])([\s\S]*?)\1\s*\)/g, function(w, q, inner){ texts.push(__normWS(inner)); return ''; });
if (out.indexOf(':has-text') !== -1) return __UNS;
return { css: out.trim() || '*', texts: texts };
}
function __resolve(sel){
sel = sel.trim();
// Trailing ">> nth=N" (.first => nth=0, .last => nth=-1, .nth(k) => nth=k).
let nth = 0, hasNth = false;
const m = sel.match(/^([\s\S]*?)\s*>>\s*nth=(-?\d+)\s*$/);
if (m) { sel = m[1].trim(); nth = parseInt(m[2], 10); hasNth = true; }
if (sel.indexOf('>>') !== -1) return __UNS; // chaining we don't reimplement
if (sel.indexOf('internal:') !== -1) return __UNS; // get_by_* engines
let list;
if (sel.indexOf('xpath=') === 0) { list = __byXPath(sel.slice(6)); }
else if (sel.indexOf('//') === 0 || sel.indexOf('(//') === 0 || sel.indexOf('..') === 0) { list = __byXPath(sel); }
else if (sel.indexOf('text=') === 0) { list = __byText(sel.slice(5)); }
else {
let css = (sel.indexOf('css=') === 0) ? sel.slice(4) : sel;
const ht = __extractHasText(css);
if (ht === __UNS) return __UNS;
try { list = Array.prototype.slice.call(document.querySelectorAll(ht.css)); }
catch (e) { return __UNS; }
if (ht.texts.length) {
list = list.filter(function(el){
const t = __normWS(el.textContent).toLowerCase();
return ht.texts.every(function(x){ return t.includes(x.toLowerCase()); });
});
}
}
if (list === __UNS) return __UNS;
if (!list || !list.length) return null;
let idx = hasNth ? (nth < 0 ? list.length + nth : nth) : 0;
if (idx < 0 || idx >= list.length) return null;
return list[idx];
}
"""
# ---------------------------------------------------------------------------
# JS: per-operation reads (each returns {r: 'ok'|'not_found'|'unsupported', ...})
# ---------------------------------------------------------------------------
_BOX_OP = r"""
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __rc = __el.getBoundingClientRect();
// Playwright's bounding_box returns null for elements with no box (display:none).
if (__rc.width === 0 && __rc.height === 0 && __rc.x === 0 && __rc.y === 0) return { r: 'not_found' };
return { r: 'ok', box: { x: __rc.x, y: __rc.y, width: __rc.width, height: __rc.height } };
"""
_ACTIONABLE_OP = r"""
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __st = getComputedStyle(__el);
const __rc = __el.getBoundingClientRect();
const __visible = __st.visibility !== 'hidden' && __st.display !== 'none' && (__rc.width > 0 || __rc.height > 0);
const __tag = __el.tagName.toLowerCase();
const __enabled = !(__el.disabled === true || __el.getAttribute('aria-disabled') === 'true');
const __editable = __enabled && !__el.readOnly &&
(__tag === 'input' || __tag === 'textarea' || __tag === 'select' || __el.isContentEditable === true);
return { r: 'ok', visible: __visible, enabled: __enabled, editable: __editable };
"""
_VIEWPORT_JS = "(() => ({ width: window.innerWidth, height: window.innerHeight }))()"
def _wrap(selector: str, op: str) -> str:
"""Assemble a full isolated-world expression: resolver + one op."""
return (
"(() => {\n"
"const __SEL = " + json.dumps(selector) + ";\n"
+ _RESOLVER_BODY + "\n" + op + "\n})()"
)
def build_box_js(selector: str) -> str:
"""JS reading the element's bounding box (getBoundingClientRect, viewport-space)."""
return _wrap(selector, _BOX_OP)
def build_actionable_js(selector: str) -> str:
"""JS reading visible/enabled/editable for the element."""
return _wrap(selector, _ACTIONABLE_OP)
def build_pointer_js(selector: str, x: float, y: float) -> str:
"""JS hit-testing elementFromPoint(x, y) against the resolved element.
``x``/``y`` are viewport coordinates (same space as getBoundingClientRect and
the CDP mouse), so no iframe offset is needed — the isolated world runs in the
main frame's document.
"""
op = (
"const __el = __resolve(__SEL);\n"
"if (__el === 'UNSUPPORTED') return { r: 'unsupported' };\n"
"if (!__el) return { r: 'not_found' };\n"
"const __t = document.elementFromPoint(" + repr(float(x)) + ", " + repr(float(y)) + ");\n"
"if (!__t) return { r: 'ok', hit: false, covering: 'none' };\n"
"let __n = __t;\n"
"while (__n) { if (__n === __el) return { r: 'ok', hit: true }; __n = __n.parentNode; }\n"
"if (__el.contains(__t)) return { r: 'ok', hit: true };\n"
"return { r: 'ok', hit: false, covering: __t.tagName || 'unknown' };\n"
)
return _wrap(selector, op)
def parse_result(raw: Any) -> Tuple[str, Optional[dict]]:
"""Normalize an isolated-world evaluate() result into (status, data).
``world.evaluate`` returns ``None`` on any CDP error / JS exception, so a
non-dict result is treated as ``unsupported`` (fall back to Playwright) —
never as ``not_found`` (which would suppress a real element).
"""
if not isinstance(raw, dict):
return (UNSUPPORTED, None)
r = raw.get("r")
if r == OK:
return (OK, raw)
if r == NOT_FOUND:
return (NOT_FOUND, None)
return (UNSUPPORTED, None)
def eval_parsed(world: Any, expression: str) -> Tuple[str, Optional[dict]]:
"""Evaluate ``expression`` in the world and parse it. Any exception from the
world (e.g. the CDP session/context can't be created) is treated as
``unsupported`` so the caller falls back to the regular Playwright read rather
than propagating the error out of the humanized action."""
try:
return parse_result(world.evaluate(expression))
except Exception:
return (UNSUPPORTED, None)
async def async_eval_parsed(world: Any, expression: str) -> Tuple[str, Optional[dict]]:
"""Async variant of :func:`eval_parsed`."""
try:
return parse_result(await world.evaluate(expression))
except Exception:
return (UNSUPPORTED, None)
+99 -24
View File
@@ -6,6 +6,10 @@
*/
import type { Page, Frame, ElementHandle } from 'playwright-core';
import {
buildActionableJs, buildBoxJs, buildPointerJs, evalParsed, getWorld,
OK, NOT_FOUND, UNSUPPORTED, type StealthWorld,
} from './stealthDom.js';
// ---------------------------------------------------------------------------
// Error hierarchy
@@ -90,6 +94,58 @@ function backoffSleep(attempt: number): Promise<void> {
// Pre-scroll actionability
// ---------------------------------------------------------------------------
/**
* Run the actionability checks through the isolated world.
*
* Returns true when handled (throwing the specific ActionabilityError on a failed
* check), or false when the selector/world is unsupported so the caller falls back
* to the regular Playwright read.
*/
async function stealthActionable(
pageOrFrame: Page | Frame,
selector: string,
checks: ReadonlySet<CheckName>,
): Promise<boolean> {
const world = getWorld(pageOrFrame);
if (!world) return false;
const { status, data } = await evalParsed(world, buildActionableJs(selector));
if (status === UNSUPPORTED) return false;
if (status === NOT_FOUND) {
// Every check-set includes 'attached'; not present yet -> throw so the retry
// loop backs off and re-reads in-world (mirrors waitFor({ state: 'attached' })).
throw new ElementNotAttachedError(selector);
}
if (checks.has('visible') && !data.visible) throw new ElementNotVisibleError(selector);
if (checks.has('enabled') && !data.enabled) throw new ElementNotEnabledError(selector);
if (checks.has('editable') && !data.editable) throw new ElementNotEditableError(selector);
return true;
}
/**
* Bounding box via the isolated world, falling back to Playwright. Returns the box,
* or null when the element is not present. Only an unsupported selector (or missing
* world) reaches Playwright's `boundingBox`; a genuine not-found stays in-world.
*/
async function readBox(
pageOrFrame: Page | Frame,
selector: string,
remainingMs: number,
): Promise<{ x: number; y: number; width: number; height: number } | null> {
const world = getWorld(pageOrFrame);
if (world) {
const { status, data } = await evalParsed(world, buildBoxJs(selector));
if (status === OK) return data.box;
if (status === NOT_FOUND) return null;
// UNSUPPORTED -> Playwright below
}
try {
const loc = pageOrFrame.locator(selector).first();
return await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
} catch {
return null;
}
}
export async function ensureActionable(
pageOrFrame: Page | Frame,
selector: string,
@@ -111,26 +167,30 @@ export async function ensureActionable(
}
try {
const loc = pageOrFrame.locator(selector).first();
// Prefer the isolated-world read; fall back to Playwright's locator
// predicates only for selector grammar the isolated world can't resolve.
if (!await stealthActionable(pageOrFrame, selector, checks)) {
const loc = pageOrFrame.locator(selector).first();
if (checks.has('attached')) {
try {
await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotAttachedError(selector);
if (checks.has('attached')) {
try {
await loc.waitFor({ state: 'attached', timeout: Math.max(1, Math.min(remainingMs, 2000)) });
} catch {
throw new ElementNotAttachedError(selector);
}
}
}
if (checks.has('visible')) {
if (!await loc.isVisible()) throw new ElementNotVisibleError(selector);
}
if (checks.has('visible')) {
if (!await loc.isVisible()) throw new ElementNotVisibleError(selector);
}
if (checks.has('enabled')) {
if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector);
}
if (checks.has('enabled')) {
if (!await loc.isEnabled()) throw new ElementNotEnabledError(selector);
}
if (checks.has('editable')) {
if (!await loc.isEditable()) throw new ElementNotEditableError(selector);
if (checks.has('editable')) {
if (!await loc.isEditable()) throw new ElementNotEditableError(selector);
}
}
return;
@@ -175,13 +235,12 @@ export async function ensureStable(
const remainingMs = Math.max(0, deadline - Date.now());
if (remainingMs <= 0) throw new ElementNotStableError(selector);
const loc = pageOrFrame.locator(selector).first();
const box1 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
const box1 = await readBox(pageOrFrame, selector, remainingMs);
if (!box1) throw new ElementNotAttachedError(selector);
await new Promise(r => setTimeout(r, 100));
const box2 = await loc.boundingBox({ timeout: Math.max(1, Math.min(remainingMs, 1000)) });
const box2 = await readBox(pageOrFrame, selector, remainingMs);
if (!box2) throw new ElementNotAttachedError(selector);
if (!boxesDiffer(box1, box2)) return;
@@ -234,13 +293,29 @@ export async function checkPointerEvents(
let lastMiss: string | null = null;
while (true) {
// Isolated-world hit test; the passed-in `stealth` world is reused, falling
// back to Playwright only for unsupported selectors.
const world: StealthWorld | null = stealth ?? getWorld(pageOrFrame);
let result: any = null;
try {
const loc = pageOrFrame.locator(selector).first();
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
} catch {
result = null;
let handled = false;
if (world) {
const { status, data } = await evalParsed(world, buildPointerJs(selector, x, y));
if (status === OK) {
result = { hit: !!data.hit, covering: data.covering ?? 'unknown' };
handled = true;
} else if (status === NOT_FOUND) {
result = null; // indeterminate -> proceed (fail-open)
handled = true;
}
}
if (!handled) {
try {
const loc = pageOrFrame.locator(selector).first();
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
} catch {
result = null;
}
}
// An indeterminate result fails open — failing closed would block legitimate
+24
View File
@@ -710,6 +710,30 @@ function patchSingleFrame(
originals: any,
stealth: StealthEval,
): void {
// The main frame's Locator actions (page.locator(sel).click() etc.) delegate,
// in Playwright, to the main frame's own method. Route those to the already-
// patched page-level methods so they use the full humanized path with the
// isolated-world pre-click reads. Only true sub-frames use the frame-scoped
// path below (iterFrames() includes the main frame). Mirrors the Python
// wrapper, which intercepts at Locator.click and routes to page.click.
if (frame === page.mainFrame()) {
const p = page as any;
(frame as any).click = (selector: string, options?: HumanActionOptions) => p.click(selector, options);
(frame as any).dblclick = (selector: string, options?: HumanActionOptions) => p.dblclick(selector, options);
(frame as any).hover = (selector: string, options?: HumanActionOptions) => p.hover(selector, options);
(frame as any).type = (selector: string, text: string, options?: HumanActionOptions) => p.type(selector, text, options);
(frame as any).fill = (selector: string, value: string, options?: HumanActionOptions) => p.fill(selector, value, options);
(frame as any).check = (selector: string, options?: HumanActionOptions) => p.check(selector, options);
(frame as any).uncheck = (selector: string, options?: HumanActionOptions) => p.uncheck(selector, options);
(frame as any).selectOption = (selector: string, values: any, options?: HumanActionOptions) => p.selectOption(selector, values, options);
(frame as any).press = (selector: string, key: string, options?: HumanActionOptions) => p.press(selector, key, options);
(frame as any).pressSequentially = (selector: string, text: string, options?: HumanActionOptions) => p.pressSequentially(selector, text, options);
(frame as any).tap = (selector: string, options?: HumanActionOptions) => p.tap(selector, options);
(frame as any).clear = (selector: string, options?: HumanActionOptions) => p.clear(selector, options);
// dragAndDrop has no humanized page equivalent; left on the native path (still detectable, out of scope).
return;
}
// Save originals for methods that need fallback
const origFrameClick = frame.click.bind(frame);
const origFrameDblclick = frame.dblclick.bind(frame);
+32 -4
View File
@@ -5,6 +5,7 @@
import type { Page } from 'playwright-core';
import { HumanConfig, rand, randRange, randIntRange, sleep } from './config.js';
import { RawMouse, humanMove } from './mouse.js';
import { buildBoxJs, evalParsed, getWorld, OK, NOT_FOUND, UNSUPPORTED, VIEWPORT_JS } from './stealthDom.js';
interface ElementBounds {
x: number;
@@ -58,9 +59,17 @@ export async function humanScrollIntoView(
// dimensions so humanize works headed (the stealth-relevant mode).
let viewport = page.viewportSize();
if (!viewport) {
viewport = await page.evaluate(
() => ({ width: window.innerWidth, height: window.innerHeight }),
);
// Read the live window dimensions through the isolated world, consistent with
// the other geometry reads here.
const world = getWorld(page);
if (world) {
try { viewport = await world.evaluate(VIEWPORT_JS); } catch { /* fall back below */ }
}
if (!viewport) {
viewport = await page.evaluate(
() => ({ width: window.innerWidth, height: window.innerHeight }),
);
}
}
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
@@ -175,11 +184,30 @@ export async function scrollToElement(
);
}
async function getElementBox(
export async function getElementBox(
page: Page,
selector: string,
timeout: number = 30000,
): Promise<ElementBounds | null> {
// Read geometry through the isolated world when available; a not-found is retried
// briefly in-world (SPA re-renders) and only an unsupported selector reaches
// Playwright's boundingBox.
const world = getWorld(page);
if (world) {
let { status, data } = await evalParsed(world, buildBoxJs(selector));
if (status === OK) return data.box;
if (status === NOT_FOUND) {
const deadline = Date.now() + Math.min(timeout, 2000);
while (Date.now() < deadline) {
await sleep(50);
({ status, data } = await evalParsed(world, buildBoxJs(selector)));
if (status === OK) return data.box;
if (status === UNSUPPORTED) break;
}
if (status !== UNSUPPORTED) return null;
}
// UNSUPPORTED -> Playwright fallback below
}
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
+204
View File
@@ -0,0 +1,204 @@
/**
* Isolated-world DOM reads for the humanize layer.
*
* The pre-click helpers (`ensureActionable`, `scrollToElement` geometry,
* `checkPointerEvents`) read element state and geometry. This module performs
* those reads inside the CDP isolated execution context (`(page as any)._stealth`,
* a `StealthEval` created via `Page.createIsolatedWorld`) rather than through
* Playwright's selector/evaluate machinery.
*
* The isolated world resolves elements with plain DOM APIs (`document.querySelector`
* etc.), so this module reimplements the subset of Playwright's selector grammar
* the humanize layer commonly receives:
*
* - plain CSS / `css=` — including the `:has-text("...")` pseudo
* - `text=` engine (quoted = exact, unquoted = case-insensitive substring)
* - `xpath=` / leading `//`
* - a trailing `>> nth=N` (what `.first()` / `.nth(k)` / `.last()` append)
*
* Anything richer (`>>` chaining, `internal:*` engines from `getByRole`/`getByText`,
* `:visible`, `:nth-match`, layout pseudos, …) returns `unsupported` so the caller
* keeps using the regular Playwright read for that call. Correctness wins on tie:
* a mis-resolved element yields wrong coordinates, so uncertain grammar always
* defers to Playwright rather than guessing.
*
* Mirror of the Python wrapper's `cloakbrowser/human/stealth_dom.py`; the resolver
* JavaScript string is identical across wrappers.
*/
export const OK = 'ok';
export const NOT_FOUND = 'not_found';
export const UNSUPPORTED = 'unsupported';
export interface StealthWorld {
evaluate(expression: string): Promise<any>;
}
/** The isolated-world evaluator attached to a Page (or null when absent). */
export function getWorld(pageOrFrame: unknown): StealthWorld | null {
const w = (pageOrFrame as any)?._stealth;
return w ?? null;
}
// Defines `__resolve(sel)` returning the matched Element, `null` (no match), or
// the string 'UNSUPPORTED' (grammar not reimplemented). `__SEL` is inlined below.
const RESOLVER_BODY = `
const __UNS = 'UNSUPPORTED';
function __normWS(s){ return (s || '').replace(/\\s+/g, ' ').trim(); }
function __byXPath(xp){
try {
const r = document.evaluate(xp, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const out = [];
for (let i = 0; i < r.snapshotLength; i++) { out.push(r.snapshotItem(i)); }
return out;
} catch (e) { return __UNS; }
}
function __matchText(el, needle, exact){
const t = __normWS(el.textContent);
if (exact) return t === needle;
return t.toLowerCase().includes(String(needle).toLowerCase());
}
function __byText(arg){
arg = arg.trim();
let exact = false, needle = arg;
const q = arg.charAt(0);
if ((q === '"' || q === "'") && arg.charAt(arg.length - 1) === q) { exact = true; needle = arg.slice(1, -1); }
needle = __normWS(needle);
const matches = [];
const all = document.querySelectorAll('*');
for (const el of all) { if (__matchText(el, needle, exact)) matches.push(el); }
return matches.filter(el => !matches.some(o => o !== el && el.contains(o)));
}
function __extractHasText(css){
if (/:has-text\\(\\s*[^'"]/.test(css)) return __UNS;
const texts = [];
const out = css.replace(/:has-text\\(\\s*(['"])([\\s\\S]*?)\\1\\s*\\)/g, function(w, q, inner){ texts.push(__normWS(inner)); return ''; });
if (out.indexOf(':has-text') !== -1) return __UNS;
return { css: out.trim() || '*', texts: texts };
}
function __resolve(sel){
sel = sel.trim();
let nth = 0, hasNth = false;
const m = sel.match(/^([\\s\\S]*?)\\s*>>\\s*nth=(-?\\d+)\\s*$/);
if (m) { sel = m[1].trim(); nth = parseInt(m[2], 10); hasNth = true; }
if (sel.indexOf('>>') !== -1) return __UNS;
if (sel.indexOf('internal:') !== -1) return __UNS;
let list;
if (sel.indexOf('xpath=') === 0) { list = __byXPath(sel.slice(6)); }
else if (sel.indexOf('//') === 0 || sel.indexOf('(//') === 0 || sel.indexOf('..') === 0) { list = __byXPath(sel); }
else if (sel.indexOf('text=') === 0) { list = __byText(sel.slice(5)); }
else {
let css = (sel.indexOf('css=') === 0) ? sel.slice(4) : sel;
const ht = __extractHasText(css);
if (ht === __UNS) return __UNS;
try { list = Array.prototype.slice.call(document.querySelectorAll(ht.css)); }
catch (e) { return __UNS; }
if (ht.texts.length) {
list = list.filter(function(el){
const t = __normWS(el.textContent).toLowerCase();
return ht.texts.every(function(x){ return t.includes(x.toLowerCase()); });
});
}
}
if (list === __UNS) return __UNS;
if (!list || !list.length) return null;
let idx = hasNth ? (nth < 0 ? list.length + nth : nth) : 0;
if (idx < 0 || idx >= list.length) return null;
return list[idx];
}
`;
const BOX_OP = `
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __rc = __el.getBoundingClientRect();
if (__rc.width === 0 && __rc.height === 0 && __rc.x === 0 && __rc.y === 0) return { r: 'not_found' };
return { r: 'ok', box: { x: __rc.x, y: __rc.y, width: __rc.width, height: __rc.height } };
`;
const ACTIONABLE_OP = `
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __st = getComputedStyle(__el);
const __rc = __el.getBoundingClientRect();
const __visible = __st.visibility !== 'hidden' && __st.display !== 'none' && (__rc.width > 0 || __rc.height > 0);
const __tag = __el.tagName.toLowerCase();
const __enabled = !(__el.disabled === true || __el.getAttribute('aria-disabled') === 'true');
const __editable = __enabled && !__el.readOnly &&
(__tag === 'input' || __tag === 'textarea' || __tag === 'select' || __el.isContentEditable === true);
return { r: 'ok', visible: __visible, enabled: __enabled, editable: __editable };
`;
/** Live window dimensions, read in the isolated world (no_viewport headed mode). */
export const VIEWPORT_JS = '(() => ({ width: window.innerWidth, height: window.innerHeight }))()';
function wrap(selector: string, op: string): string {
return `(() => {\nconst __SEL = ${JSON.stringify(selector)};\n${RESOLVER_BODY}\n${op}\n})()`;
}
/** JS reading the element's bounding box (getBoundingClientRect, viewport-space). */
export function buildBoxJs(selector: string): string {
return wrap(selector, BOX_OP);
}
/** JS reading visible/enabled/editable for the element. */
export function buildActionableJs(selector: string): string {
return wrap(selector, ACTIONABLE_OP);
}
/**
* JS hit-testing elementFromPoint(x, y) against the resolved element. `x`/`y` are
* viewport coordinates (same space as getBoundingClientRect and the CDP mouse), so
* no iframe offset is needed — the isolated world runs in the main frame's document.
*/
export function buildPointerJs(selector: string, x: number, y: number): string {
const op = `
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __t = document.elementFromPoint(${Number(x)}, ${Number(y)});
if (!__t) return { r: 'ok', hit: false, covering: 'none' };
let __n = __t;
while (__n) { if (__n === __el) return { r: 'ok', hit: true }; __n = __n.parentNode; }
if (__el.contains(__t)) return { r: 'ok', hit: true };
return { r: 'ok', hit: false, covering: __t.tagName || 'unknown' };
`;
return wrap(selector, op);
}
export interface ParsedResult {
status: typeof OK | typeof NOT_FOUND | typeof UNSUPPORTED;
data?: any;
}
/**
* Normalize an isolated-world evaluate() result into { status, data }.
*
* `StealthEval.evaluate` returns `undefined` on any CDP error / JS exception, so a
* non-object result is treated as `unsupported` (fall back to Playwright) — never
* as `not_found` (which would suppress a real element).
*/
export function parseResult(raw: any): ParsedResult {
if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) {
return { status: UNSUPPORTED };
}
if (raw.r === OK) return { status: OK, data: raw };
if (raw.r === NOT_FOUND) return { status: NOT_FOUND };
return { status: UNSUPPORTED };
}
/**
* Evaluate `expression` in the world and parse the result. Any throw from the
* world (e.g. the CDP session/context can't be created) is treated as
* `unsupported` so the caller falls back to the regular Playwright read rather
* than propagating the error out of the humanized action.
*/
export async function evalParsed(world: StealthWorld, expression: string): Promise<ParsedResult> {
try {
return parseResult(await world.evaluate(expression));
} catch {
return { status: UNSUPPORTED };
}
}
+22
View File
@@ -446,6 +446,28 @@ describe("patchPage frame patching", () => {
expect(originalPageClick).not.toHaveBeenCalled();
});
it("main-frame click delegates to the humanized page.click (not the frame path)", async () => {
// Regression guard: page.locator(sel).click() reaches the MAIN frame's click,
// which must route to the humanized page.click (isolated-world pre-click reads),
// NOT the frame-scoped locator path that reads via Playwright and is detectable.
const { patchPage } = await import("../src/human/index.js");
const mainFrame = { ...buildMockFrame(), childFrames: vi.fn(() => []) };
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default", { mouse_min_steps: 1, mouse_max_steps: 1 });
const cursor = { x: 0, y: 0, initialized: true };
patchPage(page as any, cfg, cursor as any);
// Swap the humanized page.click for a spy, then drive the main frame's click.
const clickSpy = vi.fn(async () => {});
(page as any).click = clickSpy;
await (mainFrame as any).click("button.submit", { timeout: 1234 });
expect(clickSpy).toHaveBeenCalledWith("button.submit", { timeout: 1234 });
// must NOT fall through to the frame-scoped locator (the pre-fix leak path)
expect(mainFrame.locator).not.toHaveBeenCalled();
});
it.each([
["type", async (frame: any) => frame.type("input.email", "@")],
["fill", async (frame: any) => frame.fill("input.email", "@")],
+200
View File
@@ -0,0 +1,200 @@
import { describe, it, expect } from "vitest";
import {
buildBoxJs, buildActionableJs, buildPointerJs, parseResult,
OK, NOT_FOUND, UNSUPPORTED,
} from "../src/human/stealthDom.js";
import {
ensureActionable, checkPointerEvents, CHECKS_CLICK,
ElementNotVisibleError, ElementNotEnabledError, ElementNotAttachedError,
ElementNotReceivingEventsError,
} from "../src/human/actionability.js";
import { getElementBox } from "../src/human/scroll.js";
// ---------------------------------------------------------------------------
// Test doubles
// ---------------------------------------------------------------------------
/** Fake isolated world: returns a canned value (or per-expression callable). */
function fakeWorld(response: any) {
const calls: string[] = [];
return {
calls,
async evaluate(expr: string) {
calls.push(expr);
return typeof response === "function" ? response(expr) : response;
},
};
}
/** A page whose .locator throws — proves the world handled the read (no Playwright). */
function noLocatorPage(world: any) {
return {
_stealth: world,
locator() { throw new Error("Playwright locator must not be used when the world handles the read"); },
} as any;
}
/** Minimal DOM element stub for running the shipped resolver JS under Node. */
function el(tag: string, text = "", kids: any[] = []) {
const e: any = {
tagName: tag, textContent: text, children: kids,
disabled: false, readOnly: false, isContentEditable: false,
getAttribute: () => null,
};
e.contains = (o: any) => o === e || (e.children || []).some((c: any) => c.contains && c.contains(o));
e.getBoundingClientRect = () => ({ x: 5, y: 6, width: 20, height: 10 });
return e;
}
/** Execute a shipped builder's JS expression against a recording DOM stub. */
function runBuilder(js: string, matches: any[], point?: any) {
const document = {
querySelectorAll: (_s: string) => matches.slice(),
evaluate: () => ({ snapshotLength: matches.length, snapshotItem: (i: number) => matches[i] }),
elementFromPoint: (_x: number, _y: number) => point ?? null,
};
const fn = new Function("document", "XPathResult", "getComputedStyle", "return " + js);
return fn(document, { ORDERED_NODE_SNAPSHOT_TYPE: 7 }, () => ({ visibility: "visible", display: "block" }));
}
// ---------------------------------------------------------------------------
// Builders + parseResult
// ---------------------------------------------------------------------------
describe("stealthDom builders", () => {
it("box JS escapes the selector and reads geometry", () => {
const js = buildBoxJs('a"b');
expect(js).toContain('"a\\"b"');
expect(js).toContain("getBoundingClientRect");
});
it("pointer JS inlines coordinates", () => {
expect(buildPointerJs("#x", 1.5, 2.5)).toContain("elementFromPoint(1.5, 2.5)");
});
it("parseResult maps statuses", () => {
expect(parseResult({ r: "ok", box: { x: 1 } })).toEqual({ status: OK, data: { r: "ok", box: { x: 1 } } });
expect(parseResult({ r: "not_found" })).toEqual({ status: NOT_FOUND });
expect(parseResult({ r: "unsupported" })).toEqual({ status: UNSUPPORTED });
// evaluate returns undefined on error -> unsupported, never not_found
expect(parseResult(undefined)).toEqual({ status: UNSUPPORTED });
expect(parseResult(null)).toEqual({ status: UNSUPPORTED });
expect(parseResult([])).toEqual({ status: UNSUPPORTED });
});
});
// ---------------------------------------------------------------------------
// Shipped resolver JS semantics (run under Node against DOM stubs)
// ---------------------------------------------------------------------------
describe("resolver semantics (shipped JS)", () => {
it(":has-text + trailing nth resolves and reads a box", () => {
const r = runBuilder(buildBoxJs("button:has-text('Submit') >> nth=0"), [el("BUTTON", "Submit"), el("BUTTON", "other")]);
expect(r.r).toBe("ok");
expect(r.box).toEqual({ x: 5, y: 6, width: 20, height: 10 });
});
it("unsupported grammar is reported", () => {
expect(runBuilder(buildBoxJs("internal:role=button"), []).r).toBe("unsupported");
expect(runBuilder(buildBoxJs("a >> b"), [el("A")]).r).toBe("unsupported");
});
it("genuine not-found", () => {
expect(runBuilder(buildBoxJs("button"), []).r).toBe("not_found");
});
it("actionable reads visibility/enabled", () => {
const r = runBuilder(buildActionableJs("button"), [el("BUTTON", "x")]);
expect(r.r).toBe("ok");
expect(r.visible).toBe(true);
expect(r.enabled).toBe(true);
});
it("pointer hit-test resolves against the element", () => {
const target = el("BUTTON", "x");
const r = runBuilder(buildPointerJs("button", 5, 5), [target], target);
expect(r).toEqual({ r: "ok", hit: true });
});
});
// ---------------------------------------------------------------------------
// Rewired helpers: world-handled path must never touch Playwright
// ---------------------------------------------------------------------------
describe("ensureActionable via isolated world", () => {
it("ok returns without Playwright", async () => {
const world = fakeWorld({ r: "ok", visible: true, enabled: true, editable: true });
await ensureActionable(noLocatorPage(world), "#x", CHECKS_CLICK, 100);
expect(world.calls.length).toBe(1);
});
it("not visible throws", async () => {
const page = noLocatorPage(fakeWorld({ r: "ok", visible: false, enabled: true, editable: true }));
await expect(ensureActionable(page, "#x", CHECKS_CLICK, 100)).rejects.toBeInstanceOf(ElementNotVisibleError);
});
it("disabled throws", async () => {
const page = noLocatorPage(fakeWorld({ r: "ok", visible: true, enabled: false, editable: true }));
await expect(ensureActionable(page, "#x", CHECKS_CLICK, 100)).rejects.toBeInstanceOf(ElementNotEnabledError);
});
it("not_found throws attached", async () => {
const page = noLocatorPage(fakeWorld({ r: "not_found" }));
await expect(ensureActionable(page, "#x", CHECKS_CLICK, 100)).rejects.toBeInstanceOf(ElementNotAttachedError);
});
it("unsupported falls back to Playwright", async () => {
const loc = { first: () => ({
waitFor: async () => {}, isVisible: async () => true, isEnabled: async () => true, isEditable: async () => true,
}) };
let called = false;
const page: any = { _stealth: fakeWorld({ r: "unsupported" }), locator: () => { called = true; return loc; } };
await ensureActionable(page, "internal:role=button", CHECKS_CLICK, 100);
expect(called).toBe(true);
});
});
describe("getElementBox via isolated world", () => {
it("ok returns box without Playwright", async () => {
const box = { x: 10, y: 20, width: 30, height: 40 };
expect(await getElementBox(noLocatorPage(fakeWorld({ r: "ok", box })), "#x")).toEqual(box);
});
it("not_found stays in-world and returns null", async () => {
expect(await getElementBox(noLocatorPage(fakeWorld({ r: "not_found" })), "#x", 100)).toBeNull();
});
it("unsupported falls back to Playwright", async () => {
const box = { x: 1, y: 2, width: 3, height: 4 };
let called = false;
const page: any = {
_stealth: fakeWorld({ r: "unsupported" }),
locator: () => { called = true; return { first: () => ({ boundingBox: async () => box }) }; },
};
expect(await getElementBox(page, "internal:role=button")).toEqual(box);
expect(called).toBe(true);
});
});
describe("checkPointerEvents via isolated world", () => {
it("hit returns", async () => {
const world = fakeWorld({ r: "ok", hit: true });
await checkPointerEvents(noLocatorPage(world), "#x", 5, 5, world, 200);
});
it("miss throws", async () => {
const world = fakeWorld({ r: "ok", hit: false, covering: "DIV" });
await expect(checkPointerEvents(noLocatorPage(world), "#x", 5, 5, world, 200))
.rejects.toBeInstanceOf(ElementNotReceivingEventsError);
});
it("unsupported falls back to Playwright", async () => {
const world = fakeWorld({ r: "unsupported" });
let called = false;
const page: any = { _stealth: world, locator: () => { called = true; return { first: () => ({
boundingBox: async () => ({ x: 0, y: 0, width: 10, height: 10 }), evaluate: async () => ({ hit: true }),
}) }; } };
await checkPointerEvents(page, "internal:role=button", 5, 5, world, 200);
expect(called).toBe(true);
});
});
+184
View File
@@ -2338,6 +2338,190 @@ class TestPointerEventsFailOpen:
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
# =========================================================================
# Isolated-world DOM helper (stealth_dom) + rewired actionability/scroll
# =========================================================================
class _FakeWorld:
"""Sync stand-in for page._stealth_world. Returns a canned dict (or a
per-expression callable) and records the expressions it was asked to run."""
def __init__(self, response):
self.response = response
self.calls = []
def evaluate(self, expr):
self.calls.append(expr)
return self.response(expr) if callable(self.response) else self.response
class _AsyncFakeWorld:
def __init__(self, response):
self.response = response
self.calls = []
async def evaluate(self, expr):
self.calls.append(expr)
return self.response(expr) if callable(self.response) else self.response
def _no_locator_page(world):
"""A page whose .locator explodes — proves the isolated world handled the
read and Playwright was never touched (the whole point of the change)."""
page = MagicMock()
page._stealth_world = world
page.locator = MagicMock(side_effect=AssertionError("Playwright locator must not be used when the isolated world handles the read"))
return page
class TestStealthDomBuilders:
def test_box_js_escapes_selector(self):
from cloakbrowser.human.stealth_dom import build_box_js
js = build_box_js('a"b')
assert '"a\\"b"' in js
assert "getBoundingClientRect" in js
def test_actionable_js_reads_visibility(self):
from cloakbrowser.human.stealth_dom import build_actionable_js
js = build_actionable_js("#x")
assert '"#x"' in js
assert "visible" in js and "getComputedStyle" in js
def test_pointer_js_inlines_coords(self):
from cloakbrowser.human.stealth_dom import build_pointer_js
js = build_pointer_js("#x", 1.5, 2.5)
assert "elementFromPoint(1.5, 2.5)" in js
def test_parse_result(self):
from cloakbrowser.human.stealth_dom import parse_result, OK, NOT_FOUND, UNSUPPORTED
assert parse_result({"r": "ok", "box": {"x": 1}}) == (OK, {"r": "ok", "box": {"x": 1}})
assert parse_result({"r": "not_found"}) == (NOT_FOUND, None)
assert parse_result({"r": "unsupported"}) == (UNSUPPORTED, None)
# world.evaluate returns None on CDP/JS error -> unsupported, never not_found
assert parse_result(None) == (UNSUPPORTED, None)
assert parse_result("UNSUPPORTED") == (UNSUPPORTED, None)
assert parse_result([]) == (UNSUPPORTED, None)
class TestEnsureActionableStealth:
def test_ok_returns_without_playwright(self):
from cloakbrowser.human.actionability import ensure_actionable, CHECKS_CLICK
world = _FakeWorld({"r": "ok", "visible": True, "enabled": True, "editable": True})
page = _no_locator_page(world)
ensure_actionable(page, "#x", CHECKS_CLICK, timeout=100)
assert len(world.calls) == 1 # single in-world read, no locator fallback
def test_not_visible_raises(self):
from cloakbrowser.human.actionability import ensure_actionable, CHECKS_CLICK, ElementNotVisibleError
page = _no_locator_page(_FakeWorld({"r": "ok", "visible": False, "enabled": True, "editable": True}))
with pytest.raises(ElementNotVisibleError):
ensure_actionable(page, "#x", CHECKS_CLICK, timeout=100)
def test_disabled_raises(self):
from cloakbrowser.human.actionability import ensure_actionable, CHECKS_CLICK, ElementNotEnabledError
page = _no_locator_page(_FakeWorld({"r": "ok", "visible": True, "enabled": False, "editable": True}))
with pytest.raises(ElementNotEnabledError):
ensure_actionable(page, "#x", CHECKS_CLICK, timeout=100)
def test_not_found_raises_attached(self):
from cloakbrowser.human.actionability import ensure_actionable, CHECKS_CLICK, ElementNotAttachedError
page = _no_locator_page(_FakeWorld({"r": "not_found"}))
with pytest.raises(ElementNotAttachedError):
ensure_actionable(page, "#x", CHECKS_CLICK, timeout=100)
def test_unsupported_falls_back_to_playwright(self):
from cloakbrowser.human.actionability import ensure_actionable, CHECKS_CLICK
world = _FakeWorld({"r": "unsupported"})
page = MagicMock()
page._stealth_world = world
loc = MagicMock()
loc.wait_for = MagicMock()
loc.is_visible = MagicMock(return_value=True)
loc.is_enabled = MagicMock(return_value=True)
loc.is_editable = MagicMock(return_value=True)
page.locator = MagicMock(return_value=MagicMock(first=loc))
ensure_actionable(page, "internal:role=button", CHECKS_CLICK, timeout=100)
page.locator.assert_called() # unsupported grammar -> Playwright read
def test_no_world_uses_playwright(self):
from cloakbrowser.human.actionability import ensure_actionable, CHECKS_CLICK
page = MagicMock()
page._stealth_world = None
loc = MagicMock()
loc.wait_for = MagicMock()
loc.is_visible = MagicMock(return_value=True)
loc.is_enabled = MagicMock(return_value=True)
page.locator = MagicMock(return_value=MagicMock(first=loc))
ensure_actionable(page, "#x", CHECKS_CLICK, timeout=100)
page.locator.assert_called()
class TestGetElementBoxStealth:
def test_ok_returns_box_without_playwright(self):
from cloakbrowser.human.scroll import _get_element_box
box = {"x": 10.0, "y": 20.0, "width": 30.0, "height": 40.0}
page = _no_locator_page(_FakeWorld({"r": "ok", "box": box}))
assert _get_element_box(page, "#x") == box
def test_not_found_returns_none_stays_in_world(self):
from cloakbrowser.human.scroll import _get_element_box
page = _no_locator_page(_FakeWorld({"r": "not_found"}))
assert _get_element_box(page, "#x", timeout=100) is None
def test_unsupported_falls_back(self):
from cloakbrowser.human.scroll import _get_element_box
box = {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0}
page = MagicMock()
page._stealth_world = _FakeWorld({"r": "unsupported"})
loc = MagicMock()
loc.bounding_box = MagicMock(return_value=box)
page.locator = MagicMock(return_value=MagicMock(first=loc))
assert _get_element_box(page, "internal:role=button") == box
page.locator.assert_called()
class TestCheckPointerEventsStealth:
def test_hit_returns(self):
from cloakbrowser.human.actionability import check_pointer_events
world = _FakeWorld({"r": "ok", "hit": True})
page = _no_locator_page(world)
check_pointer_events(page, "#x", 5, 5, stealth=world, timeout=200)
def test_miss_raises(self):
from cloakbrowser.human.actionability import check_pointer_events, ElementNotReceivingEventsError
world = _FakeWorld({"r": "ok", "hit": False, "covering": "DIV"})
page = _no_locator_page(world)
with pytest.raises(ElementNotReceivingEventsError):
check_pointer_events(page, "#x", 5, 5, stealth=world, timeout=200)
def test_unsupported_falls_back(self):
from cloakbrowser.human.actionability import check_pointer_events
world = _FakeWorld({"r": "unsupported"})
page = MagicMock()
page._stealth_world = world
loc = MagicMock()
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10})
loc.evaluate = MagicMock(return_value={"hit": True})
page.locator = MagicMock(return_value=MagicMock(first=loc))
check_pointer_events(page, "internal:role=button", 5, 5, stealth=world, timeout=200)
page.locator.assert_called()
class TestStealthAsync:
def test_async_ensure_actionable_ok(self):
from cloakbrowser.human.actionability_async import async_ensure_actionable
from cloakbrowser.human.actionability import CHECKS_CLICK
world = _AsyncFakeWorld({"r": "ok", "visible": True, "enabled": True, "editable": True})
page = _no_locator_page(world)
asyncio.run(async_ensure_actionable(page, "#x", CHECKS_CLICK, timeout=100))
assert len(world.calls) == 1
def test_async_get_element_box_ok(self):
from cloakbrowser.human.scroll_async import _get_element_box_async
box = {"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0}
page = _no_locator_page(_AsyncFakeWorld({"r": "ok", "box": box}))
assert asyncio.run(_get_element_box_async(page, "#x")) == box
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
+102
View File
@@ -0,0 +1,102 @@
"""Resolver-JS selector-semantics tests.
``cloakbrowser/human/stealth_dom.py`` embeds a JavaScript selector resolver that
runs in the browser's isolated world. Its selector *semantics* (``:has-text``,
``text=``, ``xpath=``, trailing ``>> nth=N``, and the unsupported-grammar
fallback) can't be exercised from pure Python — they need a JS engine + DOM.
This test extracts the exact ``_RESOLVER_BODY`` string that ships and runs it
under Node against recording DOM stubs, asserting each selector routes to the
right engine / element (or reports ``unsupported``). Skipped when ``node`` is not
on PATH; CI has Node (the JS wrapper builds there).
"""
import shutil
import subprocess
import pytest
from cloakbrowser.human.stealth_dom import _RESOLVER_BODY
node = shutil.which("node")
pytestmark = pytest.mark.skipif(node is None, reason="node not on PATH")
# Harness: define the shipped resolver body, then drive __resolve() with a
# recording DOM. querySelectorAll/evaluate record their args and return
# caller-supplied matches, so we assert both the classification and the engine.
_HARNESS = r"""
const RESOLVER = %s;
function el(tag, text, kids){
const e = { tagName: tag, textContent: text || '', children: kids || [] };
e.contains = (o) => (o === e) || (e.children || []).some(c => c.contains && c.contains(o));
return e;
}
function run(sel, matches, xpathMatches){
const calls = { css: null, xpath: null };
const document = {
querySelectorAll(s){ calls.css = s; return (matches || []).slice(); },
evaluate(xp){ calls.xpath = xp; const m = xpathMatches || [];
return { snapshotLength: m.length, snapshotItem: i => m[i] }; },
};
const src = RESOLVER + `
return (function(){
const __SEL = ${JSON.stringify(sel)};
const el = __resolve(__SEL);
const cls = (el === 'UNSUPPORTED') ? 'unsupported' : (el === null ? 'not_found' : 'ok');
return { cls, text: el && el.textContent, calls };
})();`;
return new Function('document', 'calls', 'XPathResult', src)(document, calls, { ORDERED_NODE_SNAPSHOT_TYPE: 7 });
}
let fails = 0;
function eq(name, got, want){
if (got !== want){ fails++; console.log('FAIL ' + name + ' | got ' + JSON.stringify(got) + ' want ' + JSON.stringify(want)); }
}
// :has-text on CSS, with trailing nth=0 (.first)
let r = run("button:has-text('Submit') >> nth=0", [ el('BUTTON','Submit'), el('BUTTON','other') ]);
eq('has-text cls', r.cls, 'ok'); eq('has-text css engine', r.calls.css, 'button'); eq('has-text picks match', r.text, 'Submit');
// plain CSS + .first
r = run('#x .c >> nth=0', [ el('SPAN','hi') ]); eq('plain css cls', r.cls, 'ok'); eq('plain css engine', r.calls.css, '#x .c');
// chaining and get_by_* engines are unsupported
eq('chaining', run('a >> b', [el('A')]).cls, 'unsupported');
eq('internal role', run('internal:role=button', []).cls, 'unsupported');
// xpath (explicit prefix and leading //)
r = run('xpath=//button', [], [ el('BUTTON','x') ]); eq('xpath= cls', r.cls, 'ok'); eq('xpath= arg', r.calls.xpath, '//button');
eq('// route', run('//button', [], [ el('BUTTON','x') ]).cls, 'ok');
// text= engine picks the innermost matching element
let inner = el('SPAN','hi'); let outer = el('DIV','hi',[inner]);
r = run('text=hi', [ outer, inner ]); eq('text= cls', r.cls, 'ok'); eq('text= smallest', r.text, 'hi');
eq('text exact quoted case-sensitive miss', run('text="Hi"', [ el('DIV','hi') ]).cls, 'not_found');
// :has-text with a regex arg is not reimplemented
eq('has-text regex', run(':has-text(/re/)', [ el('DIV','x') ]).cls, 'unsupported');
// multiple :has-text clauses AND together
r = run("div:has-text('a'):has-text('b')", [ el('DIV','a and b'), el('DIV','only a') ]);
eq('multi has-text cls', r.cls, 'ok'); eq('multi has-text css', r.calls.css, 'div'); eq('multi has-text match', r.text, 'a and b');
// nth variants
eq('.last (nth=-1)', run('button >> nth=-1', [ el('BUTTON','1'), el('BUTTON','2') ]).text, '2');
eq('nth=1', run('button >> nth=1', [ el('BUTTON','1'), el('BUTTON','2') ]).text, '2');
// css= prefix + genuine not-found
eq('css= prefix engine', run('css=button', [ el('BUTTON','1') ]).calls.css, 'button');
eq('not found', run('button', []).cls, 'not_found');
if (fails) { console.log(fails + ' FAILED'); process.exit(1); }
console.log('ALL PASS');
"""
def test_resolver_selector_semantics():
import json
script = _HARNESS % json.dumps(_RESOLVER_BODY)
result = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30)
assert result.returncode == 0, f"resolver JS failed:\n{result.stdout}\n{result.stderr}"
assert "ALL PASS" in result.stdout