mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db0b5f1946 | ||
|
|
babef04e07 | ||
|
|
f8026a7b39 | ||
|
|
71f57d00d1 | ||
|
|
e9735392e8 | ||
|
|
0d41a4f023 | ||
|
|
80d9f7c14e | ||
|
|
114b3c826b | ||
|
|
c07c2b6b4a | ||
|
|
13b1b98b68 |
@@ -123,7 +123,7 @@ jobs:
|
|||||||
cloakhq/cloakbrowser:latest
|
cloakhq/cloakbrowser:latest
|
||||||
provenance: true
|
provenance: true
|
||||||
sbom: true
|
sbom: true
|
||||||
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
- name: Sign image
|
- name: Sign image
|
||||||
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
|
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
|
||||||
- name: Attest build provenance
|
- name: Attest build provenance
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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]** Per-call `human_config` override — pass `human_config={...}` to individual humanized methods to override global HumanConfig on a per-action basis (#183)
|
||||||
|
|||||||
@@ -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_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary |
|
||||||
| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks |
|
| `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_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
|
## 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
|
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
|
||||||
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
|
||||||
- [@kitiho](https://github.com/kitiho) — null viewport fix
|
- [@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
|
- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration
|
||||||
|
|||||||
+31
-6
@@ -22,6 +22,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -61,6 +62,9 @@ BASE_CHROME_ARGS = [
|
|||||||
|
|
||||||
BASE_CDP_PORT = 5100
|
BASE_CDP_PORT = 5100
|
||||||
|
|
||||||
|
SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||||
|
RESERVED_SEEDS = {"__default__"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ChromeProcess — one running Chrome instance
|
# ChromeProcess — one running Chrome instance
|
||||||
@@ -111,6 +115,14 @@ class ChromePool:
|
|||||||
self._locks[seed] = asyncio.Lock()
|
self._locks[seed] = asyncio.Lock()
|
||||||
return self._locks[seed]
|
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:
|
def _allocate_port(self) -> int:
|
||||||
"""Find a free port starting from _next_port."""
|
"""Find a free port starting from _next_port."""
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
@@ -159,6 +171,11 @@ class ChromePool:
|
|||||||
seed_key = "__default__"
|
seed_key = "__default__"
|
||||||
actual_seed = str(random.randint(10000, 99999))
|
actual_seed = str(random.randint(10000, 99999))
|
||||||
else:
|
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
|
seed_key = seed
|
||||||
actual_seed = seed
|
actual_seed = seed
|
||||||
|
|
||||||
@@ -232,7 +249,7 @@ class ChromePool:
|
|||||||
if not await self._wait_for_cdp(port):
|
if not await self._wait_for_cdp(port):
|
||||||
process.kill()
|
process.kill()
|
||||||
await asyncio.to_thread(process.wait, timeout=5)
|
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(
|
raise web.HTTPBadGateway(
|
||||||
text=json.dumps({"error": "Chrome failed to start"}),
|
text=json.dumps({"error": "Chrome failed to start"}),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
@@ -266,8 +283,7 @@ class ChromePool:
|
|||||||
await asyncio.to_thread(proc.process.wait, timeout=5)
|
await asyncio.to_thread(proc.process.wait, timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.process.kill()
|
proc.process.kill()
|
||||||
# Clean up user data dir (can be slow for large profiles)
|
await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir)
|
||||||
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
|
|
||||||
if self._default is proc:
|
if self._default is proc:
|
||||||
self._default = None
|
self._default = None
|
||||||
self._locks.pop(key, None)
|
self._locks.pop(key, None)
|
||||||
@@ -559,8 +575,8 @@ async def on_shutdown(app: web.Application) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _default_data_dir() -> str:
|
def _default_data_dir() -> str:
|
||||||
"""Smart default: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
"""Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
|
||||||
if os.path.exists("/.dockerenv"):
|
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
|
||||||
return "/tmp/cloakserve"
|
return "/tmp/cloakserve"
|
||||||
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
return str(Path.home() / ".cloakbrowser" / "cloakserve")
|
||||||
|
|
||||||
@@ -624,6 +640,13 @@ def main() -> None:
|
|||||||
binary = ensure_binary()
|
binary = ensure_binary()
|
||||||
config, global_args = parse_cli_args(sys.argv[1:])
|
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(
|
pool = ChromePool(
|
||||||
binary=binary,
|
binary=binary,
|
||||||
global_args=global_args,
|
global_args=global_args,
|
||||||
@@ -662,7 +685,9 @@ def main() -> None:
|
|||||||
port,
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.27"
|
__version__ = "0.3.28"
|
||||||
|
|||||||
+24
-8
@@ -25,6 +25,7 @@ from .human.config import HumanConfigOverrides, HumanPreset
|
|||||||
|
|
||||||
logger = logging.getLogger("cloakbrowser")
|
logger = logging.getLogger("cloakbrowser")
|
||||||
|
|
||||||
|
|
||||||
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
|
# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)
|
||||||
_VIEWPORT_UNSET = object()
|
_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
|
truncate them at special chars like '='. Idempotent: pre-encoded input stays
|
||||||
the same (decoded then re-encoded).
|
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
|
On unparseable input (invalid port, broken IPv6 literal, etc.) logs a
|
||||||
warning and returns the original string — preserves pre-fix pass-through
|
warning and returns the original string — preserves pre-fix pass-through
|
||||||
behavior so Chromium's own error handling kicks in.
|
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.
|
# urlparse returns None for absent components, "" for present-but-empty.
|
||||||
if parsed.username is None and parsed.password is None:
|
if parsed.username is None and parsed.password is None:
|
||||||
return url
|
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
|
# Preserve the colon separator when password component is present, even if
|
||||||
# empty, so `user:@host` stays `user:@host`.
|
# empty, so `user:@host` stays `user:@host`.
|
||||||
if parsed.password is not None:
|
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:
|
else:
|
||||||
|
raw_pass = None
|
||||||
enc_pass = None
|
enc_pass = None
|
||||||
return _assemble_socks_url(
|
normalized = _assemble_socks_url(
|
||||||
parsed.scheme, parsed.hostname or "", parsed.port,
|
parsed.scheme, parsed.hostname or "", parsed.port,
|
||||||
enc_user, enc_pass,
|
enc_user, enc_pass,
|
||||||
parsed.path, parsed.params, parsed.query, parsed.fragment,
|
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:
|
def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None:
|
||||||
@@ -874,7 +891,7 @@ def maybe_resolve_geoip(
|
|||||||
if not geoip or not proxy:
|
if not geoip or not proxy:
|
||||||
return timezone, locale, None
|
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)
|
proxy_url = _extract_proxy_url(proxy)
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
@@ -882,8 +899,7 @@ def maybe_resolve_geoip(
|
|||||||
|
|
||||||
# When both tz/locale are explicit, still resolve exit IP for WebRTC
|
# When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||||
if timezone is not None and locale is not None:
|
if timezone is not None and locale is not None:
|
||||||
from .geoip import _resolve_exit_ip
|
exit_ip = resolve_proxy_exit_ip(proxy_url)
|
||||||
exit_ip = _resolve_exit_ip(proxy_url)
|
|
||||||
return timezone, locale, exit_ip
|
return timezone, locale, exit_ip
|
||||||
|
|
||||||
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
|
geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url)
|
||||||
@@ -918,8 +934,8 @@ def _resolve_webrtc_args(
|
|||||||
del args[idx]
|
del args[idx]
|
||||||
return args
|
return args
|
||||||
try:
|
try:
|
||||||
from .geoip import _resolve_exit_ip
|
from .geoip import resolve_proxy_exit_ip
|
||||||
exit_ip = _resolve_exit_ip(proxy_url)
|
exit_ip = resolve_proxy_exit_ip(proxy_url)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto")
|
||||||
args = list(args)
|
args = list(args)
|
||||||
|
|||||||
+65
-5
@@ -12,6 +12,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
@@ -27,6 +29,8 @@ GEOIP_DB_URL = (
|
|||||||
)
|
)
|
||||||
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
|
GEOIP_DB_FILENAME = "GeoLite2-City.mmdb"
|
||||||
GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days
|
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 ISO code → BCP 47 locale (covers ~90 % of proxy traffic)
|
||||||
COUNTRY_LOCALE_MAP: dict[str, str] = {
|
COUNTRY_LOCALE_MAP: dict[str, str] = {
|
||||||
@@ -77,11 +81,16 @@ def resolve_proxy_geo_with_ip(
|
|||||||
if db_path is None:
|
if db_path is None:
|
||||||
return None, None, 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
|
# Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||||
ip = _resolve_exit_ip(proxy_url)
|
ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline))
|
||||||
if ip is None:
|
if ip is None and not _deadline_expired(deadline):
|
||||||
ip = _resolve_proxy_ip(proxy_url)
|
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
|
return None, None, None
|
||||||
|
|
||||||
try:
|
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."""
|
"""Discover the proxy's actual exit IP by connecting through it."""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
deadline = _deadline_from_timeout(timeout or 0)
|
||||||
|
|
||||||
for url in _IP_ECHO_URLS:
|
for url in _IP_ECHO_URLS:
|
||||||
try:
|
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()
|
resp.raise_for_status()
|
||||||
ip = resp.text.strip()
|
ip = resp.text.strip()
|
||||||
# Validate it looks like an IP
|
# Validate it looks like an IP
|
||||||
|
|||||||
+1
-1
@@ -215,7 +215,7 @@ const page = await browser.newPage();
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js >= 20
|
- Node.js >= 20
|
||||||
- One of: `playwright-core` >= 1.40 or `puppeteer-core` >= 21
|
- One of: `playwright-core` >= 1.53 or `puppeteer-core` >= 21
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloakbrowser",
|
"name": "cloakbrowser",
|
||||||
"version": "0.3.27",
|
"version": "0.3.28",
|
||||||
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"mmdb-lib": ">=2.0.0",
|
"mmdb-lib": ">=2.0.0",
|
||||||
"playwright-core": ">=1.40.0",
|
"playwright-core": ">=1.53.0",
|
||||||
"puppeteer-core": ">=21.0.0",
|
"puppeteer-core": ">=21.0.0",
|
||||||
"socks-proxy-agent": ">=10.0.0"
|
"socks-proxy-agent": ">=10.0.0"
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"mmdb-lib": "^3.0.2",
|
"mmdb-lib": "^3.0.2",
|
||||||
"socks-proxy-agent": "^10.0.0",
|
"socks-proxy-agent": "^10.0.0",
|
||||||
"playwright-core": "^1.40.0",
|
"playwright-core": "^1.53.0",
|
||||||
"puppeteer-core": "^21.0.0",
|
"puppeteer-core": "^21.0.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vitest": "^1.0.0"
|
"vitest": "^1.0.0"
|
||||||
|
|||||||
+53
-10
@@ -22,6 +22,7 @@ const GEOIP_DB_URL =
|
|||||||
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
"https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb";
|
||||||
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
|
const GEOIP_DB_FILENAME = "GeoLite2-City.mmdb";
|
||||||
const GEOIP_UPDATE_INTERVAL_MS = 30 * 86_400_000; // 30 days
|
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). */
|
/** Country ISO code → BCP 47 locale (covers ~90% of proxy traffic). */
|
||||||
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
export const COUNTRY_LOCALE_MAP: Record<string, string> = {
|
||||||
@@ -68,10 +69,18 @@ export async function resolveProxyGeo(
|
|||||||
const dbPath = await ensureGeoipDb();
|
const dbPath = await ensureGeoipDb();
|
||||||
if (!dbPath) return { timezone: null, locale: null, exitIp: null };
|
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
|
// Exit IP (through proxy) is most accurate — gateway DNS may differ from exit
|
||||||
let ip = await resolveExitIp(proxyUrl);
|
let ip = await resolveExitIp(proxyUrl, remainingMs(deadline));
|
||||||
if (!ip) ip = await resolveProxyIp(proxyUrl);
|
if (!ip && !deadlineExpired(deadline)) ip = await resolveProxyIp(proxyUrl);
|
||||||
if (!ip) return { timezone: null, locale: null, exitIp: null };
|
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 {
|
try {
|
||||||
const buf = fs.readFileSync(dbPath);
|
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
|
// Proxy IP resolution
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -128,7 +161,8 @@ const IP_ECHO_URLS = [
|
|||||||
"https://ifconfig.me/ip",
|
"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);
|
const isSocks = isSocksProxy(proxyUrl);
|
||||||
|
|
||||||
// SOCKS5: tunnel through the SOCKS5 proxy via socks-proxy-agent
|
// 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);
|
const agent = new SocksProxyAgent(proxyUrl);
|
||||||
|
|
||||||
for (const echoUrl of IP_ECHO_URLS) {
|
for (const echoUrl of IP_ECHO_URLS) {
|
||||||
|
const remaining = remainingMs(deadline);
|
||||||
|
if (remaining !== undefined && remaining <= 0) return null;
|
||||||
try {
|
try {
|
||||||
const ip = await new Promise<string | null>((resolve) => {
|
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 = "";
|
let data = "";
|
||||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
||||||
res.on("end", () => {
|
res.on("end", () => {
|
||||||
@@ -173,6 +209,8 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
|||||||
const proxyUrlObj = new URL(proxyUrl);
|
const proxyUrlObj = new URL(proxyUrl);
|
||||||
|
|
||||||
for (const echoUrl of IP_ECHO_URLS) {
|
for (const echoUrl of IP_ECHO_URLS) {
|
||||||
|
const remaining = remainingMs(deadline);
|
||||||
|
if (remaining !== undefined && remaining <= 0) return null;
|
||||||
try {
|
try {
|
||||||
const ip = await new Promise<string | null>((resolve, reject) => {
|
const ip = await new Promise<string | null>((resolve, reject) => {
|
||||||
const targetUrl = new URL(echoUrl);
|
const targetUrl = new URL(echoUrl);
|
||||||
@@ -190,13 +228,14 @@ async function resolveExitIp(proxyUrl: string): Promise<string | null> {
|
|||||||
).toString("base64"),
|
).toString("base64"),
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
timeout: 10_000,
|
timeout: Math.min(10_000, remaining ?? 10_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
connectReq.on("connect", (_res, socket) => {
|
connectReq.on("connect", (_res, socket) => {
|
||||||
|
const innerRemaining = remainingMs(deadline);
|
||||||
const req = https.request(
|
const req = https.request(
|
||||||
echoUrl,
|
echoUrl,
|
||||||
{ socket, timeout: 5_000 } as any,
|
{ socket, timeout: Math.min(5_000, innerRemaining ?? 5_000) } as any,
|
||||||
(res) => {
|
(res) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
res.on("data", (chunk: Buffer) => (data += chunk.toString()));
|
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("error", () => resolve(null));
|
||||||
|
req.on("timeout", () => { req.destroy(); resolve(null); });
|
||||||
req.end();
|
req.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +301,9 @@ async function downloadGeoipDb(dest: string): Promise<void> {
|
|||||||
|
|
||||||
const tmpPath = `${dest}.tmp.${Date.now()}`;
|
const tmpPath = `${dest}.tmp.${Date.now()}`;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(GEOIP_DB_URL, { redirect: "follow" });
|
const response = await fetch(GEOIP_DB_URL, {
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
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
|
// When both tz/locale are explicit, still resolve exit IP for WebRTC
|
||||||
if (options.timezone && options.locale) {
|
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 };
|
return { timezone: options.timezone, locale: options.locale, exitIp };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +406,7 @@ export async function resolveWebrtcArgs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = await resolveExitIp(proxyUrl);
|
const ip = await resolveExitIp(proxyUrl, getGeoipTimeoutMs());
|
||||||
const result = [...args];
|
const result = [...args];
|
||||||
if (ip) {
|
if (ip) {
|
||||||
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
result[idx] = `--fingerprint-webrtc-ip=${ip}`;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
|
import type { Browser, Page, Frame, CDPSession, ElementHandle, BrowserContext } from 'puppeteer-core';
|
||||||
import type { HumanConfig } from '../human/config.js';
|
import type { HumanConfig, HumanActionOptions } from '../human/config.js';
|
||||||
import { resolveConfig, mergeConfig, rand, randRange, sleep } 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 { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from '../human/mouse.js';
|
||||||
import { humanType } from './keyboard.js';
|
import { humanType } from './keyboard.js';
|
||||||
@@ -319,7 +319,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==== goto ====
|
// ==== 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);
|
const response = await originals.goto(url, options);
|
||||||
stealth.invalidate();
|
stealth.invalidate();
|
||||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
@@ -327,11 +331,16 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==== click (with clickCount support for dblclick) ====
|
// ==== 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();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
@@ -355,11 +364,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==== hover ====
|
// ==== hover ====
|
||||||
const humanHoverFn = async (selector: string, options?: any) => {
|
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
@@ -371,8 +380,10 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==== type ====
|
// ==== type ====
|
||||||
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
delay?: number;
|
||||||
|
}) => {
|
||||||
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
await sleep(randRange(callCfg.field_switch_delay));
|
await sleep(randRange(callCfg.field_switch_delay));
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, options);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
@@ -395,7 +406,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==== tap ====
|
// ==== tap ====
|
||||||
const humanTapFn = async (selector: string, options?: any) => {
|
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -413,14 +424,19 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Mouse patches
|
// 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 ensureCursorInit();
|
||||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||||
cursor.x = x;
|
cursor.x = x;
|
||||||
cursor.y = y;
|
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 ensureCursorInit();
|
||||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||||
cursor.x = x;
|
cursor.x = x;
|
||||||
@@ -455,7 +471,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
(page.mouse as any).dragAndDrop = async (
|
(page.mouse as any).dragAndDrop = async (
|
||||||
start: { x: number; y: number },
|
start: { x: number; y: number },
|
||||||
target: { x: number; y: number },
|
target: { x: number; y: number },
|
||||||
options?: any,
|
options?: { delay?: number },
|
||||||
) => {
|
) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, start.x, start.y, cfg);
|
||||||
@@ -475,12 +491,12 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Keyboard patches
|
// Keyboard patches
|
||||||
// ============================================================
|
// ============================================================
|
||||||
page.keyboard.type = async (text: string, options?: any) => {
|
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
|
||||||
const cdp = await ensureCdp();
|
const cdp = await ensureCdp();
|
||||||
await humanType(page, rawKb, text, cfg, cdp);
|
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 sleep(rand(20, 60));
|
||||||
await originals.keyboardDown(key as any);
|
await originals.keyboardDown(key as any);
|
||||||
await sleep(randRange(cfg.key_hold));
|
await sleep(randRange(cfg.key_hold));
|
||||||
@@ -551,7 +567,11 @@ function patchElementHandle(
|
|||||||
return els;
|
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);
|
const el = await origWaitForSelector(selector, options);
|
||||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return el;
|
return el;
|
||||||
@@ -603,14 +623,18 @@ function patchSingleElementHandle(
|
|||||||
return children;
|
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);
|
const child = await origElWaitForSelector(selector, options);
|
||||||
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return child;
|
return child;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
|
// --- Helper: get box and move cursor. Accepts a per-call ``callCfg``
|
||||||
// so type/fill overrides like ``el.type(text, { human_config: {...} })``
|
// so type/fill overrides like ``el.type(text, { typing_delay: 30 })``
|
||||||
// carry through to mouse timing for that single call. Also scrolls into
|
// carry through to mouse timing for that single call. Also scrolls into
|
||||||
// view first so off-screen elements work (#129, #172 follow-up).
|
// view first so off-screen elements work (#129, #172 follow-up).
|
||||||
const moveToElement = async (callCfg: HumanConfig = cfg) => {
|
const moveToElement = async (callCfg: HumanConfig = cfg) => {
|
||||||
@@ -633,7 +657,7 @@ function patchSingleElementHandle(
|
|||||||
const target = clickTarget(box, isInp, callCfg);
|
const target = clickTarget(box, isInp, callCfg);
|
||||||
|
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||||
@@ -643,8 +667,13 @@ function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.click() ---
|
// --- el.click() ---
|
||||||
(el as any).click = async (options?: any) => {
|
(el as any).click = async (options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElClick(options);
|
if (!info) return origElClick(options);
|
||||||
|
|
||||||
@@ -667,8 +696,8 @@ function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.type() ---
|
// --- el.type() ---
|
||||||
(el as any).type = async (text: string, options?: any) => {
|
(el as any).type = async (text: string, options?: HumanActionOptions & { delay?: number }) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElType(text, options);
|
if (!info) return origElType(text, options);
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
@@ -684,8 +713,8 @@ function patchSingleElementHandle(
|
|||||||
// page.click(). Only patched when the underlying ElementHandle exposes
|
// page.click(). Only patched when the underlying ElementHandle exposes
|
||||||
// ``scrollIntoView`` (Puppeteer v22+).
|
// ``scrollIntoView`` (Puppeteer v22+).
|
||||||
if (origElScrollIntoView) {
|
if (origElScrollIntoView) {
|
||||||
(el as any).scrollIntoView = async (options?: any) => {
|
(el as any).scrollIntoView = async (options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
await (page as any)._ensureCursorInit();
|
await (page as any)._ensureCursorInit();
|
||||||
try {
|
try {
|
||||||
const { cursorX, cursorY } = await humanScrollIntoView(
|
const { cursorX, cursorY } = await humanScrollIntoView(
|
||||||
@@ -703,7 +732,7 @@ function patchSingleElementHandle(
|
|||||||
|
|
||||||
// --- el.press() ---
|
// --- el.press() ---
|
||||||
if (origElPress) {
|
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 sleep(rand(20, 60));
|
||||||
await originals.keyboardDown(key as any);
|
await originals.keyboardDown(key as any);
|
||||||
await sleep(randRange(cfg.key_hold));
|
await sleep(randRange(cfg.key_hold));
|
||||||
@@ -742,7 +771,7 @@ function patchSingleElementHandle(
|
|||||||
|
|
||||||
// --- el.drop() ---
|
// --- el.drop() ---
|
||||||
if (origElDrop) {
|
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 srcBox = await draggable.boundingBox();
|
||||||
const tgtBox = await el.boundingBox();
|
const tgtBox = await el.boundingBox();
|
||||||
|
|
||||||
@@ -772,7 +801,7 @@ function patchSingleElementHandle(
|
|||||||
|
|
||||||
// --- el.dragAndDrop() ---
|
// --- el.dragAndDrop() ---
|
||||||
if (origElDragAndDrop) {
|
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 srcBox = await el.boundingBox();
|
||||||
const tgtBox = await targetEl.boundingBox();
|
const tgtBox = await targetEl.boundingBox();
|
||||||
|
|
||||||
@@ -836,15 +865,22 @@ function patchSingleFrame(
|
|||||||
|
|
||||||
const origFrameSelect = frame.select.bind(frame);
|
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);
|
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);
|
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);
|
await (page as any).type(selector, text, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -858,7 +894,7 @@ function patchSingleFrame(
|
|||||||
await (page as any).focus(selector);
|
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);
|
await (page as any).click(selector, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -881,7 +917,11 @@ function patchSingleFrame(
|
|||||||
return els;
|
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);
|
const el = await origFrameWaitForSelector(selector, options);
|
||||||
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return el;
|
return el;
|
||||||
@@ -927,7 +967,7 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
|||||||
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
|
for (const methodName of ['createBrowserContext', 'createIncognitoBrowserContext'] as const) {
|
||||||
if (typeof (browser as any)[methodName] === 'function') {
|
if (typeof (browser as any)[methodName] === 'function') {
|
||||||
const origCreateContext = (browser as any)[methodName].bind(browser);
|
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 context: BrowserContext = await origCreateContext(options);
|
||||||
|
|
||||||
const origCtxNewPage = context.newPage.bind(context);
|
const origCtxNewPage = context.newPage.bind(context);
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export interface HumanConfig {
|
|||||||
|
|
||||||
export type HumanPreset = 'default' | 'careful';
|
export type HumanPreset = 'default' | 'careful';
|
||||||
|
|
||||||
|
export type HumanActionOptions = Partial<HumanConfig> & {
|
||||||
|
timeout?: number;
|
||||||
|
human_config?: Partial<HumanConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Default preset
|
// Default preset
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
import type { Page, Frame, ElementHandle, CDPSession } from 'playwright-core';
|
||||||
import type { HumanConfig } from './config.js';
|
import type { HumanConfig, HumanActionOptions } from './config.js';
|
||||||
import { rand, randRange, sleep, mergeConfig } from './config.js';
|
import { rand, randRange, sleep, mergeConfig } from './config.js';
|
||||||
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
import { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
import { humanType } from './keyboard.js';
|
import { humanType } from './keyboard.js';
|
||||||
@@ -125,16 +125,21 @@ export function patchSingleElementHandle(
|
|||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
(el as any).waitForSelector = async (selector: string, options?: any) => {
|
(el as any).waitForSelector = async (selector: string, options?: {
|
||||||
const child = await origElWaitForSelector(selector, 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);
|
if (child) patchSingleElementHandle(child, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return child;
|
return child;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helper: get bounding box and move cursor to element ---
|
// --- Helper: get bounding box and move cursor to element ---
|
||||||
// Accepts a per-call ``callCfg`` so type/fill overrides like
|
// Accepts a per-call ``callCfg`` so type/fill overrides like
|
||||||
// ``el.type(text, { human_config: { typing_delay: 30 } })`` carry through to
|
// ``el.type(text, { human_config: { typing_delay: 30 } })`` or
|
||||||
// mouse movement & idle timing for that single call.
|
// ``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
|
// Also scrolls the element into view first so off-screen elements work
|
||||||
// (#129, #172 follow-up): otherwise boundingBox() returns null and we'd
|
// (#129, #172 follow-up): otherwise boundingBox() returns null and we'd
|
||||||
// silently fall back to the unpatched native method.
|
// silently fall back to the unpatched native method.
|
||||||
@@ -164,7 +169,7 @@ export function patchSingleElementHandle(
|
|||||||
const target = clickTarget(box, isInp, callCfg);
|
const target = clickTarget(box, isInp, callCfg);
|
||||||
|
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
await humanMove(raw, cursor.x, cursor.y, target.x, target.y, callCfg);
|
||||||
@@ -174,16 +179,33 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.click() ---
|
// --- el.click() ---
|
||||||
(el as any).click = async (options?: any) => {
|
(el as any).click = async (options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElClick(options);
|
if (!info) return origElClick(options);
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- el.dblclick() ---
|
// --- el.dblclick() ---
|
||||||
(el as any).dblclick = async (options?: any) => {
|
(el as any).dblclick = async (options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElDblclick(options);
|
if (!info) return origElDblclick(options);
|
||||||
await raw.down({ clickCount: 2 });
|
await raw.down({ clickCount: 2 });
|
||||||
@@ -192,16 +214,24 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.hover() ---
|
// --- el.hover() ---
|
||||||
(el as any).hover = async (options?: any) => {
|
(el as any).hover = async (options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
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);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElHover(options);
|
if (!info) return origElHover(options);
|
||||||
// Just move — no click
|
// Just move — no click
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- el.type() ---
|
// --- el.type() ---
|
||||||
(el as any).type = async (text: string, options?: any) => {
|
(el as any).type = async (text: string, options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
delay?: number;
|
||||||
|
noWaitAfter?: boolean;
|
||||||
|
}) => {
|
||||||
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElType(text, options);
|
if (!info) return origElType(text, options);
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
@@ -212,8 +242,11 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.fill() ---
|
// --- el.fill() ---
|
||||||
(el as any).fill = async (value: string, options?: any) => {
|
(el as any).fill = async (value: string, options?: HumanActionOptions & {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
force?: boolean;
|
||||||
|
noWaitAfter?: boolean;
|
||||||
|
}) => {
|
||||||
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const info = await moveToElement(callCfg);
|
const info = await moveToElement(callCfg);
|
||||||
if (!info) return origElFill(value, options);
|
if (!info) return origElFill(value, options);
|
||||||
await humanClick(raw, info.isInp, callCfg);
|
await humanClick(raw, info.isInp, callCfg);
|
||||||
@@ -229,7 +262,7 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.press() ---
|
// --- 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 sleep(rand(20, 60));
|
||||||
await originals.keyboardDown(key);
|
await originals.keyboardDown(key);
|
||||||
await sleep(randRange(cfg.key_hold));
|
await sleep(randRange(cfg.key_hold));
|
||||||
@@ -237,7 +270,11 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.selectOption() ---
|
// --- 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();
|
const info = await moveToElement();
|
||||||
if (!info) return origElSelectOption(values, options);
|
if (!info) return origElSelectOption(values, options);
|
||||||
await humanClick(raw, false, cfg);
|
await humanClick(raw, false, cfg);
|
||||||
@@ -246,7 +283,13 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.check() ---
|
// --- 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 {
|
try {
|
||||||
const checked = await el.isChecked();
|
const checked = await el.isChecked();
|
||||||
if (checked) return; // Already checked
|
if (checked) return; // Already checked
|
||||||
@@ -257,7 +300,13 @@ export function patchSingleElementHandle(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- el.uncheck() ---
|
// --- 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 {
|
try {
|
||||||
const checked = await el.isChecked();
|
const checked = await el.isChecked();
|
||||||
if (!checked) return; // Already unchecked
|
if (!checked) return; // Already unchecked
|
||||||
@@ -269,7 +318,13 @@ export function patchSingleElementHandle(
|
|||||||
|
|
||||||
// --- el.setChecked() ---
|
// --- el.setChecked() ---
|
||||||
if (origElSetChecked) {
|
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 {
|
try {
|
||||||
const current = await el.isChecked();
|
const current = await el.isChecked();
|
||||||
if (current === checked) return;
|
if (current === checked) return;
|
||||||
@@ -281,7 +336,14 @@ export function patchSingleElementHandle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- el.tap() ---
|
// --- 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();
|
const info = await moveToElement();
|
||||||
if (!info) return origElTap(options);
|
if (!info) return origElTap(options);
|
||||||
await humanClick(raw, info.isInp, cfg);
|
await humanClick(raw, info.isInp, cfg);
|
||||||
@@ -302,8 +364,8 @@ export function patchSingleElementHandle(
|
|||||||
// wheel sequence used by page.click() etc. Falls back to the native
|
// wheel sequence used by page.click() etc. Falls back to the native
|
||||||
// method if the element is detached or scrolling fails.
|
// method if the element is detached or scrolling fails.
|
||||||
if (origElScrollIntoViewIfNeeded) {
|
if (origElScrollIntoViewIfNeeded) {
|
||||||
(el as any).scrollIntoViewIfNeeded = async (options?: any) => {
|
(el as any).scrollIntoViewIfNeeded = async (options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
const ensureCursorInit = (page as any)._ensureCursorInit;
|
const ensureCursorInit = (page as any)._ensureCursorInit;
|
||||||
if (ensureCursorInit) await ensureCursorInit();
|
if (ensureCursorInit) await ensureCursorInit();
|
||||||
try {
|
try {
|
||||||
@@ -360,8 +422,12 @@ export function patchPageElementHandles(
|
|||||||
// Patch page.waitForSelector()
|
// Patch page.waitForSelector()
|
||||||
if (typeof page.waitForSelector === 'function') {
|
if (typeof page.waitForSelector === 'function') {
|
||||||
const origWaitForSelector = page.waitForSelector.bind(page);
|
const origWaitForSelector = page.waitForSelector.bind(page);
|
||||||
(page as any).waitForSelector = async (selector: string, options?: any) => {
|
(page as any).waitForSelector = async (selector: string, options?: {
|
||||||
const el = await origWaitForSelector(selector, 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);
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
@@ -408,8 +474,12 @@ export function patchFrameElementHandles(
|
|||||||
// Patch frame.waitForSelector()
|
// Patch frame.waitForSelector()
|
||||||
if (typeof frame.waitForSelector === 'function') {
|
if (typeof frame.waitForSelector === 'function') {
|
||||||
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
const origFrameWaitForSelector = frame.waitForSelector.bind(frame);
|
||||||
(frame as any).waitForSelector = async (selector: string, options?: any) => {
|
(frame as any).waitForSelector = async (selector: string, options?: {
|
||||||
const el = await origFrameWaitForSelector(selector, 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);
|
if (el) patchSingleElementHandle(el, page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
return el;
|
return el;
|
||||||
};
|
};
|
||||||
|
|||||||
+182
-66
@@ -23,7 +23,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
import type { Browser, BrowserContext, Page, Frame, CDPSession } from 'playwright-core';
|
||||||
import { HumanConfig, resolveConfig, mergeConfig, 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 { RawMouse, RawKeyboard, humanMove, humanClick, clickTarget, humanIdle } from './mouse.js';
|
||||||
import { humanType } from './keyboard.js';
|
import { humanType } from './keyboard.js';
|
||||||
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
import { scrollToElement, humanScrollIntoView } from './scroll.js';
|
||||||
@@ -295,7 +295,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- goto (invalidate isolated world on navigation) ---
|
// --- 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);
|
const response = await originals.goto(url, options);
|
||||||
stealth.invalidate();
|
stealth.invalidate();
|
||||||
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
patchFrames(page, cfg, cursor, raw, rawKb, originals, stealth);
|
||||||
@@ -303,11 +307,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- click ---
|
// --- click ---
|
||||||
const humanClickFn = async (selector: string, options?: any) => {
|
const humanClickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
@@ -321,12 +325,13 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- dblclick ---
|
// --- dblclick ---
|
||||||
const humanDblclickFn = async (selector: string, options?: any) => {
|
const humanDblclickFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
cursor.y = cursorY;
|
cursor.y = cursorY;
|
||||||
@@ -341,11 +346,11 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- hover ---
|
// --- hover ---
|
||||||
const humanHoverFn = async (selector: string, options?: any) => {
|
const humanHoverFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await ensureCursorInit();
|
await ensureCursorInit();
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (callCfg.idle_between_actions) {
|
if (callCfg.idle_between_actions) {
|
||||||
await humanIdle(raw, rand(callCfg.idle_between_duration[0], callCfg.idle_between_duration[1]), cursor.x, cursor.y, callCfg);
|
await humanIdle(raw, cursor.x, cursor.y, callCfg);
|
||||||
}
|
}
|
||||||
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
const { box, cursorX, cursorY } = await scrollToElement(page, raw, selector, cursor.x, cursor.y, callCfg, options?.timeout);
|
||||||
cursor.x = cursorX;
|
cursor.x = cursorX;
|
||||||
@@ -357,8 +362,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- type ---
|
// --- type ---
|
||||||
const humanTypeFn = async (selector: string, text: string, options?: any) => {
|
const humanTypeFn = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
await sleep(randRange(callCfg.field_switch_delay));
|
await sleep(randRange(callCfg.field_switch_delay));
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, options);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
@@ -367,8 +372,8 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- fill (clears existing content first) ---
|
// --- fill (clears existing content first) ---
|
||||||
const humanFillFn = async (selector: string, value: string, options?: any) => {
|
const humanFillFn = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||||
const callCfg = mergeConfig(cfg, options?.human_config);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
await sleep(randRange(callCfg.field_switch_delay));
|
await sleep(randRange(callCfg.field_switch_delay));
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, options);
|
||||||
await sleep(rand(100, 250));
|
await sleep(rand(100, 250));
|
||||||
@@ -381,9 +386,9 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- clear ---
|
// --- clear ---
|
||||||
const humanClearFn = async (selector: string, options?: any) => {
|
const humanClearFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector, options);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
await originals.keyboardPress(SELECT_ALL);
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
@@ -392,46 +397,48 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- check ---
|
// --- check ---
|
||||||
const humanCheckFn = async (selector: string, options?: any) => {
|
const humanCheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
if (cfg.idle_between_actions) {
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
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);
|
||||||
}
|
}
|
||||||
const checked = await originals.isChecked(selector).catch(() => false);
|
const checked = await originals.isChecked(selector).catch(() => false);
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector, options);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- uncheck ---
|
// --- uncheck ---
|
||||||
const humanUncheckFn = async (selector: string, options?: any) => {
|
const humanUncheckFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
if (cfg.idle_between_actions) {
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
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);
|
||||||
}
|
}
|
||||||
const checked = await originals.isChecked(selector).catch(() => true);
|
const checked = await originals.isChecked(selector).catch(() => true);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector, options);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- selectOption ---
|
// --- selectOption ---
|
||||||
const humanSelectOptionFn = async (selector: string, values: any, options?: any) => {
|
const humanSelectOptionFn = async (selector: string, values: any, options?: HumanActionOptions) => {
|
||||||
await humanHoverFn(selector);
|
await humanHoverFn(selector, options);
|
||||||
await sleep(rand(100, 300));
|
await sleep(rand(100, 300));
|
||||||
return originals.selectOption(selector, values, options);
|
return originals.selectOption(selector, values, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- press (checks focus first — avoids redundant mouse moves) ---
|
// --- press (checks focus first — avoids redundant mouse moves) ---
|
||||||
const humanPressFn = async (selector: string, key: string, options?: any) => {
|
const humanPressFn = async (selector: string, key: string, options?: HumanActionOptions) => {
|
||||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector);
|
await humanClickFn(selector, options);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
await originals.keyboardPress(key);
|
await originals.keyboardPress(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- pressSequentially ---
|
// --- 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);
|
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
|
||||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
if (!await isSelectorFocused(stealth, page, selector)) {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, options);
|
||||||
}
|
}
|
||||||
@@ -441,7 +448,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- tap ---
|
// --- tap ---
|
||||||
const humanTapFn = async (selector: string, options?: any) => {
|
const humanTapFn = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await humanClickFn(selector, options);
|
await humanClickFn(selector, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -461,14 +468,20 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
(page as any).clear = humanClearFn;
|
(page as any).clear = humanClearFn;
|
||||||
|
|
||||||
// --- mouse patches ---
|
// --- 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 ensureCursorInit();
|
||||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||||
cursor.x = x;
|
cursor.x = x;
|
||||||
cursor.y = y;
|
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 ensureCursorInit();
|
||||||
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
await humanMove(raw, cursor.x, cursor.y, x, y, cfg);
|
||||||
cursor.x = x;
|
cursor.x = x;
|
||||||
@@ -477,7 +490,7 @@ function patchPage(page: Page, cfg: HumanConfig, cursor: CursorState): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- keyboard patches ---
|
// --- keyboard patches ---
|
||||||
page.keyboard.type = async (text: string, options?: any) => {
|
page.keyboard.type = async (text: string, options?: { delay?: number }) => {
|
||||||
const cdp = await ensureCdp();
|
const cdp = await ensureCdp();
|
||||||
await humanType(page, rawKb, text, cfg, cdp);
|
await humanType(page, rawKb, text, cfg, cdp);
|
||||||
};
|
};
|
||||||
@@ -529,16 +542,37 @@ function patchFrames(
|
|||||||
stealth: StealthEval,
|
stealth: StealthEval,
|
||||||
): void {
|
): void {
|
||||||
for (const frame of iterFrames(page)) {
|
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)
|
// Patch frame-level ElementHandle selectors ($, $$, waitForSelector)
|
||||||
patchFrameElementHandles(frame, page, cfg, cursor, raw, rawKb, originals, stealth);
|
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(
|
function patchSingleFrame(
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
page: Page,
|
page: Page,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
|
cursor: CursorState,
|
||||||
|
raw: RawMouse,
|
||||||
|
rawKb: RawKeyboard,
|
||||||
originals: any,
|
originals: any,
|
||||||
stealth: StealthEval,
|
stealth: StealthEval,
|
||||||
): void {
|
): void {
|
||||||
@@ -546,58 +580,132 @@ function patchSingleFrame(
|
|||||||
(frame as any)._humanPatched = true;
|
(frame as any)._humanPatched = true;
|
||||||
|
|
||||||
// Save originals for methods that need fallback
|
// 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 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);
|
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
|
||||||
|
|
||||||
(frame as any).click = async (selector: string, options?: any) => {
|
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
|
||||||
await (page as any).click(selector, options);
|
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) => {
|
const frameClick = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).dblclick(selector, options);
|
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) => {
|
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
|
||||||
await (page as any).hover(selector, options);
|
|
||||||
|
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) => {
|
(frame as any).click = frameClick;
|
||||||
await (page as any).type(selector, text, options);
|
|
||||||
|
(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) => {
|
(frame as any).hover = frameHover;
|
||||||
await (page as any).fill(selector, value, options);
|
|
||||||
|
(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) => {
|
(frame as any).fill = async (selector: string, value: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).check(selector, options);
|
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) => {
|
(frame as any).check = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).uncheck(selector, options);
|
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) => {
|
(frame as any).uncheck = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).hover(selector);
|
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));
|
await sleep(rand(100, 300));
|
||||||
return origFrameSelectOption(selector, values, options);
|
return origFrameSelectOption(selector, values, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
(frame as any).press = async (selector: string, key: string, options?: any) => {
|
(frame as any).press = async (selector: string, key: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).press(selector, key, options);
|
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) => {
|
(frame as any).pressSequentially = async (selector: string, text: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).pressSequentially(selector, text, options);
|
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) => {
|
(frame as any).tap = async (selector: string, options?: HumanActionOptions) => {
|
||||||
await (page as any).tap(selector, options);
|
await frameClick(selector, options).catch(() => origFrameTap?.(selector, options));
|
||||||
};
|
};
|
||||||
|
|
||||||
(frame as any).clear = async (selector: string, options?: any) => {
|
(frame as any).clear = async (selector: string, options?: HumanActionOptions) => {
|
||||||
if (!await isSelectorFocused(stealth, page, selector)) {
|
if (!await isFrameSelectorFocused(frame, selector)) {
|
||||||
await (page as any).click(selector);
|
await frameClick(selector, options);
|
||||||
}
|
}
|
||||||
await sleep(rand(50, 150));
|
await sleep(rand(50, 150));
|
||||||
await originals.keyboardPress(SELECT_ALL);
|
await originals.keyboardPress(SELECT_ALL);
|
||||||
@@ -605,9 +713,17 @@ function patchSingleFrame(
|
|||||||
await originals.keyboardPress('Backspace');
|
await originals.keyboardPress('Backspace');
|
||||||
};
|
};
|
||||||
|
|
||||||
(frame as any).dragAndDrop = async (source: string, target: string, options?: any) => {
|
(frame as any).dragAndDrop = async (source: string, target: string, options?: {
|
||||||
const srcBox = await frame.locator(source).boundingBox().catch(() => null);
|
force?: boolean;
|
||||||
const tgtBox = await frame.locator(target).boundingBox().catch(() => null);
|
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) {
|
if (srcBox && tgtBox) {
|
||||||
const sx = srcBox.x + srcBox.width / 2;
|
const sx = srcBox.x + srcBox.width / 2;
|
||||||
@@ -676,14 +792,14 @@ export function patchBrowser(browser: Browser, cfg: HumanConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const origNewContext = browser.newContext.bind(browser);
|
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);
|
const context = await origNewContext(options);
|
||||||
patchContext(context, cfg);
|
patchContext(context, cfg);
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
const origNewPage = browser.newPage.bind(browser);
|
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);
|
const page = await origNewPage(options);
|
||||||
if (!(page as any)._original) {
|
if (!(page as any)._original) {
|
||||||
const ctx = page.context();
|
const ctx = page.context();
|
||||||
|
|||||||
+21
-1
@@ -172,13 +172,33 @@ export async function humanClick(
|
|||||||
// Human idle / drift
|
// 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,
|
raw: RawMouse,
|
||||||
seconds: number,
|
seconds: number,
|
||||||
cx: number,
|
cx: number,
|
||||||
cy: number,
|
cy: number,
|
||||||
cfg: HumanConfig,
|
cfg: HumanConfig,
|
||||||
|
): Promise<void>;
|
||||||
|
export async function humanIdle(
|
||||||
|
raw: RawMouse,
|
||||||
|
secondsOrCx: number,
|
||||||
|
cxOrCy: number,
|
||||||
|
cyOrCfg: number | HumanConfig,
|
||||||
|
maybeCfg?: HumanConfig,
|
||||||
): Promise<void> {
|
): 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;
|
const endTime = Date.now() + seconds * 1000;
|
||||||
let x = cx;
|
let x = cx;
|
||||||
let y = cy;
|
let y = cy;
|
||||||
|
|||||||
+13
-1
@@ -136,7 +136,19 @@ export function normalizeSocksStringUrl(urlStr: string): string {
|
|||||||
const encPass = hasPassword
|
const encPass = hasPassword
|
||||||
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
|
||||||
: null;
|
: 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) {
|
} catch (e) {
|
||||||
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
|
console.warn(`[cloakbrowser] Could not normalize SOCKS5 proxy URL, passing through unchanged: ${(e as Error).message}`);
|
||||||
return urlStr;
|
return urlStr;
|
||||||
|
|||||||
+59
-2
@@ -1,5 +1,17 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { COUNTRY_LOCALE_MAP, resolveProxyIp } from "../src/geoip.js";
|
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", () => {
|
describe("resolveProxyIp", () => {
|
||||||
it("returns literal IPv4 from proxy URL", async () => {
|
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", () => {
|
describe("COUNTRY_LOCALE_MAP", () => {
|
||||||
it("contains common countries", () => {
|
it("contains common countries", () => {
|
||||||
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
|
for (const code of ["US", "GB", "DE", "FR", "JP", "BR", "IL", "RU"]) {
|
||||||
|
|||||||
+257
-186
@@ -106,9 +106,9 @@ describe("humanMove", () => {
|
|||||||
return {
|
return {
|
||||||
raw: {
|
raw: {
|
||||||
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
|
move: vi.fn(async (x: number, y: number) => { moves.push({ x, y }); }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
},
|
},
|
||||||
moves,
|
moves,
|
||||||
};
|
};
|
||||||
@@ -180,10 +180,10 @@ describe("humanClick", () => {
|
|||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => { callOrder.push("down"); }),
|
down: vi.fn(async () => { callOrder.push("down"); }),
|
||||||
up: vi.fn(async () => { callOrder.push("up"); }),
|
up: vi.fn(async () => { callOrder.push("up"); }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
await humanClick(raw, false, cfg);
|
await humanClick(raw, false, cfg);
|
||||||
expect(raw.down).toHaveBeenCalledTimes(1);
|
expect(raw.down).toHaveBeenCalledTimes(1);
|
||||||
@@ -199,12 +199,12 @@ describe("humanIdle", () => {
|
|||||||
it("calls raw.move at least once during idle", async () => {
|
it("calls raw.move at least once during idle", async () => {
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
await humanIdle(raw, 10, 100, 100, cfg);
|
await humanIdle(raw, 100, 100, cfg);
|
||||||
expect(raw.move).toHaveBeenCalled();
|
expect(raw.move).toHaveBeenCalled();
|
||||||
}, 15000);
|
}, 15000);
|
||||||
});
|
});
|
||||||
@@ -265,7 +265,7 @@ describe("patchPage fill", () => {
|
|||||||
const cursor = { x: 0, y: 0, initialized: false };
|
const cursor = { x: 0, y: 0, initialized: false };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
try { await (page as any).fill("input#name", "hello"); } catch (_) {}
|
try { await (page as any).fill("input#name", "hello"); } catch (_) { }
|
||||||
|
|
||||||
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
const expected = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
||||||
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
const wrong = process.platform === "darwin" ? "Control+a" : "Meta+a";
|
||||||
@@ -298,7 +298,7 @@ describe("patchPage check/uncheck idle", () => {
|
|||||||
const cursor = { x: 100, y: 100, initialized: true };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
try { await (page as any).check("input#cb"); } catch (_) {}
|
try { await (page as any).check("input#cb"); } catch (_) { }
|
||||||
|
|
||||||
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
// humanCheckFn → humanIdle → humanClickFn → humanClick → raw.down
|
||||||
expect(downCalled).toBe(true);
|
expect(downCalled).toBe(true);
|
||||||
@@ -321,7 +321,7 @@ describe("patchPage check/uncheck idle", () => {
|
|||||||
const cursor = { x: 100, y: 100, initialized: true };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
try { await (page as any).uncheck("input#cb"); } catch (_) {}
|
try { await (page as any).uncheck("input#cb"); } catch (_) { }
|
||||||
|
|
||||||
expect(downCalled).toBe(true);
|
expect(downCalled).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -354,7 +354,7 @@ describe("patchPage press focus", () => {
|
|||||||
const cursor = { x: 50, y: 50, initialized: true };
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
|
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||||
|
|
||||||
expect(downCount).toBeGreaterThan(0);
|
expect(downCount).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -372,7 +372,7 @@ describe("patchPage press focus", () => {
|
|||||||
const cursor = { x: 50, y: 50, initialized: true };
|
const cursor = { x: 50, y: 50, initialized: true };
|
||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
try { await (page as any).press("input#field", "Enter"); } catch (_) {}
|
try { await (page as any).press("input#field", "Enter"); } catch (_) { }
|
||||||
|
|
||||||
expect(downCount).toBe(0);
|
expect(downCount).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -398,6 +398,69 @@ describe("patchPage frame patching", () => {
|
|||||||
|
|
||||||
expect((childFrame as any)._humanPatched).toBe(true);
|
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]),
|
pages: vi.fn(() => [page]),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
newPage: vi.fn(async () => buildMockPage()),
|
newPage: vi.fn(async () => buildMockPage()),
|
||||||
addInitScript: vi.fn(async () => {}),
|
addInitScript: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const browser: any = {
|
const browser: any = {
|
||||||
contexts: vi.fn(() => [context]),
|
contexts: vi.fn(() => [context]),
|
||||||
@@ -494,7 +557,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
|||||||
pages: vi.fn(() => [page]),
|
pages: vi.fn(() => [page]),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
newPage: vi.fn(async () => buildMockPage()),
|
newPage: vi.fn(async () => buildMockPage()),
|
||||||
addInitScript: vi.fn(async () => {}),
|
addInitScript: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const browser: any = {
|
const browser: any = {
|
||||||
contexts: vi.fn(() => [context]),
|
contexts: vi.fn(() => [context]),
|
||||||
@@ -505,7 +568,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
|||||||
patchBrowser(browser, resolveConfig("default"));
|
patchBrowser(browser, resolveConfig("default"));
|
||||||
|
|
||||||
// Click through the patched method — should go through humanize path
|
// Click through the patched method — should go through humanize path
|
||||||
try { await (page as any).click("button"); } catch (_) {}
|
try { await (page as any).click("button"); } catch (_) { }
|
||||||
|
|
||||||
expect(downCalled).toBe(true);
|
expect(downCalled).toBe(true);
|
||||||
}, 30000);
|
}, 30000);
|
||||||
@@ -518,7 +581,7 @@ describe("patchBrowser CDP-connected workflow", () => {
|
|||||||
pages: vi.fn(() => [newPage]),
|
pages: vi.fn(() => [newPage]),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
newPage: vi.fn(async () => buildMockPage()),
|
newPage: vi.fn(async () => buildMockPage()),
|
||||||
addInitScript: vi.fn(async () => {}),
|
addInitScript: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const browser: any = {
|
const browser: any = {
|
||||||
contexts: vi.fn(() => []),
|
contexts: vi.fn(() => []),
|
||||||
@@ -542,27 +605,27 @@ describe("patchBrowser CDP-connected workflow", () => {
|
|||||||
function buildMockPage(overrides: Record<string, any> = {}): any {
|
function buildMockPage(overrides: Record<string, any> = {}): any {
|
||||||
const mainFrameObj = overrides.mainFrameReturn ?? {
|
const mainFrameObj = overrides.mainFrameReturn ?? {
|
||||||
childFrames: vi.fn(() => []),
|
childFrames: vi.fn(() => []),
|
||||||
click: vi.fn(async () => {}),
|
click: vi.fn(async () => { }),
|
||||||
dblclick: vi.fn(async () => {}),
|
dblclick: vi.fn(async () => { }),
|
||||||
hover: vi.fn(async () => {}),
|
hover: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
fill: vi.fn(async () => {}),
|
fill: vi.fn(async () => { }),
|
||||||
check: vi.fn(async () => {}),
|
check: vi.fn(async () => { }),
|
||||||
uncheck: vi.fn(async () => {}),
|
uncheck: vi.fn(async () => { }),
|
||||||
selectOption: vi.fn(async () => {}),
|
selectOption: vi.fn(async () => { }),
|
||||||
press: vi.fn(async () => {}),
|
press: vi.fn(async () => { }),
|
||||||
clear: vi.fn(async () => {}),
|
clear: vi.fn(async () => { }),
|
||||||
dragAndDrop: vi.fn(async () => {}),
|
dragAndDrop: vi.fn(async () => { }),
|
||||||
locator: vi.fn(() => ({
|
locator: vi.fn(() => ({
|
||||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
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 makeLocator = () => {
|
||||||
const loc: any = {
|
const loc: any = {
|
||||||
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
boundingBox: vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||||
scrollIntoViewIfNeeded: vi.fn(async () => {}),
|
scrollIntoViewIfNeeded: vi.fn(async () => { }),
|
||||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
};
|
};
|
||||||
loc.first = vi.fn(() => loc);
|
loc.first = vi.fn(() => loc);
|
||||||
@@ -571,33 +634,33 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
|||||||
|
|
||||||
const page: any = {
|
const page: any = {
|
||||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||||
addInitScript: vi.fn(async () => {}),
|
addInitScript: vi.fn(async () => { }),
|
||||||
mouse: {
|
mouse: {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
click: vi.fn(async () => {}),
|
click: vi.fn(async () => { }),
|
||||||
dblclick: vi.fn(async () => {}),
|
dblclick: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
},
|
},
|
||||||
keyboard: {
|
keyboard: {
|
||||||
press: overrides.keyboardPress
|
press: overrides.keyboardPress
|
||||||
? vi.fn(overrides.keyboardPress)
|
? vi.fn(overrides.keyboardPress)
|
||||||
: vi.fn(async () => {}),
|
: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
insertText: vi.fn(async () => {}),
|
insertText: vi.fn(async () => { }),
|
||||||
},
|
},
|
||||||
click: vi.fn(async () => {}),
|
click: vi.fn(async () => { }),
|
||||||
dblclick: vi.fn(async () => {}),
|
dblclick: vi.fn(async () => { }),
|
||||||
hover: vi.fn(async () => {}),
|
hover: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
fill: vi.fn(async () => {}),
|
fill: vi.fn(async () => { }),
|
||||||
check: vi.fn(async () => {}),
|
check: vi.fn(async () => { }),
|
||||||
uncheck: vi.fn(async () => {}),
|
uncheck: vi.fn(async () => { }),
|
||||||
selectOption: vi.fn(async () => {}),
|
selectOption: vi.fn(async () => { }),
|
||||||
press: vi.fn(async () => {}),
|
press: vi.fn(async () => { }),
|
||||||
goto: vi.fn(async () => ({})),
|
goto: vi.fn(async () => ({})),
|
||||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
locator: vi.fn(() => makeLocator()),
|
locator: vi.fn(() => makeLocator()),
|
||||||
@@ -606,10 +669,10 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
|
|||||||
frames: vi.fn(() => []),
|
frames: vi.fn(() => []),
|
||||||
context: vi.fn(() => ({
|
context: vi.fn(() => ({
|
||||||
pages: vi.fn(() => []),
|
pages: vi.fn(() => []),
|
||||||
addInitScript: vi.fn(async () => {}),
|
addInitScript: vi.fn(async () => { }),
|
||||||
})),
|
})),
|
||||||
url: vi.fn(() => "about:blank"),
|
url: vi.fn(() => "about:blank"),
|
||||||
waitForTimeout: vi.fn(async () => {}),
|
waitForTimeout: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
@@ -623,8 +686,8 @@ describe("humanType non-ASCII", () => {
|
|||||||
const insertedChars: string[] = [];
|
const insertedChars: string[] = [];
|
||||||
const raw = {
|
const raw = {
|
||||||
down: vi.fn(async (k: string) => { downKeys.push(k); }),
|
down: vi.fn(async (k: string) => { downKeys.push(k); }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
|
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
|
||||||
};
|
};
|
||||||
return { raw, downKeys, insertedChars };
|
return { raw, downKeys, insertedChars };
|
||||||
@@ -696,18 +759,18 @@ describe("humanType non-ASCII", () => {
|
|||||||
|
|
||||||
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
function buildMockElementHandle(overrides: Record<string, any> = {}): any {
|
||||||
const el: any = {
|
const el: any = {
|
||||||
click: vi.fn(async () => {}),
|
click: vi.fn(async () => { }),
|
||||||
dblclick: vi.fn(async () => {}),
|
dblclick: vi.fn(async () => { }),
|
||||||
hover: vi.fn(async () => {}),
|
hover: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
fill: vi.fn(async () => {}),
|
fill: vi.fn(async () => { }),
|
||||||
press: vi.fn(async () => {}),
|
press: vi.fn(async () => { }),
|
||||||
selectOption: vi.fn(async () => {}),
|
selectOption: vi.fn(async () => { }),
|
||||||
check: vi.fn(async () => {}),
|
check: vi.fn(async () => { }),
|
||||||
uncheck: vi.fn(async () => {}),
|
uncheck: vi.fn(async () => { }),
|
||||||
setChecked: vi.fn(async () => {}),
|
setChecked: vi.fn(async () => { }),
|
||||||
tap: vi.fn(async () => {}),
|
tap: vi.fn(async () => { }),
|
||||||
focus: vi.fn(async () => {}),
|
focus: vi.fn(async () => { }),
|
||||||
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
boundingBox: overrides.boundingBox ?? vi.fn(async () => ({ x: 100, y: 100, width: 200, height: 30 })),
|
||||||
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
evaluate: overrides.evaluate ?? vi.fn(async () => false),
|
||||||
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
isChecked: overrides.isChecked ?? vi.fn(async () => false),
|
||||||
@@ -725,21 +788,21 @@ describe("patchSingleElementHandle", () => {
|
|||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 100, y: 100, initialized: true };
|
const cursor = { x: 100, y: 100, initialized: true };
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const rawKb = {
|
const rawKb = {
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
insertText: vi.fn(async () => {}),
|
insertText: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const originals = {
|
const originals = {
|
||||||
keyboardPress: vi.fn(async () => {}),
|
keyboardPress: vi.fn(async () => { }),
|
||||||
keyboardDown: vi.fn(async () => {}),
|
keyboardDown: vi.fn(async () => { }),
|
||||||
keyboardUp: vi.fn(async () => {}),
|
keyboardUp: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
@@ -762,23 +825,23 @@ describe("patchSingleElementHandle", () => {
|
|||||||
move: vi.fn(async () => { moveCount++; }),
|
move: vi.fn(async () => { moveCount++; }),
|
||||||
down: vi.fn(async () => { downCalled = true; }),
|
down: vi.fn(async () => { downCalled = true; }),
|
||||||
up: vi.fn(async () => { upCalled = true; }),
|
up: vi.fn(async () => { upCalled = true; }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const rawKb = {
|
const rawKb = {
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
insertText: vi.fn(async () => {}),
|
insertText: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const originals = {
|
const originals = {
|
||||||
keyboardPress: vi.fn(async () => {}),
|
keyboardPress: vi.fn(async () => { }),
|
||||||
keyboardDown: vi.fn(async () => {}),
|
keyboardDown: vi.fn(async () => { }),
|
||||||
keyboardUp: vi.fn(async () => {}),
|
keyboardUp: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
const page = buildMockPage();
|
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);
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
@@ -796,22 +859,22 @@ describe("patchSingleElementHandle", () => {
|
|||||||
|
|
||||||
let downCalled = false;
|
let downCalled = false;
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => { downCalled = true; }),
|
down: vi.fn(async () => { downCalled = true; }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const rawKb = {
|
const rawKb = {
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
insertText: 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 el = buildMockElementHandle();
|
||||||
const page = buildMockPage();
|
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);
|
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 cursor = { x: 50, y: 50, initialized: true };
|
||||||
|
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const rawKb = {
|
const rawKb = {
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
insertText: 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 el = buildMockElementHandle({ evaluate: vi.fn(async () => true) }); // isInput = true
|
||||||
const page = buildMockPage();
|
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);
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
@@ -860,26 +923,26 @@ describe("patchSingleElementHandle", () => {
|
|||||||
|
|
||||||
const pressedKeys: string[] = [];
|
const pressedKeys: string[] = [];
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const rawKb = {
|
const rawKb = {
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
insertText: vi.fn(async () => {}),
|
insertText: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
const originals = {
|
const originals = {
|
||||||
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
|
keyboardPress: vi.fn(async (key: string) => { pressedKeys.push(key); }),
|
||||||
keyboardDown: vi.fn(async () => {}),
|
keyboardDown: vi.fn(async () => { }),
|
||||||
keyboardUp: vi.fn(async () => {}),
|
keyboardUp: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||||
const page = buildMockPage();
|
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);
|
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 { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
const page = buildMockPage();
|
const page = buildMockPage();
|
||||||
@@ -913,9 +976,9 @@ describe("patchSingleElementHandle", () => {
|
|||||||
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
const { patchSingleElementHandle } = await import("../src/human/elementhandle.js");
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const child = buildMockElementHandle();
|
const child = buildMockElementHandle();
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
@@ -935,9 +998,9 @@ describe("patchPageElementHandles", () => {
|
|||||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
const page = buildMockPage();
|
const page = buildMockPage();
|
||||||
@@ -955,9 +1018,9 @@ describe("patchPageElementHandles", () => {
|
|||||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const el1 = buildMockElementHandle();
|
const el1 = buildMockElementHandle();
|
||||||
const el2 = buildMockElementHandle();
|
const el2 = buildMockElementHandle();
|
||||||
@@ -977,9 +1040,9 @@ describe("patchPageElementHandles", () => {
|
|||||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
const page = buildMockPage();
|
const page = buildMockPage();
|
||||||
@@ -997,9 +1060,9 @@ describe("patchPageElementHandles", () => {
|
|||||||
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
const { patchPageElementHandles } = await import("../src/human/elementhandle.js");
|
||||||
const cfg = resolveConfig("default");
|
const cfg = resolveConfig("default");
|
||||||
const cursor = { x: 0, y: 0, initialized: false };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const page = buildMockPage();
|
const page = buildMockPage();
|
||||||
(page as any).$ = vi.fn(async () => null);
|
(page as any).$ = vi.fn(async () => null);
|
||||||
@@ -1034,21 +1097,29 @@ describe("patchPage integrates ElementHandle patching", () => {
|
|||||||
|
|
||||||
|
|
||||||
function buildMockFrame(): any {
|
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 {
|
return {
|
||||||
click: vi.fn(async () => {}),
|
click: vi.fn(async () => { }),
|
||||||
dblclick: vi.fn(async () => {}),
|
dblclick: vi.fn(async () => { }),
|
||||||
hover: vi.fn(async () => {}),
|
hover: vi.fn(async () => { }),
|
||||||
type: vi.fn(async () => {}),
|
type: vi.fn(async () => { }),
|
||||||
fill: vi.fn(async () => {}),
|
fill: vi.fn(async () => { }),
|
||||||
check: vi.fn(async () => {}),
|
check: vi.fn(async () => { }),
|
||||||
uncheck: vi.fn(async () => {}),
|
uncheck: vi.fn(async () => { }),
|
||||||
selectOption: vi.fn(async () => {}),
|
selectOption: vi.fn(async () => { }),
|
||||||
press: vi.fn(async () => {}),
|
press: vi.fn(async () => { }),
|
||||||
clear: vi.fn(async () => {}),
|
pressSequentially: vi.fn(async () => { }),
|
||||||
dragAndDrop: vi.fn(async () => {}),
|
tap: vi.fn(async () => { }),
|
||||||
locator: vi.fn(() => ({
|
clear: vi.fn(async () => { }),
|
||||||
boundingBox: vi.fn(async () => ({ x: 0, y: 0, width: 100, height: 30 })),
|
dragAndDrop: vi.fn(async () => { }),
|
||||||
})),
|
locator: vi.fn(() => locator),
|
||||||
childFrames: vi.fn(() => []),
|
childFrames: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1098,10 +1169,10 @@ describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
|||||||
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
||||||
};
|
};
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await scrollToElement(page, raw, "#x", 0, 0, cfg, 5000);
|
await scrollToElement(page, raw, "#x", 0, 0, cfg, 5000);
|
||||||
@@ -1118,10 +1189,10 @@ describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
|||||||
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
locator: vi.fn(() => ({ first: () => ({ boundingBox }) })),
|
||||||
};
|
};
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await scrollToElement(page, raw, "#x", 0, 0, cfg);
|
await scrollToElement(page, raw, "#x", 0, 0, cfg);
|
||||||
@@ -1153,10 +1224,10 @@ describe("page.click(selector, { timeout }) forwards timeout to scroll", () => {
|
|||||||
|
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Per-call human_config override
|
// Per-call human config override
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
describe("page.type / page.fill accept per-call human_config override", () => {
|
describe("page.type / page.fill accept per-call human config override", () => {
|
||||||
it("page.type forwards merged config to humanType", async () => {
|
it("page.type forwards nested human_config to humanType", async () => {
|
||||||
const keyboardMod = await import("../src/human/keyboard.js");
|
const keyboardMod = await import("../src/human/keyboard.js");
|
||||||
const scrollMod = await import("../src/human/scroll.js");
|
const scrollMod = await import("../src/human/scroll.js");
|
||||||
const { patchPage } = await import("../src/human/index.js");
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
@@ -1196,7 +1267,7 @@ describe("page.type / page.fill accept per-call human_config override", () => {
|
|||||||
scrollSpy.mockRestore();
|
scrollSpy.mockRestore();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it("page.fill forwards merged config to humanType", async () => {
|
it("page.fill forwards flat config to humanType", async () => {
|
||||||
const keyboardMod = await import("../src/human/keyboard.js");
|
const keyboardMod = await import("../src/human/keyboard.js");
|
||||||
const scrollMod = await import("../src/human/scroll.js");
|
const scrollMod = await import("../src/human/scroll.js");
|
||||||
const { patchPage } = await import("../src/human/index.js");
|
const { patchPage } = await import("../src/human/index.js");
|
||||||
@@ -1222,7 +1293,7 @@ describe("page.type / page.fill accept per-call human_config override", () => {
|
|||||||
patchPage(page as any, cfg, cursor as any);
|
patchPage(page as any, cfg, cursor as any);
|
||||||
|
|
||||||
await (page as any).fill("#password", "secret", {
|
await (page as any).fill("#password", "secret", {
|
||||||
human_config: { typing_delay: 150 },
|
typing_delay: 150,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(captured.typing_delay).toBe(150);
|
expect(captured.typing_delay).toBe(150);
|
||||||
@@ -1242,13 +1313,13 @@ describe("page.type / page.fill accept per-call human_config override", () => {
|
|||||||
async (_page, _raw, _text, callCfg) => { captured = callCfg; },
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
const el = buildMockElementHandle({ evaluate: vi.fn(async () => true) });
|
||||||
const page = buildMockPage();
|
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);
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
|
|
||||||
@@ -1270,10 +1341,10 @@ describe("humanScrollIntoView", () => {
|
|||||||
|
|
||||||
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
// Box centered in viewport — squarely in scroll_target_zone
|
// Box centered in viewport — squarely in scroll_target_zone
|
||||||
const inViewBox = { x: 200, y: 300, width: 50, height: 30 };
|
const inViewBox = { x: 200, y: 300, width: 50, height: 30 };
|
||||||
@@ -1295,10 +1366,10 @@ describe("humanScrollIntoView", () => {
|
|||||||
|
|
||||||
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
const page: any = { viewportSize: () => ({ width: 1280, height: 720 }) };
|
||||||
const raw = {
|
const raw = {
|
||||||
move: vi.fn(async () => {}),
|
move: vi.fn(async () => { }),
|
||||||
down: vi.fn(async () => {}),
|
down: vi.fn(async () => { }),
|
||||||
up: vi.fn(async () => {}),
|
up: vi.fn(async () => { }),
|
||||||
wheel: vi.fn(async () => {}),
|
wheel: vi.fn(async () => { }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const boxes = [
|
const boxes = [
|
||||||
@@ -1332,14 +1403,14 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
|
|||||||
|
|
||||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||||
const cursor = { x: 50, y: 50, initialized: true };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
el.scrollIntoViewIfNeeded = vi.fn(async () => {});
|
el.scrollIntoViewIfNeeded = vi.fn(async () => { });
|
||||||
const page = buildMockPage();
|
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);
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
await el.scrollIntoViewIfNeeded();
|
await el.scrollIntoViewIfNeeded();
|
||||||
@@ -1358,15 +1429,15 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
|
|||||||
|
|
||||||
const cfg = resolveConfig("default", { idle_between_actions: false });
|
const cfg = resolveConfig("default", { idle_between_actions: false });
|
||||||
const cursor = { x: 50, y: 50, initialized: true };
|
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 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 rawKb = { down: vi.fn(async () => { }), up: vi.fn(async () => { }), type: vi.fn(async () => { }), insertText: vi.fn(async () => { }) };
|
||||||
const originals = { keyboardPress: vi.fn(async () => {}), keyboardDown: vi.fn(async () => {}), keyboardUp: vi.fn(async () => {}) };
|
const originals = { keyboardPress: vi.fn(async () => { }), keyboardDown: vi.fn(async () => { }), keyboardUp: vi.fn(async () => { }) };
|
||||||
|
|
||||||
const nativeFallback = vi.fn(async () => {});
|
const nativeFallback = vi.fn(async () => { });
|
||||||
const el = buildMockElementHandle();
|
const el = buildMockElementHandle();
|
||||||
el.scrollIntoViewIfNeeded = nativeFallback;
|
el.scrollIntoViewIfNeeded = nativeFallback;
|
||||||
const page = buildMockPage();
|
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);
|
patchSingleElementHandle(el, page as any, cfg, cursor as any, raw, rawKb, originals, null);
|
||||||
await el.scrollIntoViewIfNeeded();
|
await el.scrollIntoViewIfNeeded();
|
||||||
|
|||||||
+60
-1
@@ -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 { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
|
||||||
import type { LaunchOptions } from "../src/types.js";
|
import type { LaunchOptions } from "../src/types.js";
|
||||||
|
|
||||||
@@ -262,4 +262,63 @@ describe("resolveProxyConfig", () => {
|
|||||||
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
|
const { proxyArgs } = resolveProxyConfig("socks5://user:a@b@c@host:1080");
|
||||||
expect(proxyArgs).toEqual(["--proxy-server=socks5://user:a%40b%40c@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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
// patchPage stealth infrastructure (Puppeteer)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ parse_connection_params = _mod.parse_connection_params
|
|||||||
parse_cli_args = _mod.parse_cli_args
|
parse_cli_args = _mod.parse_cli_args
|
||||||
ChromePool = _mod.ChromePool
|
ChromePool = _mod.ChromePool
|
||||||
_default_data_dir = _mod._default_data_dir
|
_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")
|
pool.disconnect("a")
|
||||||
assert pool._connections["a"] == 1
|
assert pool._connections["a"] == 1
|
||||||
assert pool._connections["b"] == 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"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Unit tests for GeoIP-based timezone/locale detection."""
|
"""Unit tests for GeoIP-based timezone/locale detection."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -144,6 +145,20 @@ def test_maybe_resolve_fills_both():
|
|||||||
assert ip == "5.6.7.8"
|
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
|
# _is_private_ip
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -284,6 +284,42 @@ class TestResolveProxyConfig:
|
|||||||
_, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080")
|
_, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080")
|
||||||
assert args == ["--proxy-server=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):
|
def test_socks5_string_no_creds_unchanged(self):
|
||||||
_, args = _resolve_proxy_config("socks5://host:1080")
|
_, args = _resolve_proxy_config("socks5://host:1080")
|
||||||
assert args == ["--proxy-server=socks5://host:1080"]
|
assert args == ["--proxy-server=socks5://host:1080"]
|
||||||
|
|||||||
Reference in New Issue
Block a user