From 234fe7e40691f8ca970642849c9f6e524cf3efcf Mon Sep 17 00:00:00 2001 From: chrissbaumann Date: Thu, 14 May 2026 12:52:46 +0200 Subject: [PATCH] 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) --- tests/test_e2e.py | 143 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/test_e2e.py diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..e5f4e94 --- /dev/null +++ b/tests/test_e2e.py @@ -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()