diff --git a/tests/test_headless.py b/tests/test_headless.py index 2b17ea5..d979b34 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -13,6 +13,8 @@ does no I/O — Xvfb is only spawned in ``start()``, which we never call. """ from __future__ import annotations +import sys + import pytest import invisible_playwright._headless as headless @@ -85,11 +87,65 @@ def test_windows_desktop_initial_state_is_clean(): @pytest.mark.unit +@pytest.mark.skipif(sys.platform != "win32", reason="exercises Win32 ctypes") def test_windows_desktop_stop_is_idempotent_without_start(): """``stop()`` after never calling ``start()`` must be a no-op, so - ``__exit__`` from a failed launch can call it unconditionally.""" + ``__exit__`` from a failed launch can call it unconditionally. + + Skipped off Windows because ``stop()`` unconditionally resolves + ``ctypes.windll.user32`` at the top of the function — that symbol + only exists on Windows. The early-return logic is safe because + callers only instantiate this class via ``make_virtual_display()`` + which already routes on ``sys.platform == 'win32'``. + """ vd = _WindowsVirtualDesktop() vd.stop() vd.stop() assert vd._desktop is None assert vd._original_handle == 0 + + +# ────────────────────────────────────────────────────────────────────── +# _LinuxVirtualDisplay — construction-only smoke tests. ``start()`` is +# E2E because it spawns Xvfb; ``stop()`` is safe to call when no Xvfb +# was ever started, so we exercise that path explicitly. +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_linux_virtual_display_initial_state_is_clean(): + """Construction must not spawn Xvfb or mutate the environment — only + ``start()`` does. Mirrors the Windows construction-state test.""" + vd = _LinuxVirtualDisplay() + assert vd._proc is None + assert vd._display is None + assert vd._saved_env == {} + + +@pytest.mark.unit +def test_linux_virtual_display_geometry_default(): + """Default geometry is 1920x1080x24 — matches the profile sampler's + default screen and avoids the Xvfb default of 1280x1024 which the + fingerprint pipeline never produces.""" + vd = _LinuxVirtualDisplay() + assert vd._geometry == "1920x1080x24" + + +@pytest.mark.unit +def test_linux_virtual_display_custom_geometry(): + """Caller-supplied width/height feed straight into the Xvfb geometry + spec; the depth is always 24 (Firefox/ANGLE assume true-color).""" + vd = _LinuxVirtualDisplay(width=2560, height=1440) + assert vd._geometry == "2560x1440x24" + + +@pytest.mark.unit +def test_linux_virtual_display_stop_without_start_is_safe(): + """``stop()`` before ``start()`` must be a no-op — supports the + ``__exit__`` path on a launcher that failed before Xvfb was spawned. + Verifies no AttributeError on env restore (saved_env is empty).""" + vd = _LinuxVirtualDisplay() + vd.stop() + vd.stop() + assert vd._proc is None + assert vd._display is None diff --git a/tests/test_prefs.py b/tests/test_prefs.py index 41c0448..6e31cd9 100644 --- a/tests/test_prefs.py +++ b/tests/test_prefs.py @@ -5,6 +5,7 @@ import pytest from invisible_playwright._fpforge import generate_profile from invisible_playwright.prefs import ( + _LINUX_GENERIC_FONT_FACTORS, _accept_language, _font_metrics_for_platform, _WIN_LIGHT_COLORS, @@ -13,8 +14,9 @@ from invisible_playwright.prefs import ( @pytest.mark.unit -def test_translate_includes_gpu_renderer_windows(): +def test_translate_includes_gpu_renderer_windows(monkeypatch): """On Windows, renderer/vendor are cleared so ANGLE reports native hardware.""" + monkeypatch.setattr(sys, "platform", "win32") p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p) assert prefs["zoom.stealth.webgl.renderer"] == "" @@ -356,3 +358,158 @@ def test_lan_ip_seed_zero_has_no_zero_octets(): assert octets[1] == "168" assert int(octets[2]) >= 1 assert int(octets[3]) >= 1 + + +# ────────────────────────────────────────────────────────────────────── +# Linux-specific tests — exercise the branches that only fire when +# ``sys.platform.startswith("linux")``. Patched via ``monkeypatch`` so +# these run on any host CI environment. +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_font_metrics_linux_prepends_generic_factors(monkeypatch): + # FM1: Linux prepends the GTK/DejaVu compensation block to the + # per-font metrics string sampled from the profile. + monkeypatch.setattr(sys, "platform", "linux") + out = _font_metrics_for_platform("Arial|1.0,Verdana|0.9,") + assert out.startswith(_LINUX_GENERIC_FONT_FACTORS) + assert out.endswith("Arial|1.0,Verdana|0.9,") + + +@pytest.mark.unit +def test_font_metrics_linux_empty_input_returns_empty(monkeypatch): + # FM1b: even on Linux, empty profile metrics short-circuits before + # the prepend so we never emit a metrics pref containing only the + # generic block (which would surface as a tampering signal). + monkeypatch.setattr(sys, "platform", "linux") + assert _font_metrics_for_platform("") == "" + + +@pytest.mark.unit +def test_font_metrics_linux2_variant_uses_linux_branch(monkeypatch): + # FM1c: ``sys.platform`` can be ``linux2`` on older Pythons / odd + # WSL builds. ``startswith("linux")`` accepts both. + monkeypatch.setattr(sys, "platform", "linux2") + out = _font_metrics_for_platform("Verdana|0.9,") + assert out.startswith(_LINUX_GENERIC_FONT_FACTORS) + + +@pytest.mark.unit +def test_gpu_renderer_set_from_profile_on_linux(monkeypatch): + # PG1: on Linux we spoof to the profile's Windows-ANGLE renderer + # string so cross-platform sessions present a consistent Windows GPU. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer + assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor + assert prefs["zoom.stealth.webgl.renderer"] # non-empty + + +@pytest.mark.unit +def test_msaa_from_profile_on_linux(monkeypatch): + # PG3: on Linux, MSAA comes from the profile's sampled value rather + # than being pinned to 4 (which is the Windows ANGLE default). + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42, pin={"webgl.msaa_samples": 8}) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.webgl.msaa"] == 8 + assert prefs["webgl.msaa-samples"] == 8 + assert prefs["webgl.msaa-force"] is True + + +@pytest.mark.unit +def test_msaa_zero_disables_force_on_linux(monkeypatch): + # PG3b: MSAA=0 means "no MSAA" so ``webgl.msaa-force`` must be False. + # Verifies the ``> 0`` guard on the force flag. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42, pin={"webgl.msaa_samples": 0}) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.webgl.msaa"] == 0 + assert prefs["webgl.msaa-force"] is False + + +@pytest.mark.unit +def test_canvas_noise_mask_intel_on_linux(monkeypatch): + # CN1: Intel renderer → 1/16 noise (mask=15). Pinning the renderer + # exercises the live ``_renderer_lo`` branch on Linux (where the + # value is read from the profile rather than hardcoded as on Windows). + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile( + seed=42, + pin={ + "gpu.renderer": "ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)", + "gpu.vendor": "Google Inc. (Intel)", + }, + ) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 15 + + +@pytest.mark.unit +def test_canvas_noise_mask_nvidia_on_linux(monkeypatch): + # CN2: NVIDIA/AMD renderer → 1/8 noise (mask=7). The "intel" substring + # check must NOT match here. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile( + seed=42, + pin={ + "gpu.renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11 vs_5_0 ps_5_0, D3D11)", + "gpu.vendor": "Google Inc. (NVIDIA)", + }, + ) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 7 + + +@pytest.mark.unit +def test_webgl_extensions_preserved_on_linux(monkeypatch): + # WE1: on Linux the curated WebGL1/2 extension lists from _BASELINE + # remain in the prefs dict so the patched binary publishes them + # instead of native Mesa's set. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42) + prefs = translate_profile_to_prefs(p) + assert prefs["zoom.stealth.webgl.extensions"] + assert prefs["zoom.stealth.webgl2.extensions"] + # Spot-check a canonical Windows ANGLE extension is in the list. + assert "ANGLE_instanced_arrays" in prefs["zoom.stealth.webgl.extensions"] + assert "OVR_multiview2" in prefs["zoom.stealth.webgl2.extensions"] + + +@pytest.mark.unit +def test_xvfb_workarounds_applied_on_linux(monkeypatch): + # XW1: Linux Firefox under Xvfb can't run WebRender, so we force the + # software path. These are added via ``setdefault`` so callers can + # still override them via ``extra_prefs``. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42) + prefs = translate_profile_to_prefs(p) + assert prefs["gfx.webrender.all"] is False + assert prefs["gfx.webrender.force-disabled"] is True + assert prefs["webgl.force-enabled"] is True + + +@pytest.mark.unit +def test_xvfb_workarounds_caller_can_override(monkeypatch): + # XW1b: the workarounds are added with ``setdefault``, so a user- + # supplied ``extra_prefs`` value wins. Verifies the override path + # doesn't get clobbered by the platform branch. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42) + prefs = translate_profile_to_prefs( + p, extra_prefs={"webgl.force-enabled": False} + ) + assert prefs["webgl.force-enabled"] is False + + +@pytest.mark.unit +def test_virtual_display_no_op_on_linux(monkeypatch): + # VD3: ``virtual_display`` is a Windows-only concept (CreateDesktop + # alt-desktop GPU sandbox workaround). Even when True, Linux must + # not pick up ``security.sandbox.gpu.level``. + monkeypatch.setattr(sys, "platform", "linux") + p = generate_profile(seed=42) + prefs = translate_profile_to_prefs(p, virtual_display=True) + assert "security.sandbox.gpu.level" not in prefs