diff --git a/pyproject.toml b/pyproject.toml index 3fe45c8..7793173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,12 @@ markers = [ "linux_only: tests that require Linux platform", ] addopts = "-m 'not slow and not e2e'" +# tests/playwright-upstream/ is a vendored Microsoft Playwright test suite +# used for compatibility verification on demand. It has its own deps +# (pixelmatch with API not matching our version) and a conftest that fails +# collection in our env. Run it explicitly with --override-ini for compat +# audits, not on every push. +norecursedirs = ["playwright-upstream"] [project.scripts] invisible-playwright = "invisible_playwright.cli:main" diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py new file mode 100644 index 0000000..00fd1a6 --- /dev/null +++ b/tests/test_service_worker.py @@ -0,0 +1,249 @@ +"""Service worker interception regression tests — issue #18 root cause. + +The bug: `juggler/content/NetworkObserver.js:channelIntercepted` called +`interceptedChannel.interceptAfterServiceWorkerResets()` — an IDL method +that upstream Playwright adds via a C++ patch (InterceptedHttpChannel.cpp ++ nsINetworkInterceptController.idl). Our fork was missing those patches +until firefox-6, so the call threw TypeError → C++ NetworkObserver was +left in an inconsistent state → content process disposal manifested as +"page crash" on sites whose service workers fall through to the network +(e.g., id.sky.com). + +These tests inline-serve a service worker via data: URLs / blob URLs +where possible — no external network required. They assert the page +stays alive across SW registration + fetch lifecycle. + +Run: + pytest tests/test_service_worker.py -m e2e -v + +For dev iteration: + INVPW_BINARY_PATH=/path/to/firefox.exe pytest tests/test_service_worker.py -m e2e -v +""" +from __future__ import annotations + +import http.server +import os +import socketserver +import sys +import threading + +import pytest + +from invisible_playwright import InvisiblePlaywright +from invisible_playwright.constants import BINARY_ENTRY_REL + + +@pytest.fixture(scope="session") +def firefox_binary(): + env_path = os.environ.get("INVPW_BINARY_PATH") + if env_path: + from pathlib import Path + if Path(env_path).exists(): + return env_path + pytest.skip(f"INVPW_BINARY_PATH={env_path!r} does not exist") + 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 not cached; run `python -m invisible_playwright fetch` " + "or set INVPW_BINARY_PATH" + ) + return str(entry) + + +# --------------------------------------------------------------------------- +# Local HTTP fixture server — service workers need a real http(s) origin +# (data: and about:blank are opaque-origin, no SW registration possible). +# --------------------------------------------------------------------------- + + +class _SWFixtureHandler(http.server.BaseHTTPRequestHandler): + """Serves a tiny set of routes for SW lifecycle testing.""" + + PAGES = { + "/": (200, "text/html", b""" +sw-host + + + +"""), + "/sw.js": (200, "application/javascript", b""" +self.addEventListener('install', e => self.skipWaiting()); +self.addEventListener('activate', e => e.waitUntil(clients.claim())); +self.addEventListener('fetch', e => { + if (e.request.url.endsWith('/from-sw')) { + e.respondWith(new Response('hello from SW', { + headers: {'content-type': 'text/plain'}, + })); + } + // Fall through for everything else - exercises the + // interceptAfterServiceWorkerResets path that was broken pre-firefox-6. +}); +"""), + "/from-sw": (200, "text/plain", b"network-fallback"), + "/from-network": (200, "text/plain", b"net-only"), + } + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path in self.PAGES: + status, ctype, body = self.PAGES[path] + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + # SW requires HTTPS or localhost — we're on localhost so plain http is fine + self.send_header("Service-Worker-Allowed", "/") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *args, **kwargs): + pass # silence stdout + + +@pytest.fixture(scope="module") +def fixture_server(): + """Spin up a localhost HTTP server with SW-friendly headers. Yields + the base URL (e.g., 'http://127.0.0.1:54321').""" + httpd = socketserver.TCPServer(("127.0.0.1", 0), _SWFixtureHandler) + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}" + finally: + httpd.shutdown() + httpd.server_close() + + +@pytest.fixture(scope="module") +def page(firefox_binary): + with InvisiblePlaywright( + seed=42, + binary_path=firefox_binary, + headless=True, + ) as browser: + ctx = browser.new_context() + p = ctx.new_page() + yield p + + +# --------------------------------------------------------------------------- +# Regression tests +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +def test_service_worker_registration_does_not_crash_page(page, fixture_server): + """Navigate to a page that registers a SW. The page must survive the + registration. Pre-firefox-6 this crashed if the SW path hit the missing + `interceptAfterServiceWorkerResets()` IDL method.""" + crashed = {"v": False} + page.on("crash", lambda p: crashed.__setitem__("v", True)) + + page.goto(f"{fixture_server}/", timeout=15_000) + # Wait for SW to register (or fail cleanly) + page.wait_for_function( + "window.__swState !== 'loading'", timeout=10_000 + ) + state = page.evaluate("window.__swState") + assert not crashed["v"], f"page crashed during SW registration (state={state!r})" + # state should be 'registered' or 'failed:...' (Firefox supports SW) + assert state in ("registered",) or state.startswith("failed:"), ( + f"unexpected SW state: {state!r}" + ) + + +@pytest.mark.e2e +def test_page_with_sw_can_navigate_repeatedly(page, fixture_server): + """Once a SW is registered, repeated navigations exercise the + interception path on every request. Pre-firefox-6, this hit the C++ + crash after a few cycles.""" + crashed = {"v": False} + page.on("crash", lambda p: crashed.__setitem__("v", True)) + + page.goto(f"{fixture_server}/", timeout=15_000) + page.wait_for_function("window.__swState !== 'loading'", timeout=10_000) + + # 5 reloads — the SW fetch handler runs each time + for _ in range(5): + page.reload(timeout=15_000) + assert not crashed["v"] + assert page.evaluate("document.title") == "sw-host" + + +@pytest.mark.e2e +def test_fetch_through_sw_returns_sw_synthesized_response(page, fixture_server): + """The SW intercepts `/from-sw` and synthesizes a response without + hitting the network. Verifies the SW fetch path is functional — this + is the exact flow that crashed in id.sky.com.""" + page.goto(f"{fixture_server}/", timeout=15_000) + page.wait_for_function("window.__swState === 'registered'", timeout=10_000) + + # First request to /from-sw routes through the SW + body = page.evaluate("""async (base) => { + const r = await fetch(base + '/from-sw'); + return await r.text(); + }""", fixture_server) + # Either the SW served 'hello from SW' (intercepted) or the network + # served 'network-fallback' (if SW didn't claim yet). Both are OK — + # the regression we test is that it doesn't CRASH. + assert body in ("hello from SW", "network-fallback"), ( + f"unexpected /from-sw response body: {body!r}" + ) + + +@pytest.mark.e2e +def test_sw_fall_through_to_network_does_not_crash(page, fixture_server): + """Request a URL the SW doesn't handle → falls through to network. + This is the `interceptAfterServiceWorkerResets()` code path: the SW + decides not to handle, the channel goes back to network. Without the + C++ patch, this is where the C++ side ended up in an inconsistent + state.""" + crashed = {"v": False} + page.on("crash", lambda p: crashed.__setitem__("v", True)) + + page.goto(f"{fixture_server}/", timeout=15_000) + page.wait_for_function("window.__swState === 'registered'", timeout=10_000) + + # /from-network is NOT intercepted by SW — exercises the fall-through + body = page.evaluate("""async (base) => { + const r = await fetch(base + '/from-network'); + return await r.text(); + }""", fixture_server) + assert body == "net-only" + assert not crashed["v"] + + +@pytest.mark.e2e +def test_sw_unregister_then_register_again(page, fixture_server): + """Unregistering then re-registering exercises lifecycle bookkeeping + in the C++ InterceptedHttpChannel state machine.""" + crashed = {"v": False} + page.on("crash", lambda p: crashed.__setitem__("v", True)) + + page.goto(f"{fixture_server}/", timeout=15_000) + page.wait_for_function("window.__swState === 'registered'", timeout=10_000) + + # Unregister all SWs then register again + result = page.evaluate("""async () => { + const regs = await navigator.serviceWorker.getRegistrations(); + for (const r of regs) await r.unregister(); + const r2 = await navigator.serviceWorker.register('/sw.js'); + return r2.scope; + }""") + assert "/" in result + assert not crashed["v"]