mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
feat: add launch_context_async() + JS contextOptions escape hatch (#141)
Python: add async counterpart to launch_context(). Forwards all kwargs to browser.new_context() — enables storage_state, permissions, extra_http_headers, etc. without needing a persistent profile folder. JS: launchContext() and launchPersistentContext() silently dropped unknown options. New contextOptions field in LaunchContextOptions is spread into newContext() to forward arbitrary Playwright context options (e.g. storageState, permissions, geolocation).
This commit is contained in:
@@ -6,6 +6,11 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141.
|
||||||
|
- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`.
|
||||||
|
|
||||||
## [0.3.24] — 2026-04-10
|
## [0.3.24] — 2026-04-10
|
||||||
|
|
||||||
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS.
|
||||||
|
|||||||
@@ -316,6 +316,38 @@ page.goto("https://protected-site.com")
|
|||||||
context.close()
|
context.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Extra kwargs are forwarded to Playwright's `browser.new_context()` — use this for `storage_state`, `permissions`, `extra_http_headers`, etc. without needing a persistent profile folder:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cloakbrowser import launch_context
|
||||||
|
|
||||||
|
# Restore a saved session (cookies, localStorage) from a JSON file
|
||||||
|
context = launch_context(storage_state="state.json")
|
||||||
|
page = context.new_page()
|
||||||
|
page.goto("https://example.com")
|
||||||
|
# Save state back for next run
|
||||||
|
context.storage_state(path="state.json")
|
||||||
|
context.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `launch_context_async()`
|
||||||
|
|
||||||
|
Async counterpart to `launch_context()`. Same signature and kwargs forwarding:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from cloakbrowser import launch_context_async
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
ctx = await launch_context_async(storage_state="state.json")
|
||||||
|
page = await ctx.new_page()
|
||||||
|
await page.goto("https://example.com")
|
||||||
|
await ctx.storage_state(path="state.json")
|
||||||
|
await ctx.close()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
### `launch_persistent_context()`
|
### `launch_persistent_context()`
|
||||||
|
|
||||||
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
|
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Usage:
|
|||||||
browser.close()
|
browser.close()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
|
||||||
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__
|
||||||
@@ -32,6 +32,7 @@ __all__ = [
|
|||||||
"launch",
|
"launch",
|
||||||
"launch_async",
|
"launch_async",
|
||||||
"launch_context",
|
"launch_context",
|
||||||
|
"launch_context_async",
|
||||||
"launch_persistent_context",
|
"launch_persistent_context",
|
||||||
"launch_persistent_context_async",
|
"launch_persistent_context_async",
|
||||||
"ensure_binary",
|
"ensure_binary",
|
||||||
|
|||||||
@@ -585,6 +585,130 @@ def launch_context(
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
async def launch_context_async(
|
||||||
|
headless: bool = True,
|
||||||
|
proxy: str | ProxySettings | None = None,
|
||||||
|
args: list[str] | None = None,
|
||||||
|
stealth_args: bool = True,
|
||||||
|
user_agent: str | None = None,
|
||||||
|
viewport: dict | None = _VIEWPORT_UNSET,
|
||||||
|
locale: str | None = None,
|
||||||
|
timezone: str | None = None,
|
||||||
|
color_scheme: Literal["light", "dark", "no-preference"] | None = None,
|
||||||
|
geoip: bool = False,
|
||||||
|
backend: str | None = None,
|
||||||
|
humanize: bool = False,
|
||||||
|
human_preset: HumanPreset = "default",
|
||||||
|
human_config: HumanConfigOverrides | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
"""Async version of launch_context().
|
||||||
|
|
||||||
|
Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||||
|
All extra kwargs are forwarded to ``browser.new_context()`` — use this for
|
||||||
|
``storage_state``, ``permissions``, ``extra_http_headers``, etc. without needing
|
||||||
|
a persistent profile folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headless: Run in headless mode (default True).
|
||||||
|
proxy: Proxy URL string or Playwright proxy dict (see launch() for details).
|
||||||
|
args: Additional Chromium CLI arguments.
|
||||||
|
stealth_args: Include default stealth fingerprint args (default True).
|
||||||
|
user_agent: Custom user agent string.
|
||||||
|
viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}.
|
||||||
|
Pass None to disable viewport emulation (use OS window size).
|
||||||
|
locale: Browser locale, e.g. "en-US".
|
||||||
|
timezone: IANA timezone (e.g. 'America/New_York').
|
||||||
|
color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'.
|
||||||
|
geoip: Auto-detect timezone/locale from proxy IP (default False).
|
||||||
|
backend: Playwright backend — 'playwright' (default) or 'patchright'.
|
||||||
|
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
|
||||||
|
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
|
||||||
|
human_config: Custom humanize config mapping to override preset values.
|
||||||
|
**kwargs: Passed to browser.new_context() — e.g. storage_state, permissions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Playwright BrowserContext object (async API).
|
||||||
|
Call ``await .close()`` when done — this also closes the underlying browser.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> import asyncio
|
||||||
|
>>> from cloakbrowser import launch_context_async
|
||||||
|
>>>
|
||||||
|
>>> async def main():
|
||||||
|
... # Load saved session (cookies, localStorage)
|
||||||
|
... ctx = await launch_context_async(
|
||||||
|
... headless=True,
|
||||||
|
... storage_state="state.json",
|
||||||
|
... )
|
||||||
|
... page = await ctx.new_page()
|
||||||
|
... await page.goto("https://example.com")
|
||||||
|
... # Save state back
|
||||||
|
... await ctx.storage_state(path="state.json")
|
||||||
|
... await ctx.close()
|
||||||
|
>>>
|
||||||
|
>>> asyncio.run(main())
|
||||||
|
"""
|
||||||
|
timezone = _resolve_timezone(timezone, kwargs)
|
||||||
|
|
||||||
|
# Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure
|
||||||
|
# resolved values flow to binary flags
|
||||||
|
timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale)
|
||||||
|
if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)):
|
||||||
|
args = list(args or [])
|
||||||
|
args.append(f"--fingerprint-webrtc-ip={exit_ip}")
|
||||||
|
# --fingerprint-timezone is process-wide (reads CommandLine in renderer),
|
||||||
|
# so it applies to ALL contexts, not just the default one.
|
||||||
|
# locale and timezone are set via binary flags only — no CDP emulation.
|
||||||
|
browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
|
||||||
|
timezone=timezone, locale=locale, backend=backend)
|
||||||
|
|
||||||
|
context_kwargs: dict[str, Any] = {}
|
||||||
|
if user_agent:
|
||||||
|
context_kwargs["user_agent"] = user_agent
|
||||||
|
if viewport is _VIEWPORT_UNSET:
|
||||||
|
context_kwargs["viewport"] = DEFAULT_VIEWPORT
|
||||||
|
elif viewport is None:
|
||||||
|
context_kwargs["no_viewport"] = True
|
||||||
|
else:
|
||||||
|
context_kwargs["viewport"] = viewport
|
||||||
|
if color_scheme:
|
||||||
|
context_kwargs["color_scheme"] = color_scheme
|
||||||
|
context_kwargs.update(kwargs)
|
||||||
|
|
||||||
|
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
||||||
|
# triggers browser cleanup — otherwise the underlying Chromium process
|
||||||
|
# leaks when the awaiting task is cancelled.
|
||||||
|
try:
|
||||||
|
context = await browser.new_context(**context_kwargs)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
await browser.close()
|
||||||
|
except BaseException:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Patch close() to also close the browser (and its Playwright instance)
|
||||||
|
_original_ctx_close = context.close
|
||||||
|
|
||||||
|
async def _close_context_with_cleanup() -> None:
|
||||||
|
try:
|
||||||
|
await _original_ctx_close()
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
context.close = _close_context_with_cleanup
|
||||||
|
|
||||||
|
# Human-like behavioral patching (async variant)
|
||||||
|
if humanize:
|
||||||
|
from .human import patch_context_async
|
||||||
|
from .human.config import resolve_config
|
||||||
|
cfg = resolve_config(human_preset, human_config)
|
||||||
|
patch_context_async(context, cfg)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Backend resolution
|
# Backend resolution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+30
-1
@@ -3,7 +3,7 @@
|
|||||||
* Mirrors Python cloakbrowser/browser.py.
|
* Mirrors Python cloakbrowser/browser.py.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Browser, BrowserContext } from "playwright-core";
|
import type { Browser, BrowserContext, BrowserContextOptions } from "playwright-core";
|
||||||
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
import type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions } from "./types.js";
|
||||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||||
import { buildArgs } from "./args.js";
|
import { buildArgs } from "./args.js";
|
||||||
@@ -21,6 +21,29 @@ export function resolveTimezone<T extends { timezone?: string; timezoneId?: stri
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip `locale` and `timezoneId` from user-provided contextOptions — both route
|
||||||
|
* through detectable CDP emulation. The wrapper's top-level `locale`/`timezone`
|
||||||
|
* fields use binary flags instead (undetectable). Warn so users notice.
|
||||||
|
*/
|
||||||
|
function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserContextOptions> {
|
||||||
|
if (!ctx) return {};
|
||||||
|
const { locale, timezoneId, ...rest } = ctx;
|
||||||
|
if (locale !== undefined) {
|
||||||
|
console.warn(
|
||||||
|
"[cloakbrowser] contextOptions.locale ignored — use top-level `locale` " +
|
||||||
|
"instead (routes through binary flag, avoids detectable CDP emulation)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (timezoneId !== undefined) {
|
||||||
|
console.warn(
|
||||||
|
"[cloakbrowser] contextOptions.timezoneId ignored — use top-level `timezone` " +
|
||||||
|
"instead (routes through binary flag, avoids detectable CDP emulation)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch stealth Chromium browser via Playwright.
|
* Launch stealth Chromium browser via Playwright.
|
||||||
*
|
*
|
||||||
@@ -104,6 +127,9 @@ export async function launchContext(
|
|||||||
let context: BrowserContext;
|
let context: BrowserContext;
|
||||||
try {
|
try {
|
||||||
context = await browser.newContext({
|
context = await browser.newContext({
|
||||||
|
// contextOptions first — explicit wrapper fields below override it.
|
||||||
|
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||||
|
...filterStealthCtxOptions(options.contextOptions),
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
@@ -178,6 +204,9 @@ export async function launchPersistentContext(
|
|||||||
args,
|
args,
|
||||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||||
...(proxyOption ? { proxy: proxyOption } : {}),
|
...(proxyOption ? { proxy: proxyOption } : {}),
|
||||||
|
// contextOptions before explicit wrapper fields so explicit wins.
|
||||||
|
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||||
|
...filterStealthCtxOptions(options.contextOptions),
|
||||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Shared types for cloakbrowser launch wrappers.
|
* Shared types for cloakbrowser launch wrappers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { BrowserContextOptions } from "playwright-core";
|
||||||
import type { HumanConfig, HumanPreset } from "./human/config.js";
|
import type { HumanConfig, HumanPreset } from "./human/config.js";
|
||||||
|
|
||||||
export interface LaunchOptions {
|
export interface LaunchOptions {
|
||||||
@@ -45,6 +46,15 @@ export interface LaunchContextOptions extends LaunchOptions {
|
|||||||
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";
|
||||||
|
/**
|
||||||
|
* Extra options forwarded directly to Playwright's `browser.newContext()` —
|
||||||
|
* e.g. `storageState`, `permissions`, `geolocation`, `extraHTTPHeaders`,
|
||||||
|
* `httpCredentials`. Use this for context-level options not surfaced as
|
||||||
|
* top-level fields. `locale` and `timezoneId` are stripped here to avoid
|
||||||
|
* detectable CDP emulation — use the top-level `locale` and `timezone`
|
||||||
|
* wrapper fields instead (they route through undetectable binary flags).
|
||||||
|
*/
|
||||||
|
contextOptions?: BrowserContextOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
export interface LaunchPersistentContextOptions extends LaunchContextOptions {
|
||||||
|
|||||||
@@ -130,6 +130,60 @@ describe("launchContext (unit)", () => {
|
|||||||
// Browser also closed
|
// Browser also closed
|
||||||
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
expect(mockBrowser.close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards contextOptions to newContext (storageState, etc.)", async () => {
|
||||||
|
const { launchContext } = await import("../src/playwright.js");
|
||||||
|
await launchContext({
|
||||||
|
contextOptions: {
|
||||||
|
storageState: "state.json",
|
||||||
|
permissions: ["geolocation"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||||
|
expect(ctxArgs.storageState).toBe("state.json");
|
||||||
|
expect(ctxArgs.permissions).toEqual(["geolocation"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit top-level fields win over contextOptions on collision", async () => {
|
||||||
|
const { launchContext } = await import("../src/playwright.js");
|
||||||
|
await launchContext({
|
||||||
|
userAgent: "Explicit/1.0",
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
colorScheme: "dark",
|
||||||
|
contextOptions: {
|
||||||
|
userAgent: "ShouldBeOverridden/9.9",
|
||||||
|
viewport: { width: 9999, height: 9999 },
|
||||||
|
colorScheme: "light",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||||
|
expect(ctxArgs.userAgent).toBe("Explicit/1.0");
|
||||||
|
expect(ctxArgs.viewport).toEqual({ width: 1280, height: 720 });
|
||||||
|
expect(ctxArgs.colorScheme).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips locale and timezoneId from contextOptions (stealth-sensitive)", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const { launchContext } = await import("../src/playwright.js");
|
||||||
|
await launchContext({
|
||||||
|
contextOptions: {
|
||||||
|
storageState: "state.json",
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctxArgs = mockBrowser.newContext.mock.calls[0][0];
|
||||||
|
// Stealth-sensitive keys stripped — they would reintroduce detectable CDP emulation.
|
||||||
|
expect(ctxArgs.locale).toBeUndefined();
|
||||||
|
expect(ctxArgs.timezoneId).toBeUndefined();
|
||||||
|
// Benign keys preserved
|
||||||
|
expect(ctxArgs.storageState).toBe("state.json");
|
||||||
|
// Warning was logged for both stripped keys
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("launchPersistentContext (unit)", () => {
|
describe("launchPersistentContext (unit)", () => {
|
||||||
@@ -207,4 +261,53 @@ describe("launchPersistentContext (unit)", () => {
|
|||||||
expect(args.userAgent).toBe("Custom/1.0");
|
expect(args.userAgent).toBe("Custom/1.0");
|
||||||
expect(args.colorScheme).toBe("dark");
|
expect(args.colorScheme).toBe("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards contextOptions to launchPersistentContext", async () => {
|
||||||
|
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||||
|
await launchPersistentContext({
|
||||||
|
userDataDir: "/tmp/profile",
|
||||||
|
contextOptions: {
|
||||||
|
permissions: ["geolocation"],
|
||||||
|
extraHTTPHeaders: { "X-Custom": "1" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||||
|
expect(args.permissions).toEqual(["geolocation"]);
|
||||||
|
expect(args.extraHTTPHeaders).toEqual({ "X-Custom": "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit top-level fields win over contextOptions in persistent context", async () => {
|
||||||
|
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||||
|
await launchPersistentContext({
|
||||||
|
userDataDir: "/tmp/profile",
|
||||||
|
userAgent: "Explicit/1.0",
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
contextOptions: {
|
||||||
|
userAgent: "ShouldBeOverridden/9.9",
|
||||||
|
viewport: { width: 9999, height: 9999 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||||
|
expect(args.userAgent).toBe("Explicit/1.0");
|
||||||
|
expect(args.viewport).toEqual({ width: 1280, height: 720 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips locale and timezoneId from contextOptions (persistent context)", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const { launchPersistentContext } = await import("../src/playwright.js");
|
||||||
|
await launchPersistentContext({
|
||||||
|
userDataDir: "/tmp/profile",
|
||||||
|
contextOptions: {
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = mockChromium.launchPersistentContext.mock.calls[0][1];
|
||||||
|
expect(args.locale).toBeUndefined();
|
||||||
|
expect(args.timezoneId).toBeUndefined();
|
||||||
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock, call, patch
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -207,3 +207,125 @@ def test_kwargs_passthrough(mock_launch, _mock_bin):
|
|||||||
# Verify kwarg did NOT leak to launch()
|
# Verify kwarg did NOT leak to launch()
|
||||||
launch_kwargs = mock_launch.call_args[1]
|
launch_kwargs = mock_launch.call_args[1]
|
||||||
assert "record_video_dir" not in launch_kwargs
|
assert "record_video_dir" not in launch_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async: launch_context_async()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_async_browser():
|
||||||
|
"""Create a mock async browser whose new_context() returns a mock context."""
|
||||||
|
browser = AsyncMock()
|
||||||
|
context = AsyncMock()
|
||||||
|
browser.new_context.return_value = context
|
||||||
|
return browser, context
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch_async")
|
||||||
|
async def test_async_storage_state_forwarded(mock_launch_async, _mock_bin):
|
||||||
|
"""storage_state kwarg forwarded to browser.new_context() in async path.
|
||||||
|
|
||||||
|
This is the motivating use case from issue #141.
|
||||||
|
"""
|
||||||
|
browser, context = _make_mock_async_browser()
|
||||||
|
mock_launch_async.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context_async
|
||||||
|
await launch_context_async(storage_state="state.json")
|
||||||
|
|
||||||
|
ctx_kwargs = browser.new_context.call_args
|
||||||
|
assert ctx_kwargs[1]["storage_state"] == "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch_async")
|
||||||
|
async def test_async_default_viewport(mock_launch_async, _mock_bin):
|
||||||
|
"""DEFAULT_VIEWPORT applied when no viewport given (async)."""
|
||||||
|
browser, context = _make_mock_async_browser()
|
||||||
|
mock_launch_async.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context_async
|
||||||
|
await launch_context_async()
|
||||||
|
|
||||||
|
ctx_kwargs = browser.new_context.call_args
|
||||||
|
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch_async")
|
||||||
|
async def test_async_locale_flows_to_binary_not_cdp(mock_launch_async, _mock_bin):
|
||||||
|
"""locale flows to launch_async() for --lang flag, NOT to new_context() CDP."""
|
||||||
|
browser, context = _make_mock_async_browser()
|
||||||
|
mock_launch_async.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context_async
|
||||||
|
await launch_context_async(locale="de-DE", timezone="Europe/Berlin")
|
||||||
|
|
||||||
|
# Binary flags
|
||||||
|
assert mock_launch_async.call_args[1]["locale"] == "de-DE"
|
||||||
|
assert mock_launch_async.call_args[1]["timezone"] == "Europe/Berlin"
|
||||||
|
# Not in context — would trigger detectable CDP emulation
|
||||||
|
ctx_kwargs = browser.new_context.call_args
|
||||||
|
assert "locale" not in ctx_kwargs[1]
|
||||||
|
assert "timezone_id" not in ctx_kwargs[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch_async")
|
||||||
|
async def test_async_close_closes_browser(mock_launch_async, _mock_bin):
|
||||||
|
"""await ctx.close() also closes the underlying browser."""
|
||||||
|
browser, context = _make_mock_async_browser()
|
||||||
|
original_ctx_close = context.close
|
||||||
|
mock_launch_async.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context_async
|
||||||
|
ctx = await launch_context_async()
|
||||||
|
|
||||||
|
await ctx.close()
|
||||||
|
original_ctx_close.assert_called_once()
|
||||||
|
browser.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch_async")
|
||||||
|
async def test_async_error_closes_browser(mock_launch_async, _mock_bin):
|
||||||
|
"""If new_context() raises in async path, browser is still closed."""
|
||||||
|
browser = AsyncMock()
|
||||||
|
browser.new_context.side_effect = RuntimeError("context creation failed")
|
||||||
|
mock_launch_async.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context_async
|
||||||
|
with pytest.raises(RuntimeError, match="context creation failed"):
|
||||||
|
await launch_context_async()
|
||||||
|
|
||||||
|
browser.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||||
|
@patch("cloakbrowser.browser.launch_async")
|
||||||
|
async def test_async_cancellation_closes_browser(mock_launch_async, _mock_bin):
|
||||||
|
"""asyncio.CancelledError during new_context() still closes browser.
|
||||||
|
|
||||||
|
CancelledError derives from BaseException (not Exception) in Python 3.8+,
|
||||||
|
so the cleanup must catch BaseException to prevent browser process leaks
|
||||||
|
when the awaiting task is cancelled.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
browser = AsyncMock()
|
||||||
|
browser.new_context.side_effect = asyncio.CancelledError()
|
||||||
|
mock_launch_async.return_value = browser
|
||||||
|
|
||||||
|
from cloakbrowser.browser import launch_context_async
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await launch_context_async()
|
||||||
|
|
||||||
|
browser.close.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user