From 4f6254469e8df617502e49aba965478420a02c41 Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:59:47 +0200 Subject: [PATCH] test(launcher, headless, async_api): add 32 Phase 10 gap-coverage tests Final sweep adds unit tests for the modules left at 0% direct coverage after Phases 1-9: - launcher._tz_env: 7 tests covering the IANA -> POSIX mapping including the Phoenix / Honolulu no-DST regression cases - launcher._humanize_max_seconds, _default_context_kwargs: 11 tests on the constructor-side helpers (no browser launch) - _headless.make_virtual_display dispatcher + _WindowsVirtualDesktop init/teardown: 8 tests (Linux dispatch branch covered without spawning Xvfb, since __init__ does no I/O) - async_api.InvisiblePlaywright constructor parity with sync: 8 tests guarding against drift between the two APIs Suite: 230 -> 264 passing. Pyramid stays clean: 243 unit / 12 integration / 9 e2e. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_async_api.py | 83 ++++++++++++++++ tests/test_headless.py | 95 ++++++++++++++++++ tests/test_launcher_helpers.py | 171 +++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 tests/test_async_api.py create mode 100644 tests/test_headless.py create mode 100644 tests/test_launcher_helpers.py diff --git a/tests/test_async_api.py b/tests/test_async_api.py new file mode 100644 index 0000000..da818ee --- /dev/null +++ b/tests/test_async_api.py @@ -0,0 +1,83 @@ +"""Constructor-parity tests for the async ``InvisiblePlaywright``. + +The async API mirrors the sync launcher (same prefs pipeline, same +profile generation, same proxy handling). The only async-specific +surface is ``__aenter__`` / ``__aexit__`` and an awaitable ``new_page`` +patch — both require a real Firefox binary to exercise meaningfully and +are covered by the sync E2E tests via parity arguments. + +What we test here without launching a browser: the constructor builds +the same eager Profile, clamps the seed identically, and surfaces pin +validation errors at construction time. These guards keep the async +class from silently drifting away from the sync class as features land. +""" +from __future__ import annotations + +import pytest + +from invisible_playwright.async_api import InvisiblePlaywright as AsyncIP +from invisible_playwright.launcher import InvisiblePlaywright as SyncIP + + +@pytest.mark.unit +def test_async_explicit_seed_is_stored(): + ip = AsyncIP(seed=42) + assert ip.seed == 42 + + +@pytest.mark.unit +def test_async_random_seed_is_positive_int31(): + """Same int31 contract as sync: the C++ side rejects ``seed <= 0`` and + a 32-bit value risks the high bit looking negative.""" + ip = AsyncIP() + assert isinstance(ip.seed, int) + assert 0 < ip.seed < 2**31 + + +@pytest.mark.unit +def test_async_random_seed_varies_across_instances(): + seeds = {AsyncIP().seed for _ in range(5)} + assert len(seeds) > 1 + + +@pytest.mark.unit +def test_async_profile_built_eagerly_in_constructor(): + """Pin validation must fire before ``__aenter__`` — otherwise a user + only learns their pin is wrong when the browser launch starts.""" + ip = AsyncIP(seed=42) + assert ip._profile is not None + assert ip._profile.seed == 42 + + +@pytest.mark.unit +def test_async_invalid_pin_raises_in_constructor(): + with pytest.raises(ValueError): + AsyncIP(seed=42, pin={"not_a_real_field": 1}) + + +@pytest.mark.unit +def test_async_and_sync_share_seed_for_same_input(): + """Same seed → identical Profile across the two APIs. Both lean on + ``generate_profile(seed)``; if they diverge it means one of them + started doing extra sampling.""" + seed = 12345 + a = AsyncIP(seed=seed) + s = SyncIP(seed=seed) + assert a._profile == s._profile + + +@pytest.mark.unit +def test_async_seed_coerced_from_float(): + """``int(seed)`` truncation — matches sync clamping behaviour.""" + ip = AsyncIP(seed=42.9) + assert ip.seed == 42 + + +@pytest.mark.unit +def test_async_default_context_kwargs_match_sync(): + """The two ``_default_context_kwargs`` implementations must produce + the same dict for the same inputs. Guards against the async copy + drifting away when sync adds new keys.""" + a = AsyncIP(seed=42, timezone="America/New_York", locale="de-DE") + s = SyncIP(seed=42, timezone="America/New_York", locale="de-DE") + assert a._default_context_kwargs() == s._default_context_kwargs() diff --git a/tests/test_headless.py b/tests/test_headless.py new file mode 100644 index 0000000..2b17ea5 --- /dev/null +++ b/tests/test_headless.py @@ -0,0 +1,95 @@ +"""Unit tests for the ``_headless`` virtual-display dispatcher. + +The dispatcher (``make_virtual_display``) is the only piece of +``_headless`` we can exercise as a unit test on a single platform: +``_WindowsVirtualDesktop`` actually creates a Win32 desktop on +construction's later ``start()`` call, and ``_LinuxVirtualDisplay`` calls +``Xvfb`` — both belong in integration/E2E coverage. The dispatcher's +job is pure platform routing, which we patch via ``monkeypatch``. + +Per scope: Windows-specific + platform-agnostic only. We still cover +the Linux dispatch branch because instantiating ``_LinuxVirtualDisplay`` +does no I/O — Xvfb is only spawned in ``start()``, which we never call. +""" +from __future__ import annotations + +import pytest + +import invisible_playwright._headless as headless +from invisible_playwright._headless import ( + _LinuxVirtualDisplay, + _WindowsVirtualDesktop, + make_virtual_display, +) + + +@pytest.mark.unit +def test_make_virtual_display_returns_windows_desktop_on_win32(monkeypatch): + monkeypatch.setattr(headless.sys, "platform", "win32") + vd = make_virtual_display() + assert isinstance(vd, _WindowsVirtualDesktop) + + +@pytest.mark.unit +def test_make_virtual_display_returns_linux_xvfb_on_linux(monkeypatch): + """``__init__`` of ``_LinuxVirtualDisplay`` does no I/O — only ``start()`` + spawns Xvfb. Exercising the dispatcher here is safe on any host.""" + monkeypatch.setattr(headless.sys, "platform", "linux") + vd = make_virtual_display() + assert isinstance(vd, _LinuxVirtualDisplay) + + +@pytest.mark.unit +def test_make_virtual_display_accepts_linux_variants(monkeypatch): + """``sys.platform`` can be ``linux2`` on older Pythons / WSL builds. + The dispatcher uses ``startswith("linux")`` to accept all variants.""" + monkeypatch.setattr(headless.sys, "platform", "linux2") + assert isinstance(make_virtual_display(), _LinuxVirtualDisplay) + + +@pytest.mark.unit +def test_make_virtual_display_raises_on_darwin(monkeypatch): + """macOS is unsupported — the dispatcher must raise with a clear + message rather than returning a no-op shim. ``InvisiblePlaywright`` + relies on this to bail before launching Firefox on a system where + the patched binary doesn't exist.""" + monkeypatch.setattr(headless.sys, "platform", "darwin") + with pytest.raises(RuntimeError, match="Windows and Linux only"): + make_virtual_display() + + +@pytest.mark.unit +def test_make_virtual_display_raises_on_unsupported_platform(monkeypatch): + monkeypatch.setattr(headless.sys, "platform", "freebsd14") + with pytest.raises(RuntimeError, match="Windows and Linux only"): + make_virtual_display() + + +@pytest.mark.unit +def test_make_virtual_display_error_mentions_offending_platform(monkeypatch): + """Error message should include the actual ``sys.platform`` so the + user can diagnose why their CI / weird container is being rejected.""" + monkeypatch.setattr(headless.sys, "platform", "sunos5") + with pytest.raises(RuntimeError, match="sunos5"): + make_virtual_display() + + +@pytest.mark.unit +def test_windows_desktop_initial_state_is_clean(): + """Construction must not allocate Win32 resources — only ``start()`` + does. Allows users to instantiate ``InvisiblePlaywright`` without + pywin32 installed; the import error fires lazily when ``start()`` runs.""" + vd = _WindowsVirtualDesktop() + assert vd._desktop is None + assert vd._original_handle == 0 + + +@pytest.mark.unit +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.""" + vd = _WindowsVirtualDesktop() + vd.stop() + vd.stop() + assert vd._desktop is None + assert vd._original_handle == 0 diff --git a/tests/test_launcher_helpers.py b/tests/test_launcher_helpers.py new file mode 100644 index 0000000..5122e88 --- /dev/null +++ b/tests/test_launcher_helpers.py @@ -0,0 +1,171 @@ +"""Unit tests for pure helpers in ``launcher.py``. + +These cover code paths that are not exercised by the E2E launcher tests +(`test_e2e.py`) because they live in private helpers below the Playwright +boundary. The tests instantiate ``InvisiblePlaywright`` for the methods +that read ``self._profile`` but never enter ``__enter__``, so no Firefox +binary or virtual display is required. +""" +from __future__ import annotations + +import pytest + +from invisible_playwright import InvisiblePlaywright +from invisible_playwright.launcher import ( + _CHROME_H, + _CHROME_W, + _IANA_TO_POSIX_TZ, + _TASKBAR_H, + _tz_env, +) + + +# ── _tz_env (IANA → POSIX) ──────────────────────────────────────────── + + +@pytest.mark.unit +def test_tz_env_eastern_us_maps_to_posix_with_dst(): + """Eastern US zones share the same POSIX form; spot-check a few.""" + assert _tz_env("America/New_York") == "EST5EDT" + assert _tz_env("America/Detroit") == "EST5EDT" + assert _tz_env("America/Indiana/Indianapolis") == "EST5EDT" + + +@pytest.mark.unit +def test_tz_env_central_mountain_pacific_map_to_posix_with_dst(): + assert _tz_env("America/Chicago") == "CST6CDT" + assert _tz_env("America/Denver") == "MST7MDT" + assert _tz_env("America/Los_Angeles") == "PST8PDT" + + +@pytest.mark.unit +def test_tz_env_phoenix_strips_dst(): + """Arizona (outside Navajo Nation) does NOT observe DST. The POSIX + form must be ``MST7`` (no second segment) — using ``MST7MDT`` caused + FP Pro to deduce vpn_origin_timezone=America/Denver from a 60-minute + offset error in summer. Guard against regression of that mapping. + """ + assert _tz_env("America/Phoenix") == "MST7" + + +@pytest.mark.unit +def test_tz_env_honolulu_strips_dst(): + """Hawaii does not observe DST. POSIX form ``HST10`` (no DST segment).""" + assert _tz_env("Pacific/Honolulu") == "HST10" + + +@pytest.mark.unit +def test_tz_env_passthrough_for_unmapped_zone(): + """Zones outside the lookup table fall through to their IANA name — + glibc on Linux reads /usr/share/zoneinfo directly. Windows MSVCRT + won't understand them but that's accepted; the mapping covers the + common residential-proxy zones.""" + assert _tz_env("Europe/Berlin") == "Europe/Berlin" + assert _tz_env("Asia/Tokyo") == "Asia/Tokyo" + + +@pytest.mark.unit +def test_tz_env_empty_string_passes_through(): + """Empty string is never set as ``TZ`` by the caller, but the helper + is still defensive — return it unchanged rather than raising.""" + assert _tz_env("") == "" + + +@pytest.mark.unit +def test_iana_to_posix_phoenix_and_honolulu_present(): + """Sanity-check the no-DST entries are still in the mapping; deleting + them would silently revert the Phoenix DST bug.""" + assert _IANA_TO_POSIX_TZ["America/Phoenix"] == "MST7" + assert _IANA_TO_POSIX_TZ["Pacific/Honolulu"] == "HST10" + + +# ── InvisiblePlaywright._humanize_max_seconds ───────────────────────── + + +@pytest.mark.unit +def test_humanize_true_defaults_to_one_and_a_half_seconds(): + ip = InvisiblePlaywright(seed=42, humanize=True) + assert ip._humanize_max_seconds() == 1.5 + + +@pytest.mark.unit +def test_humanize_float_passes_through_as_seconds(): + ip = InvisiblePlaywright(seed=42, humanize=2.5) + assert ip._humanize_max_seconds() == 2.5 + + +@pytest.mark.unit +def test_humanize_int_coerced_to_float(): + """``humanize=3`` is valid (truthy, not ``True``) → float coercion.""" + ip = InvisiblePlaywright(seed=42, humanize=3) + out = ip._humanize_max_seconds() + assert out == 3.0 + assert isinstance(out, float) + + +@pytest.mark.unit +def test_humanize_small_float_passes_through(): + """Below the default cap — the user's value wins.""" + ip = InvisiblePlaywright(seed=42, humanize=0.4) + assert ip._humanize_max_seconds() == 0.4 + + +# ── InvisiblePlaywright._default_context_kwargs ─────────────────────── + + +@pytest.mark.unit +def test_default_context_viewport_subtracts_window_chrome(): + """Viewport must fit inside the spoofed screen with the headed + window chrome subtracted. Otherwise Playwright complains about the + viewport being larger than the screen.""" + ip = InvisiblePlaywright(seed=42) + kw = ip._default_context_kwargs() + p = ip._profile + assert kw["viewport"]["width"] == p.screen.width - _CHROME_W + assert kw["viewport"]["height"] == p.screen.height - _TASKBAR_H - _CHROME_H + + +@pytest.mark.unit +def test_default_context_screen_matches_profile(): + ip = InvisiblePlaywright(seed=42) + kw = ip._default_context_kwargs() + p = ip._profile + assert kw["screen"] == {"width": p.screen.width, "height": p.screen.height} + assert kw["device_scale_factor"] == p.screen.dpr + + +@pytest.mark.unit +def test_default_context_color_scheme_follows_dark_theme(): + """``color_scheme`` must match ``profile.dark_theme`` so the Playwright + realm tells matchMedia the same thing the prefs tell the chrome.""" + ip_dark = InvisiblePlaywright(seed=42, pin={"dark_theme": True}) + ip_light = InvisiblePlaywright(seed=42, pin={"dark_theme": False}) + assert ip_dark._default_context_kwargs()["color_scheme"] == "dark" + assert ip_light._default_context_kwargs()["color_scheme"] == "light" + + +@pytest.mark.unit +def test_default_context_includes_timezone_when_set(): + ip = InvisiblePlaywright(seed=42, timezone="America/New_York") + assert ip._default_context_kwargs()["timezone_id"] == "America/New_York" + + +@pytest.mark.unit +def test_default_context_omits_timezone_when_empty(): + """Default ``timezone=""`` means "let the host TZ leak through" — + Playwright must not receive ``timezone_id`` at all in that case, + otherwise it overrides to the literal empty string.""" + ip = InvisiblePlaywright(seed=42) + assert "timezone_id" not in ip._default_context_kwargs() + + +@pytest.mark.unit +def test_default_context_includes_locale_when_set(): + ip = InvisiblePlaywright(seed=42, locale="de-DE") + assert ip._default_context_kwargs()["locale"] == "de-DE" + + +@pytest.mark.unit +def test_default_context_omits_locale_when_empty(): + ip = InvisiblePlaywright(seed=42, locale="") + assert "locale" not in ip._default_context_kwargs()