Compare commits

..
5 Commits
Author SHA1 Message Date
CloakHQ 767eb16a82 release: v0.3.12 — locale spoofing patch, WebGPU hardening, binary flags for tz/locale
Binary: 145.0.7632.159.4 (linux), 32 patches.
Wrapper: bare proxy format, ANGLE GPU strings, README updates.
2026-03-10 06:27:16 +01:00
CloakHQ 04255cf412 fix: use binary flags for timezone/locale instead of detectable CDP emulation
- Remove locale and timezone_id from Playwright context kwargs (CDP)
- Pass timezone via --fingerprint-timezone binary flag (process-wide)
- Pass locale via --lang + --fingerprint-locale binary flags
- Accept both timezone and timezone_id param names silently (no deprecation)
- Update all wrapper tests to verify binary args, not CDP context params
2026-03-10 03:56:57 +01:00
CloakHQ 1fb554e061 fix: support bare proxy format (user:pass@host:port) without scheme
Normalize bare proxy strings by prepending http:// before parsing when
@ is present but :// is absent. Tests added for Python and JS.
2026-03-09 19:35:07 +01:00
CloakHQ 748013bf83 fix: use ANGLE-wrapped GPU strings for realistic WebGL fingerprint
Bare vendor/renderer strings are detectable — real Chrome reports
ANGLE-wrapped values through WebGL's getParameter API.
2026-03-09 02:25:33 +01:00
CloakHQ eeea366047 ci: upgrade npm for OIDC trusted publishing (requires npm >= 11.5.1) 2026-03-08 23:54:18 +01:00
24 changed files with 328 additions and 221 deletions
+2
View File
@@ -85,6 +85,8 @@ jobs:
with: with:
node-version: 22 node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm
run: npm install -g npm@latest
- name: Build - name: Build
run: cd js && npm ci && npm run build run: cd js && npm ci && npm run build
- name: Publish to npm - name: Publish to npm
+11
View File
@@ -6,6 +6,17 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [0.3.12] — 2026-03-10
- **[binary]** Upgrade Linux build to 145.0.7632.159.4
- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation
- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency
- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing)
- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks
- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector
- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix
- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint
## [0.3.11] — 2026-03-08 ## [0.3.11] — 2026-03-08
- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123)) - **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123))
+16 -19
View File
@@ -11,6 +11,7 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a> <a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a> <a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a> <a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p> </p>
<br> <br>
@@ -35,7 +36,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong> Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p> </p>
- **31 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior - **32 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes - **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -109,16 +110,15 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop. > ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Latest: v0.3.11 (Chromium 145.0.7632.159.2) ## Latest: v0.3.12 (Chromium 145.0.7632.159.4)
- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful` - **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior - **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior
- **`cloakserve` CDP server** — no longer requires socat. Chrome binds directly to `0.0.0.0:9222` via native flag support - **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
- **31 fingerprint patches** (Linux x64) — 5 new patches since v0.3.10, plus GPU fingerprint accuracy fixes for NVIDIA and Apple Silicon profiles - **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
- **All 4 platforms** Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145 - **32 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required - **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required
- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale - **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale
- **Playwright + Puppeteer from one package** — `import from 'cloakbrowser'` or `import from 'cloakbrowser/puppeteer'`. Same binary, your choice of API
- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection - **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
See the full [CHANGELOG.md](CHANGELOG.md) for details. See the full [CHANGELOG.md](CHANGELOG.md) for details.
@@ -129,7 +129,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. - **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser.
- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface. - **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface.
- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed. - **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed.
- **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path. - **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, agent-browser, Claude computer use, and OpenAI Operator. Also tested with Playwright, Puppeteer, and Selenium point any Chromium-based framework at the binary path.
CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know. CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know.
@@ -203,7 +203,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 31 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking. The binary includes 32 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking.
These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -231,7 +231,7 @@ browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com",
# With extra Chrome args # With extra Chrome args
browser = launch(args=["--disable-gpu"]) browser = launch(args=["--disable-gpu"])
# With timezone and locale (sets both binary flags and Playwright context) # With timezone and locale (sets binary flags — no detectable CDP emulation)
browser = launch(timezone="America/New_York", locale="en-US") browser = launch(timezone="America/New_York", locale="en-US")
# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip]) # Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip])
@@ -527,6 +527,7 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-platform-version` | Client Hints platform version | | `--fingerprint-platform-version` | Client Hints platform version |
| `--fingerprint-location` | Geolocation coordinates | | `--fingerprint-location` | Geolocation coordinates |
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) | | `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-locale` | Locale (e.g. `en-US`) |
| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) | | `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory | | `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements | | `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements |
@@ -791,17 +792,13 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
``` ```
**New update broke something? Roll back to the previous version** **New update broke something? Roll back to the previous version**
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary: Install a specific wrapper version to downgrade both the wrapper and the binary it downloads:
```bash ```bash
# Linux pip install cloakbrowser==0.3.11 # Python
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome npm install cloakbrowser@0.3.11 # JavaScript
docker pull cloakhq/cloakbrowser:0.3.11 # Docker
# macOS
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe
``` ```
Each wrapper version pins its own binary version, so downgrading the wrapper automatically gets you the matching binary on next launch.
**macOS: "App is damaged" or Gatekeeper blocks launch** **macOS: "App is damaged" or Gatekeeper blocks launch**
The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it: The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it:
@@ -916,7 +913,7 @@ All binary releases are GPG-signed and include GitHub artifact attestations for
```bash ```bash
# Verify GPG signature # Verify GPG signature
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.3 git verify-tag chromium-v145.0.7632.159.4
# Verify binary attestation # Verify binary attestation
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.11" __version__ = "0.3.12"
+30 -28
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import logging import logging
import os import os
import warnings
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
@@ -26,10 +25,9 @@ from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None: def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone.""" """Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs: if "timezone_id" in kwargs:
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
if timezone is None: if timezone is None:
timezone = kwargs.pop("timezone_id") timezone = kwargs.pop("timezone_id")
else: else:
@@ -279,7 +277,7 @@ def launch_persistent_context(
""" """
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -291,14 +289,12 @@ def launch_persistent_context(
user_data_dir, user_data_dir,
) )
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -394,7 +390,7 @@ async def launch_persistent_context_async(
""" """
async_playwright = _import_async_playwright(_resolve_backend(backend)) async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -406,14 +402,12 @@ async def launch_persistent_context_async(
user_data_dir, user_data_dir,
) )
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -491,25 +485,21 @@ def launch_context(
Returns: Returns:
Playwright BrowserContext object. Playwright BrowserContext object.
""" """
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to both binary flags AND context params # resolved values flow to binary flags
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Skip --fingerprint-timezone binary flag: it only applies to the default # --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# context and interferes with Playwright's timezone_id on new contexts. # so it applies to ALL contexts, not just the default one.
# Timezone is set via browser.new_context(timezone_id=...) below instead. # locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=None, locale=locale, backend=backend) timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -585,6 +575,11 @@ def _import_async_playwright(backend: str):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _ensure_proxy_scheme(proxy_url: str) -> str:
"""Prepend http:// to schemeless proxy URLs so parsers can extract hostname."""
return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _maybe_resolve_geoip( def _maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | ProxySettings | None, proxy: str | ProxySettings | None,
@@ -600,6 +595,7 @@ def _maybe_resolve_geoip(
proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
if not proxy_url: if not proxy_url:
return timezone, locale return timezone, locale
proxy_url = _ensure_proxy_scheme(proxy_url)
geo_tz, geo_locale = resolve_proxy_geo(proxy_url) geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is None: if timezone is None:
timezone = geo_tz timezone = geo_tz
@@ -640,7 +636,7 @@ def _build_args(
logger.debug("Arg override: %s -> %s", seen[key], flag) logger.debug("Arg override: %s -> %s", seen[key], flag)
seen[key] = flag seen[key] = flag
if locale: if locale:
key = "--lang" for key in ("--lang", "--fingerprint-locale"):
flag = f"{key}={locale}" flag = f"{key}={locale}"
if key in seen: if key in seen:
logger.debug("Arg override: %s -> %s", seen[key], flag) logger.debug("Arg override: %s -> %s", seen[key], flag)
@@ -653,12 +649,18 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
"""Parse proxy URL, extracting credentials into separate Playwright fields. """Parse proxy URL, extracting credentials into separate Playwright fields.
Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"} Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"}
Also handles: no credentials, URL-encoded special chars, socks5://, missing port. Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http).
""" """
parsed = urlparse(proxy) # Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials.
normalized = proxy
if "@" in proxy and "://" not in proxy:
normalized = f"http://{proxy}"
parsed = urlparse(normalized)
if not parsed.username: if not parsed.username:
return {"server": proxy} return {"server": proxy} # no creds — return original unchanged
# Rebuild server URL without credentials # Rebuild server URL without credentials
netloc = parsed.hostname or "" netloc = parsed.hostname or ""
+4 -4
View File
@@ -15,10 +15,10 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference). # CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version. # Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.159.3" CHROMIUM_VERSION = "145.0.7632.159.4"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.3", "linux-x64": "145.0.7632.159.4",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109.2",
@@ -56,8 +56,8 @@ def get_default_stealth_args() -> list[str]:
# auto-generated by the binary from the seed (v14+). # auto-generated by the binary from the seed (v14+).
return base + [ return base + [
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
] ]
+3 -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.** Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **26 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals - **32 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config - **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
@@ -81,7 +81,7 @@ const browser = await launch({
args: ['--fingerprint=12345'], args: ['--fingerprint=12345'],
}); });
// With timezone and locale (sets --fingerprint-timezone and --lang binary flags) // With timezone and locale
const browser = await launch({ const browser = await launch({
timezone: 'America/New_York', timezone: 'America/New_York',
locale: 'en-US', locale: 'en-US',
@@ -93,7 +93,7 @@ const browser = await launch({
geoip: true, geoip: true,
}); });
// Browser + context in one call (timezone/locale set both binary flags AND context) // Browser + context in one call (timezone/locale set via binary flags)
const context = await launchContext({ const context = await launchContext({
userAgent: 'Custom UA', userAgent: 'Custom UA',
viewport: { width: 1920, height: 1080 }, viewport: { width: 1920, height: 1080 },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.11", "version": "0.3.12",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+6 -5
View File
@@ -38,12 +38,13 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(key, flag); seen.set(key, flag);
} }
if (options.locale) { if (options.locale) {
const key = "--lang"; for (const k of ["--lang", "--fingerprint-locale"] as const) {
const flag = `${key}=${options.locale}`; const flag = `${k}=${options.locale}`;
if (seen.has(key)) { if (seen.has(k)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`); if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(k)} -> ${flag}`);
}
seen.set(k, flag);
} }
seen.set(key, flag);
} }
return [...seen.values()]; return [...seen.values()];
} }
+4 -4
View File
@@ -27,10 +27,10 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference). // CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version. // Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.3"; export const CHROMIUM_VERSION = "145.0.7632.159.4";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.3", "linux-x64": "145.0.7632.159.4",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.109.2",
@@ -222,7 +222,7 @@ export function getDefaultStealthArgs(): string[] {
return [ return [
...base, ...base,
"--fingerprint-platform=windows", "--fingerprint-platform=windows",
"--fingerprint-gpu-vendor=NVIDIA Corporation", "--fingerprint-gpu-vendor=Google Inc. (NVIDIA)",
"--fingerprint-gpu-renderer=NVIDIA GeForce RTX 3070", "--fingerprint-gpu-renderer=ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002484) Direct3D11 vs_5_0 ps_5_0, D3D11)",
]; ];
} }
+22
View File
@@ -14,6 +14,8 @@ import { createWriteStream } from "node:fs";
import dns from "node:dns/promises"; import dns from "node:dns/promises";
import net from "node:net"; import net from "node:net";
import { getCacheDir } from "./config.js"; import { getCacheDir } from "./config.js";
import type { LaunchOptions } from "./types.js";
import { ensureProxyScheme } from "./proxy.js";
// P3TERX mirror of MaxMind GeoLite2-City — no license key needed // P3TERX mirror of MaxMind GeoLite2-City — no license key needed
const GEOIP_DB_URL = const GEOIP_DB_URL =
@@ -260,3 +262,23 @@ function maybeTriggerUpdate(dbPath: string): void {
// Fire-and-forget background update // Fire-and-forget background update
downloadGeoipDb(dbPath).catch(() => {}); downloadGeoipDb(dbPath).catch(() => {});
} }
/**
* Auto-fill timezone/locale from proxy IP when geoip is enabled.
* Shared by the Playwright and Puppeteer wrappers.
*/
export async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
let proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
proxyUrl = ensureProxyScheme(proxyUrl);
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+11 -29
View File
@@ -9,11 +9,11 @@ import { DEFAULT_VIEWPORT } from "./config.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/** @internal Migrate deprecated timezoneId → timezone, warn once. Exported for testing. */ /** @internal Accept both timezone and timezoneId — either works, no warning. Exported for testing. */
export function migrateTimezoneId<T extends { timezone?: string; timezoneId?: string }>(options: T): T { export function resolveTimezone<T extends { timezone?: string; timezoneId?: string }>(options: T): T {
if (options.timezoneId != null) { if (options.timezoneId != null) {
console.warn("[cloakbrowser] timezoneId is deprecated, use timezone instead");
const merged = { ...options, timezone: options.timezone ?? options.timezoneId }; const merged = { ...options, timezone: options.timezone ?? options.timezoneId };
delete (merged as any).timezoneId; delete (merged as any).timezoneId;
return merged; return merged;
@@ -85,21 +85,19 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
export async function launchContext( export async function launchContext(
options: LaunchContextOptions = {} options: LaunchContextOptions = {}
): Promise<BrowserContext> { ): Promise<BrowserContext> {
options = migrateTimezoneId(options); options = resolveTimezone(options);
// Resolve geoip BEFORE launch() to avoid double-resolution // Resolve geoip BEFORE launch() to avoid double-resolution
const resolved = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
// Skip --fingerprint-timezone binary flag: it only applies to the default // --fingerprint-timezone is process-wide (reads CommandLine in renderer),
// context and interferes with Playwright's timezoneId on new contexts. // so it applies to ALL contexts, not just the default one.
// Timezone is set via browser.newContext(timezoneId: ...) below instead. // locale and timezone are set via binary flags only — no CDP emulation.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined }); const browser = await launch({ ...options, ...resolved, geoip: false });
let context: BrowserContext; let context: BrowserContext;
try { try {
context = await browser.newContext({ context = await browser.newContext({
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT, viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
}); });
} catch (err) { } catch (err) {
@@ -152,13 +150,15 @@ export async function launchContext(
export async function launchPersistentContext( export async function launchPersistentContext(
options: LaunchPersistentContextOptions options: LaunchPersistentContextOptions
): Promise<BrowserContext> { ): Promise<BrowserContext> {
options = migrateTimezoneId(options); options = resolveTimezone(options);
const { chromium } = await import("playwright-core"); const { chromium } = await import("playwright-core");
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary()); const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
const resolved = await maybeResolveGeoip(options); const resolved = await maybeResolveGeoip(options);
const args = buildArgs({ ...options, ...resolved }); const args = buildArgs({ ...options, ...resolved });
// locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
// — NOT via Playwright context kwargs which use detectable CDP emulation.
const context = await chromium.launchPersistentContext(options.userDataDir, { const context = await chromium.launchPersistentContext(options.userDataDir, {
executablePath: binaryPath, executablePath: binaryPath,
headless: options.headless ?? true, headless: options.headless ?? true,
@@ -169,8 +169,6 @@ export async function launchPersistentContext(
: {}), : {}),
...(options.userAgent ? { userAgent: options.userAgent } : {}), ...(options.userAgent ? { userAgent: options.userAgent } : {}),
viewport: options.viewport ?? DEFAULT_VIEWPORT, viewport: options.viewport ?? DEFAULT_VIEWPORT,
...(resolved.locale ? { locale: resolved.locale } : {}),
...(resolved.timezone ? { timezoneId: resolved.timezone } : {}),
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}), ...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
...options.launchOptions, ...options.launchOptions,
}); });
@@ -193,21 +191,5 @@ export async function launchPersistentContext(
// Internal // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
/** @internal Exposed for unit tests only. */ /** @internal Exposed for unit tests only. */
export { buildArgs as _buildArgsForTest } from "./args.js"; export { buildArgs as _buildArgsForTest } from "./args.js";
+14 -2
View File
@@ -8,16 +8,28 @@ export interface ParsedProxy {
password?: string; password?: string;
} }
/**
* Prepend http:// to schemeless proxy URLs so parsers can extract hostname.
* Used by geoip resolution which only needs a valid hostname, not auth fields.
*/
export function ensureProxyScheme(proxyUrl: string): string {
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
}
/** /**
* Parse a proxy URL, extracting credentials into separate fields. * Parse a proxy URL, extracting credentials into separate fields.
* *
* Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" } * Handles: "http://user:pass@host:port" -> { server: "http://host:port", username: "user", password: "pass" }
* Also handles: no credentials, URL-encoded special chars, socks5://, missing port. * Also handles: no credentials, URL-encoded special chars, socks5://, missing port,
* and bare proxy strings without a scheme (e.g. "user:pass@host:port" -> treated as http).
*/ */
export function parseProxyUrl(proxy: string): ParsedProxy { export function parseProxyUrl(proxy: string): ParsedProxy {
let url: URL; let url: URL;
// Bare format: "user:pass@host:port" — new URL() throws without a scheme.
const normalized =
proxy.includes("@") && !proxy.includes("://") ? `http://${proxy}` : proxy;
try { try {
url = new URL(proxy); url = new URL(normalized);
} catch { } catch {
// Not a parseable URL (e.g. bare "host:port") — pass through as-is // Not a parseable URL (e.g. bare "host:port") — pass through as-is
return { server: proxy }; return { server: proxy };
+1 -16
View File
@@ -8,6 +8,7 @@ import type { LaunchOptions } from "./types.js";
import { buildArgs } from "./args.js"; import { buildArgs } from "./args.js";
import { ensureBinary } from "./download.js"; import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.js";
/** /**
* Launch stealth Chromium browser via Puppeteer. * Launch stealth Chromium browser via Puppeteer.
@@ -83,19 +84,3 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// Internal // Internal
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function maybeResolveGeoip(
options: LaunchOptions
): Promise<{ timezone?: string; locale?: string }> {
if (!options.geoip || !options.proxy) return { timezone: options.timezone, locale: options.locale };
if (options.timezone && options.locale) return { timezone: options.timezone, locale: options.locale };
const { resolveProxyGeo } = await import("./geoip.js");
const proxyUrl = typeof options.proxy === "string" ? options.proxy : options.proxy.server;
if (!proxyUrl) return { timezone: options.timezone, locale: options.locale };
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(proxyUrl);
return {
timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined,
};
}
+1 -1
View File
@@ -39,7 +39,7 @@ export interface LaunchContextOptions extends LaunchOptions {
viewport?: { width: number; height: number }; viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */ /** Browser locale, e.g. "en-US". */
locale?: string; locale?: string;
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */ /** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string; timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */ /** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference"; colorScheme?: "light" | "dark" | "no-preference";
+17 -10
View File
@@ -9,7 +9,7 @@ import {
getDownloadUrl, getDownloadUrl,
getFallbackDownloadUrl, getFallbackDownloadUrl,
} from "../src/config.js"; } from "../src/config.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js"; import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => { describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => { it("CHROMIUM_VERSION matches expected format", () => {
@@ -97,21 +97,24 @@ describe("buildArgs timezone/locale", () => {
expect(args).toContain("--fingerprint-timezone=America/New_York"); expect(args).toContain("--fingerprint-timezone=America/New_York");
}); });
it("injects --lang when locale is set", () => { it("injects --lang and --fingerprint-locale when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" }); const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US"); expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
}); });
it("injects both when both are set", () => { it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" }); const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin"); expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE"); expect(args).toContain("--lang=de-DE");
expect(args).toContain("--fingerprint-locale=de-DE");
}); });
it("injects timezone/locale even when stealthArgs=false", () => { it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" }); const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York"); expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US"); expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false); expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
}); });
@@ -119,6 +122,7 @@ describe("buildArgs timezone/locale", () => {
const args = _buildArgsForTest({}); const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false); expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false); expect(args.some(a => a.startsWith("--lang="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
}); });
}); });
@@ -147,14 +151,17 @@ describe("buildArgs deduplication", () => {
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York"); expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
}); });
it("locale param overrides user --lang arg", () => { it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({ const args = _buildArgsForTest({
args: ["--lang=de-DE"], args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale: "en-US", locale: "en-US",
}); });
const langArgs = args.filter(a => a.startsWith("--lang=")); const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1); expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US"); expect(langArgs[0]).toBe("--lang=en-US");
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
expect(localeArgs).toHaveLength(1);
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
}); });
it("no duplicate flag keys in output", () => { it("no duplicate flag keys in output", () => {
@@ -175,29 +182,29 @@ describe("buildArgs deduplication", () => {
}); });
}); });
describe("migrateTimezoneId deprecation", () => { describe("resolveTimezone alias", () => {
it("migrates timezoneId to timezone", () => { it("resolves timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris"); expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId"); expect(result).not.toHaveProperty("timezoneId");
}); });
it("preserves explicit timezone over timezoneId", () => { it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC"); expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId"); expect(result).not.toHaveProperty("timezoneId");
}); });
it("returns options unchanged when no timezoneId", () => { it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" }; const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts); const result = resolveTimezone(opts);
expect(result).toBe(opts); // same reference, no copy expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC"); expect(result.timezone).toBe("UTC");
}); });
it("returns options unchanged when neither is set", () => { it("returns options unchanged when neither is set", () => {
const opts = {}; const opts = {};
const result = migrateTimezoneId(opts); const result = resolveTimezone(opts);
expect(result).toBe(opts); expect(result).toBe(opts);
}); });
}); });
+11
View File
@@ -25,6 +25,17 @@ describe("resolveProxyIp", () => {
it("returns null for empty string", async () => { it("returns null for empty string", async () => {
expect(await resolveProxyIp("")).toBeNull(); expect(await resolveProxyIp("")).toBeNull();
}); });
it("returns null for schemeless proxy (shows why normalization is needed)", async () => {
// no scheme — new URL() gives empty hostname for both bare formats
expect(await resolveProxyIp("user:pass@10.50.96.5:8888")).toBeNull();
expect(await resolveProxyIp("10.50.96.5:8888")).toBeNull();
});
it("extracts IP after normalization (http:// prepended by maybeResolveGeoip)", async () => {
expect(await resolveProxyIp("http://user:pass@10.50.96.5:8888")).toBe("10.50.96.5");
expect(await resolveProxyIp("http://10.50.96.5:8888")).toBe("10.50.96.5");
});
}); });
describe("COUNTRY_LOCALE_MAP", () => { describe("COUNTRY_LOCALE_MAP", () => {
+11 -10
View File
@@ -96,20 +96,20 @@ describe("launchContext (unit)", () => {
expect(ctxArgs.userAgent).toBe("Custom/1.0"); expect(ctxArgs.userAgent).toBe("Custom/1.0");
}); });
it("passes timezone to context timezoneId, not to launch", async () => { it("passes timezone via binary flag, not CDP context", async () => {
const { launchContext } = await import("../src/playwright.js"); const { launchContext } = await import("../src/playwright.js");
await launchContext({ timezone: "America/New_York" }); await launchContext({ timezone: "America/New_York" });
// launch() called with timezone: undefined (skipped for binary flag) // launch() called with --fingerprint-timezone binary flag
const launchArgs = mockChromium.launch.mock.calls[0][0]; const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) => const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=") a.startsWith("--fingerprint-timezone=America/New_York")
); );
expect(hasTimezoneFlag).toBe(false); expect(hasTimezoneFlag).toBe(true);
// newContext() gets timezoneId // NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBe("America/New_York"); expect(ctxArgs.timezoneId).toBeUndefined();
}); });
it("forwards colorScheme to newContext", async () => { it("forwards colorScheme to newContext", async () => {
@@ -165,7 +165,7 @@ describe("launchPersistentContext (unit)", () => {
expect(args.viewport).toEqual(DEFAULT_VIEWPORT); expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
}); });
it("passes timezone and locale to context", async () => { it("passes timezone and locale via binary args, not CDP context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js"); const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ await launchPersistentContext({
userDataDir: "/tmp/profile", userDataDir: "/tmp/profile",
@@ -174,11 +174,12 @@ describe("launchPersistentContext (unit)", () => {
}); });
const args = mockChromium.launchPersistentContext.mock.calls[0][1]; const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.timezoneId).toBe("Asia/Tokyo"); // Binary args (native, undetectable)
expect(args.locale).toBe("ja-JP");
// Also in binary args
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo"); expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(args.args).toContain("--lang=ja-JP"); expect(args.args).toContain("--lang=ja-JP");
// NOT in context kwargs (would trigger detectable CDP emulation)
expect(args.timezoneId).toBeUndefined();
expect(args.locale).toBeUndefined();
}); });
it("forwards proxy string", async () => { it("forwards proxy string", async () => {
+33
View File
@@ -82,3 +82,36 @@ describe("proxy dict type", () => {
} }
}); });
}); });
describe("bare proxy format (user:pass@host:port)", () => {
it("extracts credentials from bare format", () => {
expect(parseProxyUrl("user:pass@proxy:8080")).toEqual({
server: "http://proxy:8080",
username: "user",
password: "pass",
});
});
it("credentials not in server", () => {
const r = parseProxyUrl("user:pass@proxy1.example.com:5610");
expect(r.server).not.toContain("user");
expect(r.server).not.toContain("pass");
});
it("bare username only", () => {
const r = parseProxyUrl("user@proxy:8080");
expect(r.username).toBe("user");
expect(r.password).toBeUndefined();
expect(r.server).toBe("http://proxy:8080");
});
it("bare no port", () => {
const r = parseProxyUrl("user:pass@proxy.example.com");
expect(r.username).toBe("user");
expect(r.server).toBe("http://proxy.example.com");
});
it("bare no credentials passes through unchanged", () => {
expect(parseProxyUrl("proxy:8080")).toEqual({ server: "proxy:8080" });
});
});
+1
View File
@@ -13,6 +13,7 @@ vi.mock("../src/download.js", () => ({
vi.mock("../src/geoip.js", () => ({ vi.mock("../src/geoip.js", () => ({
resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }), resolveProxyGeo: vi.fn().mockResolvedValue({ timezone: null, locale: null }),
maybeResolveGeoip: vi.fn().mockResolvedValue({}),
})); }));
describe("puppeteer launch", () => { describe("puppeteer launch", () => {
+25 -31
View File
@@ -1,8 +1,6 @@
"""Unit tests for _build_args timezone/locale injection and deprecation compat.""" """Unit tests for _build_args timezone/locale injection and timezone alias."""
import warnings from cloakbrowser.browser import _build_args, _resolve_timezone
from cloakbrowser.browser import _build_args, _migrate_timezone_id
def test_timezone_injected(): def test_timezone_injected():
@@ -12,9 +10,10 @@ def test_timezone_injected():
def test_locale_injected(): def test_locale_injected():
"""--lang flag should appear when locale is set.""" """--lang and --fingerprint-locale flags should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US") args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
def test_both_injected(): def test_both_injected():
@@ -22,6 +21,7 @@ def test_both_injected():
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE") args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args
def test_timezone_independent_of_stealth_args(): def test_timezone_independent_of_stealth_args():
@@ -29,15 +29,17 @@ def test_timezone_independent_of_stealth_args():
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US") args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
# No stealth fingerprint args # No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args) assert not any(a.startswith("--fingerprint=") for a in args)
def test_no_flags_when_not_set(): def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None.""" """No timezone/lang/fingerprint-locale flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None) args = _build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args) assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args) assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args)
def test_extra_args_preserved(): def test_extra_args_preserved():
@@ -46,52 +48,41 @@ def test_extra_args_preserved():
assert "--disable-gpu" in args assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args assert "--lang=ja-JP" in args
assert "--fingerprint-locale=ja-JP" in args
# --- _migrate_timezone_id deprecation compat --- # --- _resolve_timezone alias ---
def test_migrate_old_param_only(): def test_resolve_timezone_id_alias():
"""timezone_id in kwargs should be promoted to timezone.""" """timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"} kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone(None, kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result == "Europe/Paris" assert result == "Europe/Paris"
assert "timezone_id" not in kwargs assert "timezone_id" not in kwargs
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
def test_migrate_new_param_wins(): def test_resolve_timezone_wins_over_alias():
"""Explicit timezone takes precedence; timezone_id is still popped.""" """Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"} kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone("UTC", kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC" assert result == "UTC"
assert "timezone_id" not in kwargs assert "timezone_id" not in kwargs
assert len(w) == 1
def test_migrate_no_old_param(): def test_resolve_no_alias():
"""No warning when timezone_id is absent.""" """No-op when timezone_id is absent."""
kwargs = {"other": "value"} kwargs = {"other": "value"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone("UTC", kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC" assert result == "UTC"
assert "other" in kwargs assert "other" in kwargs
assert len(w) == 0
def test_migrate_both_none(): def test_resolve_both_none():
"""Neither param set — returns None, no warning.""" """Neither param set — returns None."""
kwargs = {} kwargs = {}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone(None, kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result is None assert result is None
assert len(w) == 0
# --- Deduplication tests --- # --- Deduplication tests ---
@@ -126,15 +117,18 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg(): def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg.""" """Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = _build_args( args = _build_args(
stealth_args=True, stealth_args=True,
extra_args=["--lang=de-DE"], extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US", locale="en-US",
) )
lang_args = [a for a in args if a.startswith("--lang=")] lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1 assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US" assert lang_args[0] == "--lang=en-US"
locale_args = [a for a in args if a.startswith("--fingerprint-locale=")]
assert len(locale_args) == 1
assert locale_args[0] == "--fingerprint-locale=en-US"
def test_no_duplicate_flags(): def test_no_duplicate_flags():
+23 -27
View File
@@ -1,6 +1,5 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup.""" """Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
import warnings
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
import pytest import pytest
@@ -66,7 +65,7 @@ def test_user_agent(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_locale_forwarded(mock_launch, _mock_bin): def test_locale_forwarded(mock_launch, _mock_bin):
"""locale flows to both launch() binary args AND new_context().""" """locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
@@ -75,18 +74,18 @@ def test_locale_forwarded(mock_launch, _mock_bin):
# Locale in launch() call (for --lang binary flag) # Locale in launch() call (for --lang binary flag)
assert mock_launch.call_args[1]["locale"] == "de-DE" assert mock_launch.call_args[1]["locale"] == "de-DE"
# Locale in new_context() call # NOT in new_context() — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["locale"] == "de-DE" assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_timezone_via_context_not_binary(mock_launch, _mock_bin): def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...). """timezone passed to launch() for binary flag, NOT to new_context() CDP.
This is intentional: the --fingerprint-timezone binary flag only applies to the --fingerprint-timezone is process-wide (reads CommandLine in renderer),
default context and would conflict with Playwright's timezone_id on new contexts. so it applies to ALL contexts, not just the default one.
""" """
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
@@ -94,11 +93,11 @@ def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
launch_context(timezone="America/New_York") launch_context(timezone="America/New_York")
# timezone=None in launch() — binary flag skipped # timezone in launch() — binary flag set
assert mock_launch.call_args[1]["timezone"] is None assert mock_launch.call_args[1]["timezone"] == "America/New_York"
# timezone_id in new_context() # NOT in new_context() — no CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "America/New_York" assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -119,40 +118,37 @@ def test_color_scheme(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
"""geoip fills timezone+locale, both flow to correct places.""" """geoip fills timezone+locale, both flow to binary args only."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
launch_context(proxy="http://proxy:8080", geoip=True) launch_context(proxy="http://proxy:8080", geoip=True)
# Locale goes to launch() for binary flag # Both go to launch() for binary flags
assert mock_launch.call_args[1]["locale"] == "de-DE" assert mock_launch.call_args[1]["locale"] == "de-DE"
# Timezone goes to context, not binary assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
assert mock_launch.call_args[1]["timezone"] is None # Neither in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin" assert "timezone_id" not in ctx_kwargs[1]
assert ctx_kwargs[1]["locale"] == "de-DE" assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_timezone_id_deprecation(mock_launch, _mock_bin): def test_timezone_id_alias(mock_launch, _mock_bin):
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone.""" """timezone_id kwarg accepted as alias for timezone."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_context(timezone_id="Europe/Paris") launch_context(timezone_id="Europe/Paris")
assert len(w) == 1 # Resolved value flows to launch() for binary flag
assert issubclass(w[0].category, FutureWarning) assert mock_launch.call_args[1]["timezone"] == "Europe/Paris"
assert "timezone_id" in str(w[0].message) # NOT in context — no CDP emulation
# Migrated value flows to context
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Paris" assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
+20 -23
View File
@@ -3,7 +3,6 @@
All tests mock playwright to avoid needing a binary. All tests mock playwright to avoid needing a binary.
""" """
import warnings
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -87,7 +86,7 @@ def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_locale_and_timezone(_mock_bin): def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args.""" """Timezone and locale flow to binary args only, NOT to CDP context kwargs."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -95,12 +94,12 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP") launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# Context kwargs # Binary args (native, undetectable)
assert call_kwargs["timezone_id"] == "Asia/Tokyo"
assert call_kwargs["locale"] == "ja-JP"
# Binary args
assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"] assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"]
assert "--lang=ja-JP" in call_kwargs["args"] assert "--lang=ja-JP" in call_kwargs["args"]
# NOT in context kwargs (would trigger detectable CDP emulation)
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -120,7 +119,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip): def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale.""" """geoip fills missing tz/locale — flows to binary args, not CDP context."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -128,25 +127,26 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True) launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Berlin" # Binary args
assert call_kwargs["locale"] == "de-DE" assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"]
assert "--lang=de-DE" in call_kwargs["args"]
# NOT in context kwargs
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_timezone_id_deprecation(_mock_bin): def test_persistent_context_timezone_id_alias(_mock_bin):
"""Old timezone_id kwarg migrated with warning.""" """timezone_id kwarg accepted as alias for timezone."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris") launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris" assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -246,17 +246,14 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
async def test_persistent_context_async_timezone_id_deprecation(_mock_bin): async def test_persistent_context_async_timezone_id_alias(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path.""" """timezone_id kwarg accepted as alias in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm): with patch("playwright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris") await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
assert len(w) == 1
assert issubclass(w[0].category, FutureWarning)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Paris" assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"]
assert "timezone_id" not in call_kwargs
+53
View File
@@ -98,3 +98,56 @@ class TestMaybeResolveGeoip:
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert locale == "ja-JP" assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized.
tz, locale = _maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
class TestBareProxyFormat:
"""_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme)."""
def test_bare_with_credentials(self):
r = _parse_proxy_url("user:pass@proxy:8080")
assert r["username"] == "user"
assert r["password"] == "pass"
assert r["server"] == "http://proxy:8080"
def test_bare_credentials_not_in_server(self):
r = _parse_proxy_url("user:pass@proxy1.example.com:5610")
assert "user" not in r["server"]
assert "pass" not in r["server"]
def test_bare_username_only(self):
r = _parse_proxy_url("user@proxy:8080")
assert r["username"] == "user"
assert "password" not in r
assert r["server"] == "http://proxy:8080"
def test_bare_no_port(self):
r = _parse_proxy_url("user:pass@proxy.example.com")
assert r["username"] == "user"
assert r["password"] == "pass"
assert r["server"] == "http://proxy.example.com"
def test_bare_no_credentials_passthrough(self):
# "host:port" without @ — no scheme, no creds — pass through unchanged
r = _parse_proxy_url("proxy:8080")
assert r == {"server": "proxy:8080"}
def test_build_proxy_kwargs_bare(self):
r = _build_proxy_kwargs("user:pass@proxy:8080")
assert r["proxy"]["username"] == "user"
assert r["proxy"]["password"] == "pass"
assert "user" not in r["proxy"]["server"]