Compare commits

...
Author SHA1 Message Date
CloakHQ db0b5f1946 release: v0.3.28 — cloakserve path traversal fix, GeoIP timeout guard, humanize iframe scope 2026-05-11 21:43:14 +02:00
CloakHQ babef04e07 fix(cloakserve): sanitize fingerprint seed to prevent path traversal (#217)
Validate seed format with strict regex, add path containment check
before rmtree, and bind to 127.0.0.1 by default on bare metal.
2026-05-11 21:36:09 +02:00
CloakHQ f8026a7b39 chore: clean up GeoIP timeout follow-up (#213)
Remove dead null checks, document CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS
env var, credit contributor.

Fix review findings:
- Use timeout-bounded resolve_proxy_exit_ip in _resolve_webrtc_args
- Add missing timeout handler on tunneled HTTPS request in JS
- Reject nan/inf in Python timeout parsing (parity with JS)
- Recompute deadline after CONNECT succeeds in JS proxy tunnel
2026-05-11 21:15:36 +02:00
manaskarraandGitHub 71f57d00d1 fix: bound GeoIP resolution so launch cannot hang (#213)
* Fix geoip resolution timeout

* fix: keep GeoIP timeout inside resolution path
2026-05-11 20:55:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e9735392e8 chore(deps): bump sigstore/cosign-installer in the actions group (#214)
Bumps the actions group with 1 update: [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer).


Updates `sigstore/cosign-installer` from 4.1.1 to 4.1.2
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 20:55:41 +02:00
CloakHQ 0d41a4f023 refactor(js): extract HumanActionOptions type, fix frame check/uncheck error handling, align SOCKS5 log level
- Extract HumanActionOptions type alias to replace ~40 inline copies
- Frame check/uncheck: let isChecked errors propagate instead of silently clicking non-checkbox elements
- SOCKS5 credential log: console.debug → console.info (parity with Python logger.info)
- Add contributors to README
2026-05-11 00:23:31 +02:00
EternalandCloakHQ 80d9f7c14e feat(js): add types to humanized method options (#205)
Replace `any` with proper TypeScript types on all humanized method options
(Playwright, Puppeteer, ElementHandle, Frame). Support flat per-call config
overrides alongside existing `human_config` style. Internalize `humanIdle`
duration computation with backward-compatible overloads.

Co-authored-by: Eternal <chasezou09@gmail.com>
2026-05-10 23:50:01 +02:00
114b3c826b fix(js): preserve iframe scope in humanized frame actions (#201)
fix(js): preserve iframe scope in humanized frame actions

Frame actions (click, type, fill, etc.) now resolve selectors through frame.locator() instead of delegating to page.* methods, fixing iframe-scoped interactions.

Closes #184

Co-authored-by: manaskarra <manas.karra@gmail.com>
2026-05-10 18:13:29 +02:00
YouhaiandGitHub c07c2b6b4a fix(proxy): log when SOCKS5 credential auto-encoding rewrites URL (#157) (#209)
* fix(proxy): log when SOCKS5 credential auto-encoding rewrites URL (#157)

Auto URL-encoding of SOCKS5 credentials (added in v0.3.26 to fix Chromium's
'=' truncation bug) currently happens silently. Users debugging connectivity
have no way to know the wrapper rewrote their proxy URL — the original #157
thread took 8 round-trips to surface this exact ambiguity.

Emit a log when re-encoding actually changes the URL: INFO on Python's
'cloakbrowser' logger, console.debug in JavaScript. Stays silent on
already-encoded inputs and credential-less URLs to avoid false-positive
noise. Credentials are not included in the log message.

Tests: 3 new cases per language (Python caplog, JS vi.spyOn console.debug)
covering trigger / silent-when-encoded / silent-when-no-creds.

* fix(proxy): gate log on credential change, not full URL diff

Per Copilot review on #209: urlparse cosmetically lowercases scheme and
hostname, so comparing the full reconstructed URL to the input would emit
"Auto URL-encoded SOCKS5..." even for inputs like
`socks5://USER:pass@HOST.com:1080` where no credential encoding happened.

Compare raw vs encoded user/password substrings instead. Mirror the same
condition in JS for parity (JS's manual parser preserves case today, but the
credential-level compare is more robust against future changes).

Adds one regression test per language.
2026-05-10 18:09:46 +02:00
CloakHQ 13b1b98b68 fix(js): bump playwright-core minimum to >=1.53.0 (#200)
playwright-core <=1.52.0 injects __pwInitScripts into window, which
deviceandbrowserinfo.com detects as isPlaywright:true. Fixed in 1.53.0.
2026-05-07 17:31:07 +02:00
CloakHQ 0d6ce76b1d release: v0.3.27 — per-call human_config, humanize timeout fix, scrollIntoViewIfNeeded 2026-05-06 18:32:41 +02:00
CloakHQ f01902025a fix: align humanize timeout default with Playwright's 30s auto-retry (#172)
The humanize layer hardcoded timeout=2000ms for element lookups, causing
locator.click() and page.click() to fail instantly instead of retrying
for 30s like standard Playwright. Aligned all defaults to 30000ms across
Python sync/async, JS Playwright, and JS Puppeteer paths. Bumped the
outer retry sleep from 200ms to 500ms for DOM mutation settle time.
2026-05-01 20:48:06 +02:00
CloakHQ 2df8c7e2d1 fix(js): correct issue references #137#172 in humanize comments 2026-04-28 20:37:09 +02:00
lilosandGitHub 661b873dad feat: per-call human_config, timeout forwarding, humanized scrollIntoViewIfNeeded (#183) 2026-04-28 20:34:17 +02:00
31 changed files with 2578 additions and 521 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
cloakhq/cloakbrowser:latest
provenance: true
sbom: true
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance
+17
View File
@@ -8,6 +8,23 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
## [0.3.28] — 2026-05-11
- **[wrapper]** **Security**: `cloakserve` — sanitize fingerprint seed to prevent path traversal, bind to `127.0.0.1` on bare metal, detect Podman containers (#217)
- **[wrapper]** Fix GeoIP resolution hanging indefinitely — bounded with 10s timeout so `launch()` cannot stall (thanks [@manaskarra](https://github.com/manaskarra), #213)
- **[wrapper]** JS: preserve iframe scope in humanized frame actions — `check()`, `uncheck()`, `selectOption()` now execute in the correct frame (thanks [@manaskarra](https://github.com/manaskarra), #201)
- **[wrapper]** JS: add TypeScript types to humanized method options — `HumanActionOptions` type for `human_config` and `timeout` overrides (thanks [@eofreternal](https://github.com/eofreternal), #205)
- **[wrapper]** Log when SOCKS5 credential auto-encoding rewrites a proxy URL (thanks [@Youhai020616](https://github.com/Youhai020616), #209)
- **[wrapper]** JS: bump `playwright-core` peer dependency minimum to >=1.53.0 (#200)
- **[meta]** Bump sigstore/cosign-installer in CI (#214)
## [0.3.27] — 2026-05-06
- **[wrapper]** Per-call `human_config` override — pass `human_config={...}` to individual humanized methods to override global HumanConfig on a per-action basis (#183)
- **[wrapper]** Humanized `scrollIntoViewIfNeeded` — auto-scrolls with human-like behavior when `humanize=True` (#183)
- **[wrapper]** Forward `timeout` parameter through humanized Playwright methods (#183)
- **[wrapper]** Fix humanize timeout default to align with Playwright's 30s auto-retry instead of custom 2s (#172)
## [0.3.26] — 2026-04-28
- **[binary]** Windows x64 upgraded to Chromium 146.0.7680.177.4 — 57 source-level fingerprint patches (up from 33 on 145.0.7632.159.7), now matches Linux. Includes all binary improvements from 0.3.180.3.25: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3), WebRTC IP spoofing, proxy signal removal, CDP input stealth, storage quota normalization, WebAuthn/AAC/window position patches, WebGL and canvas consistency fixes, expanded GPU model database
+4 -1
View File
@@ -570,6 +570,7 @@ Access the original un-patched Playwright page at `page._original` if you need r
| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Set to `true` to skip SHA-256 verification after download |
| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it |
## Fingerprint Management
@@ -1192,5 +1193,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types
- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard
- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration
+31 -6
View File
@@ -22,6 +22,7 @@ import json
import logging
import os
import random
import re
import shutil
import socket
import subprocess
@@ -61,6 +62,9 @@ BASE_CHROME_ARGS = [
BASE_CDP_PORT = 5100
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
RESERVED_SEEDS = {"__default__"}
# ---------------------------------------------------------------------------
# ChromeProcess — one running Chrome instance
@@ -111,6 +115,14 @@ class ChromePool:
self._locks[seed] = asyncio.Lock()
return self._locks[seed]
def _safe_rmtree(self, path: str) -> None:
resolved = Path(path).resolve()
data_resolved = Path(self._data_dir).resolve()
if resolved == data_resolved or not resolved.is_relative_to(data_resolved):
logger.error("Refusing to delete path outside data_dir: %s", resolved)
return
shutil.rmtree(path, True)
def _allocate_port(self) -> int:
"""Find a free port starting from _next_port."""
for _ in range(100):
@@ -159,6 +171,11 @@ class ChromePool:
seed_key = "__default__"
actual_seed = str(random.randint(10000, 99999))
else:
if not SAFE_SEED_RE.match(seed) or seed in RESERVED_SEEDS:
raise web.HTTPBadRequest(
text=json.dumps({"error": "Invalid fingerprint seed"}),
content_type="application/json",
)
seed_key = seed
actual_seed = seed
@@ -232,7 +249,7 @@ class ChromePool:
if not await self._wait_for_cdp(port):
process.kill()
await asyncio.to_thread(process.wait, timeout=5)
await asyncio.to_thread(shutil.rmtree, user_data_dir, True)
await asyncio.to_thread(self._safe_rmtree, user_data_dir)
raise web.HTTPBadGateway(
text=json.dumps({"error": "Chrome failed to start"}),
content_type="application/json",
@@ -266,8 +283,7 @@ class ChromePool:
await asyncio.to_thread(proc.process.wait, timeout=5)
except subprocess.TimeoutExpired:
proc.process.kill()
# Clean up user data dir (can be slow for large profiles)
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir)
if self._default is proc:
self._default = None
self._locks.pop(key, None)
@@ -559,8 +575,8 @@ async def on_shutdown(app: web.Application) -> None:
# ---------------------------------------------------------------------------
def _default_data_dir() -> str:
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv"):
"""Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return "/tmp/cloakserve"
return str(Path.home() / ".cloakbrowser" / "cloakserve")
@@ -624,6 +640,13 @@ def main() -> None:
binary = ensure_binary()
config, global_args = parse_cli_args(sys.argv[1:])
if config["default_seed"] and (
not SAFE_SEED_RE.match(config["default_seed"])
or config["default_seed"] in RESERVED_SEEDS
):
logger.error("Invalid --fingerprint seed: %s", config["default_seed"])
sys.exit(1)
pool = ChromePool(
binary=binary,
global_args=global_args,
@@ -662,7 +685,9 @@ def main() -> None:
port,
)
web.run_app(app, host="0.0.0.0", port=port, print=None)
in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
host = "0.0.0.0" if in_container else "127.0.0.1"
web.run_app(app, host=host, port=port, print=None)
if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.26"
__version__ = "0.3.28"
+24 -8
View File
@@ -25,6 +25,7 @@ from .human.config import HumanConfigOverrides, HumanPreset
logger = logging.getLogger("cloakbrowser")
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
_VIEWPORT_UNSET = object()
@@ -813,6 +814,10 @@ def _normalize_socks_string_url(url: str) -> str:
truncate them at special chars like '='. Idempotent: pre-encoded input stays
the same (decoded then re-encoded).
Emits an INFO log when re-encoding actually changes the URL, so users who
previously hit silent SOCKS5 fallback (#157) can see what the wrapper did.
Silent on already-encoded inputs (no false-positive noise).
On unparseable input (invalid port, broken IPv6 literal, etc.) logs a
warning and returns the original string — preserves pre-fix pass-through
behavior so Chromium's own error handling kicks in.
@@ -828,18 +833,30 @@ def _normalize_socks_string_url(url: str) -> str:
# urlparse returns None for absent components, "" for present-but-empty.
if parsed.username is None and parsed.password is None:
return url
enc_user = quote(unquote(parsed.username), safe="") if parsed.username else ""
raw_user = parsed.username or ""
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
# Preserve the colon separator when password component is present, even if
# empty, so `user:@host` stays `user:@host`.
if parsed.password is not None:
enc_pass = quote(unquote(parsed.password), safe="") if parsed.password else ""
raw_pass = parsed.password
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
else:
raw_pass = None
enc_pass = None
return _assemble_socks_url(
normalized = _assemble_socks_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
)
# Compare credentials, not the full URL: urlparse cosmetically lowercases
# scheme and hostname, so a full-string compare would falsely fire on
# `socks5://USER:pass@HOST.com:1080` even when no encoding work happened.
if enc_user != raw_user or enc_pass != raw_pass:
logger.info(
"Auto URL-encoded SOCKS5 proxy credentials (special characters "
"detected). Pre-encode the URL to suppress this notice."
)
return normalized
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
@@ -874,7 +891,7 @@ def maybe_resolve_geoip(
if not geoip or not proxy:
return timezone, locale, None
from .geoip import resolve_proxy_geo_with_ip
from .geoip import resolve_proxy_exit_ip, resolve_proxy_geo_with_ip
proxy_url = _extract_proxy_url(proxy)
if not proxy_url:
@@ -882,8 +899,7 @@ def maybe_resolve_geoip(
# When both tz/locale are explicit, still resolve exit IP for WebRTC
if timezone is not None and locale is not None:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
exit_ip = resolve_proxy_exit_ip(proxy_url)
return timezone, locale, exit_ip
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
@@ -918,8 +934,8 @@ def _resolve_webrtc_args(
del args[idx]
return args
try:
from .geoip import _resolve_exit_ip
exit_ip = _resolve_exit_ip(proxy_url)
from .geoip import resolve_proxy_exit_ip
exit_ip = resolve_proxy_exit_ip(proxy_url)
except Exception:
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
args = list(args)
-1
View File
@@ -60,7 +60,6 @@ def _show_welcome() -> None:
sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
sys.stderr.write("\n")
sys.stderr.write(" Issues? https://github.com/CloakHQ/CloakBrowser/issues\n")
sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n")
+65 -5
View File
@@ -12,6 +12,8 @@ from __future__ import annotations
import ipaddress
import logging
import math
import os
import socket
import tempfile
import threading
@@ -27,6 +29,8 @@ GEOIP_DB_URL = (
)
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
DEFAULT_GEOIP_TIMEOUT_SECONDS = 5.0
GEOIP_TIMEOUT_ENV = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS"
# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
COUNTRY_LOCALE_MAP: dict[str, str] = {
@@ -77,11 +81,16 @@ def resolve_proxy_geo_with_ip(
if db_path is None:
return None, None, None
timeout = _get_geoip_timeout_seconds()
deadline = _deadline_from_timeout(timeout)
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
ip = _resolve_exit_ip(proxy_url)
if ip is None:
ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
if ip is None and not _deadline_expired(deadline):
ip = _resolve_proxy_ip(proxy_url)
if ip is None:
if ip is None or _deadline_expired(deadline):
if deadline is not None and _deadline_expired(deadline):
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
return None, None, None
try:
@@ -152,13 +161,64 @@ _IP_ECHO_URLS = [
]
def _resolve_exit_ip(proxy_url: str) -> str | None:
def _get_geoip_timeout_seconds() -> float:
raw = os.getenv(GEOIP_TIMEOUT_ENV)
if not raw:
return DEFAULT_GEOIP_TIMEOUT_SECONDS
try:
timeout = float(raw)
except ValueError:
timeout = float("nan")
if not math.isfinite(timeout):
logger.warning(
"Invalid %s=%r; using %.1fs",
GEOIP_TIMEOUT_ENV,
raw,
DEFAULT_GEOIP_TIMEOUT_SECONDS,
)
return DEFAULT_GEOIP_TIMEOUT_SECONDS
return max(timeout, 0.0)
def _deadline_from_timeout(timeout: float) -> float | None:
if timeout <= 0:
return None
return time.monotonic() + timeout
def _remaining_seconds(deadline: float | None) -> float | None:
if deadline is None:
return None
return max(deadline - time.monotonic(), 0.0)
def _deadline_expired(deadline: float | None) -> bool:
return deadline is not None and time.monotonic() >= deadline
def resolve_proxy_exit_ip(proxy_url: str) -> str | None:
"""Resolve only the proxy exit IP, bounded by the GeoIP timeout."""
timeout = _get_geoip_timeout_seconds()
deadline = _deadline_from_timeout(timeout)
ip = _resolve_exit_ip(proxy_url, timeout=timeout)
if ip is None and _deadline_expired(deadline):
logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout)
return ip
def _resolve_exit_ip(proxy_url: str, timeout: float | None = None) -> str | None:
"""Discover the proxy's actual exit IP by connecting through it."""
import httpx
deadline = _deadline_from_timeout(timeout or 0)
for url in _IP_ECHO_URLS:
try:
resp = httpx.get(url, proxy=proxy_url, timeout=10.0)
remaining = _remaining_seconds(deadline)
if remaining is not None and remaining <= 0:
return None
request_timeout = min(10.0, remaining) if remaining is not None else 10.0
resp = httpx.get(url, proxy=proxy_url, timeout=request_timeout)
resp.raise_for_status()
ip = resp.text.strip()
# Validate it looks like an IP
+304 -95
View File
@@ -18,23 +18,23 @@ import logging
import sys
from typing import Any, Optional
from .config import HumanConfig, HumanPreset, resolve_config
from .config import HumanConfig, HumanPreset, resolve_config, merge_config
from .config import rand, rand_range, sleep_ms, async_sleep_ms
from .mouse import RawMouse, human_move, human_click, click_target, human_idle
from .keyboard import RawKeyboard, human_type
from .scroll import scroll_to_element
from .scroll import scroll_to_element, human_scroll_into_view
from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle
from .keyboard_async import AsyncRawKeyboard, async_human_type
from .scroll_async import async_scroll_to_element
from .scroll_async import async_scroll_to_element, async_human_scroll_into_view
_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a"
__all__ = [
"patch_browser", "patch_context", "patch_page",
"patch_browser_async", "patch_context_async", "patch_page_async",
"HumanConfig", "resolve_config",
"HumanConfig", "resolve_config", "merge_config",
"human_move", "human_click", "click_target", "human_idle",
"human_type", "scroll_to_element",
"human_type", "scroll_to_element", "human_scroll_into_view",
]
logger = logging.getLogger("cloakbrowser.human")
@@ -356,6 +356,7 @@ def _patch_locator_class_sync():
_orig_tap = Locator.tap
_orig_drag_to = Locator.drag_to
_orig_clear = Locator.clear
_orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None)
def _get_selector(self):
return self._impl_obj._selector
@@ -366,36 +367,76 @@ def _patch_locator_class_sync():
def _get_cfg(self):
return getattr(self.page, '_human_cfg', None)
# Forward only options the page-level humanized methods understand
# (timeout, human_config). Other Locator-specific kwargs (force, trial,
# noWaitAfter, ...) are silently dropped — the humanized path doesn't
# consult them.
def _forward_kwargs(kwargs):
out = {}
if "timeout" in kwargs:
out["timeout"] = kwargs["timeout"]
if "human_config" in kwargs:
out["human_config"] = kwargs["human_config"]
return out
def _humanized_fill(self, value, **kwargs):
if _is_humanized(self):
self.page.fill(_get_selector(self), value)
self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
else:
_orig_fill(self, value, **kwargs)
def _humanized_click(self, **kwargs):
if _is_humanized(self):
self.page.click(_get_selector(self))
self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_click(self, **kwargs)
def _humanized_type(self, text, **kwargs):
if _is_humanized(self):
self.page.type(_get_selector(self), text)
self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
else:
_orig_type(self, text, **kwargs)
def _humanized_dblclick(self, **kwargs):
if _is_humanized(self):
self.page.dblclick(_get_selector(self))
self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_dblclick(self, **kwargs)
def _humanized_hover(self, **kwargs):
if _is_humanized(self):
self.page.hover(_get_selector(self))
self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
else:
_orig_hover(self, **kwargs)
def _humanized_scroll_into_view_if_needed(self, **kwargs):
if _is_humanized(self):
page = self.page
cfg = _get_cfg(self)
cursor = getattr(page, '_human_cursor', None)
raw = getattr(page, '_human_raw_mouse', None)
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
if call_cfg is None or cursor is None or raw is None:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
return _orig_scroll_into_view(self, **native_kwargs)
return
timeout = kwargs.get("timeout", 30000)
try:
_, nx, ny = human_scroll_into_view(
page, raw,
lambda: self.bounding_box(timeout=timeout),
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
_orig_scroll_into_view(self, **native_kwargs)
elif _orig_scroll_into_view is not None:
_orig_scroll_into_view(self, **kwargs)
def _humanized_check(self, **kwargs):
if _is_humanized(self):
cfg = _get_cfg(self)
@@ -512,6 +553,8 @@ def _patch_locator_class_sync():
Locator.tap = _humanized_tap
Locator.drag_to = _humanized_drag_to
Locator.clear = _humanized_clear
if _orig_scroll_into_view is not None:
Locator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
# ============================================================================
@@ -544,6 +587,7 @@ def _patch_locator_class_async():
_orig_tap = AsyncLocator.tap
_orig_drag_to = AsyncLocator.drag_to
_orig_clear = AsyncLocator.clear
_orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None)
def _get_selector(self):
return self._impl_obj._selector
@@ -554,36 +598,74 @@ def _patch_locator_class_async():
def _get_cfg(self):
return getattr(self.page, '_human_cfg', None)
def _forward_kwargs(kwargs):
out = {}
if "timeout" in kwargs:
out["timeout"] = kwargs["timeout"]
if "human_config" in kwargs:
out["human_config"] = kwargs["human_config"]
return out
async def _humanized_fill(self, value, **kwargs):
if _is_humanized(self):
await self.page.fill(_get_selector(self), value)
await self.page.fill(_get_selector(self), value, **_forward_kwargs(kwargs))
else:
await _orig_fill(self, value, **kwargs)
async def _humanized_click(self, **kwargs):
if _is_humanized(self):
await self.page.click(_get_selector(self))
await self.page.click(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_click(self, **kwargs)
async def _humanized_type(self, text, **kwargs):
if _is_humanized(self):
await self.page.type(_get_selector(self), text)
await self.page.type(_get_selector(self), text, **_forward_kwargs(kwargs))
else:
await _orig_type(self, text, **kwargs)
async def _humanized_dblclick(self, **kwargs):
if _is_humanized(self):
await self.page.dblclick(_get_selector(self))
await self.page.dblclick(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_dblclick(self, **kwargs)
async def _humanized_hover(self, **kwargs):
if _is_humanized(self):
await self.page.hover(_get_selector(self))
await self.page.hover(_get_selector(self), **_forward_kwargs(kwargs))
else:
await _orig_hover(self, **kwargs)
async def _humanized_scroll_into_view_if_needed(self, **kwargs):
if _is_humanized(self):
page = self.page
cfg = _get_cfg(self)
cursor = getattr(page, '_human_cursor', None)
raw = getattr(page, '_human_raw_mouse', None)
call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None
if call_cfg is None or cursor is None or raw is None:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
await _orig_scroll_into_view(self, **native_kwargs)
return
timeout = kwargs.get("timeout", 30000)
async def _get_box():
return await self.bounding_box(timeout=timeout)
try:
_, nx, ny = await async_human_scroll_into_view(
page, raw, _get_box,
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
await _orig_scroll_into_view(self, **native_kwargs)
elif _orig_scroll_into_view is not None:
await _orig_scroll_into_view(self, **kwargs)
async def _humanized_check(self, **kwargs):
if _is_humanized(self):
cfg = _get_cfg(self)
@@ -708,6 +790,8 @@ def _patch_locator_class_async():
AsyncLocator.tap = _humanized_tap
AsyncLocator.drag_to = _humanized_drag_to
AsyncLocator.clear = _humanized_clear
if _orig_scroll_into_view is not None:
AsyncLocator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed
# ============================================================================
@@ -738,6 +822,7 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
page._original = originals
page._human_cfg = cfg
page._human_cursor = cursor
# --- Stealth infrastructure ---
try:
@@ -764,6 +849,8 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
"insert_text": originals.keyboard_insert_text,
})()
page._human_raw_mouse = raw_mouse
def _ensure_cursor_init() -> None:
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
@@ -780,32 +867,36 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
def _human_click(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init()
if cfg.idle_between_actions:
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, cfg
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
)
cursor.x = cx
cursor.y = cy
is_input = _is_input_element(page, selector)
target = click_target(box, is_input, cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
target = click_target(box, is_input, call_cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
human_click(raw_mouse, is_input, cfg)
human_click(raw_mouse, is_input, call_cfg)
def _human_dblclick(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init()
if cfg.idle_between_actions:
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, cfg
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
)
cursor.x = cx
cursor.y = cy
is_input = _is_input_element(page, selector)
target = click_target(box, is_input, cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
target = click_target(box, is_input, call_cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
raw_mouse.down(click_count=2)
@@ -814,33 +905,39 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
def _human_hover(selector: str, **kwargs: Any) -> None:
_ensure_cursor_init()
if cfg.idle_between_actions:
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy = scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, cfg
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
)
cursor.x = cx
cursor.y = cy
target = click_target(box, False, cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
target = click_target(box, False, call_cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
def _human_type(selector: str, text: str, **kwargs: Any) -> None:
sleep_ms(rand_range(cfg.field_switch_delay))
_human_click(selector)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
sleep_ms(rand_range(call_cfg.field_switch_delay))
# Forward kwargs so timeout / human_config also propagate to the click
# that focuses the field.
_human_click(selector, **kwargs)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
sleep_ms(rand_range(cfg.field_switch_delay))
_human_click(selector)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
sleep_ms(rand_range(call_cfg.field_switch_delay))
_human_click(selector, **kwargs)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
def _human_check(selector: str, **kwargs: Any) -> None:
try:
@@ -958,6 +1055,7 @@ def _patch_single_element_handle_sync(
_orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap
_orig_focus = el.focus
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
# Nested selectors
_orig_qs = el.query_selector
@@ -992,39 +1090,57 @@ def _patch_single_element_handle_sync(
el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs
# Helper: move cursor to element
def _move_to_element():
# Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call
# ``human_config`` overrides on type/fill carry through to mouse timing.
# Also scrolls into view first so off-screen elements don't silently fall
# back to the unpatched native method (#129, #172 follow-up).
def _move_to_element(call_cfg: HumanConfig = cfg):
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
# Scroll into view first — best-effort. If the element can't be located
# we fall through to bounding_box() below which returns None and lets
# the caller fall back to the original Playwright method.
try:
_, nx, ny = human_scroll_into_view(
page, raw_mouse, lambda: el.bounding_box(),
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
pass
box = el.bounding_box()
if not box:
return None
is_inp = _is_input_element_handle_sync(el)
target = click_target(box, is_inp, cfg)
target = click_target(box, is_inp, call_cfg)
if cfg.idle_between_actions:
human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
if call_cfg.idle_between_actions:
human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
return {'box': box, 'is_inp': is_inp}
# --- el.click() ---
def _human_el_click(**kwargs: Any) -> None:
info = _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = _move_to_element(call_cfg)
if info is None:
return _orig_click(**kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
def _human_el_dblclick(**kwargs: Any) -> None:
info = _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = _move_to_element(call_cfg)
if info is None:
return _orig_dblclick(**kwargs)
raw_mouse.down(click_count=2)
@@ -1033,32 +1149,62 @@ def _patch_single_element_handle_sync(
# --- el.hover() ---
def _human_el_hover(**kwargs: Any) -> None:
info = _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = _move_to_element(call_cfg)
if info is None:
return _orig_hover(**kwargs)
# Just move, no click
# --- el.type() ---
def _human_el_type(text: str, **kwargs: Any) -> None:
info = _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = _move_to_element(call_cfg)
if info is None:
return _orig_type(text, **kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session)
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
# --- el.fill() ---
def _human_el_fill(value: str, **kwargs: Any) -> None:
info = _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = _move_to_element(call_cfg)
if info is None:
return _orig_fill(value, **kwargs)
human_click(raw_mouse, info['is_inp'], cfg)
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
sleep_ms(rand(30, 80))
originals.keyboard_press("Backspace")
sleep_ms(rand(50, 150))
human_type(page, raw_keyboard, value, cfg, cdp_session=cdp_session)
human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session)
# --- el.scroll_into_view_if_needed() ---
# Playwright's native version snaps the page — a strong bot signal.
# Replace with the same accelerate → cruise → decelerate → overshoot wheel
# sequence used by page.click(). Falls back to the native method if the
# element is detached or scrolling fails.
def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
if not cursor.initialized:
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
try:
originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
except Exception:
pass
try:
_, nx, ny = human_scroll_into_view(
page, raw_mouse, lambda: el.bounding_box(),
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
_orig_scroll_into_view(**native_kwargs)
# --- el.press() ---
def _human_el_press(key: str, **kwargs: Any) -> None:
@@ -1142,6 +1288,8 @@ def _patch_single_element_handle_sync(
el.set_checked = _human_el_set_checked
el.tap = _human_el_tap
el.focus = _human_el_focus
if _orig_scroll_into_view is not None:
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
def _patch_page_element_handles_sync(
@@ -1425,6 +1573,7 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
page._original = originals
page._human_cfg = cfg
page._human_cursor = cursor
# --- Stealth infrastructure (lazy-initialized, async) ---
stealth = _AsyncIsolatedWorld(page)
@@ -1454,6 +1603,8 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
"insert_text": originals.keyboard_insert_text,
})()
page._human_raw_mouse = raw_mouse
async def _ensure_cursor_init() -> None:
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
@@ -1469,32 +1620,36 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
async def _human_click(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init()
if cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, cfg
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
)
cursor.x = cx
cursor.y = cy
is_input = await _async_is_input_element(page, selector)
target = click_target(box, is_input, cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
target = click_target(box, is_input, call_cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
await async_human_click(raw_mouse, is_input, cfg)
await async_human_click(raw_mouse, is_input, call_cfg)
async def _human_dblclick(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init()
if cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, cfg
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
)
cursor.x = cx
cursor.y = cy
is_input = await _async_is_input_element(page, selector)
target = click_target(box, is_input, cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
target = click_target(box, is_input, call_cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
await raw_mouse.down(click_count=2)
@@ -1503,35 +1658,39 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
async def _human_hover(selector: str, **kwargs: Any) -> None:
await _ensure_cursor_init()
if cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
timeout = kwargs.get("timeout", 30000)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
box, cx, cy = await async_scroll_to_element(
page, raw_mouse, selector, cursor.x, cursor.y, cfg
page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=timeout,
)
cursor.x = cx
cursor.y = cy
target = click_target(box, False, cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
target = click_target(box, False, call_cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
async def _human_type(selector: str, text: str, **kwargs: Any) -> None:
await async_sleep_ms(rand_range(cfg.field_switch_delay))
await _human_click(selector)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _human_click(selector, **kwargs)
await async_sleep_ms(rand(100, 250))
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
async def _human_fill(selector: str, value: str, **kwargs: Any) -> None:
await async_sleep_ms(rand_range(cfg.field_switch_delay))
await _human_click(selector)
call_cfg = merge_config(cfg, kwargs.get("human_config"))
await async_sleep_ms(rand_range(call_cfg.field_switch_delay))
await _human_click(selector, **kwargs)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
cdp = await _ensure_cdp()
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
async def _human_check(selector: str, **kwargs: Any) -> None:
try:
@@ -1637,6 +1796,7 @@ def _patch_single_element_handle_async(
_orig_set_checked = getattr(el, 'set_checked', None)
_orig_tap = el.tap
_orig_focus = el.focus
_orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None)
# Nested selectors
_orig_qs = el.query_selector
@@ -1671,25 +1831,41 @@ def _patch_single_element_handle_async(
el.query_selector_all = _patched_qsa
el.wait_for_selector = _patched_wfs
# Helper: move cursor to element (async)
async def _move_to_element():
# Helper: move cursor to element (async). Accepts optional ``call_cfg`` so
# per-call ``human_config`` overrides on type/fill carry through to mouse
# timing. Also scrolls into view first so off-screen elements work
# (#129, #172 follow-up).
async def _move_to_element(call_cfg: HumanConfig = cfg):
if not cursor.initialized:
cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1])
cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1])
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
# Scroll into view first — best-effort.
async def _get_box():
return await el.bounding_box()
try:
_, nx, ny = await async_human_scroll_into_view(
page, raw_mouse, _get_box,
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
pass
box = await el.bounding_box()
if not box:
return None
is_inp = await _async_is_input_element_handle(el)
target = click_target(box, is_inp, cfg)
target = click_target(box, is_inp, call_cfg)
if cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg)
if call_cfg.idle_between_actions:
await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, cfg)
await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg)
cursor.x = target.x
cursor.y = target.y
return {'box': box, 'is_inp': is_inp}
@@ -1704,14 +1880,16 @@ def _patch_single_element_handle_async(
# --- el.click() ---
async def _human_el_click(**kwargs: Any) -> None:
info = await _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_click(**kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
async def _human_el_dblclick(**kwargs: Any) -> None:
info = await _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_dblclick(**kwargs)
await raw_mouse.down(click_count=2)
@@ -1720,33 +1898,62 @@ def _patch_single_element_handle_async(
# --- el.hover() ---
async def _human_el_hover(**kwargs: Any) -> None:
info = await _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_hover(**kwargs)
# --- el.type() ---
async def _human_el_type(text: str, **kwargs: Any) -> None:
info = await _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_type(text, **kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp)
await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp)
# --- el.fill() ---
async def _human_el_fill(value: str, **kwargs: Any) -> None:
info = await _move_to_element()
call_cfg = merge_config(cfg, kwargs.get("human_config"))
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_fill(value, **kwargs)
await async_human_click(raw_mouse, info['is_inp'], cfg)
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
await async_sleep_ms(rand(30, 80))
await originals.keyboard_press("Backspace")
await async_sleep_ms(rand(50, 150))
cdp = await _get_cdp()
await async_human_type(page, raw_keyboard, value, cfg, cdp_session=cdp)
await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp)
# --- el.scroll_into_view_if_needed() ---
async def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None:
call_cfg = merge_config(cfg, kwargs.get("human_config"))
if not cursor.initialized:
cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1])
cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1])
try:
await originals.mouse_move(cursor.x, cursor.y)
cursor.initialized = True
except Exception:
pass
async def _get_box():
return await el.bounding_box()
try:
_, nx, ny = await async_human_scroll_into_view(
page, raw_mouse, _get_box,
cursor.x, cursor.y, call_cfg,
)
cursor.x = nx
cursor.y = ny
except Exception:
if _orig_scroll_into_view is not None:
native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"}
await _orig_scroll_into_view(**native_kwargs)
# --- el.press() ---
async def _human_el_press(key: str, **kwargs: Any) -> None:
@@ -1830,6 +2037,8 @@ def _patch_single_element_handle_async(
el.set_checked = _human_el_set_checked
el.tap = _human_el_tap
el.focus = _human_el_focus
if _orig_scroll_into_view is not None:
el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed
def _patch_page_element_handles_async(
+19
View File
@@ -201,6 +201,25 @@ def resolve_config(
return HumanConfig(**merged)
def merge_config(base: HumanConfig, overrides: dict | None) -> HumanConfig:
"""Merge ``overrides`` (a dict of HumanConfig field names → values) on top of
``base``. Returns a new HumanConfig — ``base`` is never mutated.
Used by per-call overrides like ``page.type(sel, text, human_config={...})``
so the same page can use different timings for different inputs without
re-patching.
Unknown keys are ignored silently to keep this forgiving for callers.
"""
if not overrides:
return base
merged = {k: getattr(base, k) for k in base.__dataclass_fields__}
for k, v in overrides.items():
if k in base.__dataclass_fields__:
merged[k] = v
return HumanConfig(**merged)
# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------
+44 -13
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from typing import Any, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms
from .mouse import RawMouse, human_move
@@ -18,10 +18,15 @@ def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> boo
return top_edge >= zone_top and bottom_edge <= zone_bottom
def _get_element_box(page: Any, selector: str) -> Optional[dict]:
def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Optional[dict]:
"""Locate ``selector`` and return its bounding box.
The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)``
so callers can extend it for slow-loading elements (#172).
"""
try:
el = page.locator(selector).first
return el.bounding_box(timeout=2000)
return el.bounding_box(timeout=timeout)
except Exception:
return None
@@ -39,13 +44,21 @@ def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None:
sleep_ms(rand(8, 20))
def scroll_to_element(
def human_scroll_into_view(
page: Any,
raw: RawMouse,
selector: str,
get_box: Callable[[], Optional[dict]],
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
"""Humanized scrolling that uses an arbitrary ``get_box`` callable
instead of a CSS selector.
Used both by ``scroll_to_element`` (selector-based) and by
``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed``
(handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot
behavior runs everywhere.
"""
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
@@ -53,12 +66,9 @@ def scroll_to_element(
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = _get_element_box(page, selector)
box = get_box()
if box is None:
sleep_ms(200)
box = _get_element_box(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
raise RuntimeError("Element not found while scrolling into view")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
@@ -105,7 +115,7 @@ def scroll_to_element(
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = _get_element_box(page, selector)
box = get_box()
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
@@ -125,8 +135,29 @@ def scroll_to_element(
# Settle
sleep_ms(rand_range(cfg.scroll_settle_delay))
box = _get_element_box(page, selector)
box = get_box()
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
raise RuntimeError("Element lost after scrolling into view")
return box, cursor_x, cursor_y
def scroll_to_element(
page: Any,
raw: RawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
timeout: float = 30000,
) -> Tuple[dict, float, float]:
"""Selector-based humanized scroll.
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
(#172). Default matches Playwright's 30000ms when not specified.
"""
return human_scroll_into_view(
page, raw,
lambda: _get_element_box(page, selector, timeout),
cursor_x, cursor_y, cfg,
)
+43 -13
View File
@@ -8,17 +8,22 @@ from __future__ import annotations
import math
import random
from typing import Any, Optional, Tuple
from typing import Any, Awaitable, Callable, Optional, Tuple
from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms
from .mouse_async import AsyncRawMouse, async_human_move
from .scroll import _is_in_viewport
async def _get_element_box_async(page: Any, selector: str) -> Optional[dict]:
async def _get_element_box_async(
page: Any, selector: str, timeout: float = 30000,
) -> Optional[dict]:
"""Async variant. ``timeout`` is forwarded to Playwright's
``boundingBox(timeout=...)`` so callers can extend it for slow-loading
elements (#172)."""
try:
el = page.locator(selector).first
return await el.bounding_box(timeout=2000)
return await el.bounding_box(timeout=timeout)
except Exception:
return None
@@ -36,13 +41,20 @@ async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig)
await async_sleep_ms(rand(8, 20))
async def async_scroll_to_element(
async def async_human_scroll_into_view(
page: Any,
raw: AsyncRawMouse,
selector: str,
get_box: Callable[[], Awaitable[Optional[dict]]],
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
) -> Tuple[dict, float, float]:
"""Humanized scrolling using an arbitrary async ``get_box`` callable.
Used by both ``async_scroll_to_element`` (selector-based) and the
ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all
scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate
\u2192 overshoot behavior.
"""
viewport = page.viewport_size
if not viewport:
raise RuntimeError("Viewport size not available")
@@ -50,12 +62,9 @@ async def async_scroll_to_element(
viewport_height = viewport["height"]
viewport_width = viewport["width"]
box = await _get_element_box_async(page, selector)
box = await get_box()
if box is None:
await async_sleep_ms(200)
box = await _get_element_box_async(page, selector)
if box is None:
raise RuntimeError(f"Element not found: {selector}")
raise RuntimeError("Element not found while scrolling into view")
if _is_in_viewport(box, viewport_height, cfg):
return box, cursor_x, cursor_y
@@ -102,7 +111,7 @@ async def async_scroll_to_element(
# Check visibility every 3 steps
if i % 3 == 2 or i == total_clicks - 1:
box = await _get_element_box_async(page, selector)
box = await get_box()
if box and _is_in_viewport(box, viewport_height, cfg):
break
if scrolled >= abs_distance * 1.1:
@@ -122,8 +131,29 @@ async def async_scroll_to_element(
# Settle
await async_sleep_ms(rand_range(cfg.scroll_settle_delay))
box = await _get_element_box_async(page, selector)
box = await get_box()
if box is None:
raise RuntimeError(f"Element lost after scrolling: {selector}")
raise RuntimeError("Element lost after scrolling into view")
return box, cursor_x, cursor_y
async def async_scroll_to_element(
page: Any,
raw: AsyncRawMouse,
selector: str,
cursor_x: float, cursor_y: float,
cfg: HumanConfig,
timeout: float = 30000,
) -> Tuple[dict, float, float]:
"""Selector-based humanized scroll (async).
``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers
such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements
(#172). Default matches Playwright's 30000ms when not specified.
"""
async def _get():
return await _get_element_box_async(page, selector, timeout)
return await async_human_scroll_into_view(
page, raw, _get, cursor_x, cursor_y, cfg,
)
+1 -1
View File
@@ -215,7 +215,7 @@ const page = await browser.newPage();
## Requirements
- Node.js >= 20
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
- One of: `playwright-core` >= 1.53 or `puppeteer-core` >= 21
## Troubleshooting
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.26",
"version": "0.3.28",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
@@ -59,7 +59,7 @@
},
"peerDependencies": {
"mmdb-lib": ">=2.0.0",
"playwright-core": ">=1.40.0",
"playwright-core": ">=1.53.0",
"puppeteer-core": ">=21.0.0",
"socks-proxy-agent": ">=10.0.0"
},
@@ -84,7 +84,7 @@
"@types/node": "^20.10.0",
"mmdb-lib": "^3.0.2",
"socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.40.0",
"playwright-core": "^1.53.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0"
+53 -10
View File
@@ -22,6 +22,7 @@ const GEOIP_DB_URL =
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
const DEFAULT_GEOIP_TIMEOUT_MS = 5_000;
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
@@ -68,10 +69,18 @@ export async function resolveProxyGeo(
const dbPath = await ensureGeoipDb();
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
const timeoutMs = getGeoipTimeoutMs();
const deadline = deadlineFromTimeout(timeoutMs);
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
let ip = await resolveExitIp(proxyUrl);
if (!ip) ip = await resolveProxyIp(proxyUrl);
if (!ip) return { timezone: null, locale: null, exitIp: null };
let ip = await resolveExitIp(proxyUrl, remainingMs(deadline));
if (!ip && !deadlineExpired(deadline)) ip = await resolveProxyIp(proxyUrl);
if (!ip || deadlineExpired(deadline)) {
if (deadlineExpired(deadline)) {
console.warn(`[cloakbrowser] GeoIP resolution timed out after ${timeoutMs}ms; continuing without GeoIP`);
}
return { timezone: null, locale: null, exitIp: null };
}
try {
const buf = fs.readFileSync(dbPath);
@@ -87,6 +96,30 @@ export async function resolveProxyGeo(
}
}
function getGeoipTimeoutMs(): number {
const raw = process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS;
if (!raw) return DEFAULT_GEOIP_TIMEOUT_MS;
const timeoutSeconds = Number(raw);
if (!Number.isFinite(timeoutSeconds)) {
console.warn(`[cloakbrowser] Invalid CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS=${raw}; using ${DEFAULT_GEOIP_TIMEOUT_MS / 1000}s`);
return DEFAULT_GEOIP_TIMEOUT_MS;
}
return Math.max(timeoutSeconds, 0) * 1000;
}
function deadlineFromTimeout(timeoutMs: number): number | null {
return timeoutMs > 0 ? performance.now() + timeoutMs : null;
}
function remainingMs(deadline: number | null): number | undefined {
if (deadline === null) return undefined;
return Math.max(deadline - performance.now(), 0);
}
function deadlineExpired(deadline: number | null): boolean {
return deadline !== null && performance.now() >= deadline;
}
// ---------------------------------------------------------------------------
// Proxy IP resolution
// ---------------------------------------------------------------------------
@@ -128,7 +161,8 @@ const IP_ECHO_URLS = [
"https://ifconfig.me/ip",
];
async function resolveExitIp(proxyUrl: string): Promise<string | null> {
async function resolveExitIp(proxyUrl: string, timeoutMs?: number): Promise<string | null> {
const deadline = timeoutMs && timeoutMs > 0 ? performance.now() + timeoutMs : null;
const isSocks = isSocksProxy(proxyUrl);
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
@@ -144,9 +178,11 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
const agent = new SocksProxyAgent(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
const remaining = remainingMs(deadline);
if (remaining !== undefined && remaining <= 0) return null;
try {
const ip = await new Promise<string | null>((resolve) => {
const req = https.request(echoUrl, { agent, timeout: 10_000 }, (res) => {
const req = https.request(echoUrl, { agent, timeout: Math.min(10_000, remaining ?? 10_000) }, (res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
res.on("end", () => {
@@ -173,6 +209,8 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
const proxyUrlObj = new URL(proxyUrl);
for (const echoUrl of IP_ECHO_URLS) {
const remaining = remainingMs(deadline);
if (remaining !== undefined && remaining <= 0) return null;
try {
const ip = await new Promise<string | null>((resolve, reject) => {
const targetUrl = new URL(echoUrl);
@@ -190,13 +228,14 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
).toString("base64"),
}
: {},
timeout: 10_000,
timeout: Math.min(10_000, remaining ?? 10_000),
});
connectReq.on("connect", (_res, socket) => {
const innerRemaining = remainingMs(deadline);
const req = https.request(
echoUrl,
{ socket, timeout: 5_000 } as any,
{ socket, timeout: Math.min(5_000, innerRemaining ?? 5_000) } as any,
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
@@ -207,6 +246,7 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
}
);
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
req.end();
});
@@ -261,7 +301,9 @@ async function downloadGeoipDb(dest: string): Promise<void> {
const tmpPath = `${dest}.tmp.${Date.now()}`;
try {
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
const response = await fetch(GEOIP_DB_URL, {
redirect: "follow",
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
@@ -329,7 +371,8 @@ export async function maybeResolveGeoip(
// When both tz/locale are explicit, still resolve exit IP for WebRTC
if (options.timezone && options.locale) {
const exitIp = await resolveExitIp(proxyUrl) ?? undefined;
const timeoutMs = getGeoipTimeoutMs();
const exitIp = await resolveExitIp(proxyUrl, timeoutMs) ?? undefined;
return { timezone: options.timezone, locale: options.locale, exitIp };
}
@@ -363,7 +406,7 @@ export async function resolveWebrtcArgs(
}
try {
const ip = await resolveExitIp(proxyUrl);
const ip = await resolveExitIp(proxyUrl, getGeoipTimeoutMs());
const result = [...args];
if (ip) {
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
+141 -55
View File
@@ -47,17 +47,17 @@
*/
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
import type { HumanConfig } from '../human/config.js';
import { resolveConfig, rand, randRange, sleep } from '../human/config.js';
import type { HumanConfig, HumanActionOptions } from '../human/config.js';
import { resolveConfig, mergeConfig, rand, randRange, sleep } from '../human/config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement, smoothWheel } from './scroll.js';
import { scrollToElement, humanScrollIntoView, smoothWheel } from './scroll.js';
export type { HumanConfig } from '../human/config.js';
export { resolveConfig } from '../human/config.js';
export { resolveConfig, mergeConfig } from '../human/config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
export { scrollToElement, humanScrollIntoView } from './scroll.js';
// ============================================================================
@@ -319,7 +319,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}
// ==== goto ====
const humanGoto = async (url: string, options?: any) => {
const humanGoto = async (url: string, options?: {
referer?: string;
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
}) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
@@ -327,54 +331,64 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// ==== click (with clickCount support for dblclick) ====
const humanClickFn = async (selector: string, options?: any) => {
const humanClickFn = async (selector: string, options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, isInput, cfg);
await humanClick(raw, isInput, callCfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, isInput, cfg);
await humanClick(raw, isInput, callCfg);
}
};
// ==== hover ====
const humanHoverFn = async (selector: string, options?: any) => {
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const target = clickTarget(box, false, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
};
// ==== type ====
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions & {
delay?: number;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
await humanType(page, rawKb, text, callCfg, cdp);
};
// ==== select ====
@@ -392,7 +406,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// ==== tap ====
const humanTapFn = async (selector: string, options?: any) => {
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
await humanClickFn(selector, options);
};
@@ -410,14 +424,19 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// ============================================================
// Mouse patches
// ============================================================
page.mouse.move = async (x: number, y: number, options?: any) => {
page.mouse.move = async (x: number, y: number, options?: { steps?: number }) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
page.mouse.click = async (x: number, y: number, options?: {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
@@ -452,7 +471,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page.mouse as any).dragAndDrop = async (
start: { x: number; y: number },
target: { x: number; y: number },
options?: any,
options?: { delay?: number },
) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
@@ -472,12 +491,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
// ============================================================
// Keyboard patches
// ============================================================
page.keyboard.type = async (text: string, options?: any) => {
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
page.keyboard.press = async (key: any, options?: any) => {
page.keyboard.press = async (key: any, options?: { delay?: number }) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
@@ -548,7 +567,11 @@ function patchElementHandle(
return els;
};
(page as any).waitForSelector = async (selector: string, options?: any) => {
(page as any).waitForSelector = async (selector: string, options?: {
hidden?: boolean;
timeout?: number;
visible?: boolean;
}) => {
const el = await origWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
@@ -577,6 +600,9 @@ function patchSingleElementHandle(
const origElDragAndDrop = (el as any).dragAndDrop?.bind(el);
const origElSelect = (el as any).select?.bind(el);
const origElDrop = (el as any).drop?.bind(el);
// Puppeteer v22+ adds ElementHandle.scrollIntoView(); earlier versions
// expose it implicitly via evaluate(node => node.scrollIntoView()).
const origElScrollIntoView = (el as any).scrollIntoView?.bind(el);
// --- Nested selectors ---
const origEl$ = el.$.bind(el);
@@ -597,45 +623,69 @@ function patchSingleElementHandle(
return children;
};
(el as any).waitForSelector = async (selector: string, options?: any) => {
(el as any).waitForSelector = async (selector: string, options?: {
hidden?: boolean;
timeout?: number;
visible?: boolean;
}) => {
const child = await origElWaitForSelector(selector, options);
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get box and move cursor ---
const moveToElement = async () => {
// --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
// so type/fill overrides like ``el.type(text, { typing_delay: 30 })``
// carry through to mouse timing for that single call. Also scrolls into
// view first so off-screen elements work (#129, #172 follow-up).
const moveToElement = async (callCfg: HumanConfig = cfg) => {
await (page as any)._ensureCursorInit();
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox().then(b => b ?? null),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch { /* let boundingBox() decide */ }
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg);
const target = clickTarget(box, isInp, callCfg);
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: any) => {
const info = await moveToElement();
(el as any).click = async (options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
const clickCount = options?.clickCount ?? options?.count ?? 1;
if (clickCount >= 2) {
await humanClick(raw, info.isInp, cfg);
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(40, 90));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
} else {
await humanClick(raw, info.isInp, cfg);
await humanClick(raw, info.isInp, callCfg);
}
};
@@ -646,18 +696,43 @@ function patchSingleElementHandle(
};
// --- el.type() ---
(el as any).type = async (text: string, options?: any) => {
const info = await moveToElement();
(el as any).type = async (text: string, options?: HumanActionOptions & { delay?: number }) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg);
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
const cdp = await stealth.getCdpSession().catch(() => null);
await humanType(page, rawKb, text, cfg, cdp);
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- el.scrollIntoView() ---
// Puppeteer-only equivalent of Playwright's scrollIntoViewIfNeeded.
// Replaces the native snap-scroll (a strong bot signal) with the same
// accelerate → cruise → decelerate → overshoot wheel sequence used by
// page.click(). Only patched when the underlying ElementHandle exposes
// ``scrollIntoView`` (Puppeteer v22+).
if (origElScrollIntoView) {
(el as any).scrollIntoView = async (options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await (page as any)._ensureCursorInit();
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox().then(b => b ?? null),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch {
return origElScrollIntoView(options);
}
};
}
// --- el.press() ---
if (origElPress) {
(el as any).press = async (key: string, options?: any) => {
(el as any).press = async (key: string, options?: { delay?: number }) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key as any);
await sleep(randRange(cfg.key_hold));
@@ -696,7 +771,7 @@ function patchSingleElementHandle(
// --- el.drop() ---
if (origElDrop) {
(el as any).drop = async (draggable: ElementHandle, options?: any) => {
(el as any).drop = async (draggable: ElementHandle, options?: { delay?: number }) => {
const srcBox = await draggable.boundingBox();
const tgtBox = await el.boundingBox();
@@ -726,7 +801,7 @@ function patchSingleElementHandle(
// --- el.dragAndDrop() ---
if (origElDragAndDrop) {
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: any) => {
(el as any).dragAndDrop = async (targetEl: ElementHandle, options?: { delay?: number }) => {
const srcBox = await el.boundingBox();
const tgtBox = await targetEl.boundingBox();
@@ -790,15 +865,22 @@ function patchSingleFrame(
const origFrameSelect = frame.select.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
(frame as any).click = async (selector: string, options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle' | 'back' | 'forward';
clickCount?: number;
count?: number;
delay?: number;
}) => {
await (page as any).click(selector, options);
};
(frame as any).hover = async (selector: string, options?: any) => {
(frame as any).hover = async (selector: string, options?: HumanActionOptions) => {
await (page as any).hover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions & {
delay?: number;
}) => {
await (page as any).type(selector, text, options);
};
@@ -812,7 +894,7 @@ function patchSingleFrame(
await (page as any).focus(selector);
};
(frame as any).tap = async (selector: string, options?: any) => {
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
await (page as any).click(selector, options);
};
@@ -835,7 +917,11 @@ function patchSingleFrame(
return els;
};
(frame as any).waitForSelector = async (selector: string, options?: any) => {
(frame as any).waitForSelector = async (selector: string, options?: {
hidden?: boolean;
timeout?: number;
visible?: boolean;
}) => {
const el = await origFrameWaitForSelector(selector, options);
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
@@ -881,7 +967,7 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
if (typeof (browser as any)[methodName] === 'function') {
const origCreateContext = (browser as any)[methodName].bind(browser);
(browser as any)[methodName] = async (options?: any) => {
(browser as any)[methodName] = async (options?: Parameters<typeof origCreateContext>[0]) => {
const context: BrowserContext = await origCreateContext(options);
const origCtxNewPage = context.newPage.bind(context);
+58 -21
View File
@@ -5,7 +5,7 @@
* Changes from Playwright version:
* - page.viewport() instead of page.viewportSize()
* - page.$(selector) + el.boundingBox() instead of page.locator().boundingBox()
* - No timeout parameter on boundingBox()
* - boundingBox() has no timeout param we poll page.$() up to ``timeout`` ms
*/
import type { Page } from 'puppeteer-core';
@@ -55,22 +55,40 @@ export async function smoothWheel(
}
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
try {
const el = await page.$(selector);
if (!el) return null;
const box = await el.boundingBox();
if (!box) return null;
return { x: box.x, y: box.y, width: box.width, height: box.height };
} catch {
return null;
/**
* Poll ``page.$(selector)`` for up to ``timeout`` ms, returning the element's
* bounding box when found. ``timeout`` defaults to 30000ms when not specified.
*/
async function getElementBox(
page: Page,
selector: string,
timeout: number = 30000,
): Promise<ElementBounds | null> {
const start = Date.now();
const pollInterval = 100;
while (true) {
try {
const el = await page.$(selector);
if (el) {
const box = await el.boundingBox();
if (box) return { x: box.x, y: box.y, width: box.width, height: box.height };
}
} catch { /* keep polling */ }
if (Date.now() - start >= timeout) return null;
await sleep(pollInterval);
}
}
export async function scrollToElement(
/**
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
* ``scrollIntoView`` patch.
*/
export async function humanScrollIntoView(
page: Page,
raw: RawMouse,
selector: string,
getBox: () => Promise<ElementBounds | null>,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
@@ -78,12 +96,8 @@ export async function scrollToElement(
const viewport = page.viewport();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
@@ -134,7 +148,7 @@ export async function scrollToElement(
await sleep(pause);
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
@@ -159,8 +173,31 @@ export async function scrollToElement(
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
box = await getBox();
if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY };
}
/**
* Selector-based humanized scroll (Puppeteer).
*
* ``timeout`` controls how long we poll ``page.$(selector)`` before giving up,
* so callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
*/
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
timeout?: number,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
return humanScrollIntoView(
page, raw,
() => getElementBox(page, selector, timeout),
cursorX, cursorY, cfg,
);
}
+21
View File
@@ -70,6 +70,11 @@ export interface HumanConfig {
export type HumanPreset = 'default' | 'careful';
export type HumanActionOptions = Partial<HumanConfig> & {
timeout?: number;
human_config?: Partial<HumanConfig>;
};
// ---------------------------------------------------------------------------
// Default preset
// ---------------------------------------------------------------------------
@@ -201,6 +206,22 @@ export function resolveConfig(
return { ...base, ...overrides };
}
/**
* Merge a partial overrides object on top of an existing HumanConfig.
* Returns a new object the original ``cfg`` is never mutated.
*
* Used by per-call overrides such as ``page.type(sel, text, { human_config: { typing_delay: 30 } })``
* so the same patched page can type different fields at different speeds
* without re-patching.
*/
export function mergeConfig(
cfg: HumanConfig,
overrides?: Partial<HumanConfig> | null,
): HumanConfig {
if (!overrides) return cfg;
return { ...cfg, ...overrides };
}
// ---------------------------------------------------------------------------
// Utility: random number in range
+155 -34
View File
@@ -17,10 +17,11 @@
*/
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
import type { HumanConfig } from './config.js';
import { rand, randRange, sleep } from './config.js';
import type { HumanConfig, HumanActionOptions } from './config.js';
import { rand, randRange, sleep, mergeConfig } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { humanScrollIntoView } from './scroll.js';
// --- Platform-aware select-all shortcut ---
const SELECT_ALL = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
@@ -102,6 +103,7 @@ export function patchSingleElementHandle(
const origElSetChecked = (el as any).setChecked?.bind(el);
const origElTap = el.tap.bind(el);
const origElFocus = el.focus.bind(el);
const origElScrollIntoViewIfNeeded = (el as any).scrollIntoViewIfNeeded?.bind(el);
// Nested selectors
const origEl$ = el.$.bind(el);
@@ -123,44 +125,88 @@ export function patchSingleElementHandle(
return children;
};
(el as any).waitForSelector = async (selector: string, options?: any) => {
const child = await origElWaitForSelector(selector, options);
(el as any).waitForSelector = async (selector: string, options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
strict?: boolean;
timeout?: number;
}) => {
const child = await origElWaitForSelector(selector, options ?? {});
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
return child;
};
// --- Helper: get bounding box and move cursor to element ---
const moveToElement = async () => {
// Accepts a per-call ``callCfg`` so type/fill overrides like
// ``el.type(text, { human_config: { typing_delay: 30 } })`` or
// ``el.type(text, { typing_delay: 30 })`` carry through to mouse movement
// & idle timing for that single call.
// Also scrolls the element into view first so off-screen elements work
// (#129, #172 follow-up): otherwise boundingBox() returns null and we'd
// silently fall back to the unpatched native method.
const moveToElement = async (callCfg: HumanConfig = cfg) => {
// Ensure cursor is initialized
const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await ensureCursorInit();
// Scroll into view first so boundingBox() returns coordinates even when
// the element starts below the fold. Best-effort — if humanScrollIntoView
// throws (e.g. detached element), we let boundingBox() decide whether to
// proceed or fall back to the original method.
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox(),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch { /* let boundingBox() decide */ }
const box = await el.boundingBox();
if (!box) return null;
const isInp = await isInputElementHandle(stealth, el);
const target = clickTarget(box, isInp, cfg);
const target = clickTarget(box, isInp, callCfg);
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { box, isInp };
};
// --- el.click() ---
(el as any).click = async (options?: any) => {
const info = await moveToElement();
(el as any).click = async (options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
noWaitAfter?: boolean;
position?: { x: number; y: number };
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
await humanClick(raw, info.isInp, cfg);
await humanClick(raw, info.isInp, callCfg);
};
// --- el.dblclick() ---
(el as any).dblclick = async (options?: any) => {
const info = await moveToElement();
(el as any).dblclick = async (options?: HumanActionOptions & {
button?: 'left' | 'right' | 'middle';
delay?: number;
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
noWaitAfter?: boolean;
position?: { x: number; y: number };
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElDblclick(options);
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
@@ -168,28 +214,42 @@ export function patchSingleElementHandle(
};
// --- el.hover() ---
(el as any).hover = async (options?: any) => {
const info = await moveToElement();
(el as any).hover = async (options?: HumanActionOptions & {
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
position?: { x: number; y: number };
trial?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElHover(options);
// Just move — no click
};
// --- el.type() ---
(el as any).type = async (text: string, options?: any) => {
const info = await moveToElement();
(el as any).type = async (text: string, options?: HumanActionOptions & {
delay?: number;
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
await humanClick(raw, info.isInp, cfg);
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, text, cfg, cdpSession);
await humanType(page, rawKb, text, callCfg, cdpSession);
};
// --- el.fill() ---
(el as any).fill = async (value: string, options?: any) => {
const info = await moveToElement();
(el as any).fill = async (value: string, options?: HumanActionOptions & {
force?: boolean;
noWaitAfter?: boolean;
}) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options);
await humanClick(raw, info.isInp, cfg);
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
// Clear existing content
await originals.keyboardPress(SELECT_ALL);
@@ -198,11 +258,11 @@ export function patchSingleElementHandle(
await sleep(rand(50, 150));
let cdpSession: CDPSession | null = null;
try { cdpSession = await stealth?.getCdpSession(); } catch {}
await humanType(page, rawKb, value, cfg, cdpSession);
await humanType(page, rawKb, value, callCfg, cdpSession);
};
// --- el.press() ---
(el as any).press = async (key: string, options?: any) => {
(el as any).press = async (key: string, options?: { delay?: number; noWaitAfter?: boolean; timeout?: number }) => {
await sleep(rand(20, 60));
await originals.keyboardDown(key);
await sleep(randRange(cfg.key_hold));
@@ -210,7 +270,11 @@ export function patchSingleElementHandle(
};
// --- el.selectOption() ---
(el as any).selectOption = async (values: any, options?: any) => {
(el as any).selectOption = async (values: any, options?: {
force?: boolean;
noWaitAfter?: boolean;
timeout?: number;
}) => {
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
@@ -219,7 +283,13 @@ export function patchSingleElementHandle(
};
// --- el.check() ---
(el as any).check = async (options?: any) => {
(el as any).check = async (options?: {
force?: boolean;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
try {
const checked = await el.isChecked();
if (checked) return; // Already checked
@@ -230,7 +300,13 @@ export function patchSingleElementHandle(
};
// --- el.uncheck() ---
(el as any).uncheck = async (options?: any) => {
(el as any).uncheck = async (options?: {
force?: boolean;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
try {
const checked = await el.isChecked();
if (!checked) return; // Already unchecked
@@ -242,7 +318,13 @@ export function patchSingleElementHandle(
// --- el.setChecked() ---
if (origElSetChecked) {
(el as any).setChecked = async (checked: boolean, options?: any) => {
(el as any).setChecked = async (checked: boolean, options?: {
force?: boolean;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
try {
const current = await el.isChecked();
if (current === checked) return;
@@ -254,7 +336,14 @@ export function patchSingleElementHandle(
}
// --- el.tap() ---
(el as any).tap = async (options?: any) => {
(el as any).tap = async (options?: {
force?: boolean;
modifiers?: Array<'Alt' | 'Control' | 'ControlOrMeta' | 'Meta' | 'Shift'>;
noWaitAfter?: boolean;
position?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const info = await moveToElement();
if (!info) return origElTap(options);
await humanClick(raw, info.isInp, cfg);
@@ -268,6 +357,30 @@ export function patchSingleElementHandle(
await moveToElement(); // human-like Bézier cursor movement
await origElFocus(); // programmatic focus, no click
};
// --- el.scrollIntoViewIfNeeded() ---
// Playwright's native version snaps the page — a strong bot signal.
// Replace with the same accelerate → cruise → decelerate → overshoot
// wheel sequence used by page.click() etc. Falls back to the native
// method if the element is detached or scrolling fails.
if (origElScrollIntoViewIfNeeded) {
(el as any).scrollIntoViewIfNeeded = async (options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const ensureCursorInit = (page as any)._ensureCursorInit;
if (ensureCursorInit) await ensureCursorInit();
try {
const { cursorX, cursorY } = await humanScrollIntoView(
page, raw,
() => el.boundingBox(),
cursor.x, cursor.y, callCfg,
);
cursor.x = cursorX;
cursor.y = cursorY;
} catch {
return origElScrollIntoViewIfNeeded(options);
}
};
}
}
@@ -309,8 +422,12 @@ export function patchPageElementHandles(
// Patch page.waitForSelector()
if (typeof page.waitForSelector === 'function') {
const origWaitForSelector = page.waitForSelector.bind(page);
(page as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origWaitForSelector(selector, options);
(page as any).waitForSelector = async (selector: string, options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
strict?: boolean;
timeout?: number;
}) => {
const el = await origWaitForSelector(selector, options ?? {});
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
@@ -357,8 +474,12 @@ export function patchFrameElementHandles(
// Patch frame.waitForSelector()
if (typeof frame.waitForSelector === 'function') {
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
(frame as any).waitForSelector = async (selector: string, options?: any) => {
const el = await origFrameWaitForSelector(selector, options);
(frame as any).waitForSelector = async (selector: string, options?: {
state?: 'attached' | 'detached' | 'visible' | 'hidden';
strict?: boolean;
timeout?: number;
}) => {
const el = await origFrameWaitForSelector(selector, options ?? {});
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
return el;
};
+206 -84
View File
@@ -23,16 +23,16 @@
*/
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
import { HumanConfig, resolveConfig, rand, randRange, sleep } from './config.js';
import { HumanConfig, HumanActionOptions, resolveConfig, mergeConfig, rand, randRange, sleep } from './config.js';
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
import { humanType } from './keyboard.js';
import { scrollToElement } from './scroll.js';
import { scrollToElement, humanScrollIntoView } from './scroll.js';
import { patchPageElementHandles, patchFrameElementHandles, patchSingleElementHandle } from './elementhandle.js';
export { HumanConfig, resolveConfig } from './config.js';
export { HumanConfig, resolveConfig, mergeConfig } from './config.js';
export { humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
export { humanType } from './keyboard.js';
export { scrollToElement } from './scroll.js';
export { scrollToElement, humanScrollIntoView } from './scroll.js';
export { patchSingleElementHandle } from './elementhandle.js';
// --- Platform-aware select-all shortcut (macOS uses Meta, others use Control) ---
@@ -295,7 +295,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
}
// --- goto (invalidate isolated world on navigation) ---
const humanGoto = async (url: string, options?: any) => {
const humanGoto = async (url: string, options?: {
referer?: string;
timeout?: number;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
}) => {
const response = await originals.goto(url, options);
stealth.invalidate();
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
@@ -303,34 +307,37 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- click ---
const humanClickFn = async (selector: string, options?: any) => {
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
await humanClick(raw, isInput, cfg);
await humanClick(raw, isInput, callCfg);
};
// --- dblclick ---
const humanDblclickFn = async (selector: string, options?: any) => {
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const isInput = await isInputElement(stealth, page, selector);
const target = clickTarget(box, isInput, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
await raw.down({ clickCount: 2 });
@@ -339,46 +346,49 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- hover ---
const humanHoverFn = async (selector: string, options?: any) => {
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
await ensureCursorInit();
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, cfg);
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
cursor.x = cursorX;
cursor.y = cursorY;
const target = clickTarget(box, false, cfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, cfg);
const target = clickTarget(box, false, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
};
// --- type ---
const humanTypeFn = async (selector: string, text: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- fill (clears existing content first) ---
const humanFillFn = async (selector: string, value: string, options?: any) => {
await sleep(randRange(cfg.field_switch_delay));
await humanClickFn(selector);
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await humanClickFn(selector, options);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
const cdp = await ensureCdp();
await humanType(page, rawKb, value, cfg, cdp);
await humanType(page, rawKb, value, callCfg, cdp);
};
// --- clear ---
const humanClearFn = async (selector: string, options?: any) => {
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
await humanClickFn(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
@@ -387,55 +397,58 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- check ---
const humanCheckFn = async (selector: string, options?: any) => {
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => false);
if (!checked) {
await humanClickFn(selector);
await humanClickFn(selector, options);
}
};
// --- uncheck ---
const humanUncheckFn = async (selector: string, options?: any) => {
if (cfg.idle_between_actions) {
await humanIdle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), cursor.x, cursor.y, cfg);
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const checked = await originals.isChecked(selector).catch(() => true);
if (checked) {
await humanClickFn(selector);
await humanClickFn(selector, options);
}
};
// --- selectOption ---
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
await humanHoverFn(selector);
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
await humanHoverFn(selector, options);
await sleep(rand(100, 300));
return originals.selectOption(selector, values, options);
};
// --- press (checks focus first — avoids redundant mouse moves) ---
const humanPressFn = async (selector: string, key: string, options?: any) => {
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
await humanClickFn(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
// --- pressSequentially ---
const humanPressSequentiallyFn = async (selector: string, text: string, options?: any) => {
const humanPressSequentiallyFn = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (!await isSelectorFocused(stealth, page, selector)) {
await humanClickFn(selector);
await humanClickFn(selector, options);
}
await sleep(rand(100, 250));
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
await humanType(page, rawKb, text, callCfg, cdp);
};
// --- tap ---
const humanTapFn = async (selector: string, options?: any) => {
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
await humanClickFn(selector, options);
};
@@ -455,14 +468,20 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
(page as any).clear = humanClearFn;
// --- mouse patches ---
page.mouse.move = async (x: number, y: number, options?: any) => {
page.mouse.move = async (x: number, y: number, options?: {
steps?: number;
}) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
cursor.y = y;
};
page.mouse.click = async (x: number, y: number, options?: any) => {
page.mouse.click = async (x: number, y: number, options?: {
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
}) => {
await ensureCursorInit();
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
cursor.x = x;
@@ -471,7 +490,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
};
// --- keyboard patches ---
page.keyboard.type = async (text: string, options?: any) => {
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
const cdp = await ensureCdp();
await humanType(page, rawKb, text, cfg, cdp);
};
@@ -523,16 +542,37 @@ function patchFrames(
stealth: StealthEval,
): void {
for (const frame of iterFrames(page)) {
patchSingleFrame(frame, page, cfg, originals, stealth);
patchSingleFrame(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
}
}
function firstFrameLocator(frame: Frame, selector: string): any {
const locator = frame.locator(selector) as any;
return typeof locator.first === 'function' ? locator.first() : locator;
}
async function isFrameInputElement(frame: Frame, selector: string): Promise<boolean> {
return firstFrameLocator(frame, selector).evaluate((el: Element) => {
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea'
|| el.getAttribute('contenteditable') === 'true';
}).catch(() => false);
}
async function isFrameSelectorFocused(frame: Frame, selector: string): Promise<boolean> {
return firstFrameLocator(frame, selector).evaluate((el: Element) => el === document.activeElement)
.catch(() => false);
}
function patchSingleFrame(
frame: Frame,
page: Page,
cfg: HumanConfig,
cursor: CursorState,
raw: RawMouse,
rawKb: RawKeyboard,
originals: any,
stealth: StealthEval,
): void {
@@ -540,58 +580,132 @@ function patchSingleFrame(
(frame as any)._humanPatched = true;
// Save originals for methods that need fallback
const origFrameClick = frame.click.bind(frame);
const origFrameDblclick = frame.dblclick.bind(frame);
const origFrameHover = frame.hover.bind(frame);
const origFrameType = frame.type.bind(frame);
const origFrameFill = frame.fill.bind(frame);
const origFrameCheck = frame.check.bind(frame);
const origFrameUncheck = frame.uncheck.bind(frame);
const origFrameSelectOption = frame.selectOption.bind(frame);
const origFramePress = frame.press.bind(frame);
const origFramePressSequentially = (frame as any).pressSequentially?.bind(frame);
const origFrameTap = (frame as any).tap?.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
(frame as any).click = async (selector: string, options?: any) => {
await (page as any).click(selector, options);
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
}
const locator = firstFrameLocator(frame, selector);
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
}
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
if (!box) return null;
const isInput = inputBias || await isFrameInputElement(frame, selector);
const target = clickTarget(box, isInput, callCfg);
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
cursor.x = target.x;
cursor.y = target.y;
return { callCfg, isInput };
};
(frame as any).dblclick = async (selector: string, options?: any) => {
await (page as any).dblclick(selector, options);
const frameClick = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options);
if (!moved) return origFrameClick(selector, options);
await humanClick(raw, moved.isInput, moved.callCfg);
};
(frame as any).hover = async (selector: string, options?: any) => {
await (page as any).hover(selector, options);
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
const frameHover = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options, false);
if (!moved) return origFrameHover(selector, options);
};
(frame as any).type = async (selector: string, text: string, options?: any) => {
await (page as any).type(selector, text, options);
(frame as any).click = frameClick;
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options);
if (!moved) return origFrameDblclick(selector, options);
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
};
(frame as any).fill = async (selector: string, value: string, options?: any) => {
await (page as any).fill(selector, value, options);
(frame as any).hover = frameHover;
(frame as any).type = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await frameClick(selector, options);
await sleep(rand(100, 250));
const cdp = await getFrameCdp();
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFrameType(selector, text, options));
};
(frame as any).check = async (selector: string, options?: any) => {
await (page as any).check(selector, options);
(frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
await sleep(randRange(callCfg.field_switch_delay));
await frameClick(selector, options);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
await sleep(rand(30, 80));
await originals.keyboardPress('Backspace');
await sleep(rand(50, 150));
const cdp = await getFrameCdp();
await humanType(page, rawKb, value, callCfg, cdp).catch(() => origFrameFill(selector, value, options));
};
(frame as any).uncheck = async (selector: string, options?: any) => {
await (page as any).uncheck(selector, options);
(frame as any).check = async (selector: string, options?: HumanActionOptions) => {
const locator = firstFrameLocator(frame, selector);
if (typeof locator.isChecked !== 'function') return origFrameCheck(selector, options);
const checked = await locator.isChecked();
if (!checked) await frameClick(selector, options).catch(() => origFrameCheck(selector, options));
};
(frame as any).selectOption = async (selector: string, values: any, options?: any) => {
await (page as any).hover(selector);
(frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => {
const locator = firstFrameLocator(frame, selector);
if (typeof locator.isChecked !== 'function') return origFrameUncheck(selector, options);
const checked = await locator.isChecked();
if (checked) await frameClick(selector, options).catch(() => origFrameUncheck(selector, options));
};
(frame as any).selectOption = async (selector: string, values: any, options?: HumanActionOptions) => {
await frameHover(selector, options);
await sleep(rand(100, 300));
return origFrameSelectOption(selector, values, options);
};
(frame as any).press = async (selector: string, key: string, options?: any) => {
await (page as any).press(selector, key, options);
(frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => {
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(key);
};
(frame as any).pressSequentially = async (selector: string, text: string, options?: any) => {
await (page as any).pressSequentially(selector, text, options);
(frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(100, 250));
const cdp = await getFrameCdp();
await humanType(page, rawKb, text, callCfg, cdp).catch(() => origFramePressSequentially?.(selector, text, options));
};
(frame as any).tap = async (selector: string, options?: any) => {
await (page as any).tap(selector, options);
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
await frameClick(selector, options).catch(() => origFrameTap?.(selector, options));
};
(frame as any).clear = async (selector: string, options?: any) => {
if (!await isSelectorFocused(stealth, page, selector)) {
await (page as any).click(selector);
(frame as any).clear = async (selector: string, options?: HumanActionOptions) => {
if (!await isFrameSelectorFocused(frame, selector)) {
await frameClick(selector, options);
}
await sleep(rand(50, 150));
await originals.keyboardPress(SELECT_ALL);
@@ -599,9 +713,17 @@ function patchSingleFrame(
await originals.keyboardPress('Backspace');
};
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
(frame as any).dragAndDrop = async (source: string, target: string, options?: {
force?: boolean;
noWaitAfter?: boolean;
sourcePosition?: { x: number; y: number };
strict?: boolean;
targetPosition?: { x: number; y: number };
timeout?: number;
trial?: boolean;
}) => {
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
@@ -670,14 +792,14 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
}
const origNewContext = browser.newContext.bind(browser);
(browser as any).newContext = async (options?: any) => {
(browser as any).newContext = async (options?: Parameters<typeof origNewContext>[0]) => {
const context = await origNewContext(options);
patchContext(context, cfg);
return context;
};
const origNewPage = browser.newPage.bind(browser);
(browser as any).newPage = async (options?: any) => {
(browser as any).newPage = async (options?: Parameters<typeof origNewPage>[0]) => {
const page = await origNewPage(options);
if (!(page as any)._original) {
const ctx = page.context();
+21 -1
View File
@@ -172,13 +172,33 @@ export async function humanClick(
// Human idle / drift
// ---------------------------------------------------------------------------
export async function humanIdle(
export function humanIdle(
raw: RawMouse,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void>;
export function humanIdle(
raw: RawMouse,
seconds: number,
cx: number,
cy: number,
cfg: HumanConfig,
): Promise<void>;
export async function humanIdle(
raw: RawMouse,
secondsOrCx: number,
cxOrCy: number,
cyOrCfg: number | HumanConfig,
maybeCfg?: HumanConfig,
): Promise<void> {
const hasExplicitSeconds = maybeCfg !== undefined;
const seconds = hasExplicitSeconds
? secondsOrCx
: rand((cyOrCfg as HumanConfig).idle_between_duration[0], (cyOrCfg as HumanConfig).idle_between_duration[1]);
const cx = hasExplicitSeconds ? cxOrCy : secondsOrCx;
const cy = hasExplicitSeconds ? (cyOrCfg as number) : cxOrCy;
const cfg = hasExplicitSeconds ? maybeCfg! : (cyOrCfg as HumanConfig);
const endTime = Date.now() + seconds * 1000;
let x = cx;
let y = cy;
+43 -13
View File
@@ -38,10 +38,17 @@ async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Prom
}
}
export async function scrollToElement(
/**
* Humanized scrolling that takes an arbitrary ``getBox`` callable.
*
* Used by both ``scrollToElement`` (selector-based) and the ElementHandle
* ``scrollIntoViewIfNeeded`` patch so the same accelerate cruise
* decelerate overshoot behavior runs everywhere.
*/
export async function humanScrollIntoView(
page: Page,
raw: RawMouse,
selector: string,
getBox: () => Promise<ElementBounds | null>,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
@@ -49,12 +56,8 @@ export async function scrollToElement(
const viewport = page.viewportSize();
if (!viewport) throw new Error('Viewport size not available');
let box = await getElementBox(page, selector);
if (!box) {
await sleep(200);
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element not found: ${selector}`);
}
let box = await getBox();
if (!box) throw new Error('Element not found while scrolling into view');
if (isInViewport(box, viewport.height, cfg)) {
return { box, cursorX, cursorY };
@@ -107,7 +110,7 @@ export async function scrollToElement(
// Check visibility every 3 steps
if (i % 3 === 2 || i === totalClicks - 1) {
box = await getElementBox(page, selector);
box = await getBox();
if (box && isInViewport(box, viewport.height, cfg)) {
break;
}
@@ -133,16 +136,43 @@ export async function scrollToElement(
// Settle
await sleep(randRange(cfg.scroll_settle_delay));
box = await getElementBox(page, selector);
if (!box) throw new Error(`Element lost after scrolling: ${selector}`);
box = await getBox();
if (!box) throw new Error('Element lost after scrolling into view');
return { box, cursorX, cursorY };
}
async function getElementBox(page: Page, selector: string): Promise<ElementBounds | null> {
/**
* Selector-based humanized scroll.
*
* ``timeout`` is forwarded to Playwright's ``boundingBox({ timeout })`` so
* callers like ``page.click('#x', { timeout: 5000 })`` can wait longer for
* slow-loading elements (#172). Default matches Playwright's 30000ms when not specified.
*/
export async function scrollToElement(
page: Page,
raw: RawMouse,
selector: string,
cursorX: number,
cursorY: number,
cfg: HumanConfig,
timeout?: number,
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
return humanScrollIntoView(
page, raw,
() => getElementBox(page, selector, timeout),
cursorX, cursorY, cfg,
);
}
async function getElementBox(
page: Page,
selector: string,
timeout: number = 30000,
): Promise<ElementBounds | null> {
const el = page.locator(selector).first();
try {
const box = await el.boundingBox({ timeout: 2000 });
const box = await el.boundingBox({ timeout });
return box;
} catch {
return null;
+13 -1
View File
@@ -136,7 +136,19 @@ export function normalizeSocksStringUrl(urlStr: string): string {
const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null;
return assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
const normalized = assembleSocksUrl(scheme, encUser, encPass, hostAndRest);
// Compare credentials, not the full URL: keeps the log condition focused
// on real encoding work, not cosmetic differences (parity with the Python
// implementation, which has to skip urlparse's hostname lowercasing).
const credsChanged = encUser !== rawUserEnc
|| (hasPassword ? encPass !== rawPassEnc : false);
if (credsChanged) {
console.info(
"[cloakbrowser] Auto URL-encoded SOCKS5 proxy credentials (special " +
"characters detected). Pre-encode the URL to suppress this notice.",
);
}
return normalized;
} catch (e) {
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
return urlStr;
+59 -2
View File
@@ -1,5 +1,17 @@
import { describe, it, expect } from "vitest";
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
import { describe, it, expect, afterEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { COUNTRY_LOCALE_MAP, maybeResolveGeoip, resolveProxyGeo, resolveProxyIp } from "../src/geoip.js";
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS;
delete process.env.CLOAKBROWSER_CACHE_DIR;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("resolveProxyIp", () => {
it("returns literal IPv4 from proxy URL", async () => {
@@ -38,6 +50,51 @@ describe("resolveProxyIp", () => {
});
});
describe("maybeResolveGeoip", () => {
it("does not apply the GeoIP resolution timeout to first-use database download", async () => {
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-download-"));
tempDirs.push(cacheDir);
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.001";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
}),
} as Response);
const result = await resolveProxyGeo("http://203.0.113.10:8080");
expect(result).toEqual({ timezone: null, locale: null, exitIp: null });
expect(fetchSpy).toHaveBeenCalledOnce();
expect(fetchSpy.mock.calls[0][1]).toEqual({ redirect: "follow" });
});
it("returns quickly when GeoIP resolution times out", async () => {
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloak-geoip-timeout-"));
tempDirs.push(cacheDir);
process.env.CLOAKBROWSER_CACHE_DIR = cacheDir;
process.env.CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS = "0.025";
const start = performance.now();
const result = await maybeResolveGeoip({
geoip: true,
proxy: "http://203.0.113.10:8080",
timezone: "Europe/Paris",
locale: "fr-FR",
});
const elapsed = performance.now() - start;
expect(result).toEqual({ timezone: "Europe/Paris", locale: "fr-FR", exitIp: undefined });
expect(elapsed).toBeLessThan(500);
});
});
describe("COUNTRY_LOCALE_MAP", () => {
it("contains common countries", () => {
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
+545 -151
View File
@@ -106,9 +106,9 @@ describe("humanMove", () => {
return {
raw: {
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
},
moves,
};
@@ -180,10 +180,10 @@ describe("humanClick", () => {
const cfg = resolveConfig("default");
const callOrder: string[] = [];
const raw = {
move: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { callOrder.push("down"); }),
up: vi.fn(async () => { callOrder.push("up"); }),
wheel: vi.fn(async () => {}),
wheel: vi.fn(async () => { }),
};
await humanClick(raw, false, cfg);
expect(raw.down).toHaveBeenCalledTimes(1);
@@ -199,12 +199,12 @@ describe("humanIdle", () => {
it("calls raw.move at least once during idle", async () => {
const cfg = resolveConfig("default");
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
await humanIdle(raw, 10, 100, 100, cfg);
await humanIdle(raw, 100, 100, cfg);
expect(raw.move).toHaveBeenCalled();
}, 15000);
});
@@ -265,7 +265,7 @@ describe("patchPage fill", () => {
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).fill("input#name", "hello"); } catch (_) {}
try { await (page as any).fill("input#name", "hello"); } catch (_) { }
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
@@ -298,7 +298,7 @@ describe("patchPage check/uncheck idle", () => {
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).check("input#cb"); } catch (_) {}
try { await (page as any).check("input#cb"); } catch (_) { }
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
expect(downCalled).toBe(true);
@@ -321,7 +321,7 @@ describe("patchPage check/uncheck idle", () => {
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).uncheck("input#cb"); } catch (_) {}
try { await (page as any).uncheck("input#cb"); } catch (_) { }
expect(downCalled).toBe(true);
}, 30000);
@@ -354,7 +354,7 @@ describe("patchPage press focus", () => {
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
expect(downCount).toBeGreaterThan(0);
});
@@ -372,7 +372,7 @@ describe("patchPage press focus", () => {
const cursor = { x: 50, y: 50, initialized: true };
patchPage(page as any, cfg, cursor as any);
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
expect(downCount).toBe(0);
});
@@ -398,6 +398,69 @@ describe("patchPage frame patching", () => {
expect((childFrame as any)._humanPatched).toBe(true);
});
it("uses frame.locator for frame.click instead of page.click", async () => {
const { patchPage } = await import("../src/human/index.js");
const childFrame = buildMockFrame();
const mainFrame = {
...buildMockFrame(),
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const originalPageClick = page.click;
const cfg = resolveConfig("default", { mouse_min_steps: 1, mouse_max_steps: 1 });
const cursor = { x: 0, y: 0, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (childFrame as any).click("button.submit", { timeout: 1234 });
expect(childFrame.locator).toHaveBeenCalledWith("button.submit");
expect(originalPageClick).not.toHaveBeenCalled();
});
it.each([
["type", async (frame: any) => frame.type("input.email", "@")],
["fill", async (frame: any) => frame.fill("input.email", "@")],
["pressSequentially", async (frame: any) => frame.pressSequentially("input.email", "@")],
])("passes the page CDP session to frame.%s", async (_name, runFrameAction) => {
const { patchPage } = await import("../src/human/index.js");
const cdpSend = vi.fn(async () => ({}));
const childFrame = buildMockFrame();
const mainFrame = {
...buildMockFrame(),
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
page.context = vi.fn(() => ({
pages: vi.fn(() => []),
addInitScript: vi.fn(async () => {}),
newCDPSession: vi.fn(async () => ({ send: cdpSend })),
}));
const cfg = resolveConfig("default", {
field_switch_delay: [0, 0],
key_hold: [0, 0],
shift_down_delay: [0, 0],
shift_up_delay: [0, 0],
typing_delay: 0,
typing_delay_spread: 0,
typing_pause_chance: 0,
mistype_chance: 0,
mouse_min_steps: 1,
mouse_max_steps: 1,
idle_between_actions: false,
});
const cursor = { x: 0, y: 0, initialized: true };
patchPage(page as any, cfg, cursor as any);
await runFrameAction(childFrame);
const dispatches = cdpSend.mock.calls.filter(([method]) => method === "Input.dispatchKeyEvent");
expect(dispatches).toHaveLength(2);
expect(page.evaluate).not.toHaveBeenCalled();
});
});
// =========================================================================
@@ -465,7 +528,7 @@ describe("patchBrowser CDP-connected workflow", () => {
pages: vi.fn(() => [page]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
addInitScript: vi.fn(async () => { }),
};
const browser: any = {
contexts: vi.fn(() => [context]),
@@ -494,7 +557,7 @@ describe("patchBrowser CDP-connected workflow", () => {
pages: vi.fn(() => [page]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
addInitScript: vi.fn(async () => { }),
};
const browser: any = {
contexts: vi.fn(() => [context]),
@@ -505,7 +568,7 @@ describe("patchBrowser CDP-connected workflow", () => {
patchBrowser(browser, resolveConfig("default"));
// Click through the patched method — should go through humanize path
try { await (page as any).click("button"); } catch (_) {}
try { await (page as any).click("button"); } catch (_) { }
expect(downCalled).toBe(true);
}, 30000);
@@ -518,7 +581,7 @@ describe("patchBrowser CDP-connected workflow", () => {
pages: vi.fn(() => [newPage]),
on: vi.fn(),
newPage: vi.fn(async () => buildMockPage()),
addInitScript: vi.fn(async () => {}),
addInitScript: vi.fn(async () => { }),
};
const browser: any = {
contexts: vi.fn(() => []),
@@ -542,27 +605,27 @@ describe("patchBrowser CDP-connected workflow", () => {
function buildMockPage(overrides: Record<string, any> = {}): any {
const mainFrameObj = overrides.mainFrameReturn ?? {
childFrames: vi.fn(() => []),
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
click: vi.fn(async () => { }),
dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => { }),
type: vi.fn(async () => { }),
fill: vi.fn(async () => { }),
check: vi.fn(async () => { }),
uncheck: vi.fn(async () => { }),
selectOption: vi.fn(async () => { }),
press: vi.fn(async () => { }),
clear: vi.fn(async () => { }),
dragAndDrop: vi.fn(async () => { }),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
first: vi.fn(function(this: any) { return this; }),
first: vi.fn(function (this: any) { return this; }),
})),
};
const makeLocator = () => {
const loc: any = {
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => {}),
scrollIntoViewIfNeeded: vi.fn(async () => { }),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
};
loc.first = vi.fn(() => loc);
@@ -571,33 +634,33 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
const page: any = {
evaluate: overrides.evaluate ?? vi.fn(async () => false),
addInitScript: vi.fn(async () => {}),
addInitScript: vi.fn(async () => { }),
mouse: {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
click: vi.fn(async () => { }),
dblclick: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
},
keyboard: {
press: overrides.keyboardPress
? vi.fn(overrides.keyboardPress)
: vi.fn(async () => {}),
type: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
: vi.fn(async () => { }),
type: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
insertText: vi.fn(async () => { }),
},
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
click: vi.fn(async () => { }),
dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => { }),
type: vi.fn(async () => { }),
fill: vi.fn(async () => { }),
check: vi.fn(async () => { }),
uncheck: vi.fn(async () => { }),
selectOption: vi.fn(async () => { }),
press: vi.fn(async () => { }),
goto: vi.fn(async () => ({})),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
locator: vi.fn(() => makeLocator()),
@@ -606,10 +669,10 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
frames: vi.fn(() => []),
context: vi.fn(() => ({
pages: vi.fn(() => []),
addInitScript: vi.fn(async () => {}),
addInitScript: vi.fn(async () => { }),
})),
url: vi.fn(() => "about:blank"),
waitForTimeout: vi.fn(async () => {}),
waitForTimeout: vi.fn(async () => { }),
};
return page;
}
@@ -623,8 +686,8 @@ describe("humanType non-ASCII", () => {
const insertedChars: string[] = [];
const raw = {
down: vi.fn(async (k: string) => { downKeys.push(k); }),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
up: vi.fn(async () => { }),
type: vi.fn(async () => { }),
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
};
return { raw, downKeys, insertedChars };
@@ -696,18 +759,18 @@ describe("humanType non-ASCII", () => {
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
const el: any = {
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
press: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
setChecked: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
focus: vi.fn(async () => {}),
click: vi.fn(async () => { }),
dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => { }),
type: vi.fn(async () => { }),
fill: vi.fn(async () => { }),
press: vi.fn(async () => { }),
selectOption: vi.fn(async () => { }),
check: vi.fn(async () => { }),
uncheck: vi.fn(async () => { }),
setChecked: vi.fn(async () => { }),
tap: vi.fn(async () => { }),
focus: vi.fn(async () => { }),
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
evaluate: overrides.evaluate ?? vi.fn(async () => false),
isChecked: overrides.isChecked ?? vi.fn(async () => false),
@@ -725,21 +788,21 @@ describe("patchSingleElementHandle", () => {
const cfg = resolveConfig("default");
const cursor = { x: 100, y: 100, initialized: true };
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
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 () => {}),
keyboardPress: vi.fn(async () => { }),
keyboardDown: vi.fn(async () => { }),
keyboardUp: vi.fn(async () => { }),
};
const el = buildMockElementHandle();
@@ -762,23 +825,23 @@ describe("patchSingleElementHandle", () => {
move: vi.fn(async () => { moveCount++; }),
down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => { upCalled = true; }),
wheel: vi.fn(async () => {}),
wheel: vi.fn(async () => { }),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
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 () => {}),
keyboardPress: vi.fn(async () => { }),
keyboardDown: vi.fn(async () => { }),
keyboardUp: vi.fn(async () => { }),
};
const el = buildMockElementHandle();
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
@@ -796,22 +859,22 @@ describe("patchSingleElementHandle", () => {
let downCalled = false;
const raw = {
move: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { downCalled = true; }),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
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();
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
@@ -827,22 +890,22 @@ describe("patchSingleElementHandle", () => {
const cursor = { x: 50, y: 50, initialized: true };
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
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) }); // isInput = true
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
@@ -860,26 +923,26 @@ describe("patchSingleElementHandle", () => {
const pressedKeys: string[] = [];
const raw = {
move: vi.fn(async () => {}),
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
wheel: vi.fn(async () => {}),
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
const rawKb = {
down: vi.fn(async () => {}),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async () => {}),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
type: vi.fn(async () => { }),
insertText: vi.fn(async () => { }),
};
const originals = {
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
keyboardDown: vi.fn(async () => {}),
keyboardUp: vi.fn(async () => {}),
keyboardDown: vi.fn(async () => { }),
keyboardUp: vi.fn(async () => { }),
};
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => {});
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
@@ -894,9 +957,9 @@ describe("patchSingleElementHandle", () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: 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 raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 el = buildMockElementHandle();
const page = buildMockPage();
@@ -913,9 +976,9 @@ describe("patchSingleElementHandle", () => {
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: 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 raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 child = buildMockElementHandle();
const el = buildMockElementHandle();
@@ -935,9 +998,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: 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 raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 el = buildMockElementHandle();
const page = buildMockPage();
@@ -955,9 +1018,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: 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 raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 el1 = buildMockElementHandle();
const el2 = buildMockElementHandle();
@@ -977,9 +1040,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: 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 raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 el = buildMockElementHandle();
const page = buildMockPage();
@@ -997,9 +1060,9 @@ describe("patchPageElementHandles", () => {
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
const raw = { move: vi.fn(async () => {}), down: vi.fn(async () => {}), up: vi.fn(async () => {}), wheel: 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 raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 page = buildMockPage();
(page as any).$ = vi.fn(async () => null);
@@ -1034,21 +1097,352 @@ describe("patchPage integrates ElementHandle patching", () => {
function buildMockFrame(): any {
const locator: any = {
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
scrollIntoViewIfNeeded: vi.fn(async () => {}),
evaluate: vi.fn(async () => false),
isChecked: vi.fn(async () => false),
};
locator.first = vi.fn(() => locator);
return {
click: vi.fn(async () => {}),
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => ({
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
})),
click: vi.fn(async () => { }),
dblclick: vi.fn(async () => { }),
hover: vi.fn(async () => { }),
type: vi.fn(async () => { }),
fill: vi.fn(async () => { }),
check: vi.fn(async () => { }),
uncheck: vi.fn(async () => { }),
selectOption: vi.fn(async () => { }),
press: vi.fn(async () => { }),
pressSequentially: vi.fn(async () => { }),
tap: vi.fn(async () => { }),
clear: vi.fn(async () => { }),
dragAndDrop: vi.fn(async () => { }),
locator: vi.fn(() => locator),
childFrames: vi.fn(() => []),
};
}
// =========================================================================
// mergeConfig
// =========================================================================
describe("mergeConfig", () => {
it("returns base unchanged when overrides is undefined/null", async () => {
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
const base = rc("default");
expect(mergeConfig(base, undefined)).toBe(base);
expect(mergeConfig(base, null)).toBe(base);
});
it("creates a new object — base is never mutated", async () => {
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
const base = rc("default");
const before = base.typing_delay;
const merged = mergeConfig(base, { typing_delay: 30 });
expect(merged.typing_delay).toBe(30);
expect(base.typing_delay).toBe(before);
expect(merged).not.toBe(base);
});
it("preserves non-overridden fields", async () => {
const { mergeConfig, resolveConfig: rc } = await import("../src/human/config.js");
const base = rc("default");
const merged = mergeConfig(base, { typing_delay: 30 });
expect(merged.mouse_min_steps).toBe(base.mouse_min_steps);
expect(merged.mistype_chance).toBe(base.mistype_chance);
});
});
// =========================================================================
// Per-call timeout forwarding (issue #137)
// =========================================================================
describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
it("scrollToElement passes timeout to locator.boundingBox()", async () => {
const { scrollToElement } = await import("../src/human/scroll.js");
const cfg = resolveConfig("default");
const boundingBox = vi.fn(async () => ({ x: 100, y: 200, width: 50, height: 30 }));
const page: any = {
viewportSize: () => ({ width: 1280, height: 720 }),
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
};
const raw = {
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
await scrollToElement(page, raw, "#x", 0, 0, cfg, 5000);
expect(boundingBox).toHaveBeenCalledWith({ timeout: 5000 });
});
it("default timeout matches Playwright's 30000ms when not specified", async () => {
const { scrollToElement } = await import("../src/human/scroll.js");
const cfg = resolveConfig("default");
const boundingBox = vi.fn(async () => ({ x: 100, y: 200, width: 50, height: 30 }));
const page: any = {
viewportSize: () => ({ width: 1280, height: 720 }),
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
};
const raw = {
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
await scrollToElement(page, raw, "#x", 0, 0, cfg);
expect(boundingBox).toHaveBeenCalledWith({ timeout: 30000 });
});
it("page.click({ timeout }) reaches scrollToElement", async () => {
const scrollMod = await import("../src/human/scroll.js");
const { patchPage } = await import("../src/human/index.js");
const cfg = resolveConfig("default", { idle_between_actions: false });
let captured = -1;
const spy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy, _cfg, timeout?: number) => {
captured = timeout ?? -1;
return { box: { x: 100, y: 100, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
},
);
const page = buildMockPage();
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).click("#slow", { timeout: 5000 });
expect(captured).toBe(5000);
spy.mockRestore();
});
});
// =========================================================================
// Per-call human config override
// =========================================================================
describe("page.type / page.fill accept per-call human config override", () => {
it("page.type forwards nested human_config to humanType", async () => {
const keyboardMod = await import("../src/human/keyboard.js");
const scrollMod = await import("../src/human/scroll.js");
const { patchPage } = await import("../src/human/index.js");
// Make field_switch_delay tiny so the test runs fast
const cfg = resolveConfig("default", {
idle_between_actions: false,
field_switch_delay: [0, 1],
});
expect(cfg.typing_delay).toBe(70); // baseline
let captured: any = null;
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
);
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy) => ({
box: { x: 100, y: 100, width: 50, height: 30 },
cursorX: cx, cursorY: cy,
}),
);
const page = buildMockPage();
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).type("#email", "hi", {
human_config: { typing_delay: 30, mistype_chance: 0 },
});
expect(captured.typing_delay).toBe(30);
expect(captured.mistype_chance).toBe(0);
// Global cfg untouched
expect(cfg.typing_delay).toBe(70);
typeSpy.mockRestore();
scrollSpy.mockRestore();
}, 30000);
it("page.fill forwards flat config to humanType", async () => {
const keyboardMod = await import("../src/human/keyboard.js");
const scrollMod = await import("../src/human/scroll.js");
const { patchPage } = await import("../src/human/index.js");
const cfg = resolveConfig("default", {
idle_between_actions: false,
field_switch_delay: [0, 1],
});
let captured: any = null;
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
);
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy) => ({
box: { x: 100, y: 100, width: 50, height: 30 },
cursorX: cx, cursorY: cy,
}),
);
const page = buildMockPage();
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).fill("#password", "secret", {
typing_delay: 150,
});
expect(captured.typing_delay).toBe(150);
typeSpy.mockRestore();
scrollSpy.mockRestore();
}, 30000);
it("el.type forwards human_config to humanType", async () => {
const keyboardMod = await import("../src/human/keyboard.js");
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const cfg = resolveConfig("default", { idle_between_actions: false, mistype_chance: 0 });
const cursor = { x: 50, y: 50, initialized: true };
let captured: any = null;
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
);
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.type("abc", { human_config: { typing_delay: 25 } });
expect(captured.typing_delay).toBe(25);
typeSpy.mockRestore();
}, 30000);
});
// =========================================================================
// scrollIntoViewIfNeeded humanization
// =========================================================================
describe("humanScrollIntoView", () => {
it("skips wheel events when element is already in viewport", async () => {
const { humanScrollIntoView } = await import("../src/human/scroll.js");
const cfg = resolveConfig("default");
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
const raw = {
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
// Box centered in viewport — squarely in scroll_target_zone
const inViewBox = { x: 200, y: 300, width: 50, height: 30 };
const result = await humanScrollIntoView(page, raw, async () => inViewBox, 0, 0, cfg);
expect(result.box).toEqual(inViewBox);
expect(raw.wheel).not.toHaveBeenCalled();
});
it("fires wheel events when element is below the fold", async () => {
const { humanScrollIntoView } = await import("../src/human/scroll.js");
const cfg = resolveConfig("default", {
scroll_overshoot_chance: 0,
scroll_pre_move_delay: [0, 1],
scroll_pause_fast: [0, 1],
scroll_pause_slow: [0, 1],
scroll_settle_delay: [0, 1],
});
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
const raw = {
move: vi.fn(async () => { }),
down: vi.fn(async () => { }),
up: vi.fn(async () => { }),
wheel: vi.fn(async () => { }),
};
const boxes = [
{ x: 200, y: 2000, width: 50, height: 30 }, // far below
{ x: 200, y: 1500, width: 50, height: 30 },
{ x: 200, y: 1000, width: 50, height: 30 },
{ x: 200, y: 400, width: 50, height: 30 }, // in view
{ x: 200, y: 400, width: 50, height: 30 },
{ x: 200, y: 400, width: 50, height: 30 },
];
let i = 0;
const getBox = async () => boxes[Math.min(i++, boxes.length - 1)];
await humanScrollIntoView(page, raw, getBox, 0, 0, cfg);
expect(raw.wheel).toHaveBeenCalled();
}, 15000);
});
describe("el.scrollIntoViewIfNeeded humanization", () => {
it("calls humanScrollIntoView instead of native snap-scroll", async () => {
const scrollMod = await import("../src/human/scroll.js");
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
let called = 0;
const spy = vi.spyOn(scrollMod, "humanScrollIntoView").mockImplementation(
async (_p, _raw, _gb, cx, cy) => {
called++;
return { box: { x: 200, y: 200, width: 50, height: 30 }, cursorX: cx, cursorY: cy };
},
);
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 el = buildMockElementHandle();
el.scrollIntoViewIfNeeded = vi.fn(async () => { });
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.scrollIntoViewIfNeeded();
expect(called).toBeGreaterThan(0);
spy.mockRestore();
});
it("falls back to native scrollIntoViewIfNeeded if humanized helper throws", async () => {
const scrollMod = await import("../src/human/scroll.js");
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
const spy = vi.spyOn(scrollMod, "humanScrollIntoView").mockImplementation(
async () => { throw new Error("detached"); },
);
const cfg = resolveConfig("default", { idle_between_actions: false });
const cursor = { x: 50, y: 50, initialized: true };
const raw = { move: vi.fn(async () => { }), down: vi.fn(async () => { }), up: vi.fn(async () => { }), wheel: 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 nativeFallback = vi.fn(async () => { });
const el = buildMockElementHandle();
el.scrollIntoViewIfNeeded = nativeFallback;
const page = buildMockPage();
(page as any)._ensureCursorInit = vi.fn(async () => { });
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
await el.scrollIntoViewIfNeeded();
expect(nativeFallback).toHaveBeenCalled();
spy.mockRestore();
});
});
+60 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
@@ -262,4 +262,63 @@ describe("resolveProxyConfig", () => {
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:a%40b%40c@host:1080"]);
});
// Visibility for #157: when wrapper actually rewrites the URL, surface an
// info log so users debugging silent SOCKS5 fallback can see what happened.
it("logs info message when SOCKS5 credentials get re-encoded", () => {
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://user:pass=123@host:1080");
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining("Auto URL-encoded SOCKS5"),
);
// Credentials must not leak into the log.
const calls = debugSpy.mock.calls.flat().join(" ");
expect(calls).not.toContain("pass=123");
expect(calls).not.toContain("pass%3D123");
} finally {
debugSpy.mockRestore();
}
});
it("stays silent when SOCKS5 URL is already encoded (no log spam)", () => {
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://user:pass%3D123@host:1080");
const reencodedCalls = debugSpy.mock.calls
.flat()
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
expect(reencodedCalls).toHaveLength(0);
} finally {
debugSpy.mockRestore();
}
});
it("stays silent when SOCKS5 URL has no credentials", () => {
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://host:1080");
const reencodedCalls = debugSpy.mock.calls
.flat()
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
expect(reencodedCalls).toHaveLength(0);
} finally {
debugSpy.mockRestore();
}
});
it("stays silent when only host case differs (no credential rewrite)", () => {
// Parity with Python: log condition must track credential changes, not
// cosmetic URL-string differences (regression for Copilot's PR #209 review).
const debugSpy = vi.spyOn(console, "info").mockImplementation(() => {});
try {
resolveProxyConfig("socks5://USER:pass@HOST.com:1080");
const reencodedCalls = debugSpy.mock.calls
.flat()
.filter((arg) => typeof arg === "string" && arg.includes("Auto URL-encoded SOCKS5"));
expect(reencodedCalls).toHaveLength(0);
} finally {
debugSpy.mockRestore();
}
});
});
+48
View File
@@ -532,6 +532,52 @@ describe("Puppeteer: non-ASCII text avoids CDP shift path", () => {
});
// =========================================================================
// Per-call human config override (Puppeteer page-level)
// =========================================================================
describe("Puppeteer: page.type accepts per-call human config override", () => {
it("page.type forwards merged config to humanType", async () => {
const keyboardMod = await import("../src/human-puppeteer/keyboard.js");
const scrollMod = await import("../src/human-puppeteer/scroll.js");
const cfg = resolveConfig("default", {
idle_between_actions: false,
field_switch_delay: [0, 1],
});
expect(cfg.typing_delay).toBe(70);
let captured: any = null;
const typeSpy = vi.spyOn(keyboardMod, "humanType").mockImplementation(
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
);
const scrollSpy = vi.spyOn(scrollMod, "scrollToElement").mockImplementation(
async (_page, _raw, _sel, cx, cy) => ({
box: { x: 100, y: 100, width: 50, height: 30 },
cursorX: cx,
cursorY: cy,
}),
);
const { patchPage } = await import("../src/human-puppeteer/index.js");
const page = buildMockPage();
const cursor = { x: 100, y: 100, initialized: true };
patchPage(page as any, cfg, cursor as any);
await (page as any).type("#email", "hi", {
typing_delay: 30,
mistype_chance: 0,
});
expect(captured.typing_delay).toBe(30);
expect(captured.mistype_chance).toBe(0);
expect(cfg.typing_delay).toBe(70);
typeSpy.mockRestore();
scrollSpy.mockRestore();
});
});
// =========================================================================
// patchPage stealth infrastructure (Puppeteer)
// =========================================================================
@@ -1671,11 +1717,13 @@ describe("Puppeteer: isInputElement stealth integration via patchPage", () => {
}),
});
const mockEl = buildMockElementHandle();
const page = buildMockPage({
evaluate: vi.fn(async (...args: any[]) => {
evaluateCalls.push(args);
return false;
}),
$: vi.fn(async () => mockEl),
});
page.createCDPSession = vi.fn(async () => mockCdp);
+94
View File
@@ -22,6 +22,8 @@ parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
SAFE_SEED_RE = _mod.SAFE_SEED_RE
RESERVED_SEEDS = _mod.RESERVED_SEEDS
# ---------------------------------------------------------------------------
@@ -244,3 +246,95 @@ class TestConnectionTracking:
pool.disconnect("a")
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
# ---------------------------------------------------------------------------
# Seed validation (CVE fix — path traversal via fingerprint param)
# ---------------------------------------------------------------------------
class TestSeedValidation:
"""Verify SAFE_SEED_RE rejects path traversal and reserved names."""
@pytest.mark.parametrize("seed", [
"../foo", "../../etc", "/etc/passwd", "..", ".", "foo/bar",
"foo\\bar", "\x00evil", "", "a" * 129,
])
def test_malicious_seeds_rejected(self, seed):
assert not SAFE_SEED_RE.match(seed)
@pytest.mark.parametrize("seed", [
"__default__",
])
def test_reserved_seeds_rejected(self, seed):
assert seed in RESERVED_SEEDS
@pytest.mark.parametrize("seed", [
"12345", "my-seed_01", "ABC", "a" * 128, "0", "test-seed",
])
def test_valid_seeds_accepted(self, seed):
assert SAFE_SEED_RE.match(seed)
assert seed not in RESERVED_SEEDS
# ---------------------------------------------------------------------------
# Path containment (_safe_rmtree)
# ---------------------------------------------------------------------------
class TestSafeRmtree:
"""Verify _safe_rmtree refuses to delete outside data_dir."""
def _make_pool(self, data_dir: str):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir=data_dir,
)
def test_refuses_path_outside_data_dir(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
victim = tmp_path / "victim"
victim.mkdir()
(victim / "sentinel").touch()
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(str(victim))
assert victim.exists(), "Directory outside data_dir must not be deleted"
def test_refuses_data_dir_itself(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
(data_dir / "sentinel").touch()
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(str(data_dir))
assert data_dir.exists(), "data_dir itself must not be deleted"
def test_deletes_valid_subdirectory(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
subdir = data_dir / "seed-12345"
subdir.mkdir()
(subdir / "data").touch()
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(str(subdir))
assert not subdir.exists(), "Valid subdirectory should be deleted"
def test_refuses_traversal_path(self, tmp_path):
data_dir = tmp_path / "profiles"
data_dir.mkdir()
victim = tmp_path / "victim"
victim.mkdir()
traversal = str(data_dir / ".." / "victim")
pool = self._make_pool(str(data_dir))
pool._safe_rmtree(traversal)
assert victim.exists(), "Traversal path must not be deleted"
+15
View File
@@ -1,6 +1,7 @@
"""Unit tests for GeoIP-based timezone/locale detection."""
from unittest.mock import patch
import time
import pytest
@@ -144,6 +145,20 @@ def test_maybe_resolve_fills_both():
assert ip == "5.6.7.8"
def test_maybe_resolve_geoip_timeout_returns_existing_values(monkeypatch):
"""A stalled proxy lookup should not block launch indefinitely."""
mock_geoip2 = type("module", (), {"database": type("db", (), {"Reader": None})})()
monkeypatch.setenv("CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS", "0.05")
with patch.dict("sys.modules", {"geoip2": mock_geoip2, "geoip2.database": mock_geoip2.database}):
with patch("cloakbrowser.geoip._ensure_geoip_db", return_value=object()):
start = time.monotonic()
tz, loc, ip = maybe_resolve_geoip(True, "http://203.0.113.10:8080", None, "fr-FR")
elapsed = time.monotonic() - start
assert (tz, loc, ip) == (None, "fr-FR", None)
assert elapsed < 0.5
# ---------------------------------------------------------------------------
# _is_private_ip
# ---------------------------------------------------------------------------
+453
View File
@@ -1296,6 +1296,459 @@ class TestAsyncElementHandle:
await browser.close()
# =========================================================================
# 15. Per-call timeout forwarding (issue #137)
# =========================================================================
class TestPerCallTimeoutForwarding:
"""page.click('#x', timeout=5000) must forward 5000 to bounding_box(),
not silently use the hardcoded 2000ms in scroll."""
def test_get_element_box_default_timeout(self):
"""Default timeout matches Playwright's 30000ms."""
from cloakbrowser.human.scroll import _get_element_box
from unittest.mock import MagicMock
page = MagicMock()
loc = MagicMock()
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
page.locator = MagicMock(return_value=MagicMock(first=loc))
_get_element_box(page, "#x")
loc.bounding_box.assert_called_once_with(timeout=30000)
def test_get_element_box_custom_timeout(self):
"""Caller can pass a custom timeout that overrides the default."""
from cloakbrowser.human.scroll import _get_element_box
from unittest.mock import MagicMock
page = MagicMock()
loc = MagicMock()
loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1})
page.locator = MagicMock(return_value=MagicMock(first=loc))
_get_element_box(page, "#x", timeout=5000)
loc.bounding_box.assert_called_once_with(timeout=5000)
def test_scroll_to_element_forwards_timeout(self):
"""scroll_to_element passes timeout through to bounding_box()."""
from cloakbrowser.human.scroll import scroll_to_element
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
page = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
loc = MagicMock()
# Already in viewport so we don't actually scroll — just verify
# the timeout was forwarded on the first bounding_box() call.
loc.bounding_box = MagicMock(return_value={"x": 100, "y": 200, "width": 50, "height": 30})
page.locator = MagicMock(return_value=MagicMock(first=loc))
raw = MagicMock()
scroll_to_element(page, raw, "#x", 0, 0, cfg, timeout=7500)
loc.bounding_box.assert_called_with(timeout=7500)
def test_page_click_forwards_timeout_kwarg(self):
"""page.click(selector, timeout=...) reaches scroll_to_element.
Patches scroll_to_element module-side via monkey-patching the
cloakbrowser.human module attribute used by patch_page.
"""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
# Build a minimal page mock
page = MagicMock()
page.click = MagicMock()
page.dblclick = MagicMock()
page.hover = MagicMock()
page.type = MagicMock()
page.fill = MagicMock()
page.goto = MagicMock()
page.is_checked = MagicMock(return_value=False)
page.viewport_size = {"width": 1280, "height": 720}
page.evaluate = MagicMock(return_value=False)
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
page.mouse = MagicMock()
page.keyboard = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=None)
page.main_frame = MagicMock()
page.main_frame.child_frames = []
captured = {}
def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000):
captured["timeout"] = timeout
return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy)
with patch.object(h, "scroll_to_element", side_effect=fake_scroll):
h.patch_page(page, cfg, cursor)
page.click("#slow-button", timeout=5000)
assert captured.get("timeout") == 5000, f"expected 5000, got {captured}"
# =========================================================================
# 16. Per-call human_config override (typing speed customization)
# =========================================================================
class TestPerCallHumanConfigOverride:
"""page.type('#email', text, human_config={'typing_delay': 30}) lets users
override typing speed (and any other HumanConfig field) on a per-call
basis without re-patching the page."""
def test_merge_config_creates_new_instance(self):
from cloakbrowser.human.config import resolve_config, merge_config
base = resolve_config("default", None)
merged = merge_config(base, {"typing_delay": 30})
assert merged.typing_delay == 30
assert base.typing_delay != 30 # not mutated
# Non-overridden fields are preserved
assert merged.mouse_min_steps == base.mouse_min_steps
def test_merge_config_none_returns_base(self):
from cloakbrowser.human.config import resolve_config, merge_config
base = resolve_config("default", None)
merged = merge_config(base, None)
assert merged is base
def test_merge_config_ignores_unknown_keys(self):
from cloakbrowser.human.config import resolve_config, merge_config
base = resolve_config("default", None)
# ``not_a_real_field`` is silently dropped — callers shouldn't crash
# if they pass typos or future field names.
merged = merge_config(base, {"typing_delay": 30, "not_a_real_field": 99})
assert merged.typing_delay == 30
def test_page_type_uses_per_call_typing_delay(self):
"""page.type(..., human_config={'typing_delay': 30}) reaches human_type
with cfg.typing_delay == 30 even when patch was done with default 70."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
cfg = resolve_config("default", {
"idle_between_actions": False,
"field_switch_delay": (0, 1),
})
assert cfg.typing_delay == 70 # baseline
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page.click = MagicMock()
page.dblclick = MagicMock()
page.hover = MagicMock()
page.type = MagicMock()
page.fill = MagicMock()
page.goto = MagicMock()
page.is_checked = MagicMock(return_value=False)
page.viewport_size = {"width": 1280, "height": 720}
page.evaluate = MagicMock(return_value=False)
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
page.mouse = MagicMock()
page.keyboard = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=None)
page.main_frame = MagicMock()
page.main_frame.child_frames = []
captured = {}
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
captured["typing_delay"] = cfg_arg.typing_delay
captured["mistype_chance"] = cfg_arg.mistype_chance
def fake_scroll(*args, **kwargs):
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
with patch.object(h, "human_type", side_effect=fake_human_type), \
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
h.patch_page(page, cfg, cursor)
page.type(
"#email", "hi",
human_config={"typing_delay": 30, "mistype_chance": 0},
)
assert captured["typing_delay"] == 30
assert captured["mistype_chance"] == 0
# Global cfg untouched — per-call override doesn't leak
assert cfg.typing_delay == 70
def test_page_fill_uses_per_call_typing_delay(self):
"""Same as type, but for fill (which also clears the field first)."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
cfg = resolve_config("default", {
"idle_between_actions": False,
"field_switch_delay": (0, 1),
})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
page.is_checked = MagicMock(return_value=False)
page.evaluate = MagicMock(return_value=False)
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
page.mouse = MagicMock()
page.keyboard = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=None)
page.main_frame = MagicMock()
page.main_frame.child_frames = []
captured = {}
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
captured["typing_delay"] = cfg_arg.typing_delay
def fake_scroll(*args, **kwargs):
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100)
with patch.object(h, "human_type", side_effect=fake_human_type), \
patch.object(h, "scroll_to_element", side_effect=fake_scroll):
h.patch_page(page, cfg, cursor)
page.fill("#password", "secret", human_config={"typing_delay": 150})
assert captured["typing_delay"] == 150
def test_element_handle_type_uses_per_call_human_config(self):
"""el.type(text, human_config={...}) merges per-call overrides on the
ElementHandle path (which doesn't go through page.type)."""
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
import cloakbrowser.human as h
from unittest.mock import MagicMock, patch
cfg = resolve_config("default", {
"idle_between_actions": False,
"field_switch_delay": (0, 1),
})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 50
cursor.y = 50
page = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(
return_value={"x": 200, "y": 200, "width": 100, "height": 30}
)
el.evaluate = MagicMock(return_value=True)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
el.scroll_into_view_if_needed = MagicMock()
raw_mouse = MagicMock()
raw_keyboard = MagicMock()
captured = {}
def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None):
captured["typing_delay"] = cfg_arg.typing_delay
with patch.object(h, "human_type", side_effect=fake_human_type):
_patch_single_element_handle_sync(
el, page, cfg, cursor, raw_mouse, raw_keyboard,
page._original, None, None,
)
el.type("abc", human_config={"typing_delay": 25})
assert captured["typing_delay"] == 25
# =========================================================================
# 17. scroll_into_view_if_needed humanization
# =========================================================================
class TestScrollIntoViewIfNeeded:
"""scroll_into_view_if_needed should run through the same
accelerate cruise decelerate overshoot wheel sequence as page.click
not Playwright's instant-snap default."""
def test_human_scroll_into_view_skips_when_in_viewport(self):
"""Already-visible elements: no wheel events, just return."""
from cloakbrowser.human.scroll import human_scroll_into_view
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", None)
page = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
raw = MagicMock()
# Box is dead-center of viewport — squarely in scroll_target_zone
in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30}
box, cx, cy = human_scroll_into_view(
page, raw, lambda: in_view_box, 0, 0, cfg,
)
assert box == in_view_box
assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events"
def test_human_scroll_into_view_scrolls_when_below_fold(self):
"""Below-fold elements: wheel events fire, eventually box becomes visible."""
from cloakbrowser.human.scroll import human_scroll_into_view
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {
"scroll_overshoot_chance": 0, # deterministic
"scroll_pre_move_delay": (0, 1),
"scroll_pause_fast": (0, 1),
"scroll_pause_slow": (0, 1),
"scroll_settle_delay": (0, 1),
})
page = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
raw = MagicMock()
# First box is far below the fold; subsequent boxes "come into view"
# so the loop terminates after a few wheel bursts.
boxes = [
{"x": 200, "y": 2000, "width": 50, "height": 30},
{"x": 200, "y": 1500, "width": 50, "height": 30},
{"x": 200, "y": 1000, "width": 50, "height": 30},
{"x": 200, "y": 400, "width": 50, "height": 30}, # in viewport
{"x": 200, "y": 400, "width": 50, "height": 30},
{"x": 200, "y": 400, "width": 50, "height": 30},
]
idx = {"i": 0}
def get_box():
i = min(idx["i"], len(boxes) - 1)
idx["i"] += 1
return boxes[i]
human_scroll_into_view(page, raw, get_box, 0, 0, cfg)
assert raw.wheel.called, "Below-fold scroll should produce wheel events"
def test_element_handle_scroll_into_view_if_needed_humanized(self):
"""el.scroll_into_view_if_needed() routes through human_scroll_into_view."""
from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState
from cloakbrowser.human.config import resolve_config
import cloakbrowser.human as h
from unittest.mock import MagicMock, patch
cfg = resolve_config("default", None)
cursor = _CursorState()
cursor.initialized = True
cursor.x = 50
cursor.y = 50
page = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
page._original = MagicMock()
el = MagicMock()
el._human_patched = False
el.bounding_box = MagicMock(
return_value={"x": 200, "y": 200, "width": 50, "height": 30}
)
el.evaluate = MagicMock(return_value=False)
el.is_checked = MagicMock(return_value=False)
el.query_selector = MagicMock(return_value=None)
el.query_selector_all = MagicMock(return_value=[])
el.wait_for_selector = MagicMock(return_value=None)
# Make sure the original method exists so the patch is wired up
el.scroll_into_view_if_needed = MagicMock()
called = {"count": 0}
def fake(*args, **kwargs):
called["count"] += 1
return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100)
with patch.object(h, "human_scroll_into_view", side_effect=fake):
_patch_single_element_handle_sync(
el, page, cfg, cursor, MagicMock(), MagicMock(),
page._original, None, None,
)
# Patched method should now invoke our humanized helper
el.scroll_into_view_if_needed()
assert called["count"] >= 1, "humanized scroll helper was never called"
def test_locator_scroll_into_view_if_needed_humanized(self):
"""Locator.scroll_into_view_if_needed() also goes through humanized scroll."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
# Patch Locator class fresh
_ensure_locator_patched()
from playwright.sync_api._generated import Locator
cfg = resolve_config("default", None)
cursor = _CursorState()
cursor.x = 50
cursor.y = 50
cursor.initialized = True
page = MagicMock()
page._original = MagicMock()
page._human_cfg = cfg
page._human_cursor = cursor
page._human_raw_mouse = MagicMock()
page.viewport_size = {"width": 1280, "height": 720}
# Build a Locator-like object satisfying the patched method
loc = MagicMock(spec=Locator)
loc.page = page
impl_obj = MagicMock()
impl_obj._selector = "#x"
loc._impl_obj = impl_obj
loc.bounding_box = MagicMock(
return_value={"x": 100, "y": 100, "width": 50, "height": 30}
)
called = {"count": 0, "cfg": None}
def fake(*args, **kwargs):
called["count"] += 1
# cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg)
called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg")
return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200)
with patch.object(h, "human_scroll_into_view", side_effect=fake):
Locator.scroll_into_view_if_needed(
loc, human_config={"scroll_overshoot_chance": 0.5},
)
assert called["count"] == 1
# Per-call override merged into the cfg passed downstream
assert called["cfg"].scroll_overshoot_chance == 0.5
# Cursor was updated from the helper's return value
assert cursor.x == 200 and cursor.y == 200
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
+36
View File
@@ -284,6 +284,42 @@ class TestResolveProxyConfig:
_, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080")
assert args == ["--proxy-server=socks5://user:pass%3D123@host:1080"]
def test_socks5_string_logs_info_when_reencoding(self, caplog):
# When wrapper actually rewrites the URL (e.g. unencoded '=' in pwd),
# surface an INFO log so users debugging SOCKS5 connectivity (#157)
# can see what the wrapper did instead of being silently surprised.
import logging
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
_resolve_proxy_config("socks5://user:pass=123@host:1080")
assert any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
# Credentials must not leak into the log.
for r in caplog.records:
assert "pass=123" not in r.message
assert "pass%3D123" not in r.message
def test_socks5_string_silent_when_already_encoded(self, caplog):
# Idempotent path: pre-encoded URL produces no log noise.
import logging
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
_resolve_proxy_config("socks5://user:pass%3D123@host:1080")
assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
def test_socks5_string_silent_when_no_credentials(self, caplog):
# No userinfo at all → no encoding work → no log.
import logging
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
_resolve_proxy_config("socks5://host:1080")
assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
def test_socks5_string_silent_when_only_cosmetic_change(self, caplog):
# urlparse lowercases scheme and hostname, but credentials are
# untouched. The log must NOT fire for these cosmetic-only rewrites
# (regression for Copilot's review on PR #209).
import logging
with caplog.at_level(logging.INFO, logger="cloakbrowser"):
_resolve_proxy_config("socks5://USER:pass@HOST.com:1080")
assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records)
def test_socks5_string_no_creds_unchanged(self):
_, args = _resolve_proxy_config("socks5://host:1080")
assert args == ["--proxy-server=socks5://host:1080"]