mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-07 08:35:12 +02:00
release: 0.1.8 - fix #20 cross-origin iframe regression (pref-only)
Pin fission.webContentIsolationStrategy=0 (IsolateNothing) in baseline
prefs. FF150 ships strategy=1 (IsolateEverything) by default, which
site-isolates every cross-origin iframe into a separate webIsolated
content process even when fission.autostart=False - different from
FF146 Playwright behavior. The parent's Juggler FrameTree then sees
the iframe placeholder with no docShell, no URL and a stale execution
context, so content_frame() returns None, frame.evaluate() throws
cross-origin permission errors, and frame_locator(...).click() times
out. Reproduced with a local cross-origin HTTP harness (two 127.0.0.1
ports = two SOP origins).
The fix is a single pref because that's the level the original Juggler
code paths assume - vanilla Playwright Firefox 146 ran with the looser
default. Disabling site-isolation costs nothing for a single-user
puppet browser; process-per-browser/profile isolation is unaffected.
Caller can A/B per session via
extra_prefs={"fission.webContentIsolationStrategy": 1}.
Tests: tests/test_cross_origin_iframe.py adds 4 unit sentinels (pref
in baseline, survives translate_profile_to_prefs, extra_prefs override
works) and 5 e2e sentinels that spin up two local HTTP servers on
random free ports and verify the four protocol operations that
regressed (page.frames URL tracking, content_frame(), frame.evaluate(),
frame_locator(...).locator(...)) plus dispatch_event('click')
end-to-end, for plain, sandboxed and titled iframes. A future FF
upgrade or A/B flipping the pref will fail the suite before shipping.
Verified clean: pytest -m unit (342 passed), fppro_full.py (ALL
CRITICAL FLAGS CLEAN), fppro_consistency.py (visitor_id stable).
BINARY_VERSION stays firefox-7 - Python-only release.
Issue: https://github.com/feder-cr/invisible_playwright/issues/20
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb3755cdd5
commit
64eef4daff
4 changed files with 335 additions and 13 deletions
295
tests/test_cross_origin_iframe.py
Normal file
295
tests/test_cross_origin_iframe.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"""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 sys
|
||||
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"""<!doctype html><html><head><title>parent</title></head><body>
|
||||
<h1>parent</h1>
|
||||
<iframe id="ifr_plain" src="http://127.0.0.1:{pb}/child" width="300" height="120"></iframe>
|
||||
<iframe id="ifr_sandbox" src="http://127.0.0.1:{pb}/child" width="300" height="120"
|
||||
sandbox="allow-scripts allow-same-origin"></iframe>
|
||||
<iframe id="ifr_titled" src="http://127.0.0.1:{pb}/child" width="300" height="120"
|
||||
title="cross-origin titled iframe"></iframe>
|
||||
</body></html>""".encode("utf-8")
|
||||
child_html = b"""<!doctype html><html><body>
|
||||
<button id="ok">confirm</button>
|
||||
<button class="btn-primary">primary</button>
|
||||
<script>document.getElementById('ok').addEventListener('click', () => document.title = 'clicked')</script>
|
||||
</body></html>"""
|
||||
sa = _serve(parent_html, pa)
|
||||
sb = _serve(child_html, pb)
|
||||
try:
|
||||
yield {"parent_url": f"http://127.0.0.1:{pa}/", "child_origin": f"http://127.0.0.1:{pb}"}
|
||||
finally:
|
||||
sa.shutdown()
|
||||
sb.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def firefox_binary():
|
||||
"""Locate the cached patched Firefox binary or skip."""
|
||||
from invisible_playwright.constants import BINARY_ENTRY_REL
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_url_appears_in_page_frames(firefox_binary, cross_origin_harness):
|
||||
"""``page.frames`` must list the cross-origin iframe with its real URL.
|
||||
|
||||
Before the pref fix, the URL came back as '' because the navigation
|
||||
observer for the iframe fired in a different content process than
|
||||
the parent's FrameTree was registered in.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
urls = [f.url for f in page.frames]
|
||||
assert any(cross_origin_harness["child_origin"] in (u or "") for u in urls), (
|
||||
f"no frame had the child origin in its URL; page.frames urls = {urls!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_content_frame_resolves(firefox_binary, cross_origin_harness):
|
||||
"""``handle.content_frame()`` must return a Frame (not None) for every
|
||||
cross-origin iframe shape we care about: plain, sandboxed, titled.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
for sel in ("iframe#ifr_plain", "iframe#ifr_sandbox", "iframe#ifr_titled"):
|
||||
handle = page.query_selector(sel)
|
||||
assert handle is not None, f"{sel!r} not found in DOM"
|
||||
cf = handle.content_frame()
|
||||
assert cf is not None, f"{sel!r}: content_frame() returned None"
|
||||
assert cross_origin_harness["child_origin"] in (cf.url or ""), (
|
||||
f"{sel!r}: content_frame().url = {cf.url!r}, "
|
||||
f"expected child origin {cross_origin_harness['child_origin']!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_evaluate_returns_real_values(firefox_binary, cross_origin_harness):
|
||||
"""``frame.evaluate()`` inside the cross-origin iframe must work.
|
||||
|
||||
Pre-fix: every evaluate failed with a cross-origin SOP error because
|
||||
the iframe ended up with a stale/wrong execution context.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
cf = page.query_selector("iframe#ifr_plain").content_frame()
|
||||
assert cf is not None
|
||||
href = cf.evaluate("() => location.href")
|
||||
assert cross_origin_harness["child_origin"] in href
|
||||
title = cf.evaluate("() => document.title")
|
||||
assert isinstance(title, str)
|
||||
n_buttons = cf.evaluate("() => document.querySelectorAll('button').length")
|
||||
assert n_buttons == 2
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_frame_locator_resolves_button(firefox_binary, cross_origin_harness):
|
||||
"""``frame_locator(...).locator(...)`` must reach the button inside the iframe."""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
|
||||
for selector in ("button#ok", "button.btn-primary"):
|
||||
cnt = page.frame_locator("iframe#ifr_plain").locator(selector).count()
|
||||
assert cnt == 1, f"locator({selector!r}) found {cnt} elements (expected 1)"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_cross_origin_iframe_dispatch_event_click_works(firefox_binary, cross_origin_harness):
|
||||
"""End-to-end interaction via ``dispatch_event`` must succeed.
|
||||
|
||||
Plain ``.click()`` can trip Playwright's actionability heuristic on
|
||||
some third-party UIs (same on vanilla Playwright Firefox — not our
|
||||
regression), but ``dispatch_event('click')`` always works once the
|
||||
iframe is reachable.
|
||||
"""
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
with InvisiblePlaywright(seed=42, binary_path=firefox_binary, humanize=False) as browser:
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
page.goto(cross_origin_harness["parent_url"], wait_until="domcontentloaded", timeout=30_000)
|
||||
page.wait_for_selector("iframe#ifr_plain", timeout=10_000)
|
||||
|
||||
page.frame_locator("iframe#ifr_plain").locator("button#ok").dispatch_event(
|
||||
"click", timeout=4_000
|
||||
)
|
||||
cf = page.query_selector("iframe#ifr_plain").content_frame()
|
||||
assert cf.evaluate("() => document.title") == "clicked"
|
||||
Loading…
Add table
Add a link
Reference in a new issue