diff --git a/.gitignore b/.gitignore index 21e76c2..aaf3d70 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ captures .dolt/ *.db .beads-credential-key +.antigravitycli diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2ae25..54d0bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index ed4cbed..b985d4c 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -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 diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index fb1ed6e..c52448c 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -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} diff --git a/js/src/config.ts b/js/src/config.ts index 4b73057..9454937 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -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"]; } diff --git a/js/src/playwright.ts b/js/src/playwright.ts index 6e236f4..6f8d68b 100644 --- a/js/src/playwright.ts +++ b/js/src/playwright.ts @@ -52,15 +52,43 @@ function filterStealthCtxOptions(ctx?: BrowserContextOptions): Partial= 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 { 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[0]) => + origNewContext(options?.viewport === undefined ? { ...options, viewport: null } : options); + + const origNewPage = browser.newPage.bind(browser); + (browser as any).newPage = (options?: Parameters[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 { diff --git a/js/src/puppeteer.ts b/js/src/puppeteer.ts index c3df721..0b1c8e0 100644 --- a/js/src/puppeteer.ts +++ b/js/src/puppeteer.ts @@ -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; + // 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 { 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); diff --git a/js/tests/launch.test.ts b/js/tests/launch.test.ts index 4b667e5..addf32e 100644 --- a/js/tests/launch.test.ts +++ b/js/tests/launch.test.ts @@ -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 { const freshConfig = await import("../src/config.js"); vi.spyOn(freshConfig, "getPlatformTag").mockReturnValue("darwin-arm64"); diff --git a/js/tests/puppeteer.test.ts b/js/tests/puppeteer.test.ts index 9031d94..89873e8 100644 --- a/js/tests/puppeteer.test.ts +++ b/js/tests/puppeteer.test.ts @@ -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"); diff --git a/tests/test_launch_context.py b/tests/test_launch_context.py index e7fca1e..64ab640 100644 --- a/tests/test_launch_context.py +++ b/tests/test_launch_context.py @@ -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): diff --git a/tests/test_persistent_context.py b/tests/test_persistent_context.py index aa62131..5f28119 100644 --- a/tests/test_persistent_context.py +++ b/tests/test_persistent_context.py @@ -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):