Files
camoufox/pythonlib/tests/test_launch_environment.py
T
Jake WriterandClaude Opus 5 3f0f850bf3 fix(pythonlib): identity draws that match real machines and stay stable
Launcher-side fixes found by comparing camoufox against stock Firefox 152.0.4
on Linux, Windows 11 and macOS hosts:

- DNT / GPC: BrowserForge draws doNotTrack "1" on most Firefox samples, but a
  stock Firefox 152 reports "unspecified" and globalPrivacyControl false; the
  stock defaults are used unless the caller sets them, and both are applied as
  prefs so the API, the worker and the DNT / Sec-GPC headers agree (#760).
- Timezone and geolocation: the timezone is passed to the browser, and a
  configured position sets permissions.default.geo so permissions.query agrees
  with the auto-grant (#769, #773).
- hardwareConcurrency: the reported count is the fingerprint's and the browser
  is pinned to that many cores (cpu_affinity.py, Linux/Windows), so worker
  timing agrees with it; otherwise the host count snapped into the core counts
  real machines ship with (never 2, Firefox's resistFingerprinting value).
- Fonts: the OS base is always present in full, OS-version variants are drawn
  all-or-nothing, co-shipped groups stay together, Cascadia is never claimed
  off Windows, a native macOS/Windows identity claims only the real OS base,
  and gfx.font_rendering.fallback.async is off on Linux so per-character
  fallback does not depend on cmap-load timing.
- Speech voices: a per-OS installed-voice model (voice-manifests.json) with
  the voiceURI formats each backend really produces (voice-uris.json); no
  default voice where stock has none.
- WebGL: extensions a release Firefox never exposes are filtered, but
  OVR_multiview2 stays for Windows D3D11 renderers, which expose it.
- Media devices: a seeded draw of common per-OS devices with OS-style labels.
- Windows scrollbars follow the drawn Windows version (overlay on 11).
- Glyph-advance perturbation (fonts:spacing_seed) defaults to off: it moved
  every measureText width off the value the same font gives on a real machine.
- Launcher prefs are also exported as CAMOU_PREFS_1..N so camoufox.cfg applies
  them at startup, and the browser UI locale follows the spoofed locale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:09:40 -06:00

255 lines
11 KiB
Python

"""Regression tests for per-launch environment isolation."""
import os
import pytest
from camoufox import utils
@pytest.fixture
def isolated_launch_dependencies(monkeypatch):
"""Keep launch_options focused on environment assembly, without a browser."""
monkeypatch.setattr(utils, "add_default_addons", lambda *args, **kwargs: None)
monkeypatch.setattr(utils, "generate_fingerprint", lambda *args, **kwargs: object())
monkeypatch.setattr(utils, "from_browserforge", lambda *args, **kwargs: {})
monkeypatch.setattr(utils, "get_screen_cons", lambda *args, **kwargs: None)
monkeypatch.setattr(utils, "_generate_random_font_subset", lambda *args: [])
monkeypatch.setattr(utils, "_generate_random_voice_subset", lambda *args: [])
monkeypatch.setattr(utils, "fix_navigator_arch", lambda *args: None)
monkeypatch.setattr(utils, "fix_screen_no_taskbar", lambda *args: None)
monkeypatch.setattr(utils, "clamp_window_dimensions", lambda *args: None)
monkeypatch.setattr(utils, "set_media_devices_defaults", lambda *args: None)
monkeypatch.setattr(utils, "installed_verstr", lambda: "152.0.4-beta.28")
monkeypatch.setattr(utils, "validate_config", lambda *args, **kwargs: None)
monkeypatch.setattr(utils, "get_env_vars", lambda *args, **kwargs: {})
monkeypatch.setattr(utils, "launch_path", lambda: "/test/camoufox")
def _launch_with_virtual_display(**kwargs):
return utils.launch_options(
virtual_display=":4242",
headless=True,
block_webgl=True,
i_know_what_im_doing=True,
**kwargs,
)
def test_virtual_display_does_not_mutate_process_environment(
monkeypatch, isolated_launch_dependencies
):
monkeypatch.delenv("DISPLAY", raising=False)
monkeypatch.setenv("GDK_BACKEND", "wayland")
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
monkeypatch.setenv("MOZ_ENABLE_WAYLAND", "1")
keys = ("DISPLAY", "GDK_BACKEND", "WAYLAND_DISPLAY", "MOZ_ENABLE_WAYLAND")
before = {key: os.environ.get(key) for key in keys}
options = _launch_with_virtual_display()
assert {key: os.environ.get(key) for key in keys} == before
assert options["env"]["DISPLAY"] == ":4242"
assert options["env"]["GDK_BACKEND"] == "x11"
assert "WAYLAND_DISPLAY" not in options["env"]
assert options["env"]["MOZ_ENABLE_WAYLAND"] == "0"
def test_virtual_display_does_not_mutate_caller_environment(
isolated_launch_dependencies,
):
caller_env = {
"UNCHANGED": "value",
"GDK_BACKEND": "wayland",
"WAYLAND_DISPLAY": "wayland-0",
"MOZ_ENABLE_WAYLAND": "1",
}
before = caller_env.copy()
options = _launch_with_virtual_display(env=caller_env)
assert caller_env == before
assert options["env"]["UNCHANGED"] == "value"
assert options["env"]["DISPLAY"] == ":4242"
assert options["env"]["GDK_BACKEND"] == "x11"
assert "WAYLAND_DISPLAY" not in options["env"]
assert options["env"]["MOZ_ENABLE_WAYLAND"] == "0"
class TestFontFallbackAsyncPref:
"""Per-character font fallback must not skip families whose
charmap is not loaded yet.
Gecko's GlobalFontFallback walks the shared font list for a family covering
the character; in a content process with async fallback on it hits the
`!family.IsFullyInitialized()` branch, schedules a cmap load and SKIPS the
family, so the first measurement of a character only one bundled family
provides returns the primary family's .notdef. Linux takes that path for
every fallback (UseCmapsDuringSystemFallback), so the font-hijacker change
that restored this on macOS cannot reach it there.
macOS must NOT get the pref: it uses the CoreText fallback, and forcing the
synchronous scan changed the face picked for U+1E9E in Futura (stock 21.733
-> 27.267), measured on a real Mac mini.
"""
PREF = "gfx.font_rendering.fallback.async"
def _prefs_for(self, ua, isolated):
return utils.launch_options(
config={"navigator.userAgent": ua},
i_know_what_im_doing=True,
)["firefox_user_prefs"]
def test_linux_disables_async_font_fallback(self, isolated_launch_dependencies):
prefs = self._prefs_for("Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0", None)
assert prefs[self.PREF] is False
def test_macos_keeps_async_font_fallback(self, isolated_launch_dependencies):
prefs = self._prefs_for(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0", None
)
assert self.PREF not in prefs
def test_windows_keeps_async_font_fallback(self, isolated_launch_dependencies):
prefs = self._prefs_for(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0", None
)
assert self.PREF not in prefs
def test_caller_pref_wins(self, isolated_launch_dependencies):
prefs = utils.launch_options(
config={"navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"},
firefox_user_prefs={"gfx.font_rendering.fallback.async": True},
i_know_what_im_doing=True,
)["firefox_user_prefs"]
assert prefs[self.PREF] is True
class TestUiLocaleFollowsIntlLocale:
"""The packaged browser locale must follow the spoofed Intl locale.
With locale="fr-FR" and the browser left on en-US, Intl formatting went
French while input.validationMessage and XML parse errors stayed English --
a mix no real Firefox produces. Packages now bake in the language packs;
intl.locale.requested selects one, and must always be set because an empty
value would follow the host OS locale.
"""
PREF = "intl.locale.requested"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
def _prefs(self, **kwargs):
return utils.launch_options(
config={"navigator.userAgent": self.UA, **kwargs.pop("config", {})},
i_know_what_im_doing=True,
**kwargs,
)["firefox_user_prefs"]
def test_default_identity_pins_en_us(self, isolated_launch_dependencies):
assert self._prefs()[self.PREF] == "en-US"
def test_spoofed_locale_selects_matching_ui_locale(self, isolated_launch_dependencies):
# handle_locale adds the likely script; Gecko's filtering negotiation
# treats a packaged "fr" as a range, so fr-Latn-FR still selects fr.
assert self._prefs(locale="fr-FR")[self.PREF] == "fr-Latn-FR"
assert self._prefs(locale="pt-BR")[self.PREF] == "pt-Latn-BR"
def test_first_of_several_locales_wins(self, isolated_launch_dependencies):
assert self._prefs(locale="de-DE, en-US")[self.PREF] == "de-Latn-DE"
def test_geoip_style_config_locale_is_used(self, isolated_launch_dependencies):
config = {"locale:language": "ja", "locale:region": "JP"}
assert self._prefs(config=config)[self.PREF] == "ja-JP"
def test_script_subtag_is_kept(self, isolated_launch_dependencies):
config = {"locale:language": "zh", "locale:script": "Hant", "locale:region": "TW"}
assert self._prefs(config=config)[self.PREF] == "zh-Hant-TW"
def test_caller_pref_wins(self, isolated_launch_dependencies):
prefs = self._prefs(locale="fr-FR", firefox_user_prefs={self.PREF: "de"})
assert prefs[self.PREF] == "de"
class TestPrefsReachStartup:
"""Launcher prefs must be readable by camoufox.cfg at startup.
Playwright's non-persistent launch writes no user.js, so firefox_user_prefs
only arrived via juggler after startup; intl.locale.requested lost the race
against Gecko's pre-created string bundles on Windows.
"""
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"
def test_prefs_are_passed_as_env(self, isolated_launch_dependencies):
import orjson
opts = utils.launch_options(config={"navigator.userAgent": self.UA}, locale="fr-FR", i_know_what_im_doing=True)
chunks = sorted((k for k in opts["env"] if k.startswith("CAMOU_PREFS_")), key=lambda k: int(k.rsplit("_", 1)[1]))
assert chunks and chunks[0] == "CAMOU_PREFS_1"
prefs = orjson.loads("".join(opts["env"][k] for k in chunks))
assert prefs == opts["firefox_user_prefs"]
assert prefs["intl.locale.requested"] == "fr-Latn-FR"
def test_large_prefs_are_chunked_in_order(self, monkeypatch):
import orjson
monkeypatch.setattr(utils, "OS_NAME", "win")
prefs = {f"camoufox.test.pref{i}": "x" * 50 for i in range(200)}
env = utils.get_pref_env_vars(prefs)
assert len(env) > 1 and all(len(v) <= 2047 for v in env.values())
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
assert orjson.loads(joined) == prefs
def test_no_prefs_no_env(self):
assert utils.get_pref_env_vars({}) == {}
class TestWindowsScrollbarsFollowVersion:
"""Windows 11 draws overlay scrollbars by default (stock 152.0.4 on Win11: 0 px),
Windows 10 classic 17 px ones; the identity's font draw decides the version."""
PREF = "ui.useOverlayScrollbars"
WIN_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"
def _prefs(self, fonts, ua=None):
return utils.launch_options(
config={"navigator.userAgent": ua or self.WIN_UA, "fonts": fonts},
i_know_what_im_doing=True,
)["firefox_user_prefs"]
def test_windows_11_fonts_get_overlay(self, isolated_launch_dependencies):
assert self._prefs(["Arial", "Segoe UI Variable Text"])[self.PREF] == 1
def test_windows_10_fonts_get_classic(self, isolated_launch_dependencies):
assert self._prefs(["Arial", "Segoe UI", "Calibri"])[self.PREF] == 0
def test_other_oses_overlay(self, isolated_launch_dependencies):
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0"
assert self._prefs(["DejaVu Sans"], ua=ua)[self.PREF] == 1
def test_marker_set_is_the_font_model(self):
from camoufox.fingerprints import _BASE_VARIANT_FONTS_WINDOWS, WINDOWS_11_MARKER_FONTS
assert WINDOWS_11_MARKER_FONTS == frozenset(_BASE_VARIANT_FONTS_WINDOWS[1])
class TestNativeWindowsVariantFonts:
"""A native Windows identity claims the Win11 font variant iff the host has it."""
def test_native_windows_11_host_claims_variant(self, monkeypatch):
from camoufox import fingerprints as fp
monkeypatch.setattr(fp, "_host_has_variant_fonts", lambda target_os: True)
fonts = fp._generate_random_font_subset("windows", seed=1, native=True)
assert fp.WINDOWS_11_MARKER_FONTS <= set(fonts)
def test_native_windows_10_host_does_not(self, monkeypatch):
from camoufox import fingerprints as fp
monkeypatch.setattr(fp, "_host_has_variant_fonts", lambda target_os: False)
fonts = fp._generate_random_font_subset("windows", seed=1, native=True)
assert not (fp.WINDOWS_11_MARKER_FONTS & set(fonts))
def test_non_windows_hosts_never_report_variant(self):
from camoufox import fingerprints as fp
assert fp._host_has_variant_fonts("linux") is False
assert fp._host_has_variant_fonts("macos") is False