mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
fix(wrapper): track real window geometry on headed launches
Headed launches applied a fixed emulated viewport on top of the real browser window, yielding outerWidth < innerWidth (an impossible window). Default headed new_page()/new_context() to no_viewport so the page tracks the real window; headless keeps a deterministic viewport. Covers Python launch/launch_context/launch_persistent_context (+async) and the JS Playwright/Puppeteer wrappers. Explicit viewport still honored.
This commit is contained in:
@@ -73,3 +73,4 @@ captures
|
||||
.dolt/
|
||||
*.db
|
||||
.beads-credential-key
|
||||
.antigravitycli
|
||||
|
||||
@@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before.
|
||||
- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument.
|
||||
|
||||
## [0.3.32] — 2026-06-20
|
||||
|
||||
+92
-25
@@ -31,6 +31,79 @@ logger = logging.getLogger("cloakbrowser")
|
||||
_VIEWPORT_UNSET = object()
|
||||
|
||||
|
||||
def _default_no_viewport(browser: Any) -> None:
|
||||
"""Default ``new_page()``/``new_context()`` to ``no_viewport=True``.
|
||||
|
||||
``launch()`` returns a raw Playwright ``Browser``; a bare ``browser.new_page()``
|
||||
would otherwise inherit Playwright's emulated 1280x720 viewport, producing
|
||||
``outerWidth < innerWidth`` — a physically impossible window (bot tell). We wrap
|
||||
the two factory methods so pages track the real OS window instead. ``setdefault``
|
||||
only: an explicit ``viewport`` or ``no_viewport`` from the caller is never
|
||||
overridden (Playwright rejects passing both). Applied for headed launches only.
|
||||
Composes under humanize's ``patch_browser`` (apply this first).
|
||||
"""
|
||||
orig_new_context = browser.new_context
|
||||
orig_new_page = browser.new_page
|
||||
|
||||
def _patched_new_context(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return orig_new_context(**kwargs)
|
||||
|
||||
def _patched_new_page(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return orig_new_page(**kwargs)
|
||||
|
||||
browser.new_context = _patched_new_context
|
||||
browser.new_page = _patched_new_page
|
||||
|
||||
|
||||
def _default_no_viewport_async(browser: Any) -> None:
|
||||
"""Async variant of :func:`_default_no_viewport`."""
|
||||
orig_new_context = browser.new_context
|
||||
orig_new_page = browser.new_page
|
||||
|
||||
async def _patched_new_context(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return await orig_new_context(**kwargs)
|
||||
|
||||
async def _patched_new_page(**kwargs: Any) -> Any:
|
||||
if "viewport" not in kwargs:
|
||||
kwargs.setdefault("no_viewport", True)
|
||||
return await orig_new_page(**kwargs)
|
||||
|
||||
browser.new_context = _patched_new_context
|
||||
browser.new_page = _patched_new_page
|
||||
|
||||
|
||||
def _resolve_context_viewport(viewport: Any, headless: bool) -> dict[str, Any]:
|
||||
"""Return the viewport kwarg for a context.
|
||||
|
||||
Headed: no emulated viewport so the page tracks the real window (CDP viewport
|
||||
emulation forces outerWidth < innerWidth = a physically impossible window =
|
||||
bot tell). Headless: a fixed ``DEFAULT_VIEWPORT`` stays coherent (outer == inner)
|
||||
and keeps dimensions deterministic. Explicit ``viewport`` / ``None`` honored.
|
||||
"""
|
||||
if viewport is _VIEWPORT_UNSET:
|
||||
return {"viewport": DEFAULT_VIEWPORT} if headless else {"no_viewport": True}
|
||||
if viewport is None:
|
||||
return {"no_viewport": True}
|
||||
return {"viewport": viewport}
|
||||
|
||||
|
||||
def _drop_conflicting_viewport(context_kwargs: dict[str, Any], kwargs: dict[str, Any]) -> None:
|
||||
"""Playwright rejects passing both ``viewport`` and ``no_viewport``. ``viewport`` is a
|
||||
named parameter (never in ``**kwargs``), so the only conflict is a caller passing
|
||||
``no_viewport`` via ``**kwargs`` alongside an explicit ``viewport`` — the explicit
|
||||
``no_viewport`` wins; drop the viewport so Playwright doesn't error.
|
||||
"""
|
||||
if "no_viewport" in kwargs and "viewport" in context_kwargs:
|
||||
logger.debug("Both viewport and no_viewport requested; no_viewport (kwargs) wins")
|
||||
context_kwargs.pop("viewport", None)
|
||||
|
||||
|
||||
def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None:
|
||||
"""Accept both timezone and timezone_id — either works, no warning."""
|
||||
if "timezone_id" in kwargs:
|
||||
@@ -121,7 +194,7 @@ def launch(
|
||||
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}")
|
||||
|
||||
|
||||
chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths)
|
||||
|
||||
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
|
||||
@@ -147,6 +220,12 @@ def launch(
|
||||
|
||||
browser.close = _close_with_cleanup
|
||||
|
||||
# Headed: default new_page()/new_context() to no_viewport so the page tracks the
|
||||
# real window (avoids the impossible-window tell). Headless keeps Playwright's
|
||||
# default viewport (coherent there). Apply before humanize so the wraps compose.
|
||||
if not headless:
|
||||
_default_no_viewport(browser)
|
||||
|
||||
# Human-like behavioral patching
|
||||
if humanize:
|
||||
from .human import patch_browser
|
||||
@@ -239,6 +318,10 @@ async def launch_async( # noqa: C901
|
||||
|
||||
browser.close = _close_with_cleanup
|
||||
|
||||
# Headed: default new_page()/new_context() to no_viewport (see launch()).
|
||||
if not headless:
|
||||
_default_no_viewport_async(browser)
|
||||
|
||||
# Human-like behavioral patching (async variant)
|
||||
if humanize:
|
||||
from .human import patch_browser_async
|
||||
@@ -333,15 +416,11 @@ def launch_persistent_context(
|
||||
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
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
seed_widevine_hint(user_data_dir, binary_path)
|
||||
|
||||
@@ -463,15 +542,11 @@ async def launch_persistent_context_async(
|
||||
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
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
seed_widevine_hint(user_data_dir, binary_path)
|
||||
|
||||
@@ -571,15 +646,11 @@ def launch_context(
|
||||
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
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
try:
|
||||
context = browser.new_context(**context_kwargs)
|
||||
@@ -691,15 +762,11 @@ async def launch_context_async(
|
||||
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
|
||||
context_kwargs.update(_resolve_context_viewport(viewport, headless))
|
||||
if color_scheme:
|
||||
context_kwargs["color_scheme"] = color_scheme
|
||||
context_kwargs.update(kwargs)
|
||||
_drop_conflicting_viewport(context_kwargs, kwargs)
|
||||
|
||||
# Catch BaseException (not just Exception) so that asyncio.CancelledError
|
||||
# triggers browser cleanup — otherwise the underlying Chromium process
|
||||
|
||||
@@ -55,16 +55,19 @@ def get_default_stealth_args() -> list[str]:
|
||||
# Tell the fingerprint patches we're on macOS so GPU/UA match natively
|
||||
return base + ["--fingerprint-platform=macos"]
|
||||
|
||||
# Linux/Windows: Windows fingerprint profile
|
||||
# Hardware concurrency, device memory, screen, window size, and GPU are
|
||||
# auto-generated by the binary from the seed (v14+).
|
||||
# Linux/Windows: Windows fingerprint profile.
|
||||
# Screen and window size come from the real display, not this flag (verified:
|
||||
# identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
# headed mode — that would break outerWidth >= innerWidth coherence.
|
||||
return base + ["--fingerprint-platform=windows"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
# screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
# Default viewport — used for HEADLESS only (headed launches use no_viewport so
|
||||
# the page tracks the real window). Headless has no window chrome, so a fixed
|
||||
# viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||
# Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_VIEWPORT = {"width": 1920, "height": 947}
|
||||
|
||||
|
||||
+9
-6
@@ -200,9 +200,11 @@ export const IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swif
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default stealth arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default viewport — realistic maximized Chrome on 1080p Windows
|
||||
// screen=1920x1080, availHeight=1032 (minus 48px taskbar, binary default),
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks)
|
||||
// Default viewport — used for HEADLESS only (headed launches use no viewport so
|
||||
// the page tracks the real window). Headless has no window chrome, so a fixed
|
||||
// viewport stays coherent (outer == inner) and gives deterministic dimensions.
|
||||
// Models a maximized Chrome on 1080p Windows: screen=1920x1080,
|
||||
// innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks).
|
||||
export const DEFAULT_VIEWPORT = { width: 1920, height: 947 };
|
||||
|
||||
export function getDefaultStealthArgs(): string[] {
|
||||
@@ -219,8 +221,9 @@ export function getDefaultStealthArgs(): string[] {
|
||||
return [...base, "--fingerprint-platform=macos"];
|
||||
}
|
||||
|
||||
// Linux/Windows: spoof as Windows desktop
|
||||
// Hardware concurrency, device memory, screen, window size, and GPU are
|
||||
// auto-generated by the binary from the seed (v14+).
|
||||
// Linux/Windows: spoof as Windows desktop.
|
||||
// Screen and window size come from the real display, not this flag (verified:
|
||||
// identical across seeds), so the wrapper must not emulate a viewport on top in
|
||||
// headed mode — that would break outerWidth >= innerWidth coherence.
|
||||
return [...base, "--fingerprint-platform=windows"];
|
||||
}
|
||||
|
||||
+55
-2
@@ -52,15 +52,43 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial<BrowserCo
|
||||
* Useful when integrating CloakBrowser with an existing Playwright Browser while
|
||||
* keeping the wrapper's stealth-safe defaults for `newContext()`.
|
||||
*/
|
||||
/**
|
||||
* Effective headless mode for viewport decisions. buildLaunchOptions() spreads
|
||||
* `...options.launchOptions` LAST, so a raw `launchOptions.headless` overrides the
|
||||
* top-level field at the actual chromium.launch() call. Viewport logic must read
|
||||
* the same effective value — otherwise a headed browser gets a fixed viewport
|
||||
* (reintroducing the impossible-window tell). Playwright-specific (Puppeteer
|
||||
* resolves headless the opposite way).
|
||||
*/
|
||||
function effectiveHeadless(options: LaunchOptions): boolean {
|
||||
return (
|
||||
(options.launchOptions as { headless?: boolean } | undefined)?.headless ??
|
||||
options.headless ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export function buildContextOptions(
|
||||
options: LaunchContextOptions = {}
|
||||
): BrowserContextOptions {
|
||||
// Headed: viewport=null (no emulation) so the page tracks the real window and
|
||||
// outerWidth >= innerWidth stays coherent — CDP viewport emulation forces
|
||||
// inner > outer = a physically impossible window = bot tell. Headless has no
|
||||
// window chrome (outer == inner), so a fixed viewport stays coherent and keeps
|
||||
// dimensions deterministic. Explicit viewport (incl. null) is always honored.
|
||||
const headless = effectiveHeadless(options);
|
||||
const viewport =
|
||||
options.viewport !== undefined
|
||||
? options.viewport
|
||||
: headless
|
||||
? DEFAULT_VIEWPORT
|
||||
: null;
|
||||
return {
|
||||
// contextOptions first — explicit wrapper fields below override it.
|
||||
// filterStealthCtxOptions strips locale/timezoneId to prevent CDP detection.
|
||||
...filterStealthCtxOptions(options.contextOptions),
|
||||
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
||||
viewport: options.viewport === undefined ? DEFAULT_VIEWPORT : options.viewport,
|
||||
viewport,
|
||||
...(options.colorScheme ? { colorScheme: options.colorScheme } : {}),
|
||||
} as BrowserContextOptions;
|
||||
}
|
||||
@@ -127,10 +155,33 @@ export async function humanizeBrowser(
|
||||
export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
const { chromium } = await import("playwright-core");
|
||||
const browser = await chromium.launch(await buildLaunchOptions(options));
|
||||
// Headed: a bare browser.newPage() would inherit Playwright's emulated 1280x720
|
||||
// viewport -> outerWidth < innerWidth (impossible window = bot tell). Default
|
||||
// newPage()/newContext() to viewport:null so the page tracks the real window.
|
||||
// Headless keeps Playwright's default viewport (coherent there).
|
||||
if (!effectiveHeadless(options)) {
|
||||
applyDefaultNoViewport(browser);
|
||||
}
|
||||
await humanizeBrowser(browser, options);
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Browser's newContext()/newPage() to default to viewport:null (no
|
||||
* emulation) when the caller didn't specify a viewport. setdefault-style: an
|
||||
* explicit viewport (including null) is always honored. Apply before humanize's
|
||||
* patchBrowser so the wraps compose.
|
||||
*/
|
||||
function applyDefaultNoViewport(browser: Browser): void {
|
||||
const origNewContext = browser.newContext.bind(browser);
|
||||
(browser as any).newContext = (options?: Parameters<typeof origNewContext>[0]) =>
|
||||
origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options);
|
||||
|
||||
const origNewPage = browser.newPage.bind(browser);
|
||||
(browser as any).newPage = (options?: Parameters<typeof origNewPage>[0]) =>
|
||||
origNewPage(options?.viewport === undefined ? { ...options, viewport: null } : options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch stealth browser and return a BrowserContext with common options pre-set.
|
||||
* Closing the context also closes the browser.
|
||||
@@ -161,7 +212,9 @@ export async function launchContext(
|
||||
// --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.
|
||||
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false });
|
||||
// humanize:false on the inner launch — patchContext below applies humanize
|
||||
// exactly once (else launch()'s humanizeBrowser would patch it a second time).
|
||||
const browser = await launch({ ...options, ...resolved, args: launchArgs, geoip: false, humanize: false });
|
||||
|
||||
let context: BrowserContext;
|
||||
try {
|
||||
|
||||
+21
-1
@@ -6,13 +6,31 @@
|
||||
|
||||
import type { Browser } from "puppeteer-core";
|
||||
import type { LaunchOptions } from "./types.js";
|
||||
import { IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { DEFAULT_VIEWPORT, IGNORE_DEFAULT_ARGS } from "./config.js";
|
||||
import { buildArgs } from "./args.js";
|
||||
import { ensureBinary } from "./download.js";
|
||||
import { isSocksProxy, normalizeHttpStringUrl, parseProxyUrl, reconstructHttpUrl, resolveProxyConfig, supportsHttpProxyInlineAuth } from "./proxy.js";
|
||||
import { maybeResolveGeoip, resolveWebrtcArgs } from "./geoip.js";
|
||||
import { seedWidevineHint } from "./widevine.js";
|
||||
|
||||
/**
|
||||
* Resolve Puppeteer's defaultViewport. Headed -> null (track the real window so
|
||||
* outerWidth >= innerWidth stays coherent; Puppeteer otherwise forces an 800x600
|
||||
* emulated viewport = a physically impossible window = bot tell). Headless has no
|
||||
* window chrome (outer == inner), so a fixed viewport stays coherent and keeps
|
||||
* dimensions deterministic. A user-supplied launchOptions.defaultViewport wins.
|
||||
*/
|
||||
function resolveDefaultViewport(options: LaunchOptions): { width: number; height: number } | null {
|
||||
const launchOpts = (options.launchOptions ?? {}) as Record<string, unknown>;
|
||||
// A user-supplied defaultViewport wins (incl. explicit null). undefined is NOT
|
||||
// "supplied" — fall through to our default. Puppeteer sets `headless` AFTER the
|
||||
// launchOptions spread, so the top-level field wins at launch — match it here.
|
||||
if (launchOpts.defaultViewport !== undefined) {
|
||||
return launchOpts.defaultViewport as { width: number; height: number } | null;
|
||||
}
|
||||
return (options.headless ?? true) ? DEFAULT_VIEWPORT : null;
|
||||
}
|
||||
|
||||
/** Resolve binary path, geoip, webrtc, and build final Chrome args. */
|
||||
async function resolveArgs(options: LaunchOptions): Promise<{ binaryPath: string; args: string[] }> {
|
||||
const binaryPath = process.env.CLOAKBROWSER_BINARY_PATH || (await ensureBinary());
|
||||
@@ -125,6 +143,7 @@ export async function launch(options: LaunchOptions = {}): Promise<Browser> {
|
||||
headless: options.headless ?? true,
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
defaultViewport: resolveDefaultViewport(options),
|
||||
});
|
||||
|
||||
await applyPostLaunch(browser, options, proxyAuth);
|
||||
@@ -165,6 +184,7 @@ export async function launchPersistentContext(
|
||||
args,
|
||||
ignoreDefaultArgs: IGNORE_DEFAULT_ARGS,
|
||||
userDataDir: options.userDataDir,
|
||||
defaultViewport: resolveDefaultViewport(options),
|
||||
});
|
||||
|
||||
await applyPostLaunch(browser, options, proxyAuth);
|
||||
|
||||
@@ -84,6 +84,32 @@ describe("composable Playwright launch helpers", () => {
|
||||
expect(buildContextOptions({ viewport: null }).viewport).toBeNull();
|
||||
});
|
||||
|
||||
it("buildContextOptions uses no viewport (null) when headed, so the page tracks the real window", async () => {
|
||||
const { buildContextOptions } = await import("../src/index.js");
|
||||
|
||||
// Headed: no emulated viewport (CDP emulation would force outerWidth < innerWidth).
|
||||
expect(buildContextOptions({ headless: false }).viewport).toBeNull();
|
||||
// Headless keeps the deterministic default.
|
||||
expect(buildContextOptions({ headless: true }).viewport).toEqual(DEFAULT_VIEWPORT);
|
||||
// Explicit viewport always honored, even headed.
|
||||
const custom = { width: 800, height: 600 };
|
||||
expect(buildContextOptions({ headless: false, viewport: custom }).viewport).toEqual(custom);
|
||||
});
|
||||
|
||||
it("buildContextOptions reads effective headless from launchOptions.headless", async () => {
|
||||
const { buildContextOptions } = await import("../src/index.js");
|
||||
|
||||
// buildLaunchOptions spreads launchOptions LAST, so launchOptions.headless wins
|
||||
// at the actual launch. Viewport must follow it — a raw headless:false (browser
|
||||
// actually headed) must NOT get a fixed viewport (would reintroduce outer<inner).
|
||||
expect(buildContextOptions({ launchOptions: { headless: false } }).viewport).toBeNull();
|
||||
// And launchOptions.headless:true forces the deterministic viewport even if the
|
||||
// top-level field said headed.
|
||||
expect(
|
||||
buildContextOptions({ headless: false, launchOptions: { headless: true } }).viewport,
|
||||
).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("buildLaunchOptions returns Playwright options without launching a browser", async () => {
|
||||
const freshConfig = await import("../src/config.js");
|
||||
vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
|
||||
@@ -65,6 +65,53 @@ describe("puppeteer launch", () => {
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(false);
|
||||
});
|
||||
|
||||
it("headless (default) uses a fixed defaultViewport; headed uses null", async () => {
|
||||
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
// Headless (default): deterministic viewport.
|
||||
await launch();
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toEqual(DEFAULT_VIEWPORT);
|
||||
|
||||
// Headed: null so the page tracks the real window (else Puppeteer forces 800x600).
|
||||
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||
await launch({ headless: false });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("honors an explicit launchOptions.defaultViewport (incl. null)", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
const custom = { width: 640, height: 480 };
|
||||
await launch({ headless: true, launchOptions: { defaultViewport: custom } });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toEqual(custom);
|
||||
|
||||
// Explicit null honored even in headless (would otherwise default to DEFAULT_VIEWPORT).
|
||||
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||
await launch({ headless: true, launchOptions: { defaultViewport: null } });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("Puppeteer headless precedence: top-level headless wins over launchOptions.headless", async () => {
|
||||
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
// Puppeteer sets headless AFTER the launchOptions spread, so top-level wins at
|
||||
// launch — the viewport decision must follow the same (top-level) value.
|
||||
await launch({ headless: true, launchOptions: { headless: false } });
|
||||
const opts = vi.mocked(puppeteerMock.default.launch).mock.calls[0][0];
|
||||
expect(opts.headless).toBe(true);
|
||||
expect(opts.defaultViewport).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("adds --proxy-server for string proxy", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
await launch({ proxy: "http://proxy:8080" });
|
||||
@@ -212,6 +259,22 @@ describe("puppeteer launchPersistentContext", () => {
|
||||
expect(callArgs.args.some((a: string) => a.startsWith("--fingerprint="))).toBe(true);
|
||||
});
|
||||
|
||||
it("headed persistent context uses null defaultViewport (tracks real window)", async () => {
|
||||
const { DEFAULT_VIEWPORT } = await import("../src/config.js");
|
||||
const { launchPersistentContext } = await import("../src/puppeteer.js");
|
||||
|
||||
await launchPersistentContext({ userDataDir: "./my-profile", headless: false });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toBeNull();
|
||||
|
||||
vi.mocked(puppeteerMock.default.launch).mockClear();
|
||||
await launchPersistentContext({ userDataDir: "./my-profile", headless: true });
|
||||
expect(
|
||||
vi.mocked(puppeteerMock.default.launch).mock.calls[0][0].defaultViewport
|
||||
).toEqual(DEFAULT_VIEWPORT);
|
||||
});
|
||||
|
||||
it("uses page.authenticate fallback for http proxy in persistent context on unsupported platform", async () => {
|
||||
const config = await import("../src/config.js");
|
||||
vi.spyOn(config, "getPlatformTag").mockReturnValue("darwin-arm64");
|
||||
|
||||
@@ -33,6 +33,82 @@ def test_default_viewport(mock_launch, _mock_bin):
|
||||
assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_headed_no_viewport(mock_launch, _mock_bin):
|
||||
"""Headed (headless=False): no emulated viewport — no_viewport=True so the page
|
||||
tracks the real window (CDP viewport emulation would force outerWidth < innerWidth)."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(headless=False)
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args[1]
|
||||
assert ctx_kwargs.get("no_viewport") is True
|
||||
assert "viewport" not in ctx_kwargs
|
||||
|
||||
|
||||
def test_default_no_viewport_helper():
|
||||
"""_default_no_viewport defaults new_page()/new_context() to no_viewport=True,
|
||||
but never overrides an explicit viewport (Playwright rejects passing both)."""
|
||||
from cloakbrowser.browser import _default_no_viewport
|
||||
|
||||
browser = MagicMock()
|
||||
orig_new_page = browser.new_page
|
||||
orig_new_context = browser.new_context
|
||||
_default_no_viewport(browser)
|
||||
|
||||
browser.new_page()
|
||||
orig_new_page.assert_called_once_with(no_viewport=True)
|
||||
browser.new_context()
|
||||
orig_new_context.assert_called_once_with(no_viewport=True)
|
||||
|
||||
# Explicit viewport respected — no_viewport NOT injected.
|
||||
orig_new_page.reset_mock()
|
||||
browser.new_page(viewport={"width": 800, "height": 600})
|
||||
orig_new_page.assert_called_once_with(viewport={"width": 800, "height": 600})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_no_viewport_helper_async():
|
||||
"""_default_no_viewport_async mirrors the sync helper for async new_page/new_context."""
|
||||
from cloakbrowser.browser import _default_no_viewport_async
|
||||
|
||||
browser = MagicMock()
|
||||
browser.new_page = AsyncMock()
|
||||
browser.new_context = AsyncMock()
|
||||
orig_new_page = browser.new_page
|
||||
orig_new_context = browser.new_context
|
||||
_default_no_viewport_async(browser)
|
||||
|
||||
await browser.new_page()
|
||||
orig_new_page.assert_awaited_once_with(no_viewport=True)
|
||||
await browser.new_context()
|
||||
orig_new_context.assert_awaited_once_with(no_viewport=True)
|
||||
|
||||
# Explicit viewport respected — no_viewport NOT injected.
|
||||
orig_new_page.reset_mock()
|
||||
await browser.new_page(viewport={"width": 800, "height": 600})
|
||||
orig_new_page.assert_awaited_once_with(viewport={"width": 800, "height": 600})
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_conflicting_viewport_kwargs_deduped(mock_launch, _mock_bin):
|
||||
"""If a caller forces no_viewport via **kwargs alongside viewport=, only one
|
||||
reaches Playwright (which rejects both). The explicit kwargs value wins."""
|
||||
browser, context = _make_mock_browser()
|
||||
mock_launch.return_value = browser
|
||||
|
||||
from cloakbrowser.browser import launch_context
|
||||
launch_context(viewport={"width": 1280, "height": 800}, no_viewport=True)
|
||||
|
||||
ctx_kwargs = browser.new_context.call_args[1]
|
||||
assert ctx_kwargs.get("no_viewport") is True
|
||||
assert "viewport" not in ctx_kwargs
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.launch")
|
||||
def test_custom_viewport(mock_launch, _mock_bin):
|
||||
|
||||
@@ -55,6 +55,22 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
|
||||
assert call_kwargs["viewport"] == DEFAULT_VIEWPORT
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
def test_persistent_context_headed_no_viewport(_mock_geoip, _mock_bin):
|
||||
"""Headed (headless=False): no_viewport=True instead of DEFAULT_VIEWPORT so the
|
||||
page tracks the real window (avoids the outerWidth < innerWidth tell)."""
|
||||
pw_cm, pw, context = _make_mock_pw_and_context()
|
||||
|
||||
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm):
|
||||
from cloakbrowser.browser import launch_persistent_context
|
||||
launch_persistent_context("/tmp/profile", headless=False)
|
||||
|
||||
call_kwargs = pw.chromium.launch_persistent_context.call_args[1]
|
||||
assert call_kwargs.get("no_viewport") is True
|
||||
assert "viewport" not in call_kwargs
|
||||
|
||||
|
||||
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
|
||||
@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None))
|
||||
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
|
||||
|
||||
Reference in New Issue
Block a user