Files
camoufox/pythonlib/tests/test_viewport_default.py
Jake Writer 0e4151f820 fix: page-recycle hang under spoofed window dims, and the unmerged halves of #637-#647
Fixes the new_page() hang from #666, and restores the pythonlib/ + settings/
halves of #637-#647 that were dropped when those PRs were consolidated into #666
(that PR only carried patches/ + additions/, so these never actually landed).

## new_page() hangs when window.outer* is spoofed (#666)

The outer-size hijack in browser-init.patch pinned the chrome documentElement to
the spoofed size. That caps .browserStack, which caps the content viewport, so
the content window can never reach the size Juggler asks for in
updateViewportSize() -- and awaitViewportDimensions awaits exact equality with
no timeout, so it deadlocks rather than erroring. The second new_page() hung
forever and took the context with it.

The pin was never load-bearing: GetOuterWidth/GetOuterHeight already consult
MaskConfig unconditionally (fingerprint-injection.patch), so window.outerWidth is
spoofed in C++ regardless of the real chrome window size. Resizing is enough.

Measured on the official v152.0.4-beta.26 build (headless):

    config      before        after
    none        pass          pass
    inner       pass          pass
    outer       HANG          pass
    both        HANG          pass  (iw:360 ih:740 ow:360 oh:800 -- exact)

This corrects the diagnosis in #666, which blamed the inner+outer combination and
the `!(outerWidth || outerHeight)` guard. outer* ALONE is sufficient to hang, and
dropping inner* does not help, so that guard is not the culprit.

Also fixed driver-side: Playwright's implicit 1280x720 viewport is what asks for
the impossible size, so the driver now defaults to no_viewport when the config
spoofs any window dimension. That fixes the hang on already-released builds
without a rebuild. An explicit viewport=/no_viewport= from the caller wins.

## WebRTC ICE prefs (#538)

#666 merged the C++ half of the WebRTC fix but not the prefs, so the shipped
build still has no_host=true and none of the proxy_only prefs.
proxy_only_if_behind_proxy is the pref that actually stops the real-IP leak: it
prevents a UDP STUN request routing around a TCP proxy. no_host=false keeps the
stock two-candidate shape, which obfuscate_host_addresses makes leak-free.

## Also restored from the consolidation

- fix(proxy): dom.security.https_first rewrote http:// before the launch-arg
  proxy filter saw it, breaking CONNECT-only proxies (#638).
- fix(stealth): speech-voice spoofing + stop leaking host voices (#646).
- fix(stealth): clamp inner <= outer <= avail <= screen; BrowserForge can emit
  impossible geometries that leak as tells (#647).

Refs: https://github.com/daijro/camoufox/pull/666
Refs: https://github.com/daijro/camoufox/issues/538
2026-07-16 14:38:39 -06:00

81 lines
2.5 KiB
Python

"""Driver-side guard for daijro/camoufox#666.
Playwright's implicit 1280x720 viewport makes Juggler ask a spoofed window to
resize to a size it can never reach, deadlocking new_page(). The driver defaults
to no_viewport whenever the config spoofs any window dimension, which fixes the
hang on *already-released* browser builds -- no rebuild needed.
"""
import pytest
from camoufox.utils import attach_no_viewport_default, spoofs_window_dimensions
def _opts(config_blob: str):
"""Launch options with the config chunked the way launch_options() does."""
chunks = [config_blob[i : i + 10] for i in range(0, len(config_blob), 10)] or [""]
return {"env": {f"CAMOU_CONFIG_{i + 1}": c for i, c in enumerate(chunks)}}
@pytest.mark.parametrize(
"config, expected",
[
('{"window.outerWidth": 360}', True),
('{"window.innerHeight": 740}', True),
('{"document.body.clientWidth": 360}', True),
('{"screen.width": 360}', False),
('{"navigator.userAgent": "x"}', False),
("{}", False),
],
)
def test_detects_window_dimension_spoofing(config, expected):
assert spoofs_window_dimensions(_opts(config)) is expected
def test_reassembles_chunks_in_index_order():
"""CAMOU_CONFIG_10 must not sort before CAMOU_CONFIG_2 -- a lexicographic
join would corrupt the key we search for."""
blob = '{"padding": "' + "x" * 200 + '", "window.outerWidth": 360}'
assert spoofs_window_dimensions(_opts(blob)) is True
def test_no_env_is_not_spoofed():
assert spoofs_window_dimensions({}) is False
class _FakeBrowser:
def __init__(self):
self.calls = []
def new_page(self, **kwargs):
self.calls.append(kwargs)
return "page"
def new_context(self, **kwargs):
self.calls.append(kwargs)
return "context"
def test_defaults_to_no_viewport():
b = attach_no_viewport_default(_FakeBrowser())
b.new_page()
b.new_context()
assert b.calls == [{"no_viewport": True}, {"no_viewport": True}]
@pytest.mark.parametrize(
"override",
[{"viewport": {"width": 800, "height": 600}}, {"no_viewport": False}],
)
def test_explicit_caller_choice_always_wins(override):
"""We must never override an explicit viewport decision."""
b = attach_no_viewport_default(_FakeBrowser())
b.new_page(**override)
assert b.calls == [override]
def test_other_kwargs_are_forwarded():
b = attach_no_viewport_default(_FakeBrowser())
b.new_page(locale="en-US")
assert b.calls == [{"locale": "en-US", "no_viewport": True}]