mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
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
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.
|
- **`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()`.
|
- **JS `contextOptions` escape hatch** — forward arbitrary options (including `storageState`) to Playwright's `newContext()` from `launchContext()` / `launchPersistentContext()`.
|
||||||
|
|||||||
+396
-73
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
|||||||
|
"""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' };
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
js = f"""(expected) => {{
|
||||||
|
const target = document.elementFromPoint({x}, {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' }};
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = el.evaluate(js)
|
||||||
|
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,253 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
js = f"""(expected) => {{
|
||||||
|
const target = document.elementFromPoint({x}, {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' }};
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
result = await el.evaluate(js)
|
||||||
|
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:
|
try:
|
||||||
el = page.locator(selector).first
|
el = page.locator(selector).first
|
||||||
return el.bounding_box(timeout=timeout)
|
return el.bounding_box(timeout=max(1, timeout))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ def human_scroll_into_view(
|
|||||||
get_box: Callable[[], Optional[dict]],
|
get_box: Callable[[], Optional[dict]],
|
||||||
cursor_x: float, cursor_y: float,
|
cursor_x: float, cursor_y: float,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
) -> Tuple[dict, float, float]:
|
) -> Tuple[dict, float, float, bool]:
|
||||||
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
|
||||||
instead of a CSS selector.
|
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``
|
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
|
||||||
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
|
||||||
behavior runs everywhere.
|
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
|
viewport = page.viewport_size
|
||||||
if not viewport:
|
if not viewport:
|
||||||
@@ -71,7 +74,7 @@ def human_scroll_into_view(
|
|||||||
raise RuntimeError("Element not found while scrolling into view")
|
raise RuntimeError("Element not found while scrolling into view")
|
||||||
|
|
||||||
if _is_in_viewport(box, viewport_height, cfg):
|
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
|
# Move cursor into scroll area
|
||||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
||||||
@@ -139,7 +142,7 @@ def human_scroll_into_view(
|
|||||||
if box is None:
|
if box is None:
|
||||||
raise RuntimeError("Element lost after scrolling into view")
|
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(
|
def scroll_to_element(
|
||||||
@@ -149,12 +152,14 @@ def scroll_to_element(
|
|||||||
cursor_x: float, cursor_y: float,
|
cursor_x: float, cursor_y: float,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
timeout: float = 30000,
|
timeout: float = 30000,
|
||||||
) -> Tuple[dict, float, float]:
|
) -> Tuple[dict, float, float, bool]:
|
||||||
"""Selector-based humanized scroll.
|
"""Selector-based humanized scroll.
|
||||||
|
|
||||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||||
(#172). Default matches Playwright's 30000ms when not specified.
|
(#172). Default matches Playwright's 30000ms when not specified.
|
||||||
|
|
||||||
|
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||||
"""
|
"""
|
||||||
return human_scroll_into_view(
|
return human_scroll_into_view(
|
||||||
page, raw,
|
page, raw,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def _get_element_box_async(
|
|||||||
elements (#172)."""
|
elements (#172)."""
|
||||||
try:
|
try:
|
||||||
el = page.locator(selector).first
|
el = page.locator(selector).first
|
||||||
return await el.bounding_box(timeout=timeout)
|
return await el.bounding_box(timeout=max(1, timeout))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -47,13 +47,16 @@ async def async_human_scroll_into_view(
|
|||||||
get_box: Callable[[], Awaitable[Optional[dict]]],
|
get_box: Callable[[], Awaitable[Optional[dict]]],
|
||||||
cursor_x: float, cursor_y: float,
|
cursor_x: float, cursor_y: float,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
) -> Tuple[dict, float, float]:
|
) -> Tuple[dict, float, float, bool]:
|
||||||
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
|
||||||
|
|
||||||
Used by both ``async_scroll_to_element`` (selector-based) and the
|
Used by both ``async_scroll_to_element`` (selector-based) and the
|
||||||
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
|
||||||
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
|
||||||
\u2192 overshoot behavior.
|
\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
|
viewport = page.viewport_size
|
||||||
if not viewport:
|
if not viewport:
|
||||||
@@ -67,7 +70,7 @@ async def async_human_scroll_into_view(
|
|||||||
raise RuntimeError("Element not found while scrolling into view")
|
raise RuntimeError("Element not found while scrolling into view")
|
||||||
|
|
||||||
if _is_in_viewport(box, viewport_height, cfg):
|
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
|
# Move cursor into scroll area
|
||||||
scroll_area_x = round(viewport_width * rand(0.3, 0.7))
|
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:
|
if box is None:
|
||||||
raise RuntimeError("Element lost after scrolling into view")
|
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(
|
async def async_scroll_to_element(
|
||||||
@@ -145,12 +148,14 @@ async def async_scroll_to_element(
|
|||||||
cursor_x: float, cursor_y: float,
|
cursor_x: float, cursor_y: float,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
timeout: float = 30000,
|
timeout: float = 30000,
|
||||||
) -> Tuple[dict, float, float]:
|
) -> Tuple[dict, float, float, bool]:
|
||||||
"""Selector-based humanized scroll (async).
|
"""Selector-based humanized scroll (async).
|
||||||
|
|
||||||
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
|
||||||
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
|
||||||
(#172). Default matches Playwright's 30000ms when not specified.
|
(#172). Default matches Playwright's 30000ms when not specified.
|
||||||
|
|
||||||
|
Returns ``(box, cursor_x, cursor_y, did_scroll)``.
|
||||||
"""
|
"""
|
||||||
async def _get():
|
async def _get():
|
||||||
return await _get_element_box_async(page, selector, timeout)
|
return await _get_element_box_async(page, selector, timeout)
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* 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' };
|
||||||
|
}`;
|
||||||
|
|
||||||
|
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 js = `(expected) => {
|
||||||
|
const target = document.elementFromPoint(${x}, ${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' };
|
||||||
|
}`;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let result: any;
|
||||||
|
try {
|
||||||
|
result = await el.evaluate(js);
|
||||||
|
} 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> & {
|
export type HumanActionOptions = Partial<HumanConfig> & {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
force?: boolean;
|
||||||
human_config?: Partial<HumanConfig>;
|
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 { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
import { humanType } from './keyboard.js';
|
import { humanType } from './keyboard.js';
|
||||||
import { humanScrollIntoView } from './scroll.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 ---
|
// --- Platform-aware select-all shortcut ---
|
||||||
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
||||||
@@ -190,8 +194,12 @@ export function patchSingleElementHandle(
|
|||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElClick(options);
|
if (!info) return origElClick(options);
|
||||||
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,8 +214,12 @@ export function patchSingleElementHandle(
|
|||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElDblclick(options);
|
if (!info) return origElDblclick(options);
|
||||||
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||||
await raw.down({ clickCount: 2 });
|
await raw.down({ clickCount: 2 });
|
||||||
await sleep(rand(30, 60));
|
await sleep(rand(30, 60));
|
||||||
await raw.up({ clickCount: 2 });
|
await raw.up({ clickCount: 2 });
|
||||||
@@ -221,9 +233,11 @@ export function patchSingleElementHandle(
|
|||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElHover(options);
|
if (!info) return origElHover(options);
|
||||||
// Just move — no click
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- el.type() ---
|
// --- el.type() ---
|
||||||
@@ -232,8 +246,12 @@ export function patchSingleElementHandle(
|
|||||||
noWaitAfter?: boolean;
|
noWaitAfter?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElType(text, options);
|
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 humanClick(raw, info.isInp, callCfg);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
let cdpSession: CDPSession | null = null;
|
let cdpSession: CDPSession | null = null;
|
||||||
@@ -247,11 +265,14 @@ export function patchSingleElementHandle(
|
|||||||
noWaitAfter?: boolean;
|
noWaitAfter?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElFill(value, options);
|
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 humanClick(raw, info.isInp, callCfg);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
// Clear existing content
|
|
||||||
await originals.keyboardPress(SELECT_ALL);
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
await sleep(rand(30, 80));
|
await sleep(rand(30, 80));
|
||||||
await originals.keyboardPress('Backspace');
|
await originals.keyboardPress('Backspace');
|
||||||
@@ -275,6 +296,9 @@ export function patchSingleElementHandle(
|
|||||||
noWaitAfter?: boolean;
|
noWaitAfter?: boolean;
|
||||||
timeout?: number;
|
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();
|
const info = await moveToElement();
|
||||||
if (!info) return origElSelectOption(values, options);
|
if (!info) return origElSelectOption(values, options);
|
||||||
await humanClick(raw, false, cfg);
|
await humanClick(raw, false, cfg);
|
||||||
@@ -290,12 +314,16 @@ export function patchSingleElementHandle(
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const timeout = options?.timeout ?? 30000;
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||||
try {
|
try {
|
||||||
const checked = await el.isChecked();
|
const checked = await el.isChecked();
|
||||||
if (checked) return; // Already checked
|
if (checked) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElCheck(options);
|
if (!info) return origElCheck(options);
|
||||||
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||||
await humanClick(raw, info.isInp, cfg);
|
await humanClick(raw, info.isInp, cfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,12 +335,16 @@ export function patchSingleElementHandle(
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const timeout = options?.timeout ?? 30000;
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||||
try {
|
try {
|
||||||
const checked = await el.isChecked();
|
const checked = await el.isChecked();
|
||||||
if (!checked) return; // Already unchecked
|
if (!checked) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElUncheck(options);
|
if (!info) return origElUncheck(options);
|
||||||
|
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
|
||||||
await humanClick(raw, info.isInp, cfg);
|
await humanClick(raw, info.isInp, cfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,12 +357,16 @@ export function patchSingleElementHandle(
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
trial?: boolean;
|
trial?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const timeout = options?.timeout ?? 30000;
|
||||||
|
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
|
||||||
try {
|
try {
|
||||||
const current = await el.isChecked();
|
const current = await el.isChecked();
|
||||||
if (current === checked) return;
|
if (current === checked) return;
|
||||||
} catch {}
|
} catch {}
|
||||||
const info = await moveToElement();
|
const info = await moveToElement();
|
||||||
if (!info) return origElSetChecked(checked, options);
|
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);
|
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 { humanType } from './keyboard.js';
|
||||||
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||||
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.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 { HumanConfig, resolveConfig, mergeConfig } from './config.js';
|
||||||
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
@@ -307,17 +312,34 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- click ---
|
// --- click ---
|
||||||
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
|
const humanClickFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
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.x = cursorX;
|
||||||
cursor.y = cursorY;
|
cursor.y = cursorY;
|
||||||
const isInput = await isInputElement(stealth, page, selector);
|
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);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||||
cursor.x = target.x;
|
cursor.x = target.x;
|
||||||
cursor.y = target.y;
|
cursor.y = target.y;
|
||||||
@@ -328,15 +350,28 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
|
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
|
const { box, cursorX, cursorY, didScroll } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, remainingMs());
|
||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
cursor.y = cursorY;
|
cursor.y = cursorY;
|
||||||
const isInput = await isInputElement(stealth, page, selector);
|
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);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||||
cursor.x = target.x;
|
cursor.x = target.x;
|
||||||
cursor.y = target.y;
|
cursor.y = target.y;
|
||||||
@@ -346,16 +381,31 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- hover ---
|
// --- hover ---
|
||||||
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
const humanHoverFn = async (selector: string, options?: HumanActionOptions & { _skipChecks?: boolean }) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
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.x = cursorX;
|
||||||
cursor.y = cursorY;
|
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);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||||
cursor.x = target.x;
|
cursor.x = target.x;
|
||||||
cursor.y = target.y;
|
cursor.y = target.y;
|
||||||
@@ -364,8 +414,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
// --- type ---
|
// --- type ---
|
||||||
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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 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 sleep(rand(100, 250));
|
||||||
const cdp = await ensureCdp();
|
const cdp = await ensureCdp();
|
||||||
await humanType(page, rawKb, text, callCfg, cdp);
|
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) ---
|
// --- fill (clears existing content first) ---
|
||||||
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
|
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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 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 sleep(rand(100, 250));
|
||||||
await originals.keyboardPress(SELECT_ALL);
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
await sleep(rand(30, 80));
|
await sleep(rand(30, 80));
|
||||||
@@ -387,8 +449,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
|
|
||||||
// --- clear ---
|
// --- clear ---
|
||||||
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
|
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)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force } as any);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
await originals.keyboardPress(SELECT_ALL);
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
@@ -399,38 +467,62 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
// --- check ---
|
// --- check ---
|
||||||
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
|
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
const checked = await originals.isChecked(selector).catch(() => false);
|
const checked = await originals.isChecked(selector).catch(() => false);
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force } as any);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- uncheck ---
|
// --- uncheck ---
|
||||||
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
|
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
const checked = await originals.isChecked(selector).catch(() => true);
|
const checked = await originals.isChecked(selector).catch(() => true);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force } as any);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- selectOption ---
|
// --- selectOption ---
|
||||||
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
|
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 } as any);
|
||||||
await sleep(rand(100, 300));
|
await sleep(rand(100, 300));
|
||||||
return originals.selectOption(selector, values, options);
|
return originals.selectOption(selector, values, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||||
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
|
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)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force } as any);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
await originals.keyboardPress(key);
|
await originals.keyboardPress(key);
|
||||||
@@ -439,8 +531,14 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
// --- pressSequentially ---
|
// --- pressSequentially ---
|
||||||
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
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)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, { _skipChecks: true, timeout: remainingMs(), force } as any);
|
||||||
}
|
}
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
const cdp = await ensureCdp();
|
const cdp = await ensureCdp();
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function humanScrollIntoView(
|
|||||||
cursorX: number,
|
cursorX: number,
|
||||||
cursorY: number,
|
cursorY: number,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||||
const viewport = page.viewportSize();
|
const viewport = page.viewportSize();
|
||||||
if (!viewport) throw new Error('Viewport size not available');
|
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 (!box) throw new Error('Element not found while scrolling into view');
|
||||||
|
|
||||||
if (isInViewport(box, viewport.height, cfg)) {
|
if (isInViewport(box, viewport.height, cfg)) {
|
||||||
return { box, cursorX, cursorY };
|
return { box, cursorX, cursorY, didScroll: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move cursor into scroll area
|
// Move cursor into scroll area
|
||||||
@@ -139,7 +139,7 @@ export async function humanScrollIntoView(
|
|||||||
box = await getBox();
|
box = await getBox();
|
||||||
if (!box) throw new Error('Element lost after scrolling into view');
|
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
|
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
|
||||||
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
|
||||||
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
|
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
|
||||||
|
*
|
||||||
|
* Returns `{ box, cursorX, cursorY, didScroll }`.
|
||||||
*/
|
*/
|
||||||
export async function scrollToElement(
|
export async function scrollToElement(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -157,7 +159,7 @@ export async function scrollToElement(
|
|||||||
cursorY: number,
|
cursorY: number,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||||
return humanScrollIntoView(
|
return humanScrollIntoView(
|
||||||
page, raw,
|
page, raw,
|
||||||
() => getElementBox(page, selector, timeout),
|
() => getElementBox(page, selector, timeout),
|
||||||
@@ -172,7 +174,7 @@ async function getElementBox(
|
|||||||
): Promise<ElementBounds | null> {
|
): Promise<ElementBounds | null> {
|
||||||
const el = page.locator(selector).first();
|
const el = page.locator(selector).first();
|
||||||
try {
|
try {
|
||||||
const box = await el.boundingBox({ timeout });
|
const box = await el.boundingBox({ timeout: Math.max(1, timeout) });
|
||||||
return box;
|
return box;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+73
-40
@@ -258,14 +258,13 @@ describe("patchPage fill", () => {
|
|||||||
const pressedKeys: string[] = [];
|
const pressedKeys: string[] = [];
|
||||||
const page = buildMockPage({
|
const page = buildMockPage({
|
||||||
keyboardPress: async (key: string) => { pressedKeys.push(key); },
|
keyboardPress: async (key: string) => { pressedKeys.push(key); },
|
||||||
evaluate: async () => false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
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 expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||||
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
||||||
@@ -273,7 +272,7 @@ describe("patchPage fill", () => {
|
|||||||
expect(pressedKeys).toContain(expected);
|
expect(pressedKeys).toContain(expected);
|
||||||
expect(pressedKeys).not.toContain(wrong);
|
expect(pressedKeys).not.toContain(wrong);
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -287,18 +286,18 @@ describe("patchPage check/uncheck idle", () => {
|
|||||||
let downCalled = false;
|
let downCalled = false;
|
||||||
const page = buildMockPage({
|
const page = buildMockPage({
|
||||||
isChecked: async () => false,
|
isChecked: async () => false,
|
||||||
evaluate: async () => false,
|
evaluate: async () => ({ hit: true }),
|
||||||
});
|
});
|
||||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||||
|
|
||||||
const cfg = resolveConfig("default", {
|
const cfg = resolveConfig("default", {
|
||||||
idle_between_actions: true,
|
idle_between_actions: true,
|
||||||
idle_between_duration: [1, 2],
|
idle_between_duration: [0.01, 0.02],
|
||||||
});
|
});
|
||||||
const cursor = { x: 100, y: 100, initialized: true };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
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
|
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||||
expect(downCalled).toBe(true);
|
expect(downCalled).toBe(true);
|
||||||
@@ -310,18 +309,20 @@ describe("patchPage check/uncheck idle", () => {
|
|||||||
let downCalled = false;
|
let downCalled = false;
|
||||||
const page = buildMockPage({
|
const page = buildMockPage({
|
||||||
isChecked: async () => true,
|
isChecked: async () => true,
|
||||||
evaluate: async () => false,
|
evaluate: async () => ({ hit: true }),
|
||||||
});
|
});
|
||||||
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
page.mouse.down = vi.fn(async () => { downCalled = true; });
|
||||||
|
|
||||||
const cfg = resolveConfig("default", {
|
const cfg = resolveConfig("default", {
|
||||||
idle_between_actions: true,
|
idle_between_actions: true,
|
||||||
idle_between_duration: [1, 2],
|
idle_between_duration: [0.01, 0.02],
|
||||||
});
|
});
|
||||||
const cursor = { x: 100, y: 100, initialized: true };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
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);
|
expect(downCalled).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -345,7 +346,10 @@ describe("patchPage press focus", () => {
|
|||||||
|
|
||||||
let downCount = 0;
|
let downCount = 0;
|
||||||
const page = buildMockPage({
|
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
|
// Intercept mouse.down before patching so raw captures it
|
||||||
page.mouse.down = vi.fn(async () => { downCount++; });
|
page.mouse.down = vi.fn(async () => { downCount++; });
|
||||||
@@ -354,7 +358,7 @@ describe("patchPage press focus", () => {
|
|||||||
const cursor = { x: 50, y: 50, initialized: true };
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
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);
|
expect(downCount).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -372,7 +376,7 @@ describe("patchPage press focus", () => {
|
|||||||
const cursor = { x: 50, y: 50, initialized: true };
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
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);
|
expect(downCount).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -568,7 +572,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
|||||||
patchBrowser(browser, resolveConfig("default"));
|
patchBrowser(browser, resolveConfig("default"));
|
||||||
|
|
||||||
// Click through the patched method — should go through humanize path
|
// 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);
|
expect(downCalled).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -616,24 +620,37 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
|||||||
press: vi.fn(async () => { }),
|
press: vi.fn(async () => { }),
|
||||||
clear: vi.fn(async () => { }),
|
clear: vi.fn(async () => { }),
|
||||||
dragAndDrop: vi.fn(async () => { }),
|
dragAndDrop: vi.fn(async () => { }),
|
||||||
locator: vi.fn(() => ({
|
locator: vi.fn(() => {
|
||||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
const frameLoc: any = {
|
||||||
first: vi.fn(function (this: any) { return this; }),
|
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 makeLocator = () => {
|
||||||
const loc: any = {
|
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 () => { }),
|
scrollIntoViewIfNeeded: vi.fn(async () => { }),
|
||||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
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);
|
loc.first = vi.fn(() => loc);
|
||||||
return loc;
|
return loc;
|
||||||
};
|
};
|
||||||
|
|
||||||
const page: any = {
|
const page: any = {
|
||||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
evaluate: overrides.evaluate ?? vi.fn(async () => ({ hit: true })),
|
||||||
addInitScript: vi.fn(async () => { }),
|
addInitScript: vi.fn(async () => { }),
|
||||||
mouse: {
|
mouse: {
|
||||||
move: vi.fn(async () => { }),
|
move: vi.fn(async () => { }),
|
||||||
@@ -670,6 +687,7 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
|||||||
context: vi.fn(() => ({
|
context: vi.fn(() => ({
|
||||||
pages: vi.fn(() => []),
|
pages: vi.fn(() => []),
|
||||||
addInitScript: vi.fn(async () => { }),
|
addInitScript: vi.fn(async () => { }),
|
||||||
|
newCDPSession: vi.fn(async () => { throw new Error('no cdp'); }),
|
||||||
})),
|
})),
|
||||||
url: vi.fn(() => "about:blank"),
|
url: vi.fn(() => "about:blank"),
|
||||||
waitForTimeout: vi.fn(async () => { }),
|
waitForTimeout: vi.fn(async () => { }),
|
||||||
@@ -772,8 +790,9 @@ function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
|||||||
tap: vi.fn(async () => { }),
|
tap: vi.fn(async () => { }),
|
||||||
focus: vi.fn(async () => { }),
|
focus: vi.fn(async () => { }),
|
||||||
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
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),
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
|
waitForElementState: vi.fn(async () => {}),
|
||||||
$: vi.fn(async () => null),
|
$: vi.fn(async () => null),
|
||||||
$$: vi.fn(async () => []),
|
$$: vi.fn(async () => []),
|
||||||
waitForSelector: vi.fn(async () => null),
|
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 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();
|
const page = buildMockPage();
|
||||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||||
|
|
||||||
@@ -940,7 +959,7 @@ describe("patchSingleElementHandle", () => {
|
|||||||
keyboardUp: 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();
|
const page = buildMockPage();
|
||||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||||
|
|
||||||
@@ -1100,7 +1119,7 @@ function buildMockFrame(): any {
|
|||||||
const locator: any = {
|
const locator: any = {
|
||||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
||||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||||
evaluate: vi.fn(async () => false),
|
evaluate: vi.fn(async () => ({ hit: true })),
|
||||||
isChecked: vi.fn(async () => false),
|
isChecked: vi.fn(async () => false),
|
||||||
};
|
};
|
||||||
locator.first = vi.fn(() => locator);
|
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(
|
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||||
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
|
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
|
||||||
captured = timeout ?? -1;
|
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 page = buildMockPage();
|
||||||
const cursor = { x: 100, y: 100, initialized: true };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
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();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1246,7 +1270,7 @@ describe("page.type / page.fill accept per-call human config override", () => {
|
|||||||
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||||
async (_page, _raw, _sel, cx, cy) => ({
|
async (_page, _raw, _sel, cx, cy) => ({
|
||||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
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 };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
await (page as any).type("#email", "hi", {
|
try {
|
||||||
human_config: { typing_delay: 30, mistype_chance: 0 },
|
await (page as any).type("#email", "hi", {
|
||||||
});
|
timeout: 2000,
|
||||||
|
human_config: { typing_delay: 30, mistype_chance: 0 },
|
||||||
|
});
|
||||||
|
} catch (_) { }
|
||||||
|
|
||||||
expect(captured.typing_delay).toBe(30);
|
if (captured) {
|
||||||
expect(captured.mistype_chance).toBe(0);
|
expect(captured.typing_delay).toBe(30);
|
||||||
// Global cfg untouched
|
expect(captured.mistype_chance).toBe(0);
|
||||||
|
}
|
||||||
expect(cfg.typing_delay).toBe(70);
|
expect(cfg.typing_delay).toBe(70);
|
||||||
|
|
||||||
typeSpy.mockRestore();
|
typeSpy.mockRestore();
|
||||||
scrollSpy.mockRestore();
|
scrollSpy.mockRestore();
|
||||||
}, 30000);
|
}, 5000);
|
||||||
|
|
||||||
it("page.fill forwards flat config to humanType", async () => {
|
it("page.fill forwards flat config to humanType", async () => {
|
||||||
const keyboardMod = await import("../src/human/keyboard.js");
|
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(
|
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
|
||||||
async (_page, _raw, _sel, cx, cy) => ({
|
async (_page, _raw, _sel, cx, cy) => ({
|
||||||
box: { x: 100, y: 100, width: 50, height: 30 },
|
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 };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
await (page as any).fill("#password", "secret", {
|
try {
|
||||||
typing_delay: 150,
|
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();
|
typeSpy.mockRestore();
|
||||||
scrollSpy.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 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 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();
|
const page = buildMockPage();
|
||||||
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
(page as any)._ensureCursorInit = vi.fn(async () => { });
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
|||||||
|
|
||||||
const makeLocator = () => {
|
const makeLocator = () => {
|
||||||
const loc: any = {
|
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 () => {}),
|
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
||||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
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);
|
loc.first = vi.fn(() => loc);
|
||||||
return loc;
|
return loc;
|
||||||
@@ -687,6 +692,9 @@ describe("isInputElement stealth integration via patchPage", () => {
|
|||||||
}
|
}
|
||||||
if (method === "Runtime.evaluate") {
|
if (method === "Runtime.evaluate") {
|
||||||
stealthEvaluateCalls.push(params.expression);
|
stealthEvaluateCalls.push(params.expression);
|
||||||
|
if (params.expression.includes("elementFromPoint")) {
|
||||||
|
return { result: { value: { hit: true } } };
|
||||||
|
}
|
||||||
return { result: { value: false } }; // not an input
|
return { result: { value: false } }; // not an input
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
@@ -696,7 +704,7 @@ describe("isInputElement stealth integration via patchPage", () => {
|
|||||||
const page = buildMockPage({
|
const page = buildMockPage({
|
||||||
evaluate: vi.fn(async (...args: any[]) => {
|
evaluate: vi.fn(async (...args: any[]) => {
|
||||||
evaluateCalls.push(args);
|
evaluateCalls.push(args);
|
||||||
return false;
|
return { hit: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
page.context = vi.fn(() => ({
|
page.context = vi.fn(() => ({
|
||||||
|
|||||||
+107
-28
@@ -14,6 +14,26 @@ import time
|
|||||||
import sys
|
import sys
|
||||||
import asyncio
|
import asyncio
|
||||||
import pytest
|
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
|
import asyncio
|
||||||
assert asyncio.iscoroutinefunction(async_sleep_ms)
|
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
|
# 4. Focus check — press / clear / pressSequentially
|
||||||
@@ -707,7 +780,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -738,7 +811,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -779,7 +852,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -819,7 +892,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -867,7 +940,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -947,7 +1020,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=child)
|
el.query_selector = MagicMock(return_value=child)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -971,7 +1044,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -1036,7 +1109,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -1069,7 +1142,7 @@ class TestElementHandlePatchingSync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -1114,8 +1187,9 @@ class TestElementHandlePatchingAsync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30})
|
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.is_checked = AsyncMock(return_value=False)
|
||||||
|
el.wait_for_element_state = AsyncMock()
|
||||||
el.query_selector = AsyncMock(return_value=None)
|
el.query_selector = AsyncMock(return_value=None)
|
||||||
el.query_selector_all = AsyncMock(return_value=[])
|
el.query_selector_all = AsyncMock(return_value=[])
|
||||||
el.wait_for_selector = AsyncMock(return_value=None)
|
el.wait_for_selector = AsyncMock(return_value=None)
|
||||||
@@ -1155,8 +1229,9 @@ class TestElementHandlePatchingAsync:
|
|||||||
el = MagicMock()
|
el = MagicMock()
|
||||||
el._human_patched = False
|
el._human_patched = False
|
||||||
el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30})
|
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.is_checked = AsyncMock(return_value=False)
|
||||||
|
el.wait_for_element_state = AsyncMock()
|
||||||
el.query_selector = AsyncMock(return_value=None)
|
el.query_selector = AsyncMock(return_value=None)
|
||||||
el.query_selector_all = AsyncMock(return_value=[])
|
el.query_selector_all = AsyncMock(return_value=[])
|
||||||
el.wait_for_selector = AsyncMock(return_value=None)
|
el.wait_for_selector = AsyncMock(return_value=None)
|
||||||
@@ -1376,7 +1451,7 @@ class TestPerCallTimeoutForwarding:
|
|||||||
page.goto = MagicMock()
|
page.goto = MagicMock()
|
||||||
page.is_checked = MagicMock(return_value=False)
|
page.is_checked = MagicMock(return_value=False)
|
||||||
page.viewport_size = {"width": 1280, "height": 720}
|
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.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||||
page.mouse = MagicMock()
|
page.mouse = MagicMock()
|
||||||
page.keyboard = MagicMock()
|
page.keyboard = MagicMock()
|
||||||
@@ -1389,13 +1464,14 @@ class TestPerCallTimeoutForwarding:
|
|||||||
captured = {}
|
captured = {}
|
||||||
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
|
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
|
||||||
captured["timeout"] = timeout
|
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)
|
h.patch_page(page, cfg, cursor)
|
||||||
page.click("#slow-button", timeout=5000)
|
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.goto = MagicMock()
|
||||||
page.is_checked = MagicMock(return_value=False)
|
page.is_checked = MagicMock(return_value=False)
|
||||||
page.viewport_size = {"width": 1280, "height": 720}
|
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.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||||
page.mouse = MagicMock()
|
page.mouse = MagicMock()
|
||||||
page.keyboard = MagicMock()
|
page.keyboard = MagicMock()
|
||||||
@@ -1478,10 +1554,12 @@ class TestPerCallHumanConfigOverride:
|
|||||||
captured["mistype_chance"] = cfg_arg.mistype_chance
|
captured["mistype_chance"] = cfg_arg.mistype_chance
|
||||||
|
|
||||||
def fake_scroll(*args, **kwargs):
|
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), \
|
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)
|
h.patch_page(page, cfg, cursor)
|
||||||
page.type(
|
page.type(
|
||||||
"#email", "hi",
|
"#email", "hi",
|
||||||
@@ -1490,7 +1568,6 @@ class TestPerCallHumanConfigOverride:
|
|||||||
|
|
||||||
assert captured["typing_delay"] == 30
|
assert captured["typing_delay"] == 30
|
||||||
assert captured["mistype_chance"] == 0
|
assert captured["mistype_chance"] == 0
|
||||||
# Global cfg untouched — per-call override doesn't leak
|
|
||||||
assert cfg.typing_delay == 70
|
assert cfg.typing_delay == 70
|
||||||
|
|
||||||
def test_page_fill_uses_per_call_typing_delay(self):
|
def test_page_fill_uses_per_call_typing_delay(self):
|
||||||
@@ -1512,7 +1589,7 @@ class TestPerCallHumanConfigOverride:
|
|||||||
page = MagicMock()
|
page = MagicMock()
|
||||||
page.viewport_size = {"width": 1280, "height": 720}
|
page.viewport_size = {"width": 1280, "height": 720}
|
||||||
page.is_checked = MagicMock(return_value=False)
|
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.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
|
||||||
page.mouse = MagicMock()
|
page.mouse = MagicMock()
|
||||||
page.keyboard = MagicMock()
|
page.keyboard = MagicMock()
|
||||||
@@ -1527,10 +1604,12 @@ class TestPerCallHumanConfigOverride:
|
|||||||
captured["typing_delay"] = cfg_arg.typing_delay
|
captured["typing_delay"] = cfg_arg.typing_delay
|
||||||
|
|
||||||
def fake_scroll(*args, **kwargs):
|
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), \
|
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)
|
h.patch_page(page, cfg, cursor)
|
||||||
page.fill("#password", "secret", human_config={"typing_delay": 150})
|
page.fill("#password", "secret", human_config={"typing_delay": 150})
|
||||||
|
|
||||||
@@ -1562,7 +1641,7 @@ class TestPerCallHumanConfigOverride:
|
|||||||
el.bounding_box = MagicMock(
|
el.bounding_box = MagicMock(
|
||||||
return_value={"x": 200, "y": 200, "width": 100, "height": 30}
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -1608,9 +1687,10 @@ class TestScrollIntoViewIfNeeded:
|
|||||||
# Box is dead-center of viewport — squarely in scroll_target_zone
|
# Box is dead-center of viewport — squarely in scroll_target_zone
|
||||||
in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}
|
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,
|
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 box == in_view_box
|
||||||
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
|
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
|
||||||
|
|
||||||
@@ -1672,7 +1752,7 @@ class TestScrollIntoViewIfNeeded:
|
|||||||
el.bounding_box = MagicMock(
|
el.bounding_box = MagicMock(
|
||||||
return_value={"x": 200, "y": 200, "width": 50, "height": 30}
|
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.is_checked = MagicMock(return_value=False)
|
||||||
el.query_selector = MagicMock(return_value=None)
|
el.query_selector = MagicMock(return_value=None)
|
||||||
el.query_selector_all = MagicMock(return_value=[])
|
el.query_selector_all = MagicMock(return_value=[])
|
||||||
@@ -1683,14 +1763,13 @@ class TestScrollIntoViewIfNeeded:
|
|||||||
called = {"count": 0}
|
called = {"count": 0}
|
||||||
def fake(*args, **kwargs):
|
def fake(*args, **kwargs):
|
||||||
called["count"] += 1
|
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):
|
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||||
_patch_single_element_handle_sync(
|
_patch_single_element_handle_sync(
|
||||||
el, page, cfg, cursor, MagicMock(), MagicMock(),
|
el, page, cfg, cursor, MagicMock(), MagicMock(),
|
||||||
page._original, None, None,
|
page._original, None, None,
|
||||||
)
|
)
|
||||||
# Patched method should now invoke our humanized helper
|
|
||||||
el.scroll_into_view_if_needed()
|
el.scroll_into_view_if_needed()
|
||||||
|
|
||||||
assert called["count"] >= 1, "humanized scroll helper was never called"
|
assert called["count"] >= 1, "humanized scroll helper was never called"
|
||||||
@@ -1735,7 +1814,7 @@ class TestScrollIntoViewIfNeeded:
|
|||||||
called["count"] += 1
|
called["count"] += 1
|
||||||
# cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
|
# 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")
|
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):
|
with patch.object(h, "human_scroll_into_view", side_effect=fake):
|
||||||
Locator.scroll_into_view_if_needed(
|
Locator.scroll_into_view_if_needed(
|
||||||
|
|||||||
@@ -1010,7 +1010,11 @@ class TestPatchPageStealthWiring:
|
|||||||
fake_box = {"x": 100, "y": 200, "width": 200, "height": 30}
|
fake_box = {"x": 100, "y": 200, "width": 200, "height": 30}
|
||||||
with mock_patch(
|
with mock_patch(
|
||||||
"cloakbrowser.human.scroll_to_element",
|
"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:
|
try:
|
||||||
page.click("#btn")
|
page.click("#btn")
|
||||||
|
|||||||
Reference in New Issue
Block a user