fix(fingerprint): keep the WebGL renderer coherent with the screen (#729)

BrowserForge picks navigator/screen; the GPU is drawn separately from
webgl_data.db weighted only by OS. Nothing ties the two together, so the
synthetic path emits pairs no real machine ships -- a discrete desktop GPU
behind a 1024x600 panel. Consistency checks (Pixelscan, Fingerprint.com) read
that as masking even though every individual value is plausible on its own.

Builds on @dyiapanis's #730, which identified the problem and the GPU-class
thresholds, with three changes:

  * Constrain the GPU to the screen rather than the screen to the GPU.
    sample_webgl_for_screen does rejection sampling, so the GPU keeps
    webgl_data.db's real OS-weighted distribution and the geometry -- already
    reconciled against the real display and the window box by
    clamp_screen_to_display / fix_screen_no_taskbar / clamp_window_dimensions
    / clamp_window_position -- is left alone.
  * Where no coherent GPU exists at all (BrowserForge still carries
    netbook-era geometry, and nothing in the pool drives a sub-1366x768
    panel), raise_screen_to_gpu_floor lifts the screen instead. It measures
    the screen-to-avail gap BEFORE mutating -- #730 computed it after
    overwriting screen.height, which turned a 1024x600 -> 1080 bump into a
    520px "taskbar", a fresh impossible-geometry tell -- and it runs BEFORE
    clamp_screen_to_display so a genuinely small monitor still wins and a
    headful window cannot be pushed back off its own display (#499).
  * No Apple-M Retina floor. Apple silicon also ships in the Mac mini and Mac
    Studio, which drive whatever external monitor is attached, so pinning it
    to 2560x1600 would reject real hardware and shrink the pool for nothing.

Measured over 300 synthetic fingerprints, incoherent GPU/screen pairs fall
from 54.3% to 0%, with avail <= screen and availHeight < height holding in
every trial. The screen floor is a no-op for the Linux and Windows pools
(0/400 draws below it) and fires on 3.5% of macOS draws, so the entropy cost
is confined to the implausible tail it exists to remove.

Co-authored-by: D Yiapanis <d@yiapanis.co>
This commit is contained in:
Jake Writer
2026-08-29 13:05:31 -06:00
co-authored by D Yiapanis
parent 160c806ad1
commit d6a806e2b5
3 changed files with 545 additions and 4 deletions
+204 -2
View File
@@ -4,7 +4,7 @@ import re
from dataclasses import asdict, dataclass
from pathlib import Path
from random import choice, randint, randrange, random, sample, shuffle
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, FrozenSet, List, Optional, Tuple
from browserforge.fingerprints import (
Fingerprint,
@@ -470,6 +470,201 @@ def set_media_devices_defaults(config: Dict[str, Any]) -> None:
config['mediaDevices:speakers'] = 0
# -- WebGL <-> screen coherence (#729) ---------------------------------------
#
# BrowserForge picks navigator/screen; the GPU is drawn separately from
# webgl_data.db weighted only by OS. Nothing ties the two together, so the
# synthetic path can emit pairs no real machine ships -- a discrete GPU behind
# a 1024x600 netbook panel. Consistency checks (Pixelscan, Fingerprint.com)
# read that as masking even when every individual value is plausible alone.
#
# What can honestly be claimed here is narrow, because Firefox never reports
# the GPU it actually sees. dom/canvas/SanitizeRenderer.cpp collapses every
# renderer string into one of ~11 representative device buckets before a page
# sees it (prefs webgl.sanitize-unmasked-renderer and
# webgl.enable-renderer-query, both default true; resistFingerprinting
# replaces the value with "Mozilla" outright). That file's own header comment
# gives the flavour: `"GeForce RTX 3090" => "GeForce GTX 980"`. So every RTX,
# every Quadro M/P/V/T and every GeForce 900-7999 arrive as one string, while
# "Intel(R) UHD Graphics 620" and "Mesa Intel(R) Iris(R) Xe Graphics" both
# arrive as an "Intel(R) HD Graphics" spelling.
#
# Two consequences. Matching on raw model names -- RTX, Quadro, RX, UHD, Iris,
# Mesa Intel -- can never fire, because those are exactly the strings Gecko
# collapses away. And a bucket spanning a desktop RTX 4090 and a mobile GTX
# 1650 Max-Q carries no useful screen floor: 1366x768 laptops with discrete
# NVIDIA GPUs are ordinary hardware, not a tell.
#
# So the rule below holds only what is true of *every* part behind a bucket,
# and renderers are reduced to their bucket first (see _renderer_bucket) so
# the ANGLE, nouveau and /PCIe/SSE2 spellings of one GPU land on one rule
# instead of three different ones.
# Software rasterizers. A VM or headless host reports whatever resolution the
# window manager hands it, so no screen constrains them -- and the sampler
# must never come to *prefer* them, because a software renderer is a far
# stronger "this is a bot" signal than any GPU/screen mismatch.
_SOFTWARE_RENDERERS: Tuple[str, ...] = (
'llvmpipe',
'Microsoft Basic Render Driver',
'SwiftShader',
'Generic Renderer',
)
# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Everything else in
# webgl_data.db reaches down into netbook territory and gets no floor at all:
# the "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook chipset,
# "Radeon HD 3200 Graphics" is Gecko's catch-all for a bare "AMD"/"Radeon"
# (the C-50/E-350 netbook APUs included), and Apple silicon drives arbitrary
# external monitors from a Mac mini or Mac Studio.
_DISCRETE_GPU_BUCKETS: FrozenSet[str] = frozenset(
{
'GeForce 8800 GTX',
'GeForce GTX 480',
'GeForce GTX 980',
'Radeon R9 200 Series',
}
)
# Discrete GPUs did not ship in netbooks, and netbook panels topped out at
# 1024x600. That is the whole of the claim.
#
# It is an area rather than a width x height pair because real panels do not
# dominate one another: 1280x800 and 1366x768 are both ordinary laptop
# screens, and a per-axis floor taken from either one rejects the other. A
# 1366x768 laptop with a discrete GPU is common hardware, not a tell.
_NETBOOK_MAX_PIXELS = 1024 * 600
# The three shapes SanitizeRenderer wraps a device bucket in.
_ANGLE_D3D_RE = re.compile(r'^ANGLE \([^,]*, (.*?) Direct3D.*\)$')
_ANGLE_VULKAN_RE = re.compile(r'^ANGLE \((.*)\) on Vulkan$')
_PCIE_SSE2_RE = re.compile(r'^(.*)/PCIe?/SSE2$')
def _renderer_bucket(renderer: str) -> str:
"""Reduce a reported renderer to Gecko's sanitized device bucket.
"ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or
similar" (Windows), "NVIDIA GeForce GTX 980/PCIe/SSE2" (Linux proprietary
driver) and "GeForce GTX 980, or similar" (nouveau, which loses the vendor
prefix) are one GPU class in three spellings. Without this they land on
three different rules, or none.
"""
core = renderer.removesuffix(', or similar')
match = _ANGLE_D3D_RE.match(core) or _ANGLE_VULKAN_RE.match(core)
if match:
core = match.group(1)
match = _PCIE_SSE2_RE.match(core)
if match:
core = match.group(1)
# SanitizeRenderer re-adds the "NVIDIA " prefix only when the raw string
# carried it, so one bucket arrives both with and without it.
return core.removeprefix('NVIDIA ')
# The smallest screen mainstream hardware still ships. BrowserForge's pool
# carries netbook-era geometry that essentially no 2026 device reports, and
# that is a tell on its own, whatever GPU sits behind it.
MODERN_SCREEN_FLOOR: Tuple[int, int] = (1366, 768)
def raise_screen_to_modern_floor(config: Dict[str, Any]) -> None:
"""Lift netbook-era screen geometry to something current hardware reports.
BrowserForge still draws 1024x600 and friends. Those panels left
production a decade and a half ago, so the screen is what has to move --
no GPU choice makes that profile look current.
Keeps the screen-to-avail gap intact so fix_screen_no_taskbar's invariant
survives; the window box is reconciled by clamp_window_dimensions and
clamp_window_position, which run after this. Call BEFORE
clamp_screen_to_display so a genuinely small real monitor still wins.
"""
min_w, min_h = MODERN_SCREEN_FLOOR
sw = config.get('screen.width')
sh = config.get('screen.height')
if not (sw and sh) or (sw >= min_w and sh >= min_h):
return
# Measure the gaps before mutating, or they get folded into themselves.
aw = config.get('screen.availWidth')
ah = config.get('screen.availHeight')
gap_w = sw - aw if aw else None
gap_h = sh - ah if ah else None
new_w, new_h = max(sw, min_w), max(sh, min_h)
config['screen.width'] = new_w
config['screen.height'] = new_h
if gap_w is not None:
config['screen.availWidth'] = max(1, new_w - max(0, gap_w))
if gap_h is not None:
config['screen.availHeight'] = max(1, new_h - max(0, gap_h))
def is_software_renderer(renderer: Optional[str]) -> bool:
"""Whether `renderer` is a software rasterizer rather than real hardware."""
return bool(renderer) and any(name in renderer for name in _SOFTWARE_RENDERERS)
def gpu_screen_is_plausible(
renderer: Optional[str], width: Optional[int], height: Optional[int]
) -> bool:
"""Whether `renderer` is a GPU that plausibly drives a `width` x `height` screen.
Unconstrained buckets and software rasterizers pass. The set only names
buckets whose floor holds for every part behind them, so anything absent
from it is genuinely unconstrained rather than merely unrecognised.
"""
if not renderer or not width or not height:
return True
if is_software_renderer(renderer):
return True
if _renderer_bucket(renderer) not in _DISCRETE_GPU_BUCKETS:
return True
return width * height > _NETBOOK_MAX_PIXELS
def sample_webgl_for_screen(
target_os: str,
width: Optional[int] = None,
height: Optional[int] = None,
attempts: int = 32,
) -> Dict[str, str]:
"""Sample a WebGL profile that is coherent with the screen already chosen.
Rejection sampling, so the GPU keeps webgl_data.db's real OS-weighted
distribution -- we only drop draws that contradict the screen. The screen
itself is left alone on purpose: it has already been reconciled with the
real display and the window box (clamp_screen_to_display,
fix_screen_no_taskbar, clamp_window_dimensions, clamp_window_position),
and widening it here to flatter the GPU would push a headful window back
off the monitor it is drawn on (#499).
The first draw settles hardware-vs-software at the pool's natural rate and
is never resampled once it lands on a rasterizer. Rejecting only hardware
draws would renormalise the survivors onto llvmpipe / WARP / SwiftShader:
on a small screen that turns a 1.5% software rate into a 40% one, trading
a weak incoherence for the strongest VM/headless tell there is.
Falls back to that first draw when the pool holds nothing coherent, so an
unusual screen degrades to today's behaviour rather than raising.
"""
first = sample_webgl(target_os)
renderer = first.get('webGl:renderer')
if is_software_renderer(renderer) or gpu_screen_is_plausible(renderer, width, height):
return first
for _ in range(attempts - 1):
candidate = sample_webgl(target_os)
renderer = candidate.get('webGl:renderer')
# Skip rather than accept: the class was settled by the first draw.
if is_software_renderer(renderer):
continue
if gpu_screen_is_plausible(renderer, width, height):
return candidate
return first
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
"""Pick the bundled-presets file appropriate for a given Firefox version.
@@ -812,7 +1007,14 @@ def generate_context_fingerprint(
else:
_target_os = 'mac'
try:
webgl_fp = sample_webgl(_target_os)
# Same coherence treatment launch_options applies (#729): lift
# netbook geometry, then keep the GPU consistent with whatever
# screen this identity ended up with. This path has no real
# display to reconcile against, so the floor is unconditional.
raise_screen_to_modern_floor(config)
webgl_fp = sample_webgl_for_screen(
_target_os, config.get('screen.width'), config.get('screen.height')
)
webgl_fp.pop('webGl2Enabled', None)
config.update(webgl_fp)
except Exception:
+18 -2
View File
@@ -21,7 +21,7 @@ from .exceptions import (
InvalidPropertyType,
NonFirefoxFingerprint,
)
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, set_media_devices_defaults
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults
from .geolocation import geoip_allowed, get_geolocation
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
from .locales import handle_locales
@@ -741,6 +741,16 @@ def launch_options(
if not _user_set_navigator:
fix_navigator_arch(config, target_os)
if not _user_set_screen_window:
# Lift netbook-era geometry to something current hardware reports,
# before the display clamp below so a genuinely small real monitor
# still wins (#729). Synthetic draws only: a preset is a real device,
# internally consistent by construction, and two of the bundled v150
# presets genuinely report sub-netbook screens (736x414, 960x540).
# Rewriting those to 1366x768 would break the very coherence #729 is
# about, and _user_set_screen_window is read before the preset merges
# in, so it does not cover this.
if not _used_preset:
raise_screen_to_modern_floor(config)
# Headful on a real monitor only: this bound exists so the window fits
# the screen it is drawn on. headless has no window to overflow, and
# headless='virtual' reaches here as headless=False (see async_api) with
@@ -897,7 +907,13 @@ def launch_options(
# Preset already set vendor/renderer — sample matching WebGL params
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'])
else:
webgl_fp = sample_webgl(target_os)
# Synthetic path: keep the GPU coherent with the screen BrowserForge
# already picked. Sampling the two independently yields pairs no
# real machine ships -- a discrete desktop GPU behind a 1024x600
# panel -- which consistency checks read as masking (#729).
webgl_fp = sample_webgl_for_screen(
target_os, config.get('screen.width'), config.get('screen.height')
)
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
# Merge the WebGL fingerprint into the config
@@ -0,0 +1,323 @@
"""
Tests for the WebGL <-> screen coherence helpers in camoufox.fingerprints.
Run with:
cd pythonlib && python -m pytest tests/test_webgl_screen_consistency.py -v
The regression these guard (daijro/camoufox#729): BrowserForge picks the
screen, webgl_data.db picks the GPU, and nothing ties them together -- so the
synthetic path can emit pairs no real machine ships (a discrete GPU behind a
1024x600 netbook panel).
The constraint has to be read through Gecko's renderer sanitizer
(dom/canvas/SanitizeRenderer.cpp), which collapses every renderer string into
one of ~11 device buckets before a page sees it. Two things follow, and most
of these tests exist to pin them down: raw model names never reach the page,
and a bucket spanning a desktop RTX 4090 and a mobile GTX 1650 Max-Q cannot
carry a 1080p floor.
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from camoufox import fingerprints # noqa: E402
from camoufox.fingerprints import ( # noqa: E402
MODERN_SCREEN_FLOOR,
_renderer_bucket,
gpu_screen_is_plausible,
is_software_renderer,
raise_screen_to_modern_floor,
sample_webgl_for_screen,
)
# The three spellings Gecko emits for one discrete-NVIDIA bucket.
_NV_ANGLE = "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar"
_NV_PCIE = "NVIDIA GeForce GTX 980/PCIe/SSE2"
_NV_NOUVEAU = "GeForce GTX 980, or similar"
_AMD_IGP = "ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0), or similar"
_INTEL = "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar"
_APPLE = "Apple M1, or similar"
_LLVMPIPE = "llvmpipe, or similar"
# -- bucket reduction --------------------------------------------------------
@pytest.mark.parametrize("renderer", [_NV_ANGLE, _NV_PCIE, _NV_NOUVEAU])
def test_every_spelling_of_one_gpu_reduces_to_one_bucket(renderer):
# ANGLE wrapping, the /PCIe/SSE2 suffix and nouveau's missing "NVIDIA "
# prefix are the same GPU class. Matching raw substrings put these on
# three different rules, so the same hardware got three different floors.
assert _renderer_bucket(renderer) == "GeForce GTX 980"
def test_angle_vendor_field_does_not_decide_the_bucket():
# Regression: matching the substring "ANGLE (AMD" gave this integrated
# chipset a discrete-GPU floor, while its Linux spelling got none.
assert _renderer_bucket(_AMD_IGP) == "Radeon HD 3200 Graphics"
assert _renderer_bucket("Radeon HD 3200 Graphics, or similar") == (
"Radeon HD 3200 Graphics"
)
def test_angle_vulkan_form_is_unwrapped():
assert _renderer_bucket("ANGLE (Samsung Xclipse 920) on Vulkan") == (
"Samsung Xclipse 920"
)
# -- what the table constrains ----------------------------------------------
@pytest.mark.parametrize("renderer", [_NV_ANGLE, _NV_PCIE, _NV_NOUVEAU])
def test_discrete_gpu_rejected_on_a_netbook_screen(renderer):
assert not gpu_screen_is_plausible(renderer, 1024, 600)
@pytest.mark.parametrize(
"width,height", [(1024, 768), (1280, 720), (1280, 800), (1366, 768), (1920, 1080)]
)
def test_discrete_gpu_accepted_on_any_real_laptop_panel(width, height):
# A 1366x768 laptop with a discrete NVIDIA GPU is ordinary hardware, and
# "GeForce GTX 980, or similar" is the bucket a mobile GTX 1650 Max-Q
# reports. Anything stricter rejects real machines.
assert gpu_screen_is_plausible(_NV_ANGLE, width, height)
def test_the_floor_is_an_area_not_a_per_axis_bound():
# 1280x800 and 1366x768 are both ordinary panels and neither dominates the
# other, so a per-axis floor taken from one throws out the other.
assert gpu_screen_is_plausible(_NV_ANGLE, 1280, 800)
assert gpu_screen_is_plausible(_NV_ANGLE, 1366, 768)
@pytest.mark.parametrize("width,height", [(1024, 600), (800, 480), (1024, 576)])
def test_discrete_gpu_rejected_on_netbook_panels(width, height):
assert not gpu_screen_is_plausible(_NV_ANGLE, width, height)
def test_integrated_parts_are_not_floored():
# Gecko's "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook
# chipset, and "Radeon HD 3200 Graphics" is its catch-all for a bare
# "AMD"/"Radeon" -- including netbook APUs. Neither carries a floor.
assert gpu_screen_is_plausible(_INTEL, 1024, 600)
assert gpu_screen_is_plausible(_AMD_IGP, 1024, 600)
def test_apple_silicon_is_not_pinned_to_retina():
# Apple silicon also ships in the Mac mini / Mac Studio, which drive
# whatever external monitor is attached.
assert gpu_screen_is_plausible(_APPLE, 1920, 1080)
assert gpu_screen_is_plausible(_APPLE, 1280, 800)
def test_raw_model_names_are_out_of_scope():
# Firefox never reports these: SanitizeRenderer turns an RTX 3070 into
# "GeForce GTX 980" and a UHD 620 into "Intel(R) HD Graphics 400" before
# the page sees anything. Keying on them was matching nothing.
assert gpu_screen_is_plausible(
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0)", 1024, 600
)
def test_missing_values_are_not_constrained():
assert gpu_screen_is_plausible(None, 1920, 1080)
assert gpu_screen_is_plausible(_NV_ANGLE, None, None)
# -- software rasterizers ----------------------------------------------------
@pytest.mark.parametrize(
"renderer",
[
_LLVMPIPE,
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
"Generic Renderer",
],
)
def test_software_renderers_are_unconstrained(renderer):
# A VM reports whatever the window manager hands it.
assert is_software_renderer(renderer)
assert gpu_screen_is_plausible(renderer, 1024, 600)
def test_hardware_is_not_mistaken_for_software(monkeypatch):
for renderer in (_NV_ANGLE, _INTEL, _APPLE, _AMD_IGP):
assert not is_software_renderer(renderer)
def test_software_first_draw_is_never_resampled(monkeypatch):
"""The strongest reason this sampler must not be a plain reject loop.
Rejecting hardware draws while accepting every software one renormalises
the pool onto llvmpipe / WARP / SwiftShader. On a sub-floor screen that
turned a 1.5% software rate into ~40%, trading a weak incoherence for the
strongest VM/headless tell there is. So the first draw settles the class.
"""
draws = iter([{"webGl:renderer": _LLVMPIPE}, {"webGl:renderer": _INTEL}])
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
# 1024x600 would reject a discrete GPU, but llvmpipe is plausible there and
# must be returned as drawn rather than swapped for the Intel part.
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _LLVMPIPE
def test_software_draws_are_skipped_when_resampling(monkeypatch):
# A hardware first draw settles the class as hardware, so a software
# candidate mid-loop is skipped instead of accepted -- otherwise the
# rejection loop still leaks probability mass onto the rasterizers.
draws = iter(
[
{"webGl:renderer": _NV_ANGLE}, # implausible at 1024x600
{"webGl:renderer": _LLVMPIPE}, # plausible, but wrong class
{"webGl:renderer": _INTEL}, # the coherent hardware answer
]
)
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _INTEL
def test_falls_back_to_the_first_draw_when_nothing_is_coherent(monkeypatch):
monkeypatch.setattr(
fingerprints, "sample_webgl", lambda *a, **kw: {"webGl:renderer": _NV_ANGLE}
)
fp = sample_webgl_for_screen("win", 800, 600, attempts=4)
assert fp["webGl:renderer"] == _NV_ANGLE
def test_a_plausible_first_draw_costs_one_query(monkeypatch):
# sample_webgl opens a fresh sqlite connection per call, and the common
# case (any screen at or above the floor) must not pay for 32 of them.
calls = []
def _counted(*args, **kwargs):
calls.append(args)
return {"webGl:renderer": _NV_ANGLE}
monkeypatch.setattr(fingerprints, "sample_webgl", _counted)
sample_webgl_for_screen("win", 1920, 1080)
assert len(calls) == 1
@pytest.mark.parametrize("target_os", ["win", "mac", "lin"])
def test_sampled_gpu_is_coherent_with_the_screen(target_os):
# Against the real webgl_data.db pool.
for _ in range(25):
fp = sample_webgl_for_screen(target_os, 1280, 800)
assert gpu_screen_is_plausible(fp.get("webGl:renderer"), 1280, 800)
# -- the screen floor --------------------------------------------------------
def test_screen_floor_lifts_netbook_geometry():
config = {
"screen.width": 1024,
"screen.height": 600,
"screen.availWidth": 1024,
"screen.availHeight": 560,
}
raise_screen_to_modern_floor(config)
assert (config["screen.width"], config["screen.height"]) == MODERN_SCREEN_FLOOR
# The taskbar gap has to survive, or fix_screen_no_taskbar's invariant --
# and CreepJS's noTaskbar check -- breaks.
assert config["screen.height"] - config["screen.availHeight"] == 40
assert config["screen.width"] - config["screen.availWidth"] == 0
assert config["screen.availHeight"] < config["screen.height"]
def test_screen_floor_leaves_an_adequate_screen_alone():
config = {
"screen.width": 1920,
"screen.height": 1080,
"screen.availWidth": 1920,
"screen.availHeight": 1040,
}
before = dict(config)
raise_screen_to_modern_floor(config)
assert config == before
def test_screen_floor_is_a_no_op_without_screen_values():
config = {}
raise_screen_to_modern_floor(config)
assert config == {}
# -- the two entry points must agree ----------------------------------------
@pytest.mark.parametrize("target_os", ["windows", "macos", "linux"])
def test_context_fingerprints_get_the_same_treatment(target_os):
"""generate_context_fingerprint() is the per-context API #729 names, and
build-tester drives the browser through it. It sampled the GPU with a bare
sample_webgl() and never applied the floor, so the coherence fix reached
launch_options() only."""
from camoufox.fingerprints import generate_context_fingerprint
for _ in range(15):
config = generate_context_fingerprint(os=target_os)["config"]
renderer = config.get("webGl:renderer")
width, height = config.get("screen.width"), config.get("screen.height")
assert renderer and width and height
assert gpu_screen_is_plausible(renderer, width, height)
assert width * height > 1024 * 600
def test_preset_screens_are_never_lifted():
"""Presets are real devices, coherent by construction -- #729 says so
explicitly. Two bundled v150 presets report genuinely sub-netbook screens,
and the floor used to rewrite them to 1366x768 because
_user_set_screen_window is computed before the preset merges in."""
import json
from pathlib import Path
from camoufox.utils import launch_options
presets = json.loads(
(Path(__file__).parent.parent / "camoufox" / "fingerprint-presets-v150.json").read_text()
)
def small_presets(node):
if isinstance(node, dict):
screen = node.get("screen")
if (
isinstance(screen, dict)
and screen.get("width")
and screen.get("height")
and screen["width"] * screen["height"] <= 1024 * 600
):
yield node
for value in node.values():
yield from small_presets(value)
elif isinstance(node, list):
for value in node:
yield from small_presets(value)
found = list(small_presets(presets))
assert found, "expected the v150 presets to still carry sub-netbook screens"
for preset in found:
env = launch_options(
headless=True, fingerprint_preset=preset, i_know_what_im_doing=True
)["env"]
raw = "".join(
env[k]
for k in sorted(
(k for k in env if k.startswith("CAMOU_CONFIG_")),
key=lambda k: int(k.rsplit("_", 1)[1]),
)
)
config = json.loads(raw)
assert (config["screen.width"], config["screen.height"]) == (
preset["screen"]["width"],
preset["screen"]["height"],
)