Compare commits

...
11 Commits
Author SHA1 Message Date
CloakHQ 0caa14bf7b release: v0.3.31 — proxy credential routing, humanize iframe fixes 2026-05-26 20:30:28 +02:00
CloakHQ 2a99081850 docs: add recommended config to quick-start, FPJS troubleshooting, update contributors
- Add production-ready snippet (proxy, geoip, headless, humanize) near top of both READMEs
- Add "Detected by FingerprintJS?" troubleshooting with verified flags:
  noise=false, screen dimensions, storage quota, geoip, residential proxy
- Add @sparanoid to contributors (Docker Xvfb lock fix)
- Update @eofreternal credit (iframe pointer-events fix)
- Fix stale "48 patches" in JS README
2026-05-26 18:46:13 +02:00
CloakHQ 7fc577e5c6 fix(humanize): port #303 iframe pointer-events fix to Python
Mirror the JS fix from #303 in the sync and async Python actionability
checks: compute and apply the iframe coordinate offset before
elementFromPoint, and fail open when the check itself cannot run. Add
fail-open regression tests for both Python and JS.
2026-05-25 01:17:05 +02:00
EternalandGitHub 12d02c3547 fix(humanize): correct iframe coordinate offset in pointer-events check (#303)
The ElementHandle pointer-events check passed page-space click coordinates to elementFromPoint, which runs inside the target element's frame. For elements in an iframe the coordinate spaces differ, so the check looked at the wrong point and wrongly reported the element as covered. Now the iframe offset is computed and applied. Also fails open when the check itself cannot run.

Thanks @eofreternal for the fix.
2026-05-25 01:15:09 +02:00
243c1385a0 feat(js): export buildContextOptions helper (#262)
Co-authored-by: 이민재 <19909783+honor2030@users.noreply.github.com>
2026-05-25 00:15:27 +02:00
CloakHQ 0f3dc7201b chore(deps): bump JS dev dependencies
puppeteer-core 21→25 (fixes CVEs in tar-fs, ws),
typescript 5→6, @types/node 20→25, playwright-core 1.58→1.60.
Vitest stays on v1 (v4 breaks dynamic import mocking).
2026-05-24 23:57:47 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
34d2f78e87 chore(deps): bump the actions group across 1 directory with 3 updates (#309)
Bumps the actions group with 3 updates in the / directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [docker/login-action](https://github.com/docker/login-action) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 23:40:28 +02:00
CloakHQ 41be4e0e30 chore(ci): add pip and npm ecosystems to Dependabot 2026-05-24 23:17:45 +02:00
CloakHQ 58ccdb683c fix(humanize): use shared deadline for timeout budget in frame and ElementHandle methods (#307)
Frame-level methods (click, dblclick, hover, dragAndDrop) passed the raw
timeout to each sequential operation independently, causing 3x actual
wait time when elements don't exist. ElementHandle methods had a similar
2x issue between actionability and pointer-events checks.

Port the deadline + remainingMs() pattern already used by page-level
methods. Also fix bot detection test selector after site added a hidden
duplicate submit button.
2026-05-24 23:12:07 +02:00
CloakHQ 8028ddefef feat: route HTTP proxy credentials through --proxy-server
Bypass Playwright's CDP Fetch.authRequired interceptor for authenticated
HTTP proxies by passing inline credentials via Chrome's --proxy-server
flag. Chrome sends Proxy-Authorization preemptively, avoiding the 407
round-trip that breaks on some proxies and Google domains (#182).

Gated on platform (linux-x64, windows-x64) and binary version >= 146.0.7680.177.5.
Unsupported platforms fall back to Playwright's proxy dict.
Puppeteer falls back to page.authenticate() on unsupported platforms.
2026-05-21 05:51:03 +02:00
sparanoidandGitHub 864cae2493 fix(docker): clean up stale Xvfb lock so container survives restarts (#284)
On `docker restart`, `/tmp` is preserved across container instances, so the
previous Xvfb's `/tmp/.X99-lock` survives into the new container. The new
Xvfb sees the existing lock and refuses to start, leaving the container
with no X server. Every Chrome launch then dies with "Missing X server or
$DISPLAY", and `cloakserve` returns 502 from `/json/version` forever.

Any orchestrator that restarts on unhealthy (the README-recommended
healthcheck + `restart: always`, autoheal sidecars, etc.) then enters a
permanent restart loop because every restart hits the same broken state.

This is silent for first-time users: the container appears to start
successfully (Xvfb did launch *once*), then degrades only after the first
restart. The fix is one line in the entrypoint: remove the stale lock
before starting Xvfb.

Fixes #283
2026-05-21 05:27:49 +02:00
27 changed files with 1378 additions and 807 deletions
+18
View File
@@ -8,3 +8,21 @@ updates:
actions:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
groups:
python:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/js"
schedule:
interval: "weekly"
groups:
javascript:
patterns:
- "*"
+3 -3
View File
@@ -106,14 +106,14 @@ jobs:
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Build and push
id: build
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
platforms: linux/amd64,linux/arm64
+9
View File
@@ -8,6 +8,15 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
## [0.3.31] — 2026-05-26
- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies
- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262)
- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303)
- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307)
- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284)
- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309)
## [0.3.30] — 2026-05-21
- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57)
+72 -4
View File
@@ -59,7 +59,7 @@ from cloakbrowser import launch
browser = launch()
page = browser.new_page()
page.goto("https://protected-site.com") # no more blocks
page.goto("https://example.com")
browser.close()
```
@@ -69,12 +69,34 @@ import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await page.goto('https://example.com');
await browser.close();
```
Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer))
**For sites with anti-bot protection**, add a residential proxy and these flags:
```python
browser = launch(
proxy="http://user:pass@residential-proxy:port", # residential IP, not datacenter
geoip=True, # match timezone + locale to proxy IP
headless=False, # some sites detect headless even with C++ patches
humanize=True, # human-like mouse, keyboard, scroll
)
```
```javascript
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
headless: false,
humanize: true,
});
```
See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS, Kasada, reCAPTCHA).
## Install
**Python:**
@@ -128,7 +150,7 @@ Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **L
---
## Latest: v0.3.30 (Chromium 146.0.7680.177.5)
## Latest: v0.3.31 (Chromium 146.0.7680.177.5)
- **58 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome 146 profiles
- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior
@@ -986,6 +1008,51 @@ If you're still blocked after this, check the font setup below.
---
### Detected by FingerprintJS?
FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each detection has a specific cause:
| Detection | Cause | Fix |
|-----------|-------|-----|
| **`nodriver` / bad bot** | IP reputation or missing flags | Residential proxy + config below |
| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` |
| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport |
| **Incognito** | Storage quota normalized to ~500MB | Expected tradeoff — see below |
Config that passes FPJS (verified on v0.3.30, Linux + Windows):
```python
browser = launch(
headless=False,
proxy="http://user:pass@residential-proxy:port",
geoip=True,
args=[
"--fingerprint-noise=false", # prevents tampering detection
"--fingerprint-screen-width=1920", # match your viewport
"--fingerprint-screen-height=1080",
],
)
```
```javascript
const browser = await launch({
headless: false,
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
args: [
'--fingerprint-noise=false',
'--fingerprint-screen-width=1920',
'--fingerprint-screen-height=1080',
],
});
```
For persistent contexts (`launch_persistent_context` / `launchPersistentContext`), also add `--fingerprint-storage-quota=500` to the args.
**Storage quota tradeoff:** The binary normalizes storage quota to ~500MB to pass FPJS, but this makes the session look like incognito to other detection services (e.g. BrowserScan's `notPrivate` check, -10 points). Setting `--fingerprint-storage-quota=5000` passes incognito checks but may trigger FPJS. You can't satisfy both simultaneously — choose based on what your target site checks. See the [storage quota tradeoff table](#launch_persistent_context) for details.
---
### Blocked on Kasada / Akamai sites despite correct config?
On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly.
@@ -1202,7 +1269,7 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix
- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts
- [@kitiho](https://github.com/kitiho) — null viewport fix
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types
- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix
- [@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, cold-start hardening
@@ -1212,4 +1279,5 @@ Issues and PRs welcome. If something isn't working, [open an issue](https://gith
- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake
- [@245678000000](https://github.com/245678000000) — package-lock sync
- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, composable JS launch helpers
- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup
- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass)
+8
View File
@@ -1,4 +1,12 @@
#!/bin/bash
# Clean up any stale Xvfb lock left behind by a previous container instance.
# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous
# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an
# existing lock — leaving the container with no X server, every Chrome
# launch dying with "Missing X server or $DISPLAY", and `cloakserve`
# returning 502 forever. See CloakHQ/CloakBrowser#283.
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.30"
__version__ = "0.3.31"
+96 -7
View File
@@ -774,7 +774,7 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _assemble_socks_url(
def _assemble_proxy_url(
scheme: str,
host: str,
port: int | None,
@@ -785,7 +785,7 @@ def _assemble_socks_url(
query: str = "",
fragment: str = "",
) -> str:
"""Build a SOCKS URL from already-percent-encoded credentials and host parts.
"""Build a proxy URL from already-percent-encoded credentials and host parts.
``enc_pass is None`` means no password (no colon in userinfo). Empty string
means present-but-empty (colon preserved). This mirrors the distinction
@@ -816,7 +816,7 @@ def _reconstruct_socks_url(proxy: ProxySettings) -> str:
enc_user = quote(username, safe="")
# Dict convention: empty/missing password → no colon.
enc_pass = quote(password, safe="") if password else None
return _assemble_socks_url(
return _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, parsed.path,
)
@@ -856,7 +856,7 @@ def _normalize_socks_string_url(url: str) -> str:
else:
raw_pass = None
enc_pass = None
normalized = _assemble_socks_url(
normalized = _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
@@ -1061,6 +1061,81 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result
def _has_credentials(proxy: str | ProxySettings) -> bool:
"""Check if the proxy has inline or dict-level credentials."""
if isinstance(proxy, dict):
return bool(proxy.get("username"))
return "@" in proxy
def _reconstruct_http_url(proxy: ProxySettings) -> str:
"""Reconstruct an HTTP(S) proxy URL with inline credentials from a Playwright proxy dict."""
server = proxy.get("server", "")
username = proxy.get("username", "")
password = proxy.get("password", "")
if not username:
return server
parsed = urlparse(_ensure_proxy_scheme(server))
enc_user = quote(username, safe="")
enc_pass = quote(password, safe="") if password else None
return _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass, parsed.path,
)
def _normalize_http_string_url(url: str) -> str:
"""Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
Same pattern as ``_normalize_socks_string_url`` decode then re-encode to
ensure Chromium's proxy URL parser handles special chars correctly.
"""
normalized = url if "://" in url else f"http://{url}"
try:
parsed = urlparse(normalized)
_ = parsed.port
except ValueError as e:
logger.warning("Malformed HTTP proxy URL, passing through unchanged: %s", e)
return normalized
if parsed.username is None and parsed.password is None:
return normalized
raw_user = parsed.username or ""
enc_user = quote(unquote(raw_user), safe="") if raw_user else ""
if parsed.password is not None:
raw_pass = parsed.password
enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else ""
else:
raw_pass = None
enc_pass = None
result = _assemble_proxy_url(
parsed.scheme, parsed.hostname or "", parsed.port,
enc_user, enc_pass,
parsed.path, parsed.params, parsed.query, parsed.fragment,
)
if enc_user != raw_user or enc_pass != raw_pass:
logger.info(
"Auto URL-encoded HTTP proxy credentials (special characters "
"detected). Pre-encode the URL to suppress this notice."
)
return result
_HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5"
_HTTP_PROXY_INLINE_AUTH_PLATFORMS = {"linux-x64", "windows-x64"}
def _supports_http_proxy_inline_auth() -> bool:
"""Check if the current platform's binary supports HTTP proxy inline credentials.
Requires both a supported platform AND a binary version with preemptive proxy auth.
"""
from .config import get_platform_tag, get_chromium_version, _version_tuple
tag = get_platform_tag()
if tag not in _HTTP_PROXY_INLINE_AUTH_PLATFORMS:
return False
return _version_tuple(get_chromium_version()) >= _version_tuple(_HTTP_PROXY_INLINE_AUTH_MIN_VERSION)
def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool:
"""Check if the proxy uses SOCKS5 protocol."""
if proxy is None:
@@ -1074,8 +1149,9 @@ def _resolve_proxy_config(
) -> tuple[dict[str, Any], list[str]]:
"""Resolve proxy into Playwright kwargs and Chrome args.
Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
so SOCKS5 is passed via --proxy-server Chrome arg instead.
Proxies with credentials (SOCKS5 or HTTP/HTTPS) are passed via Chrome's
--proxy-server flag with inline credentials, bypassing Playwright's CDP
auth interceptor which breaks on some proxies and Google domains (#182).
Returns:
(proxy_kwargs, extra_chrome_args) one or both will be empty.
@@ -1096,7 +1172,20 @@ def _resolve_proxy_config(
# passwords at '=' and other special chars (#157).
return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"]
# HTTP/HTTPS: use Playwright's proxy dict as before
# HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
# CDP auth interceptor, pass directly to Chrome via --proxy-server with
# inline creds. Chrome sends Proxy-Authorization preemptively, avoiding
# the 407 round-trip that breaks on some proxies (#182).
if _has_credentials(proxy) and _supports_http_proxy_inline_auth():
if isinstance(proxy, dict):
url = _reconstruct_http_url(proxy)
extra_args = [f"--proxy-server={url}"]
if proxy.get("bypass"):
extra_args.append(f"--proxy-bypass-list={proxy['bypass']}")
return {}, extra_args
return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"]
# HTTP/HTTPS without credentials: use Playwright's proxy dict
if isinstance(proxy, dict):
return {"proxy": proxy}, []
return {"proxy": _parse_proxy_url(proxy)}, []
+86 -32
View File
@@ -1257,13 +1257,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_click(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
@@ -1271,13 +1274,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_dblclick(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
raw_mouse.down(click_count=2)
sleep_ms(rand(30, 60))
raw_mouse.up(click_count=2)
@@ -1287,8 +1293,11 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_hover(**kwargs)
@@ -1298,13 +1307,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_type(text, **kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session)
@@ -1314,13 +1326,16 @@ def _patch_single_element_handle_sync(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = _move_to_element(call_cfg)
if info is None:
return _orig_fill(value, **kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], call_cfg)
sleep_ms(rand(100, 250))
originals.keyboard_press(_SELECT_ALL)
@@ -1367,8 +1382,11 @@ def _patch_single_element_handle_sync(
def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = _move_to_element()
if info is None:
return _orig_select_option(value, **kwargs)
@@ -1380,8 +1398,11 @@ def _patch_single_element_handle_sync(
def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if el.is_checked():
return
@@ -1391,15 +1412,18 @@ def _patch_single_element_handle_sync(
if info is None:
return _orig_check(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if not el.is_checked():
return
@@ -1409,15 +1433,18 @@ def _patch_single_element_handle_sync(
if info is None:
return _orig_uncheck(**kwargs)
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
current = el.is_checked()
if current == checked:
@@ -1429,7 +1456,7 @@ def _patch_single_element_handle_sync(
return _orig_set_checked(checked, **kwargs)
if info:
if not force:
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
@@ -2158,13 +2185,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_click(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
# --- el.dblclick() ---
@@ -2172,13 +2202,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_dblclick(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await raw_mouse.down(click_count=2)
await async_sleep_ms(rand(30, 60))
await raw_mouse.up(click_count=2)
@@ -2188,8 +2221,11 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_hover(**kwargs)
@@ -2199,13 +2235,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_type(text, **kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
cdp = await _get_cdp()
@@ -2216,13 +2255,16 @@ def _patch_single_element_handle_async(
call_cfg = merge_config(cfg, kwargs.get("human_config"))
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force)
info = await _move_to_element(call_cfg)
if info is None:
return await _orig_fill(value, **kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], call_cfg)
await async_sleep_ms(rand(100, 250))
await originals.keyboard_press(_SELECT_ALL)
@@ -2269,8 +2311,11 @@ def _patch_single_element_handle_async(
async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force)
info = await _move_to_element()
if info is None:
return await _orig_select_option(value, **kwargs)
@@ -2282,8 +2327,11 @@ def _patch_single_element_handle_async(
async def _human_el_check(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if await el.is_checked():
return
@@ -2293,15 +2341,18 @@ def _patch_single_element_handle_async(
if info is None:
return await _orig_check(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.uncheck() ---
async def _human_el_uncheck(**kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
if not await el.is_checked():
return
@@ -2311,15 +2362,18 @@ def _patch_single_element_handle_async(
if info is None:
return await _orig_uncheck(**kwargs)
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.set_checked() ---
async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None:
force = kwargs.get("force", False)
timeout = kwargs.get("timeout", 30000)
deadline = time.monotonic() + timeout / 1000.0
def _remaining_ms():
return max(0, (deadline - time.monotonic()) * 1000)
if not force:
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=timeout, force=force)
await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force)
try:
current = await el.is_checked()
if current == checked:
@@ -2331,7 +2385,7 @@ def _patch_single_element_handle_async(
return await _orig_set_checked(checked, **kwargs)
if info:
if not force:
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(timeout, 5000))
await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000))
await async_human_click(raw_mouse, info['is_inp'], cfg)
# --- el.tap() ---
+23 -11
View File
@@ -196,8 +196,14 @@ def ensure_stable(
# Pointer-events check (post-scroll, at actual click coordinates)
# ---------------------------------------------------------------------------
_POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
# data.box is page-space (from bounding_box); rect is frame-local. Their delta
# is the iframe offset, needed to map page-space click coords into the frame's
# own viewport before elementFromPoint. For main-frame elements the offset is 0.
_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -205,8 +211,11 @@ _POINTER_EVENTS_LOCATOR_JS = """(expected, coords) => {
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}"""
_POINTER_EVENTS_HANDLE_JS = """(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
_POINTER_EVENTS_HANDLE_JS = """(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -230,17 +239,19 @@ def check_pointer_events(
"""
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
loc = page.locator(selector).first
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
if result and result.get("hit", False):
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
@@ -322,15 +333,16 @@ def check_pointer_events_handle(
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
box = el.bounding_box()
result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
except Exception:
result = None
if result and result.get("hit", False):
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
+10 -7
View File
@@ -140,17 +140,19 @@ async def async_check_pointer_events(
) -> None:
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
loc = page.locator(selector).first
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, coords)
box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000)))
result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box})
except Exception as exc:
logger.debug("pointer_events check failed for %r: %s", selector, exc)
result = None
if result and result.get("hit", False):
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
@@ -227,15 +229,16 @@ async def async_check_pointer_events_handle(
deadline = time.monotonic() + timeout / 1000.0
attempt = 0
coords = {"x": x, "y": y}
while True:
try:
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, coords)
box = await el.bounding_box()
result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box})
except Exception:
result = None
if result and result.get("hit", False):
# Proceed if the check confirms a hit, or if it could not be determined
# (None) — failing closed would block legitimate clicks.
if result is None or result.get("hit", False):
return
covering = (result or {}).get("covering", "unknown")
+16 -3
View File
@@ -11,7 +11,7 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **48 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
- **58 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
@@ -39,11 +39,24 @@ import { launch } from 'cloakbrowser';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
```
**For sites with anti-bot protection**, add a residential proxy and these flags:
```javascript
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true, // match timezone + locale to proxy IP
headless: false, // some sites detect headless even with C++ patches
humanize: true, // human-like mouse, keyboard, scroll
});
```
See the [main README](https://github.com/CloakHQ/CloakBrowser#troubleshooting) for site-specific troubleshooting (FingerprintJS, Kasada, reCAPTCHA).
### Puppeteer
> **Note:** Playwright is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect. This is a known Puppeteer limitation, not specific to CloakBrowser.
@@ -53,7 +66,7 @@ import { launch } from 'cloakbrowser/puppeteer';
const browser = await launch();
const page = await browser.newPage();
await page.goto('https://protected-site.com');
await page.goto('https://example.com');
console.log(await page.title());
await browser.close();
```
+211 -602
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "cloakbrowser",
"version": "0.3.30",
"version": "0.3.31",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module",
"main": "dist/index.js",
@@ -81,12 +81,12 @@
"tar": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/node": "^25.9.1",
"mmdb-lib": "^3.0.2",
"playwright-core": "1.60",
"puppeteer-core": "^25.0.4",
"socks-proxy-agent": "^10.0.0",
"playwright-core": "^1.53.0",
"puppeteer-core": "^21.0.0",
"typescript": "^5.3.0",
"typescript": "^6.0.3",
"vitest": "^1.0.0"
},
"scripts": {
+16 -11
View File
@@ -197,8 +197,11 @@ export async function ensureStable(
// Pointer-events check (post-scroll, at actual click coordinates)
// ---------------------------------------------------------------------------
const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
const POINTER_EVENTS_LOCATOR_JS = `(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -206,8 +209,11 @@ const POINTER_EVENTS_LOCATOR_JS = `(expected, coords) => {
return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' };
}`;
const POINTER_EVENTS_HANDLE_JS = `(expected, coords) => {
const target = document.elementFromPoint(coords.x, coords.y);
const POINTER_EVENTS_HANDLE_JS = `(expected, data) => {
const rect = expected.getBoundingClientRect();
const frameOffsetX = data.box ? data.box.x - rect.x : 0;
const frameOffsetY = data.box ? data.box.y - rect.y : 0;
const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY);
if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' };
let node = target;
while (node) { if (node === expected) return { hit: true }; node = node.parentNode; }
@@ -225,18 +231,18 @@ export async function checkPointerEvents(
): Promise<void> {
const deadline = Date.now() + timeout;
let attempt = 0;
const coords = { x, y };
while (true) {
let result: any = null;
try {
const loc = pageOrFrame.locator(selector).first();
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, coords);
const box = await loc.boundingBox({ timeout: Math.max(1, Math.min(deadline - Date.now(), 1000)) });
result = await loc.evaluate(POINTER_EVENTS_LOCATOR_JS, { x, y, box });
} catch {
result = null;
}
if (result && result.hit) return;
if (!result || result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError(selector, covering);
@@ -317,17 +323,16 @@ export async function checkPointerEventsHandle(
const deadline = Date.now() + timeout;
let attempt = 0;
const coords = { x, y };
while (true) {
let result: any;
try {
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, coords);
const box = await el.boundingBox();
result = await el.evaluate(POINTER_EVENTS_HANDLE_JS, { x, y, box });
} catch {
result = null;
}
if (result && result.hit) return;
if (!result || result.hit) return;
const covering = (result as any)?.covering ?? 'unknown';
if (Date.now() >= deadline) throw new ElementNotReceivingEventsError('<ElementHandle>', covering);
+34 -16
View File
@@ -196,10 +196,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElClick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg);
};
@@ -216,10 +218,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CLICK, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElDblclick(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
@@ -235,7 +239,9 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_HOVER, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElHover(options);
};
@@ -248,10 +254,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = (options as any)?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElType(text, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
let cdpSession: CDPSession | null = null;
@@ -267,10 +275,12 @@ export function patchSingleElementHandle(
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_INPUT, remainingMs(), force);
const info = await moveToElement(callCfg);
if (!info) return origElFill(value, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, callCfg);
await sleep(rand(100, 250));
await originals.keyboardPress(SELECT_ALL);
@@ -298,7 +308,9 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_FOCUS, remainingMs(), force);
const info = await moveToElement();
if (!info) return origElSelectOption(values, options);
await humanClick(raw, false, cfg);
@@ -316,14 +328,16 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try {
const checked = await el.isChecked();
if (checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElCheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg);
};
@@ -337,14 +351,16 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try {
const checked = await el.isChecked();
if (!checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElUncheck(options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg);
};
@@ -359,14 +375,16 @@ export function patchSingleElementHandle(
}) => {
const force = options?.force ?? false;
const timeout = options?.timeout ?? 30000;
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, timeout, force);
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
if (!force) await ensureActionableHandle(el, CHECKS_CHECK, remainingMs(), force);
try {
const current = await el.isChecked();
if (current === checked) return;
} catch {}
const info = await moveToElement();
if (!info) return origElSetChecked(checked, options);
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(timeout, 5000));
if (!force) await checkPointerEventsHandle(el, cursor.x, cursor.y, Math.min(remainingMs(), 5000));
await humanClick(raw, info.isInp, cfg);
};
}
+29 -12
View File
@@ -691,7 +691,12 @@ function patchSingleFrame(
const origFrameTap = (frame as any).tap?.bind(frame);
const origFrameDragAndDrop = frame.dragAndDrop.bind(frame);
const moveToFrameSelector = async (selector: string, options?: HumanActionOptions, inputBias = false) => {
const moveToFrameSelector = async (
selector: string,
options: HumanActionOptions | undefined,
inputBias: boolean,
remainingMs: () => number,
) => {
const callCfg = mergeConfig(cfg, options?.human_config ?? options);
if (callCfg.idle_between_actions) {
await humanIdle(raw, cursor.x, cursor.y, callCfg);
@@ -699,9 +704,9 @@ function patchSingleFrame(
const locator = firstFrameLocator(frame, selector);
if (typeof locator.scrollIntoViewIfNeeded === 'function') {
await locator.scrollIntoViewIfNeeded({ timeout: options?.timeout }).catch(() => undefined);
await locator.scrollIntoViewIfNeeded({ timeout: Math.max(1, remainingMs()) }).catch(() => undefined);
}
const box = await locator.boundingBox({ timeout: options?.timeout ?? 30000 }).catch(() => null);
const box = await locator.boundingBox({ timeout: Math.max(1, remainingMs()) }).catch(() => null);
if (!box) return null;
const isInput = inputBias || await isFrameInputElement(frame, selector);
@@ -713,23 +718,32 @@ function patchSingleFrame(
};
const frameClick = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options);
if (!moved) return origFrameClick(selector, options);
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameClick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await humanClick(raw, moved.isInput, moved.callCfg);
};
const getFrameCdp = async () => stealth.getCdpSession().catch(() => null);
const frameHover = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options, false);
if (!moved) return origFrameHover(selector, options);
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameHover(selector, { ...options, timeout: Math.max(1, remainingMs()) });
};
(frame as any).click = frameClick;
(frame as any).dblclick = async (selector: string, options?: HumanActionOptions) => {
const moved = await moveToFrameSelector(selector, options);
if (!moved) return origFrameDblclick(selector, options);
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(0, deadline - Date.now());
const moved = await moveToFrameSelector(selector, options, false, remainingMs);
if (!moved) return origFrameDblclick(selector, { ...options, timeout: Math.max(1, remainingMs()) });
await raw.down({ clickCount: 2 });
await sleep(rand(30, 60));
await raw.up({ clickCount: 2 });
@@ -820,8 +834,11 @@ function patchSingleFrame(
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);
const timeout = options?.timeout ?? 30000;
const deadline = Date.now() + timeout;
const remainingMs = () => Math.max(1, deadline - Date.now());
const srcBox = await firstFrameLocator(frame, source).boundingBox({ timeout: remainingMs() }).catch(() => null);
const tgtBox = await firstFrameLocator(frame, target).boundingBox({ timeout: remainingMs() }).catch(() => null);
if (srcBox && tgtBox) {
const sx = srcBox.x + srcBox.width / 2;
@@ -837,7 +854,7 @@ function patchSingleFrame(
await sleep(rand(80, 150));
await originals.mouseUp();
} else {
return origFrameDragAndDrop(source, target, options);
return origFrameDragAndDrop(source, target, { ...options, timeout: Math.max(1, remainingMs()) });
}
};
}
+1 -1
View File
@@ -16,7 +16,7 @@
*/
// Launch functions (Playwright API)
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, humanizeBrowser } from "./playwright.js";
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
+22 -14
View File
@@ -44,6 +44,26 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
return rest;
}
/**
* Build Playwright BrowserContext options for CloakBrowser without launching a browser
* or creating a context.
*
* Useful when integrating CloakBrowser with an existing Playwright Browser while
* keeping the wrapper's stealth-safe defaults for `newContext()`.
*/
export function buildContextOptions(
options: LaunchContextOptions = {}
): BrowserContextOptions {
return {
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
} as BrowserContextOptions;
}
/**
* Build Playwright launch options for CloakBrowser without starting Chromium.
*
@@ -144,14 +164,7 @@ export async function launchContext(
let context: BrowserContext;
try {
context = await browser.newContext({
// contextOptions first — explicit wrapper fields below override it.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
});
context = await browser.newContext(buildContextOptions(options));
} catch (err) {
await browser.close();
throw err;
@@ -222,12 +235,7 @@ export async function launchPersistentContext(
args,
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
...(proxyOption ? { proxy: proxyOption } : {}),
// contextOptions before explicit wrapper fields so explicit wins.
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
...filterStealthCtxOptions(options.contextOptions),
...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...buildContextOptions(options),
...options.launchOptions,
});
+112 -3
View File
@@ -2,6 +2,8 @@
* Shared proxy URL parsing for Playwright and Puppeteer wrappers.
*/
import { getChromiumVersion, getPlatformTag, parseVersion } from "./config.js";
export interface ParsedProxy {
server: string;
username?: string;
@@ -155,11 +157,106 @@ export function normalizeSocksStringUrl(urlStr: string): string {
}
}
const HTTP_PROXY_INLINE_AUTH_MIN_VERSION = "146.0.7680.177.5";
const HTTP_PROXY_INLINE_AUTH_PLATFORMS = new Set(["linux-x64", "windows-x64"]);
export function supportsHttpProxyInlineAuth(): boolean {
try {
const tag = getPlatformTag();
if (!HTTP_PROXY_INLINE_AUTH_PLATFORMS.has(tag)) return false;
const current = parseVersion(getChromiumVersion());
const minimum = parseVersion(HTTP_PROXY_INLINE_AUTH_MIN_VERSION);
for (let i = 0; i < Math.max(current.length, minimum.length); i++) {
if ((current[i] ?? 0) > (minimum[i] ?? 0)) return true;
if ((current[i] ?? 0) < (minimum[i] ?? 0)) return false;
}
return true; // equal = supported
} catch {
return false;
}
}
function hasCredentials(proxy: string | ProxyDict): boolean {
if (typeof proxy === "string") return proxy.includes("@");
return !!proxy.username;
}
/**
* Reconstruct an HTTP(S) proxy URL with inline credentials from a proxy dict.
*/
export function reconstructHttpUrl(proxy: ProxyDict): string {
if (!proxy.username) return proxy.server;
const url = new URL(ensureProxyScheme(proxy.server));
url.username = encodeURIComponent(proxy.username);
if (proxy.password) url.password = encodeURIComponent(proxy.password);
return url.href.replace(/\/$/, "");
}
/**
* Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server.
* Same pattern as normalizeSocksStringUrl.
*/
export function normalizeHttpStringUrl(urlStr: string): string {
const normalized = urlStr.includes("://") ? urlStr : `http://${urlStr}`;
const schemeMatch = normalized.match(/^([a-z][a-z0-9+\-.]*):\/\/(.*)$/i);
if (!schemeMatch) return normalized;
const [, scheme, rest] = schemeMatch;
const hostStart = rest.search(/[/?#]/);
const authority = hostStart === -1 ? rest : rest.slice(0, hostStart);
const suffix = hostStart === -1 ? "" : rest.slice(hostStart);
const atIdx = authority.lastIndexOf("@");
if (atIdx === -1) return normalized;
const userinfo = authority.slice(0, atIdx);
const hostPart = authority.slice(atIdx + 1);
const bracketEnd = hostPart.lastIndexOf("]");
const portColonIdx = hostPart.indexOf(":", Math.max(bracketEnd, 0));
if (portColonIdx !== -1) {
const portStr = hostPart.slice(portColonIdx + 1);
if (portStr && !/^\d+$/.test(portStr)) {
console.warn(`[cloakbrowser] Malformed HTTP proxy URL, passing through unchanged: invalid port`);
return normalized;
}
}
const hostAndRest = hostPart + suffix;
const colonIdx = userinfo.indexOf(":");
const rawUserEnc = colonIdx === -1 ? userinfo : userinfo.slice(0, colonIdx);
const hasPassword = colonIdx !== -1;
const rawPassEnc = hasPassword ? userinfo.slice(colonIdx + 1) : "";
try {
const encUser = rawUserEnc ? encodeURIComponent(lenientDecodeURIComponent(rawUserEnc)) : "";
const encPass = hasPassword
? (rawPassEnc ? encodeURIComponent(lenientDecodeURIComponent(rawPassEnc)) : "")
: null;
let userinfoPart: string;
if (encPass !== null) {
userinfoPart = `${encUser}:${encPass}@`;
} else if (encUser) {
userinfoPart = `${encUser}@`;
} else {
userinfoPart = "";
}
const result = `${scheme}://${userinfoPart}${hostAndRest}`;
const credsChanged = encUser !== rawUserEnc
|| (hasPassword ? encPass !== rawPassEnc : false);
if (credsChanged) {
console.info(
"[cloakbrowser] Auto URL-encoded HTTP proxy credentials (special " +
"characters detected). Pre-encode the URL to suppress this notice.",
);
}
return result;
} catch (e) {
console.warn(`[cloakbrowser] Could not normalize HTTP proxy URL, passing through unchanged: ${(e as Error).message}`);
return normalized;
}
}
/**
* Resolve proxy into Playwright option and/or Chrome args.
*
* Playwright rejects SOCKS5 proxies with credentials in its proxy dict,
* so SOCKS5 is passed via --proxy-server Chrome arg instead.
* Proxies with credentials (SOCKS5 or HTTP/HTTPS on supported platforms) are
* passed via Chrome's --proxy-server flag with inline credentials, bypassing
* Playwright's CDP auth interceptor which breaks on some proxies (#182).
*/
export function resolveProxyConfig(proxy: string | ProxyDict | undefined): ProxyConfig {
if (!proxy) return { proxyArgs: [] };
@@ -177,7 +274,19 @@ export function resolveProxyConfig(proxy: string | ProxyDict | undefined): Proxy
return { proxyArgs: args };
}
// HTTP/HTTPS: use Playwright's proxy dict
// HTTP/HTTPS with credentials on supported platforms: bypass Playwright's
// CDP auth interceptor, use Chrome's preemptive Proxy-Authorization (#182).
if (hasCredentials(proxy) && supportsHttpProxyInlineAuth()) {
if (typeof proxy === "string") {
return { proxyArgs: [`--proxy-server=${normalizeHttpStringUrl(proxy)}`] };
}
const httpUrl = reconstructHttpUrl(proxy);
const args = [`--proxy-server=${httpUrl}`];
if (proxy.bypass) args.push(`--proxy-bypass-list=${proxy.bypass}`);
return { proxyArgs: args };
}
// HTTP/HTTPS without credentials (or unsupported platform): use Playwright's proxy dict
if (typeof proxy === "string") {
return { proxyOption: parseProxyUrl(proxy), proxyArgs: [] };
}
+22 -5
View File
@@ -9,7 +9,7 @@ import type { LaunchOptions } from "./types.js";
import { IGNORE_DEFAULT_ARGS } from "./config.js";
import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js";
import { isSocksProxy, parseProxyUrl, resolveProxyConfig } from "./proxy.js";
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
@@ -26,9 +26,9 @@ async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string
/**
* Resolve proxy into Chrome CLI args and optional HTTP auth credentials.
* SOCKS5: Chrome supports inline credentials natively (RFC 1929 auth).
* HTTP: Chrome does NOT support inline credentials strip them and
* use page.authenticate() for Proxy-Authorization headers instead.
* SOCKS5: Chrome handles inline credentials natively (RFC 1929 auth).
* HTTP on supported platforms: inline credentials via --proxy-server.
* HTTP on unsupported platforms: strip credentials, use page.authenticate() fallback.
*/
function resolveProxy(options: LaunchOptions, args: string[]): { username: string; password: string } | undefined {
if (!options.proxy) return undefined;
@@ -39,6 +39,23 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return undefined;
}
// On supported platforms: pass full URL with inline creds to --proxy-server
if (supportsHttpProxyInlineAuth()) {
if (typeof options.proxy === "string") {
args.push(`--proxy-server=${normalizeHttpStringUrl(options.proxy)}`);
return undefined;
}
const url = options.proxy.username
? reconstructHttpUrl(options.proxy)
: options.proxy.server;
args.push(`--proxy-server=${url}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
return undefined;
}
// Unsupported platform: strip credentials, fall back to page.authenticate()
if (typeof options.proxy === "string") {
const { server, username, password } = parseProxyUrl(options.proxy);
args.push(`--proxy-server=${server}`);
@@ -55,7 +72,7 @@ function resolveProxy(options: LaunchOptions, args: string[]): { username: strin
return username ? { username, password: password ?? "" } : undefined;
}
/** Apply proxy auth monkey-patch and humanize behavioral patching. */
/** Apply proxy auth fallback (unsupported platforms) and humanize patching. */
async function applyPostLaunch(
browser: Browser,
options: LaunchOptions,
+120
View File
@@ -1479,3 +1479,123 @@ describe("el.scrollIntoViewIfNeeded humanization", () => {
spy.mockRestore();
});
});
// =========================================================================
// Issue #307: frame.click timeout should not multiply
// =========================================================================
describe("frame.click timeout budget (#307)", () => {
it("total wait time should not exceed the specified timeout", async () => {
const { patchPage } = await import("../src/human/index.js");
const TIMEOUT_MS = 500;
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
// Build a frame where the element does NOT exist:
// scrollIntoViewIfNeeded and boundingBox each wait until their
// individual timeout before failing, and origFrameClick does the same.
const frameLoc: any = {
boundingBox: vi.fn(async (opts?: { timeout?: number }) => {
await delay(opts?.timeout ?? 30000);
return null;
}),
scrollIntoViewIfNeeded: vi.fn(async (opts?: { timeout?: number }) => {
await delay(opts?.timeout ?? 30000);
throw new Error("timeout");
}),
evaluate: vi.fn(async () => ({ hit: true })),
isChecked: vi.fn(async () => false),
};
frameLoc.first = vi.fn(() => frameLoc);
const origClickFn = vi.fn(async (_sel: string, opts?: any) => {
await delay(opts?.timeout ?? 30000);
throw new Error("timeout");
});
const childFrame: any = {
click: origClickFn,
dblclick: vi.fn(async () => {}),
hover: vi.fn(async () => {}),
type: vi.fn(async () => {}),
fill: vi.fn(async () => {}),
check: vi.fn(async () => {}),
uncheck: vi.fn(async () => {}),
selectOption: vi.fn(async () => {}),
press: vi.fn(async () => {}),
pressSequentially: vi.fn(async () => {}),
tap: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
dragAndDrop: vi.fn(async () => {}),
locator: vi.fn(() => frameLoc),
childFrames: vi.fn(() => []),
};
const mainFrame = {
...buildMockFrame(),
childFrames: vi.fn(() => [childFrame]),
};
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default", {
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);
const start = Date.now();
try {
await (childFrame as any).click("#does-not-exist", { timeout: TIMEOUT_MS });
} catch {
// expected — element doesn't exist
}
const elapsed = Date.now() - start;
// With the bug, elapsed ≈ 3 * TIMEOUT_MS (scrollIntoView + boundingBox + origClick).
// Fixed: elapsed should be ≈ 1 * TIMEOUT_MS (shared deadline).
// Allow 1.8x as upper bound to account for test overhead but catch the 3x bug.
expect(elapsed).toBeLessThan(TIMEOUT_MS * 1.8);
});
});
describe("pointer-events check fail-open", () => {
// When the check itself cannot run (evaluate / boundingBox throws -> result
// null), proceed with the click instead of blocking it until the timeout.
it("checkPointerEventsHandle returns promptly when evaluate throws", async () => {
const { checkPointerEventsHandle } = await import("../src/human/actionability.js");
const el = {
boundingBox: vi.fn().mockRejectedValue(new Error("stale handle")),
evaluate: vi.fn().mockRejectedValue(new Error("execution context destroyed")),
};
const start = Date.now();
await checkPointerEventsHandle(el as any, 100, 100, 2000); // must not throw
expect(Date.now() - start).toBeLessThan(500);
});
it("checkPointerEvents returns promptly when evaluate throws", async () => {
const { checkPointerEvents } = await import("../src/human/actionability.js");
const loc = {
first: () => loc,
boundingBox: vi.fn().mockRejectedValue(new Error("no element")),
evaluate: vi.fn().mockRejectedValue(new Error("no element")),
};
const page = { locator: vi.fn().mockReturnValue(loc) };
const start = Date.now();
await checkPointerEvents(page as any, "#x", 100, 100, null, 2000); // must not throw
expect(Date.now() - start).toBeLessThan(500);
});
it("checkPointerEventsHandle still throws when genuinely covered", async () => {
const { checkPointerEventsHandle, ElementNotReceivingEventsError } =
await import("../src/human/actionability.js");
const el = {
boundingBox: vi.fn().mockResolvedValue({ x: 0, y: 0, width: 10, height: 10 }),
evaluate: vi.fn().mockResolvedValue({ hit: false, covering: "DIV" }),
};
await expect(checkPointerEventsHandle(el as any, 5, 5, 200)).rejects.toBeInstanceOf(
ElementNotReceivingEventsError,
);
});
});
+51 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { binaryInfo } from "../src/download.js";
import { DEFAULT_VIEWPORT, getChromiumVersion } from "../src/config.js";
import * as config from "../src/config.js";
describe("binaryInfo", () => {
it("returns correct structure", () => {
@@ -39,14 +40,54 @@ describe("composable Playwright launch helpers", () => {
}
});
it("exports buildLaunchOptions and humanizeBrowser from the package entrypoint", async () => {
it("exports composable helpers from the package entrypoint", async () => {
const entry = await import("../src/index.js");
expect(entry.buildLaunchOptions).toBeTypeOf("function");
expect(entry.buildContextOptions).toBeTypeOf("function");
expect(entry.humanizeBrowser).toBeTypeOf("function");
});
it("buildContextOptions returns Playwright context options without launching a browser", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { buildContextOptions } = await import("../src/index.js");
const options = buildContextOptions({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
contextOptions: {
userAgent: "Context/9.9",
viewport: { width: 9999, height: 9999 },
colorScheme: "light",
storageState: "state.json",
locale: "de-DE",
timezoneId: "Europe/Berlin",
},
});
expect(options).toMatchObject({
userAgent: "Explicit/1.0",
viewport: { width: 1280, height: 720 },
colorScheme: "dark",
storageState: "state.json",
});
expect(options.locale).toBeUndefined();
expect(options.timezoneId).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(2);
});
it("buildContextOptions applies DEFAULT_VIEWPORT by default and allows null viewport", async () => {
const { buildContextOptions } = await import("../src/index.js");
expect(buildContextOptions().viewport).toEqual(DEFAULT_VIEWPORT);
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
});
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { buildLaunchOptions } = await import("../src/index.js");
const options = await buildLaunchOptions({
@@ -66,6 +107,9 @@ describe("composable Playwright launch helpers", () => {
password: "pass",
});
expect(options.timeout).toBe(1234);
} finally {
vi.restoreAllMocks();
}
});
it("humanizeBrowser patches an existing browser only when requested", async () => {
@@ -307,6 +351,9 @@ describe("launchPersistentContext (unit)", () => {
});
it("forwards proxy string", async () => {
const freshConfig = await import("../src/config.js");
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({
userDataDir: "/tmp/profile",
@@ -317,6 +364,9 @@ describe("launchPersistentContext (unit)", () => {
expect(args.proxy.server).toBe("http://proxy:8080");
expect(args.proxy.username).toBe("user");
expect(args.proxy.password).toBe("pass");
} finally {
vi.restoreAllMocks();
}
});
it("forwards userAgent and colorScheme", async () => {
+100 -2
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig } from "../src/proxy.js";
import { parseProxyUrl, isSocksProxy, resolveProxyConfig, reconstructHttpUrl, normalizeHttpStringUrl } from "../src/proxy.js";
import * as config from "../src/config.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => {
@@ -153,10 +154,15 @@ describe("resolveProxyConfig", () => {
expect(proxyArgs).toEqual([]);
});
it("returns playwright dict for http string", () => {
it("returns playwright dict for http string on unsupported platform", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("returns playwright dict for http dict", () => {
@@ -321,4 +327,96 @@ describe("resolveProxyConfig", () => {
debugSpy.mockRestore();
}
});
// --- HTTP with credentials → --proxy-server (supported platform + version) ---
it("routes http string with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("routes http dict with creds through --proxy-server on linux-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
expect(proxyOption).toBeUndefined();
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("includes bypass for http dict with creds on windows-x64 v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("windows-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig({
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".google.com",
});
expect(proxyArgs).toContain("--proxy-server=http://user:pass@proxy:8080");
expect(proxyArgs).toContain("--proxy-bypass-list=.google.com");
} finally {
vi.restoreAllMocks();
}
});
it("encodes special chars in http proxy password on supported platform v177.5", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { proxyArgs } = resolveProxyConfig("http://user:pass=123@proxy:8080");
expect(proxyArgs).toEqual(["--proxy-server=http://user:pass%3D123@proxy:8080"]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back on linux-x64 with old version (pre-inline-auth)", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.3");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on darwin-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toEqual({ server: "http://proxy:8080", username: "user", password: "pass" });
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
it("falls back to playwright dict for http with creds on linux-arm64", () => {
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-arm64");
try {
const { proxyOption, proxyArgs } = resolveProxyConfig("http://user:pass@proxy:8080");
expect(proxyOption).toBeDefined();
expect(proxyArgs).toEqual([]);
} finally {
vi.restoreAllMocks();
}
});
});
+32 -3
View File
@@ -84,16 +84,39 @@ describe("puppeteer launch", () => {
expect(callArgs.args).toContain("--proxy-bypass-list=.google.com,localhost");
});
it("monkey-patches newPage for proxy auth", async () => {
it("uses page.authenticate fallback for http proxy on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
// newPage should auto-authenticate
const page = await browser.newPage();
expect(page.authenticate).toHaveBeenCalledWith({
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("passes inline creds via --proxy-server on supported platform (no page.authenticate)", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("linux-x64");
vi.spyOn(config, "getChromiumVersion").mockReturnValue("146.0.7680.177.5");
try {
const { launch } = await import("../src/puppeteer.js");
const browser = await launch({ proxy: "http://user:pass@proxy:8080" });
const callArgs = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
expect(callArgs.args).toContain("--proxy-server=http://user:pass@proxy:8080");
const page = await browser.newPage();
expect(page.authenticate).not.toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
});
it("injects timezone and locale as binary flags", async () => {
@@ -189,7 +212,10 @@ describe("puppeteer launchPersistentContext", () => {
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
});
it("handles proxy auth with persistent context", async () => {
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
const config = await import("../src/config.js");
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
try {
const { launchPersistentContext } = await import("../src/puppeteer.js");
const browser = await launchPersistentContext({
userDataDir: "./my-profile",
@@ -201,6 +227,9 @@ describe("puppeteer launchPersistentContext", () => {
username: "user",
password: "pass",
});
} finally {
vi.restoreAllMocks();
}
});
it("keeps SOCKS5 credentials in --proxy-server URL", async () => {
+114 -2
View File
@@ -708,7 +708,7 @@ class TestBrowserBotDetection:
time.sleep(0.3)
page.locator('#password').fill('SecurePass!123')
time.sleep(0.5)
page.locator('button[type="submit"]').click()
page.locator('#loginForm button[type="submit"]').click()
time.sleep(5)
body = page.locator('body').text_content()
assert '"superHumanSpeed": true' not in body
@@ -725,7 +725,7 @@ class TestBrowserBotDetection:
t0 = time.time()
page.locator('#email').fill('test@example.com')
page.locator('#password').fill('MyPassword!99')
page.locator('button[type="submit"]').click()
page.locator('#loginForm button[type="submit"]').click()
elapsed_ms = int((time.time() - t0) * 1000)
time.sleep(3)
assert elapsed_ms > 3000
@@ -1828,6 +1828,118 @@ class TestScrollIntoViewIfNeeded:
assert cursor.x == 200 and cursor.y == 200
# =========================================================================
# Issue #307: frame/page click timeout should not multiply
# =========================================================================
class TestTimeoutBudget307:
"""Verify timeout budget is shared across sequential operations."""
def test_page_click_total_time_within_budget(self):
"""page.click on a missing element should not exceed ~1x the timeout."""
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, patch
TIMEOUT_MS = 500
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = 100
cursor.y = 100
page = MagicMock()
page.click = MagicMock()
page.dblclick = MagicMock()
page.hover = MagicMock()
page.type = MagicMock()
page.fill = MagicMock()
page.goto = MagicMock()
page.is_checked = MagicMock(return_value=False)
page.viewport_size = {"width": 1280, "height": 720}
page.evaluate = MagicMock(return_value={"hit": True})
page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp"))
page.mouse = MagicMock()
page.keyboard = MagicMock()
page.query_selector = MagicMock(return_value=None)
page.query_selector_all = MagicMock(return_value=[])
page.wait_for_selector = MagicMock(return_value=None)
page.main_frame = MagicMock()
page.main_frame.child_frames = []
loc = MagicMock()
loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0))
loc.is_visible = MagicMock(return_value=False)
loc.first = loc
page.locator = MagicMock(return_value=loc)
h.patch_page(page, cfg, cursor)
start = time.monotonic()
try:
page.click("#does-not-exist", timeout=TIMEOUT_MS)
except Exception:
pass
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < TIMEOUT_MS * 1.8, (
f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms"
)
class TestPointerEventsFailOpen:
"""The pointer-events check must fail open: when it cannot run (evaluate /
bounding_box throws -> result None), proceed with the click instead of
blocking it until the timeout expires."""
def test_handle_failopen_returns_on_evaluate_error(self):
from cloakbrowser.human.actionability import check_pointer_events_handle
el = MagicMock()
el.bounding_box = MagicMock(side_effect=Exception("stale handle"))
el.evaluate = MagicMock(side_effect=Exception("execution context destroyed"))
start = time.monotonic()
check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000) # must not raise
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
def test_locator_failopen_returns_on_evaluate_error(self):
from cloakbrowser.human.actionability import check_pointer_events
page = MagicMock()
loc = MagicMock()
loc.first = loc
loc.bounding_box = MagicMock(side_effect=Exception("no element"))
loc.evaluate = MagicMock(side_effect=Exception("no element"))
page.locator = MagicMock(return_value=loc)
start = time.monotonic()
check_pointer_events(page, "#x", 100, 100, timeout=2000) # must not raise
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
def test_handle_still_raises_when_covered(self):
"""A genuine 'covered' result (not None) must still raise — fail-open
only applies when the check could not be determined."""
from cloakbrowser.human.actionability import (
check_pointer_events_handle, ElementNotReceivingEventsError,
)
el = MagicMock()
el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10})
el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"})
with pytest.raises(ElementNotReceivingEventsError):
check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200)
def test_async_handle_failopen_returns_on_evaluate_error(self):
from cloakbrowser.human.actionability_async import async_check_pointer_events_handle
from unittest.mock import AsyncMock
el = MagicMock()
el.bounding_box = AsyncMock(side_effect=Exception("stale handle"))
el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed"))
start = time.monotonic()
asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000))
elapsed_ms = (time.monotonic() - start) * 1000
assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms"
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================
+3 -2
View File
@@ -165,10 +165,11 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
pw.stop.assert_called_once()
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed."""
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin, _mock_platform):
"""Proxy string parsed and passed (unsupported platform → Playwright dict)."""
pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
+118 -14
View File
@@ -55,12 +55,12 @@ class TestBuildProxyKwargs:
assert kwargs == {"proxy": {"server": "http://proxy:8080"}}
assert args == []
def test_proxy_with_auth(self):
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_proxy_with_auth(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
}
assert args == []
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
@@ -68,7 +68,9 @@ class TestBuildProxyKwargs:
assert kwargs == {"proxy": proxy_dict}
assert args == []
def test_proxy_dict_with_auth(self):
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_proxy_dict_with_auth(self, *_):
proxy_dict = {
"server": "http://proxy:8080",
"username": "user",
@@ -76,8 +78,11 @@ class TestBuildProxyKwargs:
"bypass": ".example.com",
}
kwargs, args = _resolve_proxy_config(proxy_dict)
assert kwargs == {"proxy": proxy_dict}
assert args == []
assert kwargs == {}
assert args == [
"--proxy-server=http://user:pass@proxy:8080",
"--proxy-bypass-list=.example.com",
]
class TestMaybeResolveGeoip:
@@ -183,11 +188,12 @@ class TestBareProxyFormat:
r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"}
def test_resolve_proxy_config_bare(self):
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_resolve_proxy_config_bare(self, *_):
kwargs, args = _resolve_proxy_config("user:pass@proxy:8080")
assert kwargs["proxy"]["username"] == "user"
assert kwargs["proxy"]["password"] == "pass"
assert "user" not in kwargs["proxy"]["server"]
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
class TestIsSocksProxy:
@@ -219,11 +225,17 @@ class TestResolveProxyConfig:
assert kwargs == {}
assert args == []
def test_http_string_returns_playwright_dict(self):
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_with_creds_returns_chrome_arg(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
def test_http_string_no_creds_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["server"] == "http://proxy:8080"
assert kwargs["proxy"]["username"] == "user"
assert args == []
def test_http_dict_passthrough(self):
@@ -374,3 +386,95 @@ class TestResolveProxyConfig:
# Port 0 is an unusual but valid URL component; don't silently strip it.
_, args = _resolve_proxy_config("socks5://user:pass=1@host:0")
assert args[0] == "--proxy-server=socks5://user:pass%3D1@host:0"
# --- HTTP with credentials → --proxy-server (supported platforms + version) ---
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_with_creds_on_supported_platform(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_dict_with_creds_on_supported_platform(self, *_):
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_dict_with_creds_and_bypass(self, *_):
proxy = {
"server": "http://proxy:8080",
"username": "user",
"password": "pass",
"bypass": ".google.com",
}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {}
assert "--proxy-server=http://user:pass@proxy:8080" in args
assert "--proxy-bypass-list=.google.com" in args
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_encodes_special_chars_in_password(self, *_):
_, args = _resolve_proxy_config("http://user:pass=123@proxy:8080")
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_string_encoding_idempotent(self, *_):
_, args = _resolve_proxy_config("http://user:pass%3D123@proxy:8080")
assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5")
@patch("cloakbrowser.config.get_platform_tag", return_value="windows-x64")
def test_http_string_with_creds_on_windows(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert kwargs == {}
assert args == ["--proxy-server=http://user:pass@proxy:8080"]
@patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.3")
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64")
def test_http_with_creds_old_version_falls_back(self, *_):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert args == []
# --- HTTP with credentials on unsupported platform → fallback to Playwright ---
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
def test_http_string_with_creds_on_macos_falls_back(self, _mock):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert kwargs["proxy"]["username"] == "user"
assert args == []
@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64")
def test_http_dict_with_creds_on_macos_falls_back(self, _mock):
proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []
@patch("cloakbrowser.config.get_platform_tag", return_value="linux-arm64")
def test_http_string_with_creds_on_linux_arm_falls_back(self, _mock):
kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080")
assert "proxy" in kwargs
assert args == []
# --- HTTP without credentials (all platforms) ---
def test_http_no_creds_returns_playwright_dict(self):
kwargs, args = _resolve_proxy_config("http://proxy:8080")
assert "proxy" in kwargs
assert args == []
def test_http_dict_no_creds_returns_playwright_dict(self):
proxy = {"server": "http://proxy:8080", "bypass": ".example.com"}
kwargs, args = _resolve_proxy_config(proxy)
assert kwargs == {"proxy": proxy}
assert args == []