fix: use binary flags for timezone/locale instead of detectable CDP emulation

- Remove locale and timezone_id from Playwright context kwargs (CDP)
- Pass timezone via --fingerprint-timezone binary flag (process-wide)
- Pass locale via --lang + --fingerprint-locale binary flags
- Accept both timezone and timezone_id param names silently (no deprecation)
- Update all wrapper tests to verify binary args, not CDP context params
This commit is contained in:
CloakHQ
2026-03-10 03:56:57 +01:00
parent 1fb554e061
commit 04255cf412
9 changed files with 135 additions and 152 deletions
+19 -29
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import logging import logging
import os import os
import warnings
from typing import Any, Literal, TypedDict from typing import Any, Literal, TypedDict
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
@@ -26,10 +25,9 @@ from .download import ensure_binary
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
def _migrate_timezone_id(timezone: str | None, kwargs: dict[str, Any]) -> str | None: def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
"""Pop deprecated timezone_id from kwargs, warn, return resolved timezone.""" """Accept both timezone and timezone_id — either works, no warning."""
if "timezone_id" in kwargs: if "timezone_id" in kwargs:
warnings.warn("timezone_id is deprecated, use timezone instead", FutureWarning, stacklevel=3)
if timezone is None: if timezone is None:
timezone = kwargs.pop("timezone_id") timezone = kwargs.pop("timezone_id")
else: else:
@@ -279,7 +277,7 @@ def launch_persistent_context(
""" """
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) sync_playwright = _import_sync_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -291,14 +289,12 @@ def launch_persistent_context(
user_data_dir, user_data_dir,
) )
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -394,7 +390,7 @@ async def launch_persistent_context_async(
""" """
async_playwright = _import_async_playwright(_resolve_backend(backend)) async_playwright = _import_async_playwright(_resolve_backend(backend))
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
@@ -406,14 +402,12 @@ async def launch_persistent_context_async(
user_data_dir, user_data_dir,
) )
# locale and timezone are set via binary flags (--lang, --fingerprint-timezone)
# — NOT via Playwright context kwargs which use detectable CDP emulation.
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -491,25 +485,21 @@ def launch_context(
Returns: Returns:
Playwright BrowserContext object. Playwright BrowserContext object.
""" """
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to both binary flags AND context params # resolved values flow to binary flags
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale)
# Skip --fingerprint-timezone binary flag: it only applies to the default # --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# context and interferes with Playwright's timezone_id on new contexts. # so it applies to ALL contexts, not just the default one.
# Timezone is set via browser.new_context(timezone_id=...) below instead. # locale and timezone are set via binary flags only — no CDP emulation.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=None, locale=locale, backend=backend) timezone=timezone, locale=locale, backend=backend)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT context_kwargs["viewport"] = viewport or DEFAULT_VIEWPORT
if locale:
context_kwargs["locale"] = locale
if timezone:
context_kwargs["timezone_id"] = timezone
if color_scheme: if color_scheme:
context_kwargs["color_scheme"] = color_scheme context_kwargs["color_scheme"] = color_scheme
context_kwargs.update(kwargs) context_kwargs.update(kwargs)
@@ -646,11 +636,11 @@ 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)
seen[key] = flag seen[key] = flag
return list(seen.values()) return list(seen.values())
+6 -5
View File
@@ -38,12 +38,13 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(key, flag); seen.set(key, flag);
} }
if (options.locale) { if (options.locale) {
const key = "--lang"; for (const k of ["--lang", "--fingerprint-locale"] as const) {
const flag = `${key}=${options.locale}`; const flag = `${k}=${options.locale}`;
if (seen.has(key)) { if (seen.has(k)) {
if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(key)} -> ${flag}`); if (DEBUG) console.debug(`[cloakbrowser] Arg override: ${seen.get(k)} -> ${flag}`);
}
seen.set(k, flag);
} }
seen.set(key, flag);
} }
return [...seen.values()]; return [...seen.values()];
} }
+10 -13
View File
@@ -11,10 +11,9 @@ import { ensureBinary } from "./download.js";
import { parseProxyUrl } from "./proxy.js"; import { parseProxyUrl } from "./proxy.js";
import { maybeResolveGeoip } from "./geoip.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;
@@ -86,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) {
@@ -153,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,
@@ -170,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,
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ export interface LaunchContextOptions extends LaunchOptions {
viewport?: { width: number; height: number }; viewport?: { width: number; height: number };
/** Browser locale, e.g. "en-US". */ /** Browser locale, e.g. "en-US". */
locale?: string; locale?: string;
/** @deprecated Use `timezone` (inherited from LaunchOptions) instead. */ /** IANA timezone — alias for `timezone`. Either works. */
timezoneId?: string; timezoneId?: string;
/** Color scheme preference — 'light', 'dark', or 'no-preference'. */ /** Color scheme preference — 'light', 'dark', or 'no-preference'. */
colorScheme?: "light" | "dark" | "no-preference"; colorScheme?: "light" | "dark" | "no-preference";
+17 -10
View File
@@ -9,7 +9,7 @@ import {
getDownloadUrl, getDownloadUrl,
getFallbackDownloadUrl, getFallbackDownloadUrl,
} from "../src/config.js"; } from "../src/config.js";
import { _buildArgsForTest, migrateTimezoneId } from "../src/playwright.js"; import { _buildArgsForTest, resolveTimezone } from "../src/playwright.js";
describe("config", () => { describe("config", () => {
it("CHROMIUM_VERSION matches expected format", () => { it("CHROMIUM_VERSION matches expected format", () => {
@@ -97,21 +97,24 @@ describe("buildArgs timezone/locale", () => {
expect(args).toContain("--fingerprint-timezone=America/New_York"); expect(args).toContain("--fingerprint-timezone=America/New_York");
}); });
it("injects --lang when locale is set", () => { it("injects --lang and --fingerprint-locale when locale is set", () => {
const args = _buildArgsForTest({ locale: "en-US" }); const args = _buildArgsForTest({ locale: "en-US" });
expect(args).toContain("--lang=en-US"); expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
}); });
it("injects both when both are set", () => { it("injects both when both are set", () => {
const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" }); const args = _buildArgsForTest({ timezone: "Europe/Berlin", locale: "de-DE" });
expect(args).toContain("--fingerprint-timezone=Europe/Berlin"); expect(args).toContain("--fingerprint-timezone=Europe/Berlin");
expect(args).toContain("--lang=de-DE"); expect(args).toContain("--lang=de-DE");
expect(args).toContain("--fingerprint-locale=de-DE");
}); });
it("injects timezone/locale even when stealthArgs=false", () => { it("injects timezone/locale even when stealthArgs=false", () => {
const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" }); const args = _buildArgsForTest({ stealthArgs: false, timezone: "America/New_York", locale: "en-US" });
expect(args).toContain("--fingerprint-timezone=America/New_York"); expect(args).toContain("--fingerprint-timezone=America/New_York");
expect(args).toContain("--lang=en-US"); expect(args).toContain("--lang=en-US");
expect(args).toContain("--fingerprint-locale=en-US");
expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false); expect(args.some(a => a.startsWith("--fingerprint="))).toBe(false);
}); });
@@ -119,6 +122,7 @@ describe("buildArgs timezone/locale", () => {
const args = _buildArgsForTest({}); const args = _buildArgsForTest({});
expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false); expect(args.some(a => a.startsWith("--fingerprint-timezone="))).toBe(false);
expect(args.some(a => a.startsWith("--lang="))).toBe(false); expect(args.some(a => a.startsWith("--lang="))).toBe(false);
expect(args.some(a => a.startsWith("--fingerprint-locale="))).toBe(false);
}); });
}); });
@@ -147,14 +151,17 @@ describe("buildArgs deduplication", () => {
expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York"); expect(tzArgs[0]).toBe("--fingerprint-timezone=America/New_York");
}); });
it("locale param overrides user --lang arg", () => { it("locale param overrides user --lang and --fingerprint-locale args", () => {
const args = _buildArgsForTest({ const args = _buildArgsForTest({
args: ["--lang=de-DE"], args: ["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale: "en-US", locale: "en-US",
}); });
const langArgs = args.filter(a => a.startsWith("--lang=")); const langArgs = args.filter(a => a.startsWith("--lang="));
expect(langArgs).toHaveLength(1); expect(langArgs).toHaveLength(1);
expect(langArgs[0]).toBe("--lang=en-US"); expect(langArgs[0]).toBe("--lang=en-US");
const localeArgs = args.filter(a => a.startsWith("--fingerprint-locale="));
expect(localeArgs).toHaveLength(1);
expect(localeArgs[0]).toBe("--fingerprint-locale=en-US");
}); });
it("no duplicate flag keys in output", () => { it("no duplicate flag keys in output", () => {
@@ -175,29 +182,29 @@ describe("buildArgs deduplication", () => {
}); });
}); });
describe("migrateTimezoneId deprecation", () => { describe("resolveTimezone alias", () => {
it("migrates timezoneId to timezone", () => { it("resolves timezoneId to timezone", () => {
const result = migrateTimezoneId({ timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("Europe/Paris"); expect(result.timezone).toBe("Europe/Paris");
expect(result).not.toHaveProperty("timezoneId"); expect(result).not.toHaveProperty("timezoneId");
}); });
it("preserves explicit timezone over timezoneId", () => { it("preserves explicit timezone over timezoneId", () => {
const result = migrateTimezoneId({ timezone: "UTC", timezoneId: "Europe/Paris" }); const result = resolveTimezone({ timezone: "UTC", timezoneId: "Europe/Paris" });
expect(result.timezone).toBe("UTC"); expect(result.timezone).toBe("UTC");
expect(result).not.toHaveProperty("timezoneId"); expect(result).not.toHaveProperty("timezoneId");
}); });
it("returns options unchanged when no timezoneId", () => { it("returns options unchanged when no timezoneId", () => {
const opts = { timezone: "UTC" }; const opts = { timezone: "UTC" };
const result = migrateTimezoneId(opts); const result = resolveTimezone(opts);
expect(result).toBe(opts); // same reference, no copy expect(result).toBe(opts); // same reference, no copy
expect(result.timezone).toBe("UTC"); expect(result.timezone).toBe("UTC");
}); });
it("returns options unchanged when neither is set", () => { it("returns options unchanged when neither is set", () => {
const opts = {}; const opts = {};
const result = migrateTimezoneId(opts); const result = resolveTimezone(opts);
expect(result).toBe(opts); expect(result).toBe(opts);
}); });
}); });
+11 -10
View File
@@ -96,20 +96,20 @@ describe("launchContext (unit)", () => {
expect(ctxArgs.userAgent).toBe("Custom/1.0"); expect(ctxArgs.userAgent).toBe("Custom/1.0");
}); });
it("passes timezone to context timezoneId, not to launch", async () => { it("passes timezone via binary flag, not CDP context", async () => {
const { launchContext } = await import("../src/playwright.js"); const { launchContext } = await import("../src/playwright.js");
await launchContext({ timezone: "America/New_York" }); await launchContext({ timezone: "America/New_York" });
// launch() called with timezone: undefined (skipped for binary flag) // launch() called with --fingerprint-timezone binary flag
const launchArgs = mockChromium.launch.mock.calls[0][0]; const launchArgs = mockChromium.launch.mock.calls[0][0];
const hasTimezoneFlag = launchArgs.args.some((a: string) => const hasTimezoneFlag = launchArgs.args.some((a: string) =>
a.startsWith("--fingerprint-timezone=") a.startsWith("--fingerprint-timezone=America/New_York")
); );
expect(hasTimezoneFlag).toBe(false); expect(hasTimezoneFlag).toBe(true);
// newContext() gets timezoneId // NOT in newContext() — no CDP emulation
const ctxArgs = mockBrowser.newContext.mock.calls[0][0]; const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
expect(ctxArgs.timezoneId).toBe("America/New_York"); expect(ctxArgs.timezoneId).toBeUndefined();
}); });
it("forwards colorScheme to newContext", async () => { it("forwards colorScheme to newContext", async () => {
@@ -165,7 +165,7 @@ describe("launchPersistentContext (unit)", () => {
expect(args.viewport).toEqual(DEFAULT_VIEWPORT); expect(args.viewport).toEqual(DEFAULT_VIEWPORT);
}); });
it("passes timezone and locale to context", async () => { it("passes timezone and locale via binary args, not CDP context", async () => {
const { launchPersistentContext } = await import("../src/playwright.js"); const { launchPersistentContext } = await import("../src/playwright.js");
await launchPersistentContext({ await launchPersistentContext({
userDataDir: "/tmp/profile", userDataDir: "/tmp/profile",
@@ -174,11 +174,12 @@ describe("launchPersistentContext (unit)", () => {
}); });
const args = mockChromium.launchPersistentContext.mock.calls[0][1]; const args = mockChromium.launchPersistentContext.mock.calls[0][1];
expect(args.timezoneId).toBe("Asia/Tokyo"); // Binary args (native, undetectable)
expect(args.locale).toBe("ja-JP");
// Also in binary args
expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo"); expect(args.args).toContain("--fingerprint-timezone=Asia/Tokyo");
expect(args.args).toContain("--lang=ja-JP"); expect(args.args).toContain("--lang=ja-JP");
// NOT in context kwargs (would trigger detectable CDP emulation)
expect(args.timezoneId).toBeUndefined();
expect(args.locale).toBeUndefined();
}); });
it("forwards proxy string", async () => { it("forwards proxy string", async () => {
+25 -31
View File
@@ -1,8 +1,6 @@
"""Unit tests for _build_args timezone/locale injection and deprecation compat.""" """Unit tests for _build_args timezone/locale injection and timezone alias."""
import warnings from cloakbrowser.browser import _build_args, _resolve_timezone
from cloakbrowser.browser import _build_args, _migrate_timezone_id
def test_timezone_injected(): def test_timezone_injected():
@@ -12,9 +10,10 @@ def test_timezone_injected():
def test_locale_injected(): def test_locale_injected():
"""--lang flag should appear when locale is set.""" """--lang and --fingerprint-locale flags should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US") args = _build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
def test_both_injected(): def test_both_injected():
@@ -22,6 +21,7 @@ def test_both_injected():
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE") args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args
def test_timezone_independent_of_stealth_args(): def test_timezone_independent_of_stealth_args():
@@ -29,15 +29,17 @@ def test_timezone_independent_of_stealth_args():
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US") args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args
# No stealth fingerprint args # No stealth fingerprint args
assert not any(a.startswith("--fingerprint=") for a in args) assert not any(a.startswith("--fingerprint=") for a in args)
def test_no_flags_when_not_set(): def test_no_flags_when_not_set():
"""No timezone/lang flags when params are None.""" """No timezone/lang/fingerprint-locale flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None) args = _build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args) assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args) assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args)
def test_extra_args_preserved(): def test_extra_args_preserved():
@@ -46,52 +48,41 @@ def test_extra_args_preserved():
assert "--disable-gpu" in args assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args assert "--lang=ja-JP" in args
assert "--fingerprint-locale=ja-JP" in args
# --- _migrate_timezone_id deprecation compat --- # --- _resolve_timezone alias ---
def test_migrate_old_param_only(): def test_resolve_timezone_id_alias():
"""timezone_id in kwargs should be promoted to timezone.""" """timezone_id in kwargs should be promoted to timezone."""
kwargs = {"timezone_id": "Europe/Paris"} kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone(None, kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result == "Europe/Paris" assert result == "Europe/Paris"
assert "timezone_id" not in kwargs assert "timezone_id" not in kwargs
assert len(w) == 1 and issubclass(w[0].category, FutureWarning)
def test_migrate_new_param_wins(): def test_resolve_timezone_wins_over_alias():
"""Explicit timezone takes precedence; timezone_id is still popped.""" """Explicit timezone takes precedence; timezone_id is still popped."""
kwargs = {"timezone_id": "Europe/Paris"} kwargs = {"timezone_id": "Europe/Paris"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone("UTC", kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC" assert result == "UTC"
assert "timezone_id" not in kwargs assert "timezone_id" not in kwargs
assert len(w) == 1
def test_migrate_no_old_param(): def test_resolve_no_alias():
"""No warning when timezone_id is absent.""" """No-op when timezone_id is absent."""
kwargs = {"other": "value"} kwargs = {"other": "value"}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone("UTC", kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id("UTC", kwargs)
assert result == "UTC" assert result == "UTC"
assert "other" in kwargs assert "other" in kwargs
assert len(w) == 0
def test_migrate_both_none(): def test_resolve_both_none():
"""Neither param set — returns None, no warning.""" """Neither param set — returns None."""
kwargs = {} kwargs = {}
with warnings.catch_warnings(record=True) as w: result = _resolve_timezone(None, kwargs)
warnings.simplefilter("always")
result = _migrate_timezone_id(None, kwargs)
assert result is None assert result is None
assert len(w) == 0
# --- Deduplication tests --- # --- Deduplication tests ---
@@ -126,15 +117,18 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg(): def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang arg.""" """Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = _build_args( args = _build_args(
stealth_args=True, stealth_args=True,
extra_args=["--lang=de-DE"], extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US", locale="en-US",
) )
lang_args = [a for a in args if a.startswith("--lang=")] lang_args = [a for a in args if a.startswith("--lang=")]
assert len(lang_args) == 1 assert len(lang_args) == 1
assert lang_args[0] == "--lang=en-US" assert lang_args[0] == "--lang=en-US"
locale_args = [a for a in args if a.startswith("--fingerprint-locale=")]
assert len(locale_args) == 1
assert locale_args[0] == "--fingerprint-locale=en-US"
def test_no_duplicate_flags(): def test_no_duplicate_flags():
+24 -28
View File
@@ -1,6 +1,5 @@
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup.""" """Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
import warnings
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
import pytest import pytest
@@ -66,7 +65,7 @@ def test_user_agent(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_locale_forwarded(mock_launch, _mock_bin): def test_locale_forwarded(mock_launch, _mock_bin):
"""locale flows to both launch() binary args AND new_context().""" """locale flows to launch() for --lang binary flag, NOT to new_context() CDP."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
@@ -75,18 +74,18 @@ def test_locale_forwarded(mock_launch, _mock_bin):
# Locale in launch() call (for --lang binary flag) # Locale in launch() call (for --lang binary flag)
assert mock_launch.call_args[1]["locale"] == "de-DE" assert mock_launch.call_args[1]["locale"] == "de-DE"
# Locale in new_context() call # NOT in new_context() — would trigger detectable CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["locale"] == "de-DE" assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_timezone_via_context_not_binary(mock_launch, _mock_bin): def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin):
"""timezone passed to new_context(timezone_id=...) but NOT to launch(timezone=...). """timezone passed to launch() for binary flag, NOT to new_context() CDP.
This is intentional: the --fingerprint-timezone binary flag only applies to the --fingerprint-timezone is process-wide (reads CommandLine in renderer),
default context and would conflict with Playwright's timezone_id on new contexts. so it applies to ALL contexts, not just the default one.
""" """
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
@@ -94,11 +93,11 @@ def test_timezone_via_context_not_binary(mock_launch, _mock_bin):
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
launch_context(timezone="America/New_York") launch_context(timezone="America/New_York")
# timezone=None in launch() — binary flag skipped # timezone in launch() — binary flag set
assert mock_launch.call_args[1]["timezone"] is None assert mock_launch.call_args[1]["timezone"] == "America/New_York"
# timezone_id in new_context() # NOT in new_context() — no CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "America/New_York" assert "timezone_id" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -119,40 +118,37 @@ def test_color_scheme(mock_launch, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
"""geoip fills timezone+locale, both flow to correct places.""" """geoip fills timezone+locale, both flow to binary args only."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
launch_context(proxy="http://proxy:8080", geoip=True) launch_context(proxy="http://proxy:8080", geoip=True)
# Locale goes to launch() for binary flag # Both go to launch() for binary flags
assert mock_launch.call_args[1]["locale"] == "de-DE" assert mock_launch.call_args[1]["locale"] == "de-DE"
# Timezone goes to context, not binary assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin"
assert mock_launch.call_args[1]["timezone"] is None # Neither in context — no CDP emulation
ctx_kwargs = browser.new_context.call_args ctx_kwargs = browser.new_context.call_args
assert ctx_kwargs[1]["timezone_id"] == "Europe/Berlin" assert "timezone_id" not in ctx_kwargs[1]
assert ctx_kwargs[1]["locale"] == "de-DE" assert "locale" not in ctx_kwargs[1]
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_timezone_id_deprecation(mock_launch, _mock_bin): def test_timezone_id_alias(mock_launch, _mock_bin):
"""timezone_id kwarg triggers FutureWarning, value migrated to timezone.""" """timezone_id kwarg accepted as alias for timezone."""
browser, context = _make_mock_browser() browser, context = _make_mock_browser()
mock_launch.return_value = browser mock_launch.return_value = browser
from cloakbrowser.browser import launch_context from cloakbrowser.browser import launch_context
with warnings.catch_warnings(record=True) as w: launch_context(timezone_id="Europe/Paris")
warnings.simplefilter("always")
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")
+22 -25
View File
@@ -3,7 +3,6 @@
All tests mock playwright to avoid needing a binary. All tests mock playwright to avoid needing a binary.
""" """
import warnings
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -87,7 +86,7 @@ def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_locale_and_timezone(_mock_bin): def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args.""" """Timezone and locale flow to binary args only, NOT to CDP context kwargs."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -95,12 +94,12 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP") launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
# Context kwargs # Binary args (native, undetectable)
assert call_kwargs["timezone_id"] == "Asia/Tokyo"
assert call_kwargs["locale"] == "ja-JP"
# Binary args
assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"] assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"]
assert "--lang=ja-JP" in call_kwargs["args"] assert "--lang=ja-JP" in call_kwargs["args"]
# NOT in context kwargs (would trigger detectable CDP emulation)
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@@ -120,7 +119,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip): def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale.""" """geoip fills missing tz/locale — flows to binary args, not CDP context."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
@@ -128,25 +127,26 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True) launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
call_kwargs = pw.chromium.launch_persistent_context.call_args[1] call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
assert call_kwargs["timezone_id"] == "Europe/Berlin" # Binary args
assert call_kwargs["locale"] == "de-DE" assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"]
assert "--lang=de-DE" in call_kwargs["args"]
# NOT in context kwargs
assert "timezone_id" not in call_kwargs
assert "locale" not in call_kwargs
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_timezone_id_deprecation(_mock_bin): def test_persistent_context_timezone_id_alias(_mock_bin):
"""Old timezone_id kwarg migrated with warning.""" """timezone_id kwarg accepted as alias for timezone."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w: launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris")
warnings.simplefilter("always")
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: await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris")
warnings.simplefilter("always")
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