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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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", "@")],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user