test(e2e): add 9 launcher lifecycle tests for Phase 9

Five test the constructor only (seed handling, eager profile build,
fail-fast pin validation) and always run. Four spin up the patched
Firefox and exercise the full `with InvisiblePlaywright(...)` lifecycle,
gated on a locally cached binary so CI without the binary skips
cleanly. All 230 tests pass on Windows with the binary fetched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chrissbaumann 2026-05-14 12:52:46 +02:00
parent 9c8d24408b
commit 234fe7e406

143
tests/test_e2e.py Normal file
View file

@ -0,0 +1,143 @@
"""E2E tests for the launcher lifecycle.
Tests requiring the patched Firefox binary are gated behind the
``firefox_binary`` fixture, which skips the test cleanly when the
binary is not cached locally and cannot be downloaded (e.g. no
network or no release token). The constructor-only tests (seed
handling) do not need a binary and always run.
"""
from __future__ import annotations
import sys
import pytest
from invisible_playwright import InvisiblePlaywright
from invisible_playwright.constants import BINARY_ENTRY_REL
@pytest.fixture(scope="session")
def firefox_binary():
"""Locate the patched Firefox binary or skip the calling test.
We do NOT trigger a network download here: ``ensure_binary`` would
pull a multi-hundred-megabyte archive from a private release,
which is not appropriate inside a unit/E2E test run. Instead we
look for an already-cached binary; if missing we skip.
"""
if sys.platform not in BINARY_ENTRY_REL:
pytest.skip(f"unsupported platform: {sys.platform}")
from invisible_playwright.download import cache_dir_for_version
entry = cache_dir_for_version() / BINARY_ENTRY_REL[sys.platform]
if not entry.exists():
pytest.skip(
"patched Firefox binary not cached; run `invisible-playwright fetch` "
"to enable E2E tests"
)
return str(entry)
# ────────────────────────────────────────────────────────────────────
# Constructor-only tests (no browser launch required)
# ────────────────────────────────────────────────────────────────────
@pytest.mark.e2e
def test_e3_seed_is_accessible():
"""E3: explicit seed is stored on the instance after construction."""
ip = InvisiblePlaywright(seed=42)
assert ip.seed == 42
@pytest.mark.e2e
def test_e4_random_seed_when_none():
"""E4: omitting seed → a fresh positive int31 is chosen."""
ip = InvisiblePlaywright()
assert isinstance(ip.seed, int)
assert ip.seed > 0
assert ip.seed < 2**31
@pytest.mark.e2e
def test_e4b_random_seed_varies_across_instances():
"""E4 extension: two no-seed instances pick different seeds with
overwhelming probability. ``secrets.randbits(31)`` collisions are
~1 in 2 billion, so we accept the negligible flake risk."""
seeds = {InvisiblePlaywright().seed for _ in range(5)}
assert len(seeds) > 1
@pytest.mark.e2e
def test_e6_profile_built_eagerly():
"""The constructor materializes the Profile up front so seed-driven
fields are accessible without launching a browser. Guards against
a regression where Profile generation is deferred into ``__enter__``
and an invalid pin therefore raises only at launch time.
"""
ip = InvisiblePlaywright(seed=42)
assert ip._profile is not None
assert ip._profile.seed == 42
@pytest.mark.e2e
def test_e7_invalid_pin_raises_in_constructor():
"""Invalid pin keys fail fast at construction, not at __enter__."""
with pytest.raises(ValueError):
InvisiblePlaywright(seed=42, pin={"not_a_real_field": 1})
# ────────────────────────────────────────────────────────────────────
# Lifecycle tests (require Firefox binary)
# ────────────────────────────────────────────────────────────────────
@pytest.mark.e2e
def test_e1_sync_context_manager_lifecycle(firefox_binary):
"""E1: ``with InvisiblePlaywright(...) as browser`` yields a real
Playwright Browser object that exposes ``new_context``."""
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
assert browser is not None
assert hasattr(browser, "new_context")
assert callable(browser.new_context)
@pytest.mark.e2e
def test_e2_create_context_and_page(firefox_binary):
"""E2: a context spawned from the patched browser can create a page."""
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
ctx = browser.new_context()
try:
page = ctx.new_page()
assert page is not None
assert hasattr(page, "goto")
finally:
ctx.close()
@pytest.mark.e2e
def test_e5_teardown_does_not_raise(firefox_binary):
"""E5: ``__exit__`` cleans up Playwright + virtual display without raising."""
ip = InvisiblePlaywright(seed=42, binary_path=firefox_binary)
browser = ip.__enter__()
try:
assert browser is not None
finally:
ip.__exit__(None, None, None)
# second teardown is idempotent
ip.__exit__(None, None, None)
@pytest.mark.e2e
def test_e8_new_context_defaults_from_profile(firefox_binary):
"""new_context() without kwargs should inherit profile-derived
viewport/screen. Guards the monkey-patch installed in __enter__."""
with InvisiblePlaywright(seed=42, binary_path=firefox_binary) as browser:
ctx = browser.new_context()
try:
page = ctx.new_page()
vp = page.viewport_size
assert vp is not None
assert vp["width"] > 0
assert vp["height"] > 0
finally:
ctx.close()