"""Regression tests for cross-origin / cross-process iframe interaction. History: wrapper repo issue #20 reported that a third-party cookie consent iframe was completely unreachable from Playwright in 0.1.7 — ``element_handle.content_frame()`` returned ``None``, ``frame.evaluate()`` threw cross-origin SOP errors, and ``frame_locator().click()`` timed out. Root cause was a missing pref. FF150 ships with ``fission.webContentIsolationStrategy=1`` (IsolateEverything), which site-isolates cross-origin iframes into separate webIsolated content processes even when ``fission.autostart=False``. The Juggler code paths inherited from the FF146 era assume same-process iframes. The wrapper's ``_BASELINE`` now pins the pref to 0 (IsolateNothing). These tests exist so a future Firefox upgrade or a fingerprint A/B that flips this pref by accident cannot ship without a red CI signal. Layers: * ``unit`` — ``_BASELINE`` contains the pref with the right value. No browser. * ``e2e`` — launch the real binary against a LOCAL HTTP harness on ``127.0.0.1`` (two ports = two SOP origins) and verify the four protocol operations that regressed: frame URL tracking, ``handle.content_frame()``, ``frame.evaluate()``, and ``frame_locator(...).locator(...)`` element resolution. The e2e tests run entirely offline. They never call out to a real site; the cross-origin shape is reproduced with two local HTTP servers on random free ports. """ from __future__ import annotations import socket import threading from http.server import BaseHTTPRequestHandler, HTTPServer import pytest from invisible_playwright._fpforge import generate_profile from invisible_playwright.prefs import _BASELINE, translate_profile_to_prefs # ──────────────────────────────────────────────────────────────────── # Unit layer — fast, no browser, runs on every CI # ──────────────────────────────────────────────────────────────────── @pytest.mark.unit def test_baseline_pins_web_content_isolation_strategy_to_zero(): """Regression sentinel. ``fission.webContentIsolationStrategy`` MUST be 0 (IsolateNothing). The FF150 default is 1 (IsolateEverything), which site-isolates cross-origin iframes into separate webIsolated content processes and breaks Playwright frame tracking from the parent process. """ assert _BASELINE["fission.webContentIsolationStrategy"] == 0, ( "fission.webContentIsolationStrategy must be 0 (IsolateNothing). " "If you bumped it for an A/B, cross-origin iframes will appear " "in page.frames with empty URLs and content_frame() will return " "None — see the changelog entry that introduced this test." ) @pytest.mark.unit def test_baseline_keeps_fission_autostart_off(): """Belt for the suspenders above. All three prefs are required.""" assert _BASELINE["fission.autostart"] is False assert _BASELINE["fission.autostart.session"] is False assert _BASELINE["dom.ipc.processCount.webIsolated"] == 1 @pytest.mark.unit def test_translated_profile_propagates_isolation_strategy(): """The fix must survive translate_profile_to_prefs, not just live in _BASELINE.""" p = generate_profile(seed=42) prefs = translate_profile_to_prefs(p) assert prefs["fission.webContentIsolationStrategy"] == 0 @pytest.mark.unit def test_extra_prefs_override_can_break_isolation_only_explicitly(): """If a caller wants to A/B isolation, they have to set it explicitly. The wrapper does not silently flip it back on. """ p = generate_profile(seed=42) prefs_default = translate_profile_to_prefs(p) assert prefs_default["fission.webContentIsolationStrategy"] == 0 prefs_ab = translate_profile_to_prefs( p, extra_prefs={"fission.webContentIsolationStrategy": 1} ) assert prefs_ab["fission.webContentIsolationStrategy"] == 1 # ──────────────────────────────────────────────────────────────────── # E2E layer — needs cached binary + bind to localhost ports # ──────────────────────────────────────────────────────────────────── def _free_port() -> int: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() return port class _SilentHandler(BaseHTTPRequestHandler): """Suppress per-request access logging so pytest output stays clean.""" PAYLOAD = b"" # set per-instance via subclassing def log_message(self, *_a): pass def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(self.PAYLOAD) def _serve(payload: bytes, port: int) -> HTTPServer: """Start an HTTP server on 127.0.0.1:port serving ``payload`` on every GET.""" handler_cls = type( "_H", (_SilentHandler,), {"PAYLOAD": payload} ) srv = HTTPServer(("127.0.0.1", port), handler_cls) t = threading.Thread(target=srv.serve_forever, daemon=True) t.start() return srv @pytest.fixture def cross_origin_harness(): """Spin up TWO local HTTP servers on different localhost ports. Two ports = two distinct origins under SOP (same host, different port → different origin). The parent page on port A embeds an iframe with src pointing at port B. Same cross-origin browsing-context shape as a parent-page-plus-third-party-iframe layout, fully offline. """ pa, pb = _free_port(), _free_port() parent_html = f"""