feat: support proxy dict with bypass field (#24)

The `proxy` parameter now accepts a Playwright proxy dict
({server, bypass, username, password}) in addition to URL strings.
Dict proxies are passed directly to Playwright, enabling bypass
lists and other advanced proxy options.

- Add ProxySettings TypedDict for Python type safety
- Extract server URL from dict proxies for geoip resolution
- Handle dict proxy args/auth in Puppeteer wrapper
- Strip inline credentials from dict proxy server URL in Puppeteer
- Fix JS launchContext() double-setting timezone (binary flag + context)
- Remove unnecessary non-null assertions in TS geoip helpers
- Use nullish coalescing for password fallbacks
- Add unit tests for geoip with dict proxy input
This commit is contained in:
Cloak-HQ
2026-03-04 21:16:35 +01:00
parent ca5cce2222
commit bd22e51bc2
12 changed files with 223 additions and 30 deletions
+4
View File
@@ -6,6 +6,10 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [0.3.6] — 2026-03-04
- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check
## [0.3.5] — 2026-03-04 ## [0.3.5] — 2026-03-04
- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17) - **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17)
+31
View File
@@ -203,6 +203,9 @@ browser = launch(headless=False)
# With proxy # With proxy
browser = launch(proxy="http://user:pass@proxy:8080") browser = launch(proxy="http://user:pass@proxy:8080")
# With proxy dict (bypass, separate auth fields)
browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"})
# With extra Chrome args # With extra Chrome args
browser = launch(args=["--disable-gpu"]) browser = launch(args=["--disable-gpu"])
@@ -259,6 +262,12 @@ context.close()
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan. Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan.
Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts)
- **Bypass incognito detection** (some sites flag empty, ephemeral profiles)
- **Load Chrome extensions** (extensions only work from a real user data dir)
- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic)
```python ```python
from cloakbrowser import launch_persistent_context from cloakbrowser import launch_persistent_context
@@ -615,6 +624,28 @@ patchright install-deps chromium
The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list). The macOS fingerprint profile has known inconsistencies that aggressive bot detection catches. If a site blocks you on macOS but works on Linux, switch to a Windows fingerprint profile by passing `stealth_args=False` and manually setting `--fingerprint-platform=windows` with matching GPU flags (see [Fingerprint Management](#fingerprint-management) for the full flag list).
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes:
```python
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
```
```javascript
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)** **reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead: Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
+2 -1
View File
@@ -11,7 +11,7 @@ Usage:
browser.close() browser.close()
""" """
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings
from .config import CHROMIUM_VERSION, get_default_stealth_args from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__ from ._version import __version__
@@ -28,5 +28,6 @@ __all__ = [
"check_for_update", "check_for_update",
"CHROMIUM_VERSION", "CHROMIUM_VERSION",
"get_default_stealth_args", "get_default_stealth_args",
"ProxySettings",
"__version__", "__version__",
] ]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.5" __version__ = "0.3.6"
+34 -14
View File
@@ -16,7 +16,7 @@ from __future__ import annotations
import logging import logging
import os import os
from typing import Any, Literal from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from .config import DEFAULT_VIEWPORT, get_default_stealth_args from .config import DEFAULT_VIEWPORT, get_default_stealth_args
@@ -25,9 +25,21 @@ from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
class _ProxySettingsRequired(TypedDict):
server: str
class ProxySettings(_ProxySettingsRequired, total=False):
"""Playwright-compatible proxy configuration."""
bypass: str
username: str
password: str
def launch( def launch(
headless: bool = True, headless: bool = True,
proxy: str | None = None, proxy: str | ProxySettings | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None, timezone: str | None = None,
@@ -39,7 +51,10 @@ def launch(
Args: Args:
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). proxy: Proxy URL string or Playwright proxy dict.
String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...}
passed directly to Playwright.
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
Set to False if you want to pass your own --fingerprint flags. Set to False if you want to pass your own --fingerprint flags.
@@ -94,7 +109,7 @@ def launch(
async def launch_async( async def launch_async(
headless: bool = True, headless: bool = True,
proxy: str | None = None, proxy: str | ProxySettings | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
timezone: str | None = None, timezone: str | None = None,
@@ -106,7 +121,7 @@ async def launch_async(
Args: Args:
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments to pass. args: Additional Chromium CLI arguments to pass.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag. timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
@@ -163,7 +178,7 @@ async def launch_async(
def launch_persistent_context( def launch_persistent_context(
user_data_dir: str | os.PathLike, user_data_dir: str | os.PathLike,
headless: bool = True, headless: bool = True,
proxy: str | None = None, proxy: str | ProxySettings | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
@@ -185,7 +200,7 @@ def launch_persistent_context(
Created automatically if it doesn't exist. Reuse the same path across Created automatically if it doesn't exist. Reuse the same path across
sessions to restore cookies, localStorage, cached credentials, etc. sessions to restore cookies, localStorage, cached credentials, etc.
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments. args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
@@ -259,7 +274,7 @@ def launch_persistent_context(
async def launch_persistent_context_async( async def launch_persistent_context_async(
user_data_dir: str | os.PathLike, user_data_dir: str | os.PathLike,
headless: bool = True, headless: bool = True,
proxy: str | None = None, proxy: str | ProxySettings | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
@@ -280,7 +295,7 @@ async def launch_persistent_context_async(
user_data_dir: Path to the directory where browser profile data is stored. user_data_dir: Path to the directory where browser profile data is stored.
Created automatically if it doesn't exist. Created automatically if it doesn't exist.
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy server URL (e.g. 'http://proxy:8080' or 'socks5://proxy:1080'). proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments. args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
@@ -356,7 +371,7 @@ async def launch_persistent_context_async(
def launch_context( def launch_context(
headless: bool = True, headless: bool = True,
proxy: str | None = None, proxy: str | ProxySettings | None = None,
args: list[str] | None = None, args: list[str] | None = None,
stealth_args: bool = True, stealth_args: bool = True,
user_agent: str | None = None, user_agent: str | None = None,
@@ -374,7 +389,7 @@ def launch_context(
Args: Args:
headless: Run in headless mode (default True). headless: Run in headless mode (default True).
proxy: Proxy server URL. proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
args: Additional Chromium CLI arguments. args: Additional Chromium CLI arguments.
stealth_args: Include default stealth fingerprint args (default True). stealth_args: Include default stealth fingerprint args (default True).
user_agent: Custom user agent string. user_agent: Custom user agent string.
@@ -436,7 +451,7 @@ def launch_context(
def _maybe_resolve_geoip( def _maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | None, proxy: str | ProxySettings | None,
timezone: str | None, timezone: str | None,
locale: str | None, locale: str | None,
) -> tuple[str | None, str | None]: ) -> tuple[str | None, str | None]:
@@ -446,7 +461,10 @@ def _maybe_resolve_geoip(
from .geoip import resolve_proxy_geo from .geoip import resolve_proxy_geo
geo_tz, geo_locale = resolve_proxy_geo(proxy) proxy_url = proxy.get("server") if isinstance(proxy, dict) else proxy
if not proxy_url:
return timezone, locale
geo_tz, geo_locale = resolve_proxy_geo(proxy_url)
if timezone is None: if timezone is None:
timezone = geo_tz timezone = geo_tz
if locale is None: if locale is None:
@@ -500,8 +518,10 @@ def _parse_proxy_url(proxy: str) -> dict[str, Any]:
return result return result
def _build_proxy_kwargs(proxy: str | None) -> dict[str, Any]: def _build_proxy_kwargs(proxy: str | ProxySettings | None) -> dict[str, Any]:
"""Build proxy kwargs for Playwright launch.""" """Build proxy kwargs for Playwright launch."""
if proxy is None: if proxy is None:
return {} return {}
if isinstance(proxy, dict):
return {"proxy": proxy}
return {"proxy": _parse_proxy_url(proxy)} return {"proxy": _parse_proxy_url(proxy)}
+21 -1
View File
@@ -67,6 +67,11 @@ const browser = await launch({
proxy: 'http://user:pass@proxy:8080', proxy: 'http://user:pass@proxy:8080',
}); });
// With proxy object (bypass, separate auth fields)
const browser = await launch({
proxy: { server: 'http://proxy:8080', bypass: '.google.com', username: 'user', password: 'pass' },
});
// Headed mode (visible browser window) // Headed mode (visible browser window)
const browser = await launch({ headless: false }); const browser = await launch({ headless: false });
@@ -95,7 +100,7 @@ const context = await launchContext({
timezoneId: 'America/New_York', timezoneId: 'America/New_York',
}); });
// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection // Persistent profile — stay logged in, bypass incognito detection, load extensions
const ctx = await launchPersistentContext({ const ctx = await launchPersistentContext({
userDataDir: './chrome-profile', userDataDir: './chrome-profile',
headless: false, headless: false,
@@ -195,6 +200,21 @@ const page = await browser.newPage();
## Troubleshooting ## Troubleshooting
**Site detects incognito / private browsing mode**
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launchPersistentContext()` instead — it runs with a real user profile:
```javascript
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
**reCAPTCHA v3 scores are low (0.10.3)** **reCAPTCHA v3 scores are low (0.10.3)**
Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead: Avoid `page.waitForTimeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.5", "version": "0.3.6",
"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",
+13 -4
View File
@@ -34,7 +34,9 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: ["--enable-automation"], ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}), ...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...options.launchOptions, ...options.launchOptions,
}); });
@@ -62,7 +64,10 @@ export async function launchContext(
): Promise<BrowserContext> { ): Promise<BrowserContext> {
// 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);
const browser = await launch({ ...options, ...resolved, geoip: false }); // Skip --fingerprint-timezone binary flag: it only applies to the default
// context and interferes with Playwright's timezoneId on new contexts.
// Timezone is set via browser.newContext(timezoneId: ...) below instead.
const browser = await launch({ ...options, ...resolved, geoip: false, timezone: undefined });
let context: BrowserContext; let context: BrowserContext;
try { try {
@@ -123,7 +128,9 @@ export async function launchPersistentContext(
headless: options.headless ?? true, headless: options.headless ?? true,
args, args,
ignoreDefaultArgs: ["--enable-automation"], ignoreDefaultArgs: ["--enable-automation"],
...(options.proxy ? { proxy: parseProxyUrl(options.proxy) } : {}), ...(options.proxy
? { proxy: typeof options.proxy === "string" ? parseProxyUrl(options.proxy) : options.proxy }
: {}),
...(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.locale ? { locale: resolved.locale } : {}),
@@ -146,7 +153,9 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) 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 { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy); 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 { return {
timezone: options.timezone ?? geoTz ?? undefined, timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined, locale: options.locale ?? geoLocale ?? undefined,
+23 -5
View File
@@ -34,10 +34,26 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
// so we strip them and use page.authenticate() instead. // so we strip them and use page.authenticate() instead.
let proxyAuth: { username: string; password: string } | undefined; let proxyAuth: { username: string; password: string } | undefined;
if (options.proxy) { if (options.proxy) {
const { server, username, password } = parseProxyUrl(options.proxy); if (typeof options.proxy === "string") {
args.push(`--proxy-server=${server}`); const { server, username, password } = parseProxyUrl(options.proxy);
if (username) { args.push(`--proxy-server=${server}`);
proxyAuth = { username, password: password || "" }; if (username) {
proxyAuth = { username, password: password ?? "" };
}
} else {
// Strip any inline credentials from the server URL — Chromium's
// --proxy-server doesn't support them; use page.authenticate() instead.
const parsed = parseProxyUrl(options.proxy.server);
args.push(`--proxy-server=${parsed.server}`);
if (options.proxy.bypass) {
args.push(`--proxy-bypass-list=${options.proxy.bypass}`);
}
// Explicit username/password fields take precedence over inline creds
const username = options.proxy.username ?? parsed.username;
const password = options.proxy.password ?? parsed.password;
if (username) {
proxyAuth = { username, password: password ?? "" };
}
} }
} }
@@ -74,7 +90,9 @@ async function maybeResolveGeoip(
if (options.timezone && options.locale) 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 { resolveProxyGeo } = await import("./geoip.js");
const { timezone: geoTz, locale: geoLocale } = await resolveProxyGeo(options.proxy); 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 { return {
timezone: options.timezone ?? geoTz ?? undefined, timezone: options.timezone ?? geoTz ?? undefined,
locale: options.locale ?? geoLocale ?? undefined, locale: options.locale ?? geoLocale ?? undefined,
+7 -2
View File
@@ -5,8 +5,13 @@
export interface LaunchOptions { export interface LaunchOptions {
/** Run in headless mode (default: true). */ /** Run in headless mode (default: true). */
headless?: boolean; headless?: boolean;
/** Proxy server URL, e.g. 'http://proxy:8080' or 'socks5://proxy:1080'. */ /**
proxy?: string; * Proxy server URL string or Playwright proxy object.
* String: 'http://user:pass@proxy:8080' (credentials auto-extracted).
* Object: { server: "http://proxy:8080", bypass: ".google.com", ... }
* passed directly to Playwright.
*/
proxy?: string | { server: string; bypass?: string; username?: string; password?: string };
/** Additional Chromium CLI arguments. */ /** Additional Chromium CLI arguments. */
args?: string[]; args?: string[];
/** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */ /** Include default stealth fingerprint args (default: true). Set false to use custom --fingerprint flags. */
+35
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseProxyUrl } from "../src/proxy.js"; import { parseProxyUrl } from "../src/proxy.js";
import type { LaunchOptions } from "../src/types.js";
describe("parseProxyUrl", () => { describe("parseProxyUrl", () => {
it("passes through URL without credentials", () => { it("passes through URL without credentials", () => {
@@ -47,3 +48,37 @@ describe("parseProxyUrl", () => {
expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" }); expect(parseProxyUrl("not-a-url")).toEqual({ server: "not-a-url" });
}); });
}); });
describe("proxy dict type", () => {
it("accepts string proxy in LaunchOptions", () => {
const opts: LaunchOptions = { proxy: "http://proxy:8080" };
expect(typeof opts.proxy).toBe("string");
});
it("accepts dict proxy with bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: { server: "http://proxy:8080", bypass: ".google.com,localhost" },
};
expect(typeof opts.proxy).toBe("object");
if (typeof opts.proxy === "object") {
expect(opts.proxy.server).toBe("http://proxy:8080");
expect(opts.proxy.bypass).toBe(".google.com,localhost");
}
});
it("accepts dict proxy with auth and bypass in LaunchOptions", () => {
const opts: LaunchOptions = {
proxy: {
server: "http://proxy:8080",
username: "user",
password: "pass",
bypass: ".example.com",
},
};
if (typeof opts.proxy === "object") {
expect(opts.proxy.username).toBe("user");
expect(opts.proxy.password).toBe("pass");
expect(opts.proxy.bypass).toBe(".example.com");
}
});
});
+51 -1
View File
@@ -1,6 +1,8 @@
"""Tests for proxy URL parsing and credential extraction.""" """Tests for proxy URL parsing and credential extraction."""
from cloakbrowser.browser import _build_proxy_kwargs, _parse_proxy_url from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url
class TestParseProxyUrl: class TestParseProxyUrl:
@@ -48,3 +50,51 @@ class TestBuildProxyKwargs:
assert result == { assert result == {
"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"} "proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}
} }
def test_proxy_dict_passthrough(self):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"}
result = _build_proxy_kwargs(proxy_dict)
assert result == {"proxy": proxy_dict}
def test_proxy_dict_with_auth(self):
proxy_dict = {
"server": "http://proxy:8080",
"username": "user",
"password": "pass",
"bypass": ".example.com",
}
result = _build_proxy_kwargs(proxy_dict)
assert result == {"proxy": proxy_dict}
class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_with_string_proxy(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York"
assert locale == "en-US"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London"
assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None
assert locale is None
def test_geoip_no_proxy_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(True, None, None, None)
assert tz is None
assert locale is None
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin"
assert locale == "ja-JP"