mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat(humanize): add Playwright-style actionability checks (#228)
* feat(humanize): add Playwright-style actionability checks to all interaction methods
Humanized locator/page methods now perform pre-action validation matching
Playwright's native behavior: attached, visible, enabled, editable, stable,
and receives-pointer-events checks with retry loop and backoff.
- New error hierarchy: ActionabilityError base with ElementNotAttachedError,
ElementNotVisibleError, ElementNotStableError, ElementNotEnabledError,
ElementNotEditableError, ElementNotReceivingEventsError
- force=True parameter skips all actionability checks (matches Playwright)
- Shared deadline across all steps (checks + scroll + stable + pointer)
- Post-scroll stability check only runs when scroll actually happened
- Chained methods (type/fill/check/uncheck/press) skip inner click checks
but still run pointer-events check at actual click coordinates
- Frame methods now forward kwargs (force, timeout, human_config)
- Locator patches forward force via _forward_kwargs
- Python sync + async, JS/TS implementation
* fix(humanize): forward human_config in all chained methods, use evaluate args in handle pointer checks
- Add human_config=kwargs.get("human_config") to check/uncheck/select_option/press inner calls (sync+async+JS)
- Convert check_pointer_events_handle from f-string interpolation to evaluate args pattern (sync+async+JS)
* fix(humanize): strip custom kwargs before forwarding to Playwright select_option
originals.select_option(**kwargs) passes human_config/force to Playwright
which rejects unknown kwargs with TypeError.
This commit is contained in:
@@ -128,7 +128,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
|
||||
|
||||
---
|
||||
|
||||
## Latest: v0.3.26 (Chromium 146.0.7680.177.4)
|
||||
## Latest: v0.3.28 (Chromium 146.0.7680.177.4)
|
||||
|
||||
- **`launch_context_async()`** — async counterpart to `launch_context()`. Forwards kwargs to `browser.new_context()` for `storage_state`, `permissions`, `extra_http_headers` without a persistent profile folder.
|
||||
- **JS `contextOptions` escape hatch** — forward arbitrary options (including `storageState`) to Playwright's `newContext()` from `launchContext()` / `launchPersistentContext()`.
|
||||
|
||||
+398
-73
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
||||
"""Playwright-style actionability checks for the humanize layer (sync).
|
||||
|
||||
Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, FrozenSet, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error hierarchy — all subclass RuntimeError for backward compat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActionabilityError(RuntimeError):
|
||||
"""Base for all actionability failures."""
|
||||
|
||||
def __init__(self, selector: str, check: str, message: str):
|
||||
self.selector = selector
|
||||
self.check = check
|
||||
super().__init__(f"Element {selector!r} failed {check} check: {message}")
|
||||
|
||||
|
||||
class ElementNotAttachedError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "attached", "element not found in DOM")
|
||||
|
||||
|
||||
class ElementNotVisibleError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "visible", "element is not visible")
|
||||
|
||||
|
||||
class ElementNotStableError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "stable", "element position is still changing")
|
||||
|
||||
|
||||
class ElementNotEnabledError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "enabled", "element is disabled")
|
||||
|
||||
|
||||
class ElementNotEditableError(ActionabilityError):
|
||||
def __init__(self, selector: str):
|
||||
super().__init__(selector, "editable", "element is not editable")
|
||||
|
||||
|
||||
class ElementNotReceivingEventsError(ActionabilityError):
|
||||
def __init__(self, selector: str, covering_tag: str = "unknown"):
|
||||
super().__init__(
|
||||
selector,
|
||||
"pointer_events",
|
||||
f"element is covered by <{covering_tag}>",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-set constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHECKS_CLICK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
|
||||
CHECKS_HOVER: FrozenSet[str] = frozenset({"attached", "visible", "pointer_events"})
|
||||
CHECKS_INPUT: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "editable", "pointer_events"})
|
||||
CHECKS_FOCUS: FrozenSet[str] = frozenset({"attached", "visible", "enabled"})
|
||||
CHECKS_CHECK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"})
|
||||
|
||||
_BACKOFF_MS = [100, 250, 500, 1000]
|
||||
|
||||
|
||||
def _backoff_sleep(attempt: int) -> None:
|
||||
idx = min(attempt, len(_BACKOFF_MS) - 1)
|
||||
time.sleep(_BACKOFF_MS[idx] / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-scroll actionability: attached, visible, enabled, editable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_actionable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Wait for element to pass actionability checks (pre-scroll).
|
||||
|
||||
Retries with backoff until *timeout* ms elapsed.
|
||||
Raises a specific ``ActionabilityError`` subclass on failure.
|
||||
If *force* is True, returns immediately.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
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 "visible" in checks:
|
||||
if not loc.is_visible():
|
||||
raise ElementNotVisibleError(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)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-scroll stability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _boxes_differ(a: dict, b: dict) -> bool:
|
||||
return (
|
||||
abs(a["x"] - b["x"]) > 1
|
||||
or abs(a["y"] - b["y"]) > 1
|
||||
or abs(a["width"] - b["width"]) > 1
|
||||
or abs(a["height"] - b["height"]) > 1
|
||||
)
|
||||
|
||||
|
||||
def ensure_stable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Wait for element position to stabilize (two samples 100ms apart).
|
||||
|
||||
Only call after scroll — skip if element was already in viewport.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
loc = page.locator(selector).first
|
||||
box1 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box1 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
box2 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box2 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if not _boxes_differ(box1, box2):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pointer-events check (post-scroll, at actual click coordinates)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}"""
|
||||
|
||||
_POINTER_EVENTS_HANDLE_JS = """(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}"""
|
||||
|
||||
|
||||
def check_pointer_events(
|
||||
page: Any,
|
||||
selector: str,
|
||||
x: float,
|
||||
y: float,
|
||||
stealth: Any = None,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Check that elementFromPoint(x, y) hits the expected element.
|
||||
|
||||
Uses locator.evaluate() so all Playwright selector types work
|
||||
(text=, role=, XPath, CSS, etc.). Retries with backoff for transient overlays.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
|
||||
except Exception as exc:
|
||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError(selector, covering)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementHandle variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ensure_actionable_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Actionability checks for ElementHandle (no selector needed).
|
||||
|
||||
Uses Playwright's wait_for_element_state where available.
|
||||
"""
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
label = "<ElementHandle>"
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(label, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
if "visible" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotVisibleError(label)
|
||||
|
||||
if "enabled" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEnabledError(label)
|
||||
|
||||
if "editable" in checks:
|
||||
try:
|
||||
el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEditableError(label)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
def check_pointer_events_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
x: float,
|
||||
y: float,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
"""Pointer-events check for ElementHandle."""
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
|
||||
|
||||
_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Playwright-style actionability checks for the humanize layer (async).
|
||||
|
||||
Async mirror of actionability.py — same logic, uses asyncio.sleep and await.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, FrozenSet, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .actionability import (
|
||||
ActionabilityError,
|
||||
ElementNotAttachedError,
|
||||
ElementNotVisibleError,
|
||||
ElementNotStableError,
|
||||
ElementNotEnabledError,
|
||||
ElementNotEditableError,
|
||||
ElementNotReceivingEventsError,
|
||||
_BACKOFF_MS,
|
||||
_boxes_differ,
|
||||
_POINTER_EVENTS_LOCATOR_JS,
|
||||
_POINTER_EVENTS_HANDLE_JS,
|
||||
)
|
||||
|
||||
|
||||
async def _async_backoff_sleep(attempt: int) -> None:
|
||||
idx = min(attempt, len(_BACKOFF_MS) - 1)
|
||||
await asyncio.sleep(_BACKOFF_MS[idx] / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-scroll actionability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_actionable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(selector, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
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 "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 "editable" in checks:
|
||||
if not await loc.is_editable():
|
||||
raise ElementNotEditableError(selector)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-scroll stability check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_stable(
|
||||
page: Any,
|
||||
selector: str,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
loc = page.locator(selector).first
|
||||
box1 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box1 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
box2 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000)))
|
||||
if box2 is None:
|
||||
raise ElementNotAttachedError(selector)
|
||||
|
||||
if not _boxes_differ(box1, box2):
|
||||
return
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotStableError(selector)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pointer-events check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_check_pointer_events(
|
||||
page: Any,
|
||||
selector: str,
|
||||
x: float,
|
||||
y: float,
|
||||
stealth: Any = None,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
loc = page.locator(selector).first
|
||||
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
|
||||
except Exception as exc:
|
||||
logger.debug("pointer_events check failed for %r: %s", selector, exc)
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError(selector, covering)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ElementHandle variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def async_ensure_actionable_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
checks: FrozenSet[str],
|
||||
timeout: float = 30000,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
if force:
|
||||
return
|
||||
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
last_error: Optional[ActionabilityError] = None
|
||||
label = "<ElementHandle>"
|
||||
|
||||
while True:
|
||||
remaining_ms = max(0, (deadline - time.monotonic()) * 1000)
|
||||
if remaining_ms <= 0:
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise ActionabilityError(label, "timeout", "timeout expired before first check")
|
||||
|
||||
try:
|
||||
if "visible" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotVisibleError(label)
|
||||
|
||||
if "enabled" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEnabledError(label)
|
||||
|
||||
if "editable" in checks:
|
||||
try:
|
||||
await el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000)))
|
||||
except Exception:
|
||||
raise ElementNotEditableError(label)
|
||||
|
||||
return
|
||||
|
||||
except ActionabilityError as e:
|
||||
last_error = e
|
||||
if time.monotonic() >= deadline:
|
||||
raise last_error
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
|
||||
|
||||
async def async_check_pointer_events_handle(
|
||||
page: Any,
|
||||
el: Any,
|
||||
x: float,
|
||||
y: float,
|
||||
timeout: float = 5000,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout / 1000.0
|
||||
attempt = 0
|
||||
|
||||
coords = {"x": x, "y": y}
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
|
||||
except Exception:
|
||||
result = None
|
||||
|
||||
if result and result.get("hit", False):
|
||||
return
|
||||
|
||||
covering = (result or {}).get("covering", "unknown")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
raise ElementNotReceivingEventsError("<ElementHandle>", covering)
|
||||
|
||||
await _async_backoff_sleep(attempt)
|
||||
attempt += 1
|
||||
@@ -26,7 +26,7 @@ def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Option
|
||||
"""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return el.bounding_box(timeout=timeout)
|
||||
return el.bounding_box(timeout=max(1, timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -50,7 +50,7 @@ def human_scroll_into_view(
|
||||
get_box: Callable[[], Optional[dict]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
||||
instead of a CSS selector.
|
||||
|
||||
@@ -58,6 +58,9 @@ def human_scroll_into_view(
|
||||
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
|
||||
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
||||
behavior runs everywhere.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
|
||||
when the element was already in the viewport.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
@@ -71,7 +74,7 @@ def human_scroll_into_view(
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, False
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
@@ -139,7 +142,7 @@ def human_scroll_into_view(
|
||||
if box is None:
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, True
|
||||
|
||||
|
||||
def scroll_to_element(
|
||||
@@ -149,12 +152,14 @@ def scroll_to_element(
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Selector-based humanized scroll.
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||
"""
|
||||
return human_scroll_into_view(
|
||||
page, raw,
|
||||
|
||||
@@ -23,7 +23,7 @@ async def _get_element_box_async(
|
||||
elements (#172)."""
|
||||
try:
|
||||
el = page.locator(selector).first
|
||||
return await el.bounding_box(timeout=timeout)
|
||||
return await el.bounding_box(timeout=max(1, timeout))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -47,13 +47,16 @@ async def async_human_scroll_into_view(
|
||||
get_box: Callable[[], Awaitable[Optional[dict]]],
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
||||
|
||||
Used by both ``async_scroll_to_element`` (selector-based) and the
|
||||
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
||||
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
||||
\u2192 overshoot behavior.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False
|
||||
when the element was already in the viewport.
|
||||
"""
|
||||
viewport = page.viewport_size
|
||||
if not viewport:
|
||||
@@ -67,7 +70,7 @@ async def async_human_scroll_into_view(
|
||||
raise RuntimeError("Element not found while scrolling into view")
|
||||
|
||||
if _is_in_viewport(box, viewport_height, cfg):
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, False
|
||||
|
||||
# Move cursor into scroll area
|
||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||
@@ -135,7 +138,7 @@ async def async_human_scroll_into_view(
|
||||
if box is None:
|
||||
raise RuntimeError("Element lost after scrolling into view")
|
||||
|
||||
return box, cursor_x, cursor_y
|
||||
return box, cursor_x, cursor_y, True
|
||||
|
||||
|
||||
async def async_scroll_to_element(
|
||||
@@ -145,12 +148,14 @@ async def async_scroll_to_element(
|
||||
cursor_x: float, cursor_y: float,
|
||||
cfg: HumanConfig,
|
||||
timeout: float = 30000,
|
||||
) -> Tuple[dict, float, float]:
|
||||
) -> Tuple[dict, float, float, bool]:
|
||||
"""Selector-based humanized scroll (async).
|
||||
|
||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||
(#172). Default matches Playwright's 30000ms when not specified.
|
||||
|
||||
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||
"""
|
||||
async def _get():
|
||||
return await _get_element_box_async(page, selector, timeout)
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Playwright-style actionability checks for the humanize layer.
|
||||
*
|
||||
* Checks: attached, visible, stable, enabled, editable, receives pointer events.
|
||||
* Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms.
|
||||
*/
|
||||
|
||||
import type { Page, Frame, ElementHandle } from 'playwright-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ActionabilityError extends Error {
|
||||
selector: string;
|
||||
check: string;
|
||||
|
||||
constructor(selector: string, check: string, message: string) {
|
||||
super(`Element ${JSON.stringify(selector)} failed ${check} check: ${message}`);
|
||||
this.name = 'ActionabilityError';
|
||||
this.selector = selector;
|
||||
this.check = check;
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotAttachedError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'attached', 'element not found in DOM');
|
||||
this.name = 'ElementNotAttachedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotVisibleError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'visible', 'element is not visible');
|
||||
this.name = 'ElementNotVisibleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotStableError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'stable', 'element position is still changing');
|
||||
this.name = 'ElementNotStableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotEnabledError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'enabled', 'element is disabled');
|
||||
this.name = 'ElementNotEnabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotEditableError extends ActionabilityError {
|
||||
constructor(selector: string) {
|
||||
super(selector, 'editable', 'element is not editable');
|
||||
this.name = 'ElementNotEditableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementNotReceivingEventsError extends ActionabilityError {
|
||||
coveringTag: string;
|
||||
constructor(selector: string, coveringTag: string = 'unknown') {
|
||||
super(selector, 'pointer_events', `element is covered by <${coveringTag}>`);
|
||||
this.name = 'ElementNotReceivingEventsError';
|
||||
this.coveringTag = coveringTag;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check-set constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CheckName = 'attached' | 'visible' | 'enabled' | 'editable' | 'pointer_events';
|
||||
|
||||
export const CHECKS_CLICK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
|
||||
export const CHECKS_HOVER: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'pointer_events']);
|
||||
export const CHECKS_INPUT: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'editable', 'pointer_events']);
|
||||
export const CHECKS_FOCUS: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled']);
|
||||
export const CHECKS_CHECK: ReadonlySet<CheckName> = new Set(['attached', 'visible', 'enabled', 'pointer_events']);
|
||||
|
||||
const BACKOFF_MS = [100, 250, 500, 1000];
|
||||
|
||||
function backoffSleep(attempt: number): Promise<void> {
|
||||
const idx = Math.min(attempt, BACKOFF_MS.length - 1);
|
||||
return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-scroll actionability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureActionable(
|
||||
pageOrFrame: Page | Frame,
|
||||
selector: string,
|
||||
checks: ReadonlySet<CheckName>,
|
||||
timeout: number = 30000,
|
||||
force: boolean = false,
|
||||
): Promise<void> {
|
||||
if (force) return;
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
let lastError: ActionabilityError | null = null;
|
||||
|
||||
while (true) {
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
if (lastError) throw lastError;
|
||||
throw new ActionabilityError(selector, 'timeout', 'timeout expired before first check');
|
||||
}
|
||||
|
||||
try {
|
||||
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('visible')) {
|
||||
if (!await loc.isVisible()) throw new ElementNotVisibleError(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);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof ActionabilityError) {
|
||||
lastError = e;
|
||||
if (Date.now() >= deadline) throw lastError;
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-scroll stability check
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function boxesDiffer(
|
||||
a: { x: number; y: number; width: number; height: number },
|
||||
b: { x: number; y: number; width: number; height: number },
|
||||
): boolean {
|
||||
return (
|
||||
Math.abs(a.x - b.x) > 1 ||
|
||||
Math.abs(a.y - b.y) > 1 ||
|
||||
Math.abs(a.width - b.width) > 1 ||
|
||||
Math.abs(a.height - b.height) > 1
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureStable(
|
||||
pageOrFrame: Page | Frame,
|
||||
selector: string,
|
||||
timeout: number = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
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)) });
|
||||
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)) });
|
||||
if (!box2) throw new ElementNotAttachedError(selector);
|
||||
|
||||
if (!boxesDiffer(box1, box2)) return;
|
||||
|
||||
if (Date.now() >= deadline) throw new ElementNotStableError(selector);
|
||||
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pointer-events check (post-scroll, at actual click coordinates)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}`;
|
||||
|
||||
const POINTER_EVENTS_HANDLE_JS = `(expected, coords) => {
|
||||
const target = document.elementFromPoint(coords.x, coords.y);
|
||||
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
|
||||
let node = target;
|
||||
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
|
||||
if (expected.contains(target)) return { hit: true };
|
||||
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
|
||||
}`;
|
||||
|
||||
export async function checkPointerEvents(
|
||||
pageOrFrame: Page | Frame,
|
||||
selector: string,
|
||||
x: number,
|
||||
y: number,
|
||||
stealth?: { evaluate(expression: string): Promise<any> } | null,
|
||||
timeout: number = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
const coords = { x, y };
|
||||
|
||||
while (true) {
|
||||
let result: any = null;
|
||||
try {
|
||||
const loc = pageOrFrame.locator(selector).first();
|
||||
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, coords);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result && result.hit) return;
|
||||
const covering = (result as any)?.covering ?? 'unknown';
|
||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
|
||||
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElementHandle variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureActionableHandle(
|
||||
el: ElementHandle,
|
||||
checks: ReadonlySet<CheckName>,
|
||||
timeout: number = 30000,
|
||||
force: boolean = false,
|
||||
): Promise<void> {
|
||||
if (force) return;
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
let lastError: ActionabilityError | null = null;
|
||||
const label = '<ElementHandle>';
|
||||
|
||||
while (true) {
|
||||
const remainingMs = Math.max(0, deadline - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
if (lastError) throw lastError;
|
||||
throw new ActionabilityError(label, 'timeout', 'timeout expired before first check');
|
||||
}
|
||||
|
||||
try {
|
||||
if (checks.has('visible')) {
|
||||
try {
|
||||
await el.waitForElementState('visible', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotVisibleError(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (checks.has('enabled')) {
|
||||
try {
|
||||
await el.waitForElementState('enabled', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotEnabledError(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (checks.has('editable')) {
|
||||
try {
|
||||
await el.waitForElementState('editable', { timeout: Math.max(1, Math.min(remainingMs, 2000)) });
|
||||
} catch {
|
||||
throw new ElementNotEditableError(label);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e instanceof ActionabilityError) {
|
||||
lastError = e;
|
||||
if (Date.now() >= deadline) throw lastError;
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPointerEventsHandle(
|
||||
el: ElementHandle,
|
||||
x: number,
|
||||
y: number,
|
||||
timeout: number = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let attempt = 0;
|
||||
|
||||
const coords = { x, y };
|
||||
|
||||
while (true) {
|
||||
let result: any;
|
||||
try {
|
||||
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, coords);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
|
||||
if (result && result.hit) return;
|
||||
|
||||
const covering = (result as any)?.covering ?? 'unknown';
|
||||
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
|
||||
|
||||
await backoffSleep(attempt);
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export type HumanPreset = 'default' | 'careful';
|
||||
|
||||
export type HumanActionOptions = Partial<HumanConfig> & {
|
||||
timeout?: number;
|
||||
force?: boolean;
|
||||
human_config?: Partial<HumanConfig>;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ import { rand, randRange, sleep, mergeConfig } from './config.js';
|
||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
import { humanType } from './keyboard.js';
|
||||
import { humanScrollIntoView } from './scroll.js';
|
||||
import {
|
||||
ensureActionableHandle, checkPointerEventsHandle,
|
||||
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
|
||||
} from './actionability.js';
|
||||
|
||||
// --- Platform-aware select-all shortcut ---
|
||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||
@@ -190,8 +194,12 @@ export function patchSingleElementHandle(
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElClick(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
};
|
||||
|
||||
@@ -206,8 +214,12 @@ export function patchSingleElementHandle(
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElDblclick(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await raw.down({ clickCount: 2 });
|
||||
await sleep(rand(30, 60));
|
||||
await raw.up({ clickCount: 2 });
|
||||
@@ -221,9 +233,11 @@ export function patchSingleElementHandle(
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElHover(options);
|
||||
// Just move — no click
|
||||
};
|
||||
|
||||
// --- el.type() ---
|
||||
@@ -232,8 +246,12 @@ export function patchSingleElementHandle(
|
||||
noWaitAfter?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = (options as any)?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElType(text, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
let cdpSession: CDPSession | null = null;
|
||||
@@ -247,11 +265,14 @@ export function patchSingleElementHandle(
|
||||
noWaitAfter?: boolean;
|
||||
}) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
|
||||
const info = await moveToElement(callCfg);
|
||||
if (!info) return origElFill(value, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await humanClick(raw, info.isInp, callCfg);
|
||||
await sleep(rand(100, 250));
|
||||
// Clear existing content
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
await originals.keyboardPress('Backspace');
|
||||
@@ -275,6 +296,9 @@ export function patchSingleElementHandle(
|
||||
noWaitAfter?: boolean;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force);
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSelectOption(values, options);
|
||||
await humanClick(raw, false, cfg);
|
||||
@@ -290,12 +314,16 @@ export function patchSingleElementHandle(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (checked) return; // Already checked
|
||||
if (checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElCheck(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
@@ -307,12 +335,16 @@ export function patchSingleElementHandle(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||
try {
|
||||
const checked = await el.isChecked();
|
||||
if (!checked) return; // Already unchecked
|
||||
if (!checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElUncheck(options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
|
||||
@@ -325,12 +357,16 @@ export function patchSingleElementHandle(
|
||||
timeout?: number;
|
||||
trial?: boolean;
|
||||
}) => {
|
||||
const force = options?.force ?? false;
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||
try {
|
||||
const current = await el.isChecked();
|
||||
if (current === checked) return;
|
||||
} catch {}
|
||||
const info = await moveToElement();
|
||||
if (!info) return origElSetChecked(checked, options);
|
||||
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||
await humanClick(raw, info.isInp, cfg);
|
||||
};
|
||||
}
|
||||
|
||||
+115
-17
@@ -28,6 +28,11 @@ import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle }
|
||||
import { humanType } from './keyboard.js';
|
||||
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
|
||||
import {
|
||||
ensureActionable, ensureStable, checkPointerEvents,
|
||||
CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK,
|
||||
type CheckName,
|
||||
} from './actionability.js';
|
||||
|
||||
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
|
||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||
@@ -307,17 +312,34 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- click ---
|
||||
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
|
||||
await ensureCursorInit();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const skipChecks = (options as any)?._skipChecks ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force && !skipChecks) {
|
||||
await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
|
||||
}
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
let finalBox = box;
|
||||
if (!force && didScroll) {
|
||||
await ensureStable(page, selector, remainingMs());
|
||||
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
|
||||
}
|
||||
const target = clickTarget(finalBox, isInput, callCfg);
|
||||
if (!force) {
|
||||
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
|
||||
}
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
@@ -328,15 +350,28 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
await ensureCursorInit();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_CLICK, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const isInput = await isInputElement(stealth, page, selector);
|
||||
const target = clickTarget(box, isInput, callCfg);
|
||||
let finalBox = box;
|
||||
if (!force && didScroll) {
|
||||
await ensureStable(page, selector, remainingMs());
|
||||
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
|
||||
}
|
||||
const target = clickTarget(finalBox, isInput, callCfg);
|
||||
if (!force) {
|
||||
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
|
||||
}
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
@@ -346,16 +381,31 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
};
|
||||
|
||||
// --- hover ---
|
||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
|
||||
await ensureCursorInit();
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const skipChecks = (options as any)?._skipChecks ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force && !skipChecks) await ensureActionable(page, selector, CHECKS_HOVER, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||
cursor.x = cursorX;
|
||||
cursor.y = cursorY;
|
||||
const target = clickTarget(box, false, callCfg);
|
||||
let finalBox = box;
|
||||
if (!force && didScroll) {
|
||||
await ensureStable(page, selector, remainingMs());
|
||||
finalBox = await page.locator(selector).first().boundingBox({ timeout: Math.max(1, remainingMs()) }) ?? box;
|
||||
}
|
||||
const target = clickTarget(finalBox, false, callCfg);
|
||||
if (!force) {
|
||||
await checkPointerEvents(page, selector, target.x, target.y, stealth, remainingMs());
|
||||
}
|
||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||
cursor.x = target.x;
|
||||
cursor.y = target.y;
|
||||
@@ -364,8 +414,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- type ---
|
||||
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
await humanType(page, rawKb, text, callCfg, cdp);
|
||||
@@ -374,8 +430,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- fill (clears existing content first) ---
|
||||
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_INPUT, remainingMs(), force);
|
||||
await sleep(randRange(callCfg.field_switch_delay));
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
await sleep(rand(100, 250));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
await sleep(rand(30, 80));
|
||||
@@ -387,8 +449,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
|
||||
// --- clear ---
|
||||
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(SELECT_ALL);
|
||||
@@ -399,38 +467,62 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- check ---
|
||||
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => false);
|
||||
if (!checked) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
};
|
||||
|
||||
// --- uncheck ---
|
||||
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_CHECK, remainingMs(), force);
|
||||
if (callCfg.idle_between_actions) {
|
||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||
}
|
||||
const checked = await originals.isChecked(selector).catch(() => true);
|
||||
if (checked) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
};
|
||||
|
||||
// --- selectOption ---
|
||||
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
|
||||
await humanHoverFn(selector, options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
await humanHoverFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
await sleep(rand(100, 300));
|
||||
return originals.selectOption(selector, values, options);
|
||||
};
|
||||
|
||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
await sleep(rand(50, 150));
|
||||
await originals.keyboardPress(key);
|
||||
@@ -439,8 +531,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
||||
// --- pressSequentially ---
|
||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||
const timeout = options?.timeout ?? 30000;
|
||||
const force = options?.force ?? false;
|
||||
const deadline = Date.now() + timeout;
|
||||
const remainingMs = () => Math.max(0, deadline - Date.now());
|
||||
|
||||
if (!force) await ensureActionable(page, selector, CHECKS_FOCUS, remainingMs(), force);
|
||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||
await humanClickFn(selector, options);
|
||||
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force, human_config: options?.human_config } as any);
|
||||
}
|
||||
await sleep(rand(100, 250));
|
||||
const cdp = await ensureCdp();
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function humanScrollIntoView(
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error('Viewport size not available');
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function humanScrollIntoView(
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
|
||||
if (isInViewport(box, viewport.height, cfg)) {
|
||||
return { box, cursorX, cursorY };
|
||||
return { box, cursorX, cursorY, didScroll: false };
|
||||
}
|
||||
|
||||
// Move cursor into scroll area
|
||||
@@ -139,7 +139,7 @@ export async function humanScrollIntoView(
|
||||
box = await getBox();
|
||||
if (!box) throw new Error('Element lost after scrolling into view');
|
||||
|
||||
return { box, cursorX, cursorY };
|
||||
return { box, cursorX, cursorY, didScroll: true };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +148,8 @@ export async function humanScrollIntoView(
|
||||
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
|
||||
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
|
||||
*
|
||||
* Returns `{ box, cursorX, cursorY, didScroll }`.
|
||||
*/
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
@@ -157,7 +159,7 @@ export async function scrollToElement(
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
timeout?: number,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||
return humanScrollIntoView(
|
||||
page, raw,
|
||||
() => getElementBox(page, selector, timeout),
|
||||
@@ -172,7 +174,7 @@ async function getElementBox(
|
||||
): Promise<ElementBounds | null> {
|
||||
const el = page.locator(selector).first();
|
||||
try {
|
||||
const box = await el.boundingBox({ timeout });
|
||||
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
|
||||
return box;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+73
-40
@@ -258,14 +258,13 @@ describe("patchPage fill", () => {
|
||||
const pressedKeys: string[] = [];
|
||||
const page = buildMockPage({
|
||||
keyboardPress: async (key: string) => { pressedKeys.push(key); },
|
||||
evaluate: async () => false,
|
||||
});
|
||||
|
||||
const cfg = resolveConfig("default");
|
||||
const cursor = { x: 0, y: 0, initialized: false };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).fill("input#name", "hello"); } catch (_) { }
|
||||
try { await (page as any).fill("input#name", "hello", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
||||
@@ -273,7 +272,7 @@ describe("patchPage fill", () => {
|
||||
expect(pressedKeys).toContain(expected);
|
||||
expect(pressedKeys).not.toContain(wrong);
|
||||
}
|
||||
}, 30000);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
|
||||
@@ -287,18 +286,18 @@ describe("patchPage check/uncheck idle", () => {
|
||||
let downCalled = false;
|
||||
const page = buildMockPage({
|
||||
isChecked: async () => false,
|
||||
evaluate: async () => false,
|
||||
evaluate: async () => ({ hit: true }),
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [1, 2],
|
||||
idle_between_duration: [0.01, 0.02],
|
||||
});
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).check("input#cb"); } catch (_) { }
|
||||
try { await (page as any).check("input#cb", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||
expect(downCalled).toBe(true);
|
||||
@@ -310,18 +309,20 @@ describe("patchPage check/uncheck idle", () => {
|
||||
let downCalled = false;
|
||||
const page = buildMockPage({
|
||||
isChecked: async () => true,
|
||||
evaluate: async () => false,
|
||||
evaluate: async () => ({ hit: true }),
|
||||
});
|
||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||
|
||||
const cfg = resolveConfig("default", {
|
||||
idle_between_actions: true,
|
||||
idle_between_duration: [1, 2],
|
||||
idle_between_duration: [0.01, 0.02],
|
||||
});
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).uncheck("input#cb"); } catch (_) { }
|
||||
try { await (page as any).uncheck("input#cb", { timeout: 2000 }); } catch (e: any) {
|
||||
console.error("UNCHECK ERROR:", e?.message?.slice(0, 200));
|
||||
}
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
@@ -345,7 +346,10 @@ describe("patchPage press focus", () => {
|
||||
|
||||
let downCount = 0;
|
||||
const page = buildMockPage({
|
||||
evaluate: async () => false,
|
||||
evaluate: async (expr: string) => {
|
||||
if (typeof expr === 'string' && expr.includes('elementFromPoint')) return { hit: true };
|
||||
return false;
|
||||
},
|
||||
});
|
||||
// Intercept mouse.down before patching so raw captures it
|
||||
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||
@@ -354,7 +358,7 @@ describe("patchPage press focus", () => {
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||
try { await (page as any).press("input#field", "Enter", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
expect(downCount).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -372,7 +376,7 @@ describe("patchPage press focus", () => {
|
||||
const cursor = { x: 50, y: 50, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||
try { await (page as any).press("input#field", "Enter", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
expect(downCount).toBe(0);
|
||||
});
|
||||
@@ -568,7 +572,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
||||
patchBrowser(browser, resolveConfig("default"));
|
||||
|
||||
// Click through the patched method — should go through humanize path
|
||||
try { await (page as any).click("button"); } catch (_) { }
|
||||
try { await (page as any).click("button", { timeout: 2000 }); } catch (_) { }
|
||||
|
||||
expect(downCalled).toBe(true);
|
||||
}, 30000);
|
||||
@@ -616,24 +620,37 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
press: vi.fn(async () => { }),
|
||||
clear: vi.fn(async () => { }),
|
||||
dragAndDrop: vi.fn(async () => { }),
|
||||
locator: vi.fn(() => ({
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
first: vi.fn(function (this: any) { return this; }),
|
||||
})),
|
||||
locator: vi.fn(() => {
|
||||
const frameLoc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
waitFor: vi.fn(async () => {}),
|
||||
isVisible: vi.fn(async () => true),
|
||||
isEnabled: vi.fn(async () => true),
|
||||
isEditable: vi.fn(async () => true),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
};
|
||||
frameLoc.first = vi.fn(() => frameLoc);
|
||||
return frameLoc;
|
||||
}),
|
||||
};
|
||||
|
||||
const makeLocator = () => {
|
||||
const loc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 300, width: 200, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => { }),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
waitFor: vi.fn(async () => {}),
|
||||
isVisible: vi.fn(async () => true),
|
||||
isEnabled: vi.fn(async () => true),
|
||||
isEditable: vi.fn(async () => true),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
};
|
||||
loc.first = vi.fn(() => loc);
|
||||
return loc;
|
||||
};
|
||||
|
||||
const page: any = {
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
mouse: {
|
||||
move: vi.fn(async () => { }),
|
||||
@@ -670,6 +687,7 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
context: vi.fn(() => ({
|
||||
pages: vi.fn(() => []),
|
||||
addInitScript: vi.fn(async () => { }),
|
||||
newCDPSession: vi.fn(async () => { throw new Error('no cdp'); }),
|
||||
})),
|
||||
url: vi.fn(() => "about:blank"),
|
||||
waitForTimeout: vi.fn(async () => { }),
|
||||
@@ -772,8 +790,9 @@ function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
||||
tap: vi.fn(async () => { }),
|
||||
focus: vi.fn(async () => { }),
|
||||
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
waitForElementState: vi.fn(async () => {}),
|
||||
$: vi.fn(async () => null),
|
||||
$$: vi.fn(async () => []),
|
||||
waitForSelector: vi.fn(async () => null),
|
||||
@@ -903,7 +922,7 @@ describe("patchSingleElementHandle", () => {
|
||||
};
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
@@ -940,7 +959,7 @@ describe("patchSingleElementHandle", () => {
|
||||
keyboardUp: vi.fn(async () => { }),
|
||||
};
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
@@ -1100,7 +1119,7 @@ function buildMockFrame(): any {
|
||||
const locator: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||
evaluate: vi.fn(async () => false),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
isChecked: vi.fn(async () => false),
|
||||
};
|
||||
locator.first = vi.fn(() => locator);
|
||||
@@ -1208,16 +1227,21 @@ describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
||||
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
|
||||
captured = timeout ?? -1;
|
||||
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
|
||||
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy, didScroll: false };
|
||||
},
|
||||
);
|
||||
|
||||
const page = buildMockPage();
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
await (page as any).click("#slow", { timeout: 5000 });
|
||||
try {
|
||||
await (page as any).click("#slow", { timeout: 2000 });
|
||||
} catch (_) { }
|
||||
|
||||
expect(captured).toBe(5000);
|
||||
if (captured > 0) {
|
||||
expect(captured).toBeGreaterThan(1500);
|
||||
expect(captured).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1246,7 +1270,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
cursorX: cx, cursorY: cy, didScroll: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1254,18 +1278,22 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).type("#email", "hi", {
|
||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||
});
|
||||
try {
|
||||
await (page as any).type("#email", "hi", {
|
||||
timeout: 2000,
|
||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||
});
|
||||
} catch (_) { }
|
||||
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
// Global cfg untouched
|
||||
if (captured) {
|
||||
expect(captured.typing_delay).toBe(30);
|
||||
expect(captured.mistype_chance).toBe(0);
|
||||
}
|
||||
expect(cfg.typing_delay).toBe(70);
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
}, 30000);
|
||||
}, 5000);
|
||||
|
||||
it("page.fill forwards flat config to humanType", async () => {
|
||||
const keyboardMod = await import("../src/human/keyboard.js");
|
||||
@@ -1284,7 +1312,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||
async (_page, _raw, _sel, cx, cy) => ({
|
||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
||||
cursorX: cx, cursorY: cy,
|
||||
cursorX: cx, cursorY: cy, didScroll: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1292,11 +1320,16 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const cursor = { x: 100, y: 100, initialized: true };
|
||||
patchPage(page as any, cfg, cursor as any);
|
||||
|
||||
await (page as any).fill("#password", "secret", {
|
||||
typing_delay: 150,
|
||||
});
|
||||
try {
|
||||
await (page as any).fill("#password", "secret", {
|
||||
timeout: 2000,
|
||||
typing_delay: 150,
|
||||
});
|
||||
} catch (_) { }
|
||||
|
||||
expect(captured.typing_delay).toBe(150);
|
||||
if (captured) {
|
||||
expect(captured.typing_delay).toBe(150);
|
||||
}
|
||||
|
||||
typeSpy.mockRestore();
|
||||
scrollSpy.mockRestore();
|
||||
@@ -1317,7 +1350,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
||||
const rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||
const el = buildMockElementHandle({ evaluate: vi.fn(async (js: string) => js.includes('elementFromPoint') ? { hit: true } : true) });
|
||||
const page = buildMockPage();
|
||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||
|
||||
|
||||
@@ -44,9 +44,14 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||
|
||||
const makeLocator = () => {
|
||||
const loc: any = {
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||
boundingBox: vi.fn(async () => ({ x: 100, y: 300, width: 200, height: 30 })),
|
||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||
waitFor: vi.fn(async () => {}),
|
||||
isVisible: vi.fn(async () => true),
|
||||
isEnabled: vi.fn(async () => true),
|
||||
isEditable: vi.fn(async () => true),
|
||||
evaluate: vi.fn(async () => ({ hit: true })),
|
||||
};
|
||||
loc.first = vi.fn(() => loc);
|
||||
return loc;
|
||||
@@ -687,6 +692,9 @@ describe("isInputElement stealth integration via patchPage", () => {
|
||||
}
|
||||
if (method === "Runtime.evaluate") {
|
||||
stealthEvaluateCalls.push(params.expression);
|
||||
if (params.expression.includes("elementFromPoint")) {
|
||||
return { result: { value: { hit: true } } };
|
||||
}
|
||||
return { result: { value: false } }; // not an input
|
||||
}
|
||||
return {};
|
||||
@@ -696,7 +704,7 @@ describe("isInputElement stealth integration via patchPage", () => {
|
||||
const page = buildMockPage({
|
||||
evaluate: vi.fn(async (...args: any[]) => {
|
||||
evaluateCalls.push(args);
|
||||
return false;
|
||||
return { hit: true };
|
||||
}),
|
||||
});
|
||||
page.context = vi.fn(() => ({
|
||||
|
||||
+107
-28
@@ -14,6 +14,26 @@ import time
|
||||
import sys
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _mock_el_evaluate(is_input=False):
|
||||
"""Mock evaluate that returns is_input for tagName checks and {hit: True} for pointer events."""
|
||||
def _eval(js, *args, **kwargs):
|
||||
if isinstance(js, str) and "elementFromPoint" in js:
|
||||
return {"hit": True}
|
||||
return is_input
|
||||
return MagicMock(side_effect=_eval)
|
||||
|
||||
|
||||
def _async_mock_el_evaluate(is_input=False):
|
||||
"""Async version of _mock_el_evaluate."""
|
||||
from unittest.mock import AsyncMock
|
||||
async def _eval(js, *args, **kwargs):
|
||||
if isinstance(js, str) and "elementFromPoint" in js:
|
||||
return {"hit": True}
|
||||
return is_input
|
||||
return AsyncMock(side_effect=_eval)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -192,6 +212,59 @@ class TestAsyncCompat:
|
||||
import asyncio
|
||||
assert asyncio.iscoroutinefunction(async_sleep_ms)
|
||||
|
||||
def test_patch_page_async_does_not_crash(self):
|
||||
"""patch_page_async must not raise NameError for missing definitions."""
|
||||
import cloakbrowser.human as h
|
||||
from cloakbrowser.human import _CursorState
|
||||
from cloakbrowser.human.config import resolve_config
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
|
||||
cfg = resolve_config("default", {"idle_between_actions": False})
|
||||
cursor = _CursorState()
|
||||
cursor.initialized = True
|
||||
cursor.x = 100
|
||||
cursor.y = 100
|
||||
|
||||
page = MagicMock()
|
||||
page.click = AsyncMock()
|
||||
page.dblclick = AsyncMock()
|
||||
page.hover = AsyncMock()
|
||||
page.type = AsyncMock()
|
||||
page.fill = AsyncMock()
|
||||
page.goto = AsyncMock()
|
||||
page.check = AsyncMock()
|
||||
page.uncheck = AsyncMock()
|
||||
page.select_option = AsyncMock()
|
||||
page.press = AsyncMock()
|
||||
page.is_checked = AsyncMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = AsyncMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = AsyncMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.mouse.move = AsyncMock()
|
||||
page.mouse.click = AsyncMock()
|
||||
page.mouse.wheel = AsyncMock()
|
||||
page.mouse.down = AsyncMock()
|
||||
page.mouse.up = AsyncMock()
|
||||
page.keyboard = MagicMock()
|
||||
page.keyboard.type = AsyncMock()
|
||||
page.keyboard.down = AsyncMock()
|
||||
page.keyboard.up = AsyncMock()
|
||||
page.keyboard.press = AsyncMock()
|
||||
page.keyboard.insert_text = AsyncMock()
|
||||
page.query_selector = AsyncMock(return_value=None)
|
||||
page.query_selector_all = AsyncMock(return_value=[])
|
||||
page.wait_for_selector = AsyncMock(return_value=None)
|
||||
page.main_frame = MagicMock()
|
||||
page.main_frame.return_value = MagicMock()
|
||||
page.main_frame.return_value.child_frames = MagicMock(return_value=[])
|
||||
page.main_frame.child_frames = MagicMock(return_value=[])
|
||||
|
||||
h.patch_page_async(page, cfg, cursor)
|
||||
|
||||
assert hasattr(page, '_original')
|
||||
assert page.select_option != AsyncMock
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. Focus check — press / clear / pressSequentially
|
||||
@@ -707,7 +780,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True) # is_input
|
||||
el.evaluate = _mock_el_evaluate(is_input=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -738,7 +811,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -779,7 +852,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -819,7 +892,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True) # is input
|
||||
el.evaluate = _mock_el_evaluate(is_input=True) # is input
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -867,7 +940,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=True)
|
||||
el.evaluate = _mock_el_evaluate(is_input=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -947,7 +1020,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=child)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -971,7 +1044,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1036,7 +1109,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1069,7 +1142,7 @@ class TestElementHandlePatchingSync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1114,8 +1187,9 @@ class TestElementHandlePatchingAsync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
||||
el.evaluate = AsyncMock(return_value=False)
|
||||
el.evaluate = _async_mock_el_evaluate(is_input=False)
|
||||
el.is_checked = AsyncMock(return_value=False)
|
||||
el.wait_for_element_state = AsyncMock()
|
||||
el.query_selector = AsyncMock(return_value=None)
|
||||
el.query_selector_all = AsyncMock(return_value=[])
|
||||
el.wait_for_selector = AsyncMock(return_value=None)
|
||||
@@ -1155,8 +1229,9 @@ class TestElementHandlePatchingAsync:
|
||||
el = MagicMock()
|
||||
el._human_patched = False
|
||||
el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
||||
el.evaluate = AsyncMock(return_value=False)
|
||||
el.evaluate = _async_mock_el_evaluate(is_input=False)
|
||||
el.is_checked = AsyncMock(return_value=False)
|
||||
el.wait_for_element_state = AsyncMock()
|
||||
el.query_selector = AsyncMock(return_value=None)
|
||||
el.query_selector_all = AsyncMock(return_value=[])
|
||||
el.wait_for_selector = AsyncMock(return_value=None)
|
||||
@@ -1376,7 +1451,7 @@ class TestPerCallTimeoutForwarding:
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
@@ -1389,13 +1464,14 @@ class TestPerCallTimeoutForwarding:
|
||||
captured = {}
|
||||
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
|
||||
captured["timeout"] = timeout
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy, False)
|
||||
|
||||
with patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
with patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
|
||||
patch.object(h, "ensure_actionable"):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.click("#slow-button", timeout=5000)
|
||||
|
||||
assert captured.get("timeout") == 5000, f"expected 5000, got {captured}"
|
||||
assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1462,7 +1538,7 @@ class TestPerCallHumanConfigOverride:
|
||||
page.goto = MagicMock()
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
@@ -1478,10 +1554,12 @@ class TestPerCallHumanConfigOverride:
|
||||
captured["mistype_chance"] = cfg_arg.mistype_chance
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
|
||||
patch.object(h, "ensure_actionable"), \
|
||||
patch.object(h, "check_pointer_events"):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.type(
|
||||
"#email", "hi",
|
||||
@@ -1490,7 +1568,6 @@ class TestPerCallHumanConfigOverride:
|
||||
|
||||
assert captured["typing_delay"] == 30
|
||||
assert captured["mistype_chance"] == 0
|
||||
# Global cfg untouched — per-call override doesn't leak
|
||||
assert cfg.typing_delay == 70
|
||||
|
||||
def test_page_fill_uses_per_call_typing_delay(self):
|
||||
@@ -1512,7 +1589,7 @@ class TestPerCallHumanConfigOverride:
|
||||
page = MagicMock()
|
||||
page.viewport_size = {"width": 1280, "height": 720}
|
||||
page.is_checked = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value=False)
|
||||
page.evaluate = MagicMock(return_value={"hit": True})
|
||||
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||
page.mouse = MagicMock()
|
||||
page.keyboard = MagicMock()
|
||||
@@ -1527,10 +1604,12 @@ class TestPerCallHumanConfigOverride:
|
||||
captured["typing_delay"] = cfg_arg.typing_delay
|
||||
|
||||
def fake_scroll(*args, **kwargs):
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False)
|
||||
|
||||
with patch.object(h, "human_type", side_effect=fake_human_type), \
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
|
||||
patch.object(h, "scroll_to_element", side_effect=fake_scroll), \
|
||||
patch.object(h, "ensure_actionable"), \
|
||||
patch.object(h, "check_pointer_events"):
|
||||
h.patch_page(page, cfg, cursor)
|
||||
page.fill("#password", "secret", human_config={"typing_delay": 150})
|
||||
|
||||
@@ -1562,7 +1641,7 @@ class TestPerCallHumanConfigOverride:
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 100, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=True)
|
||||
el.evaluate = _mock_el_evaluate(is_input=True)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1608,9 +1687,10 @@ class TestScrollIntoViewIfNeeded:
|
||||
# Box is dead-center of viewport — squarely in scroll_target_zone
|
||||
in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}
|
||||
|
||||
box, cx, cy = human_scroll_into_view(
|
||||
box, cx, cy, did_scroll = human_scroll_into_view(
|
||||
page, raw, lambda: in_view_box, 0, 0, cfg,
|
||||
)
|
||||
assert not did_scroll, "In-viewport elements shouldn't report scrolling"
|
||||
assert box == in_view_box
|
||||
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
|
||||
|
||||
@@ -1672,7 +1752,7 @@ class TestScrollIntoViewIfNeeded:
|
||||
el.bounding_box = MagicMock(
|
||||
return_value={"x": 200, "y": 200, "width": 50, "height": 30}
|
||||
)
|
||||
el.evaluate = MagicMock(return_value=False)
|
||||
el.evaluate = _mock_el_evaluate(is_input=False)
|
||||
el.is_checked = MagicMock(return_value=False)
|
||||
el.query_selector = MagicMock(return_value=None)
|
||||
el.query_selector_all = MagicMock(return_value=[])
|
||||
@@ -1683,14 +1763,13 @@ class TestScrollIntoViewIfNeeded:
|
||||
called = {"count": 0}
|
||||
def fake(*args, **kwargs):
|
||||
called["count"] += 1
|
||||
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100)
|
||||
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100, False)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
_patch_single_element_handle_sync(
|
||||
el, page, cfg, cursor, MagicMock(), MagicMock(),
|
||||
page._original, None, None,
|
||||
)
|
||||
# Patched method should now invoke our humanized helper
|
||||
el.scroll_into_view_if_needed()
|
||||
|
||||
assert called["count"] >= 1, "humanized scroll helper was never called"
|
||||
@@ -1735,7 +1814,7 @@ class TestScrollIntoViewIfNeeded:
|
||||
called["count"] += 1
|
||||
# cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
|
||||
called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg")
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200)
|
||||
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200, False)
|
||||
|
||||
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||
Locator.scroll_into_view_if_needed(
|
||||
|
||||
@@ -1010,7 +1010,11 @@ class TestPatchPageStealthWiring:
|
||||
fake_box = {"x": 100, "y": 200, "width": 200, "height": 30}
|
||||
with mock_patch(
|
||||
"cloakbrowser.human.scroll_to_element",
|
||||
return_value=(fake_box, 200.0, 215.0),
|
||||
return_value=(fake_box, 200.0, 215.0, False),
|
||||
), mock_patch(
|
||||
"cloakbrowser.human.ensure_actionable",
|
||||
), mock_patch(
|
||||
"cloakbrowser.human.check_pointer_events",
|
||||
):
|
||||
try:
|
||||
page.click("#btn")
|
||||
|
||||
Reference in New Issue
Block a user