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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user