mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767eb16a82 | ||
|
|
04255cf412 | ||
|
|
1fb554e061 | ||
|
|
748013bf83 | ||
|
|
eeea366047 |
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
__version__ = "0.3.11"
|
__version__ = "0.3.12"
|
||||||
|
|||||||
+30
-28
@@ -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 ""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user