From db3b0e8e55aa8fa0fd6556272d06ea1c59e6a8d4 Mon Sep 17 00:00:00 2001
From: Aryan Kumar
Date: Mon, 11 May 2026 12:23:30 +0530
Subject: [PATCH 01/13] chore: inline @percy/sdk-utils helpers for iframe +
ignore-selector parity
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Brings the same helper surface used by percy-nightwatch / percy-webdriverio
into percy-selenium-python directly: DEFAULT_MAX_FRAME_DEPTH, clamp_frame_depth,
normalize_ignore_selectors, is_unsupported_iframe_src, get_origin,
resolve_max_frame_depth, resolve_ignore_selectors, and the PercyContextLost
exception. No SDK version bump — Python doesn't share a utils package with
the JS SDKs, so the helpers ship inline.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
percy/snapshot.py | 109 ++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 100 insertions(+), 9 deletions(-)
diff --git a/percy/snapshot.py b/percy/snapshot.py
index 746bd3b..d9ba7a9 100644
--- a/percy/snapshot.py
+++ b/percy/snapshot.py
@@ -177,19 +177,110 @@ def process_frame(driver, frame_element, options, percy_dom_script):
}
-def _is_unsupported_iframe_src(frame_src):
- return (
- not frame_src or
- frame_src == "about:blank" or
- frame_src.startswith("javascript:") or
- frame_src.startswith("data:") or
- frame_src.startswith("vbscript:")
+# ---------------------------------------------------------------------------
+# Inlined SDK helpers (mirrors @percy/sdk-utils used by Node SDKs). We do not
+# bump a shared utils package — selenium-python ships these directly so that
+# behavior stays in sync with percy-nightwatch / percy-webdriverio.
+# ---------------------------------------------------------------------------
+
+DEFAULT_MAX_FRAME_DEPTH = 5
+
+
+def is_unsupported_iframe_src(frame_src):
+ """True if a frame's src cannot be navigated/loaded for serialization."""
+ if not frame_src:
+ return True
+ unsupported_exact = ("about:blank", "about:srcdoc")
+ unsupported_prefixes = (
+ "javascript:", "data:", "vbscript:", "blob:",
+ "chrome:", "chrome-extension:", "about:"
)
+ if frame_src in unsupported_exact:
+ return True
+ for prefix in unsupported_prefixes:
+ if frame_src.startswith(prefix):
+ return True
+ return False
+
+
+# Backwards-compatible private alias kept for any external callers.
+_is_unsupported_iframe_src = is_unsupported_iframe_src
+
+
+def get_origin(url):
+ """Return scheme://netloc for a URL, or None when parsing fails."""
+ try:
+ parsed = urlparse(url)
+ if not parsed.scheme or not parsed.netloc:
+ return None
+ return f"{parsed.scheme}://{parsed.netloc}"
+ except Exception: # pylint: disable=broad-except
+ return None
def _get_origin(url):
- parsed = urlparse(url)
- return f"{parsed.scheme}://{parsed.netloc}"
+ """Compat shim: previous Feature 1 code expected a non-None string."""
+ origin = get_origin(url)
+ return origin if origin is not None else ""
+
+
+def clamp_frame_depth(value, default_max=DEFAULT_MAX_FRAME_DEPTH):
+ """Clamp a user-provided depth into [1, default_max]."""
+ try:
+ n = int(value)
+ except (TypeError, ValueError):
+ return default_max
+ if n < 1:
+ return 1
+ if n > default_max:
+ return default_max
+ return n
+
+
+def normalize_ignore_selectors(value):
+ """Accept str|list|None and return a clean list[str]."""
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [value] if value.strip() else []
+ if isinstance(value, (list, tuple)):
+ return [s for s in value if isinstance(s, str) and s.strip()]
+ return []
+
+
+def resolve_max_frame_depth(options, percy_config):
+ """Read maxIframeDepth from per-snapshot options or percy.config.snapshot."""
+ options = options or {}
+ config = (percy_config or {}).get('snapshot', {}) if isinstance(percy_config, dict) else {}
+ raw = options.get('maxIframeDepth')
+ if raw is None:
+ raw = options.get('max_iframe_depth')
+ if raw is None:
+ raw = config.get('maxIframeDepth', DEFAULT_MAX_FRAME_DEPTH)
+ return clamp_frame_depth(raw)
+
+
+def resolve_ignore_selectors(options, percy_config):
+ """Read ignoreIframeSelectors from per-snapshot options or percy.config.snapshot."""
+ options = options or {}
+ config = (percy_config or {}).get('snapshot', {}) if isinstance(percy_config, dict) else {}
+ raw = options.get('ignoreIframeSelectors')
+ if raw is None:
+ raw = options.get('ignore_iframe_selectors')
+ if raw is None:
+ raw = config.get('ignoreIframeSelectors', [])
+ return normalize_ignore_selectors(raw)
+
+
+class PercyContextLost(Exception):
+ """Raised when an iframe-context switch goes wrong mid-traversal.
+
+ Carries any partial corsIframes capture already collected so the outer
+ caller can still emit a useful payload before bailing on the rest.
+ """
+ def __init__(self, message, partial_capture=None):
+ super().__init__(message)
+ self.partial_capture = partial_capture or []
def get_serialized_dom(driver, cookies, percy_dom_script=None, **kwargs):
# 1. Serialize the main page first (this adds the data-percy-element-ids)
From ce948407f0c2ba6ff93edf4f77f743a9645f8656 Mon Sep 17 00:00:00 2001
From: Aryan Kumar
Date: Mon, 11 May 2026 15:15:14 +0530
Subject: [PATCH 02/13] feat: nested CORS iframe capture with depth+cycle guard
and recovery
Implements feature parity with percy-nightwatch / percy-webdriverio for
cross-origin iframe serialization:
- Replaces the flat single-level iframe scan with a recursive
``process_frame_tree`` walk bounded by ``DEFAULT_MAX_FRAME_DEPTH``
(5, overridable via ``maxIframeDepth`` option or
``percy.config.snapshot.maxIframeDepth``).
- Adds an ancestor-URL cycle guard so frames that link back to a
previously-visited URL stop descending instead of recursing forever.
- Adds an ``enumerate_iframes_script`` JS helper that runs inside the
current frame context and returns metadata for every iframe
(src, srcdoc, percyElementId, dataPercyIgnore, matchesIgnoreSelector,
index). Nested-frame discovery now uses this script in the child
context so nested-frame origin comparisons are against the *immediate*
parent origin, not the page origin.
- ``data-percy-ignore`` attribute opt-out: any iframe with this attribute
is dropped before any switch.
- ``ignoreIframeSelectors`` option (and ``ignore_iframe_selectors`` /
``percy.config.snapshot.ignoreIframeSelectors``): selectors are baked
into the in-browser enumeration script so matching iframes are dropped
before being processed.
- Post-switch URL re-check via ``is_unsupported_iframe_src``: after
switching into a frame we read ``document.URL`` and bail if the
loaded document is about:blank, about:srcdoc, a net-error page, or
another unsupported scheme.
- ``PercyContextLost`` recovery: if ``switch_to.parent_frame()`` fails
at depth > 1 we raise ``PercyContextLost`` carrying the
``partial_capture`` collected so far. The top-level walk merges that
partial capture into the final ``corsIframes`` payload before
aborting sibling iteration (whose enumeration was performed in a
now-lost context).
All per-frame serialize calls force ``enableJavaScript=True`` to bypass
the standard iframe inlining path inside PercyDOM.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
percy/snapshot.py | 318 ++++++++++++++++++++++++++---
tests/test_snapshot.py | 453 ++++++++++++++++++++++++++++++-----------
2 files changed, 620 insertions(+), 151 deletions(-)
diff --git a/percy/snapshot.py b/percy/snapshot.py
index d9ba7a9..b0d5390 100644
--- a/percy/snapshot.py
+++ b/percy/snapshot.py
@@ -4,7 +4,7 @@
from contextlib import contextmanager
from functools import lru_cache
from time import sleep
-from urllib.parse import urlparse, urljoin
+from urllib.parse import urlparse
import requests
from selenium.webdriver import __version__ as SELENIUM_VERSION
@@ -150,7 +150,11 @@ def iframe_context(driver, frame_element):
driver.switch_to.parent_frame()
def process_frame(driver, frame_element, options, percy_dom_script):
- """Processes a single cross-origin frame to capture its snapshot."""
+ """Processes a single cross-origin frame to capture its snapshot.
+
+ Kept for backwards compatibility with existing tests/callers. New code paths
+ (nested CORS-iframe capture) go through ``process_frame_tree``.
+ """
frame_url = frame_element.get_attribute('src') or "unknown-src"
with iframe_context(driver, frame_element):
try:
@@ -177,6 +181,265 @@ def process_frame(driver, frame_element, options, percy_dom_script):
}
+# In-browser script that walks document.querySelectorAll('iframe') and returns
+# metadata for each. Mirrors percy-nightwatch's enumerateIframesScript so the
+# wire shape stays in sync. Selectors is a list[str] of CSS selectors that
+# users want to opt out of CORS iframe capture for.
+def enumerate_iframes_script(selectors):
+ selectors_json = json.dumps(list(selectors or []))
+ return (
+ "var __percySelectors = " + selectors_json + ";"
+ "var __percyIframes = document.querySelectorAll('iframe');"
+ "var __percyResult = [];"
+ "for (var i = 0; i < __percyIframes.length; i++) {"
+ " var f = __percyIframes[i];"
+ " var matchesIgnore = false;"
+ " if (__percySelectors && __percySelectors.length) {"
+ " for (var j = 0; j < __percySelectors.length; j++) {"
+ " try { if (f.matches(__percySelectors[j])) { matchesIgnore = true; break; } }"
+ " catch (e) {}"
+ " }"
+ " }"
+ " __percyResult.push({"
+ " src: f.src || '',"
+ " srcdoc: f.getAttribute('srcdoc'),"
+ " percyElementId: f.getAttribute('data-percy-element-id'),"
+ " dataPercyIgnore: f.hasAttribute('data-percy-ignore'),"
+ " matchesIgnoreSelector: matchesIgnore,"
+ " index: i"
+ " });"
+ "}"
+ "return __percyResult;"
+ )
+
+
+def _should_skip_iframe(iframe, current_origin):
+ """Mirror of nightwatch's shouldSkipIframe — pure on the enumerated metadata."""
+ if iframe.get('dataPercyIgnore'):
+ log(f"Skipping iframe marked with data-percy-ignore: {iframe.get('src') or '(no src)'}",
+ "debug")
+ return True
+ if iframe.get('matchesIgnoreSelector'):
+ log(f"Skipping iframe matching ignoreIframeSelectors: "
+ f"{iframe.get('src') or '(no src)'}", "debug")
+ return True
+ src = iframe.get('src') or ''
+ if not src or is_unsupported_iframe_src(src):
+ if src:
+ log(f"Skipping unsupported iframe src: {src}", "debug")
+ return True
+ if iframe.get('srcdoc'):
+ log(f"Skipping srcdoc iframe at index {iframe.get('index')}", "debug")
+ return True
+ frame_origin = get_origin(src)
+ if not frame_origin:
+ log(f"Skipping iframe with invalid URL: {src}", "debug")
+ return True
+ if frame_origin == current_origin:
+ log(f"Skipping same-origin iframe: {src}", "debug")
+ return True
+ if not iframe.get('percyElementId'):
+ log(f"Skipping cross-origin iframe without data-percy-element-id: {src}", "debug")
+ return True
+ return False
+
+
+def process_frame_tree(driver, iframe_meta, depth, ancestor_urls, ctx):
+ """Recursively capture a cross-origin iframe and any nested cross-origin
+ descendants. Bounded by ``ctx['max_frame_depth']`` to prevent runaway
+ recursion when pages link to each other in cycles. ``ancestor_urls`` is the
+ chain of frame URLs above this one — if the current frame's URL appears in
+ the chain we treat it as a cycle and stop descending.
+ """
+ max_frame_depth = ctx['max_frame_depth']
+ ignore_selectors = ctx['ignore_selectors']
+ serialize_options = ctx['serialize_options']
+ percy_dom_script = ctx['percy_dom_script']
+
+ if depth > max_frame_depth:
+ log(f"Reached max iframe nesting depth ({max_frame_depth}); "
+ f"stopping at {iframe_meta.get('src')}", "debug")
+ return []
+ if ancestor_urls and iframe_meta.get('src') in ancestor_urls:
+ log(f"Skipping cyclic iframe ({iframe_meta.get('src')} appears in ancestor chain)",
+ "debug")
+ return []
+
+ collected = []
+ switched_in = False
+ captured_error = None
+
+ try:
+ log(f"Processing cross-origin iframe (depth {depth}): {iframe_meta.get('src')}",
+ "debug")
+
+ # Find the iframe element by its data-percy-element-id rather than by
+ # numeric index, which avoids drift if the DOM mutated between
+ # enumeration and switch.
+ find_script = (
+ "return document.querySelector("
+ "'iframe[data-percy-element-id=\"' + arguments[0] + '\"]'"
+ ");"
+ )
+ iframe_element = driver.execute_script(
+ find_script, iframe_meta['percyElementId']
+ )
+ if not iframe_element:
+ log(f"Could not find iframe element with data-percy-element-id: "
+ f"{iframe_meta['percyElementId']}", "debug")
+ return []
+
+ driver.switch_to.frame(iframe_element)
+ switched_in = True
+
+ # Post-switch URL re-check: a frame's src attribute may have pointed
+ # somewhere reachable but the actual loaded document can be about:blank
+ # or a net-error page. Read document.URL inside the frame and bail if
+ # unsupported.
+ try:
+ inside_url = driver.execute_script("return document.URL;")
+ except Exception: # pylint: disable=broad-except
+ inside_url = None
+ if is_unsupported_iframe_src(inside_url):
+ log(f"Skipping iframe (post-switch URL unsupported): {inside_url}", "debug")
+ return []
+
+ # Inject PercyDOM and serialize. enableJavaScript is forced to True so
+ # that the standard iframe serialization path is bypassed — we handle
+ # CORS iframe serialization manually here.
+ driver.execute_script(percy_dom_script)
+ frame_options = {**serialize_options, 'enableJavaScript': True}
+ frame_result = driver.execute_script(
+ "return { snapshot: PercyDOM.serialize(" + json.dumps(frame_options) + "),"
+ " frameUrl: document.URL };"
+ )
+
+ if not frame_result or not frame_result.get('snapshot'):
+ log(f"Serialization returned empty result for frame: {iframe_meta.get('src')}",
+ "debug")
+ return []
+
+ frame_url = frame_result.get('frameUrl') or iframe_meta.get('src') or "unknown-src"
+ log(f"Captured cross-origin iframe (depth {depth}): {frame_url}", "debug")
+
+ collected.append({
+ "iframeData": {"percyElementId": iframe_meta['percyElementId']},
+ "iframeSnapshot": frame_result['snapshot'],
+ "frameUrl": frame_url
+ })
+
+ # Look for cross-origin iframes nested inside this frame and recurse.
+ # Same-origin descendants are already inlined as srcdoc by
+ # PercyDOM.serialize above. Compare each nested-frame origin against
+ # this frame's origin (the immediate parent), not the page origin.
+ if depth < max_frame_depth:
+ current_origin = get_origin(frame_url)
+ try:
+ child_iframes_raw = driver.execute_script(
+ enumerate_iframes_script(ignore_selectors)
+ )
+ except Exception as e: # pylint: disable=broad-except
+ log(f"Failed to enumerate nested iframes: {e}", "debug")
+ child_iframes_raw = []
+ child_iframes = child_iframes_raw if isinstance(child_iframes_raw, list) else []
+ next_ancestors = set(ancestor_urls or [])
+ next_ancestors.add(frame_url)
+ if iframe_meta.get('src'):
+ next_ancestors.add(iframe_meta['src'])
+ for child in child_iframes:
+ if _should_skip_iframe(child, current_origin):
+ continue
+ nested = process_frame_tree(driver, child, depth + 1, next_ancestors, ctx)
+ if nested:
+ collected.extend(nested)
+
+ return collected
+ except PercyContextLost as err:
+ # Merge any partial capture from the inner level before propagating.
+ if err.partial_capture:
+ collected.extend(err.partial_capture)
+ err.partial_capture = collected
+ raise
+ except Exception as error: # pylint: disable=broad-except
+ log(f"Failed to process cross-origin iframe {iframe_meta.get('src')}: {error}",
+ "debug")
+ captured_error = error
+ return collected
+ finally:
+ if switched_in:
+ # Step up exactly one level so an outer recursion can continue from
+ # its own context. If parent_frame fails we have no reliable way to
+ # land in the correct parent — fall back to default_content and
+ # signal the caller to stop iterating siblings (whose enumeration
+ # was performed in a now-lost context).
+ try:
+ driver.switch_to.parent_frame()
+ except Exception as e: # pylint: disable=broad-except
+ log(f"Failed to switch back to parent frame: {e}", "debug")
+ try:
+ driver.switch_to.default_content()
+ except Exception: # pylint: disable=broad-except
+ pass
+ if depth > 1:
+ lost = PercyContextLost(
+ f"Lost parent frame context: {e}",
+ partial_capture=collected
+ )
+ if captured_error is not None:
+ lost.__cause__ = captured_error
+ # pylint: disable=lost-exception
+ raise lost # noqa: B904
+
+
+def _capture_cors_iframes(driver, page_url, ctx):
+ """Top-level walk: enumerate page iframes, recurse into cross-origin ones."""
+ try:
+ try:
+ iframe_info_raw = driver.execute_script(
+ enumerate_iframes_script(ctx['ignore_selectors'])
+ )
+ except Exception as e: # pylint: disable=broad-except
+ log(f"Failed to enumerate top-level iframes: {e}", "debug")
+ return []
+ iframe_info = iframe_info_raw if isinstance(iframe_info_raw, list) else []
+ if not iframe_info:
+ return []
+
+ log(f"Found {len(iframe_info)} top-level iframe(s)", "debug")
+ page_origin = get_origin(page_url)
+ cors_iframes = []
+ skipped = 0
+
+ for iframe in iframe_info:
+ if _should_skip_iframe(iframe, page_origin):
+ skipped += 1
+ continue
+ try:
+ entries = process_frame_tree(
+ driver, iframe, 1, {page_url} if page_url else set(), ctx
+ )
+ except PercyContextLost as err:
+ log("Aborting further nested CORS capture due to lost frame context",
+ "debug")
+ if err.partial_capture:
+ cors_iframes.extend(err.partial_capture)
+ break
+ if entries:
+ cors_iframes.extend(entries)
+
+ log(f"Captured {len(cors_iframes)} cross-origin iframe(s) "
+ f"(top-level skipped: {skipped})", "debug")
+ return cors_iframes
+ except Exception as e: # pylint: disable=broad-except
+ log(f"Error capturing CORS iframes: {e}", "debug")
+ return []
+
+
+def expose_closed_shadow_roots(driver): # pylint: disable=unused-argument
+ """Stub — closed shadow DOM capture is added in a follow-up commit."""
+ return
+
+
# ---------------------------------------------------------------------------
# Inlined SDK helpers (mirrors @percy/sdk-utils used by Node SDKs). We do not
# bump a shared utils package — selenium-python ships these directly so that
@@ -282,37 +545,24 @@ def __init__(self, message, partial_capture=None):
super().__init__(message)
self.partial_capture = partial_capture or []
-def get_serialized_dom(driver, cookies, percy_dom_script=None, **kwargs):
+def get_serialized_dom(driver, cookies, percy_dom_script=None, percy_config=None, **kwargs):
# 1. Serialize the main page first (this adds the data-percy-element-ids)
dom_snapshot = driver.execute_script(f'return PercyDOM.serialize({json.dumps(kwargs)})')
- # 2. Process CORS IFrames
- try:
- page_origin = _get_origin(driver.current_url)
- iframes = driver.find_elements("tag name", "iframe")
- if iframes and percy_dom_script:
- processed_frames = []
- for frame in iframes:
- frame_src = frame.get_attribute('src')
- if _is_unsupported_iframe_src(frame_src):
- continue
-
- try:
- frame_origin = _get_origin(urljoin(driver.current_url, frame_src))
- except Exception as e:
- log(f"Skipping iframe \"{frame_src}\": {e}", "debug")
- continue
-
- if frame_origin == page_origin:
- continue
-
- result = process_frame(driver, frame, kwargs, percy_dom_script)
- if result:
- processed_frames.append(result)
-
- if processed_frames:
- dom_snapshot['corsIframes'] = processed_frames
- except Exception as e:
- log(f"Failed to process cross-origin iframes: {e}", "debug")
+ # 2. Process CORS iframes (nested, depth-capped, cycle-guarded, ignore-aware)
+ if percy_dom_script:
+ ctx = {
+ 'max_frame_depth': resolve_max_frame_depth(kwargs, percy_config),
+ 'ignore_selectors': resolve_ignore_selectors(kwargs, percy_config),
+ 'serialize_options': dict(kwargs),
+ 'percy_dom_script': percy_dom_script,
+ }
+ try:
+ page_url = driver.current_url
+ except Exception: # pylint: disable=broad-except
+ page_url = None
+ cors_iframes = _capture_cors_iframes(driver, page_url, ctx)
+ if cors_iframes:
+ dom_snapshot['corsIframes'] = cors_iframes
dom_snapshot['cookies'] = cookies
return dom_snapshot
@@ -430,7 +680,8 @@ def capture_responsive_dom(driver, cookies, config, percy_dom_script=None, **kwa
print(f'{width}x{height} ready, taking snapshot...')
_responsive_sleep()
dom_snapshot = get_serialized_dom(
- driver, cookies, percy_dom_script=percy_dom_script, **kwargs)
+ driver, cookies, percy_dom_script=percy_dom_script,
+ percy_config=config, **kwargs)
dom_snapshot['width'] = width
print(f'Taken snapshot for width: {width}, height: {height}')
dom_snapshots.append(dom_snapshot)
@@ -474,7 +725,8 @@ def percy_snapshot(driver, name, **kwargs):
)
else:
dom_snapshot = get_serialized_dom(
- driver, cookies, percy_dom_script=percy_dom_script, **kwargs)
+ driver, cookies, percy_dom_script=percy_dom_script,
+ percy_config=data['config'], **kwargs)
# Post the DOM to the snapshot endpoint with snapshot options and other info
response = requests.post(f'{PERCY_CLI_API}/percy/snapshot', json={**kwargs, **{
diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py
index 9419138..dbaa323 100644
--- a/tests/test_snapshot.py
+++ b/tests/test_snapshot.py
@@ -378,7 +378,12 @@ def test_posts_snapshots_to_the_local_percy_server_with_defer_and_responsive(sel
self.assertEqual(httpretty.last_request().path, '/percy/snapshot')
- s1 = httpretty.latest_requests()[2].parsed_body
+ snap_bodies = [
+ r.parsed_body for r in httpretty.latest_requests()
+ if r.path == '/percy/snapshot' and r.method == 'POST'
+ and isinstance(r.parsed_body, dict)
+ ]
+ s1 = snap_bodies[0]
self.assertEqual(s1['name'], 'Snapshot 1')
self.assertEqual(s1['url'], 'http://localhost:8000/')
self.assertEqual(s1['dom_snapshot'], expected_dom_snapshot)
@@ -391,15 +396,20 @@ def test_posts_snapshots_to_the_local_percy_server_for_responsive_dom_chrome(sel
driver = MockChrome.return_value
# execute_script calls (reload=False):
# [0] inject percy_dom [1] _setup_resize_listener [2] waitForResize
- # [3] resize-check w375 [4] serialize w375
- # [5] resize-check w390 [6] serialize w390 [7] restore resize-check
+ # [3] resize-check w375 [4] serialize w375 [5] enumerate iframes w375
+ # [6] resize-check w390 [7] serialize w390 [8] enumerate iframes w390
+ # [9] restore resize-check
driver.execute_script.side_effect = [
- '', '', None, 1, { 'html': 'some_dom' }, 2, { 'html': 'some_dom_1' }, 3
+ '', '', None,
+ 1, { 'html': 'some_dom' }, [],
+ 2, { 'html': 'some_dom_1' }, [],
+ 3
]
driver.get_cookies.return_value = ''
driver.execute_cdp_cmd.return_value = ''
driver.get_window_size.return_value = { 'height': 400, 'width': 800 }
- # Return empty iframe list so CORS-iframe code path is skipped
+ # find_elements is no longer used by the iframe path; left for any
+ # legacy callers.
driver.find_elements.return_value = []
mock_logger()
mock_healthcheck(widths = { "config": [375], "mobile": [390] })
@@ -425,12 +435,18 @@ def test_posts_snapshots_to_the_local_percy_server_for_responsive_dom_chrome(sel
def test_has_a_backwards_compatible_function(self):
mock_healthcheck()
mock_snapshot()
+ mock_logger()
percySnapshot(browser=self.driver, name='Snapshot')
self.assertEqual(httpretty.last_request().path, '/percy/snapshot')
- s1 = httpretty.latest_requests()[2].parsed_body
+ snap_bodies = [
+ r.parsed_body for r in httpretty.latest_requests()
+ if r.path == '/percy/snapshot' and r.method == 'POST'
+ and isinstance(r.parsed_body, dict)
+ ]
+ s1 = snap_bodies[0]
self.assertEqual(s1['name'], 'Snapshot')
self.assertEqual(s1['url'], 'http://localhost:8000/')
self.assertEqual(s1['dom_snapshot'], {
@@ -992,29 +1008,45 @@ def test_process_frame_returns_none_on_script_injection_failure(self):
self.assertIsNone(result)
+ # ------------------------------------------------------------------
+ # Helpers for nested-frame-tree tests. The new flow drives iframe
+ # discovery via driver.execute_script(enumerate_iframes_script(...))
+ # which returns a list of metadata dicts (no real WebElements).
+ # ------------------------------------------------------------------
+ @staticmethod
+ def _meta(src, percy_id, *, srcdoc=None, ignore=False, matches=False, index=0):
+ return {
+ "src": src,
+ "srcdoc": srcdoc,
+ "percyElementId": percy_id,
+ "dataPercyIgnore": ignore,
+ "matchesIgnoreSelector": matches,
+ "index": index,
+ }
+
def test_get_serialized_dom_populates_cors_iframes(self):
driver = Mock()
+ # execute_script call order:
+ # [0] main serialize [1] enumerate top-level iframes
+ # [2] querySelector (find iframe by id)
+ # [3] post-switch document.URL re-check
+ # [4] inject PercyDOM in frame
+ # [5] serialize the frame [6] enumerate nested iframes (empty)
driver.execute_script.side_effect = [
- {
- "html": '',
- "resources": [{"url": "https://cdn/main.css", "content": "m"}]},
+ {"html": '',
+ "resources": [{"url": "https://cdn/main.css", "content": "m"}]},
+ [self._meta("http://main.example.com/inner", None),
+ self._meta("https://cross.example.com/page", "cid-1", index=1)],
+ Mock(name="iframe_element"),
+ "https://cross.example.com/page",
None,
- {
- "html": "",
- "resources": [{"url": "https://cdn/frame.css", "content": "f"}]},
+ {"snapshot": {"html": "",
+ "resources": [{"url": "https://cdn/frame.css", "content": "f"}]},
+ "frameUrl": "https://cross.example.com/page"},
+ [],
]
driver.current_url = "http://main.example.com/"
- same_origin_frame = Mock()
- same_origin_frame.get_attribute = lambda attr: (
- "http://main.example.com/inner" if attr == 'src' else None
- )
- cross_origin_frame = Mock()
- cross_origin_frame.get_attribute = lambda attr: (
- "https://cross.example.com/page" if attr == 'src' else "cid-1"
- )
- driver.find_elements.return_value = [same_origin_frame, cross_origin_frame]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script="some_script")
self.assertIn("corsIframes", dom)
@@ -1023,23 +1055,18 @@ def test_get_serialized_dom_populates_cors_iframes(self):
self.assertEqual(entry["iframeData"]["percyElementId"], "cid-1")
self.assertEqual(entry["iframeSnapshot"]["html"], "")
self.assertEqual(entry["frameUrl"], "https://cross.example.com/page")
- # HTML is left unchanged (no srcdoc injection here — core handles that)
self.assertNotIn("srcdoc", dom["html"])
def test_get_serialized_dom_skips_blank_src_frames(self):
"""Frames with no src or src='about:blank' are not processed."""
driver = Mock()
- driver.execute_script.return_value = {
- "html": ''
- }
+ driver.execute_script.side_effect = [
+ {"html": ''},
+ [self._meta("about:blank", None),
+ self._meta("", None, index=1)],
+ ]
driver.current_url = "http://main.example.com/"
- blank_frame = Mock()
- blank_frame.get_attribute = lambda attr: ("about:blank" if attr == 'src' else None)
- no_src_frame = Mock()
- no_src_frame.get_attribute = lambda attr: (None if attr == 'src' else None)
- driver.find_elements.return_value = [blank_frame, no_src_frame]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script="some_script")
self.assertNotIn("corsIframes", dom)
@@ -1052,12 +1079,6 @@ def test_get_serialized_dom_no_cors_iframes_without_script(self):
}
driver.current_url = "http://main.example.com/"
- cross_origin_frame = Mock()
- cross_origin_frame.get_attribute = lambda attr: (
- "https://cross.example.com/page" if attr == 'src' else "cid-1"
- )
- driver.find_elements.return_value = [cross_origin_frame]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script=None)
self.assertNotIn("corsIframes", dom)
@@ -1065,71 +1086,62 @@ def test_get_serialized_dom_no_cors_iframes_without_script(self):
def test_get_serialized_dom_cookies_always_attached(self):
"""Cookies are always added to the dom_snapshot regardless of iframes."""
driver = Mock()
- driver.execute_script.return_value = {"html": ""}
+ driver.execute_script.side_effect = [{"html": ""}, []]
driver.current_url = "http://main.example.com/"
- driver.find_elements.return_value = []
cookies = [{"name": "session", "value": "abc"}]
- dom = local.get_serialized_dom(driver, cookies)
+ dom = local.get_serialized_dom(driver, cookies, percy_dom_script="script")
self.assertEqual(dom["cookies"], cookies)
def test_get_serialized_dom_same_host_different_scheme_is_cross_origin(self):
- """http://example.com and https://example.com differ in scheme → cross-origin.
- Previously the netloc-only check would miss this; the origin-based check catches it."""
+ """http://example.com vs https://example.com → cross-origin."""
driver = Mock()
driver.execute_script.side_effect = [
{"html": ''},
- None, # percy_dom inject into frame
- {"html": ""}, # frame serialize
+ [self._meta("https://main.example.com/widget", "percy-id-1")],
+ Mock(),
+ "https://main.example.com/widget",
+ None,
+ {"snapshot": {"html": ""}, "frameUrl": "https://main.example.com/widget"},
+ [],
]
driver.current_url = "http://main.example.com/"
- # Same host, DIFFERENT scheme — should be treated as cross-origin
- https_frame = Mock()
- https_frame.get_attribute = lambda attr: (
- "https://main.example.com/widget" if attr == 'src' else "percy-id-1"
- )
- driver.find_elements.return_value = [https_frame]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script="script")
self.assertIn("corsIframes", dom)
self.assertEqual(dom["corsIframes"][0]["iframeSnapshot"]["html"], "")
def test_get_serialized_dom_same_host_different_port_is_cross_origin(self):
- """http://example.com:3000 and http://example.com:4000 differ in port → cross-origin."""
+ """http://example.com:3000 vs http://example.com:4000 → cross-origin."""
driver = Mock()
driver.execute_script.side_effect = [
{"html": ''},
+ [self._meta("http://main.example.com:4000/widget", "percy-id-port")],
+ Mock(),
+ "http://main.example.com:4000/widget",
None,
- {"html": ""},
+ {"snapshot": {"html": ""},
+ "frameUrl": "http://main.example.com:4000/widget"},
+ [],
]
driver.current_url = "http://main.example.com:3000/"
- diff_port_frame = Mock()
- diff_port_frame.get_attribute = lambda attr: (
- "http://main.example.com:4000/widget" if attr == 'src' else "percy-id-port"
- )
- driver.find_elements.return_value = [diff_port_frame]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script="script")
self.assertIn("corsIframes", dom)
self.assertEqual(dom["corsIframes"][0]["iframeSnapshot"]["html"], "")
def test_get_serialized_dom_same_origin_is_not_cross_origin(self):
- """http://main.example.com/page1 and http://main.example.com/page2 share origin."""
+ """Same-origin iframes are skipped before any frame switch."""
driver = Mock()
- driver.execute_script.return_value = {"html": ""}
+ driver.execute_script.side_effect = [
+ {"html": ""},
+ [self._meta("http://main.example.com/inner.html", "percy-id-same")],
+ ]
driver.current_url = "http://main.example.com/"
- same_origin_frame = Mock()
- same_origin_frame.get_attribute = lambda attr: (
- "http://main.example.com/inner.html" if attr == 'src' else "percy-id-same"
- )
- driver.find_elements.return_value = [same_origin_frame]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script="script")
self.assertNotIn("corsIframes", dom)
@@ -1175,15 +1187,16 @@ def test_get_serialized_dom_corsIframes_entry_has_correct_structure(self):
driver = Mock()
driver.execute_script.side_effect = [
{"html": dom_html, "resources": []},
+ [self._meta("https://cross.example.com/page", "cid-1")],
+ Mock(),
+ "https://cross.example.com/page",
None,
- {"html": '
Frame
', "resources": [frame_resource]},
+ {"snapshot": {"html": 'Frame
',
+ "resources": [frame_resource]},
+ "frameUrl": "https://cross.example.com/page"},
+ [],
]
driver.current_url = "http://main.example.com/"
- frame = Mock()
- frame.get_attribute = lambda attr: (
- "https://cross.example.com/page" if attr == 'src' else "cid-1"
- )
- driver.find_elements.return_value = [frame]
dom = local.get_serialized_dom(driver, [], percy_dom_script="some_script")
@@ -1198,30 +1211,23 @@ def test_get_serialized_dom_multiple_cross_origin_frames(self):
"""All cross-origin frames are collected; same-origin frames are skipped."""
driver = Mock()
driver.execute_script.side_effect = [
- {
- "html": ''
- ''
- ''
- },
- None, {"html": ""},
- None, {"html": ""},
+ {"html": ''
+ ''
+ ''},
+ [self._meta("https://a.other.com/w1", "pid-1"),
+ self._meta("http://main.example.com/inner", "pid-same", index=1),
+ self._meta("https://b.other.com/w2", "pid-2", index=2)],
+ # frame 1
+ Mock(), "https://a.other.com/w1", None,
+ {"snapshot": {"html": ""}, "frameUrl": "https://a.other.com/w1"},
+ [],
+ # frame 2
+ Mock(), "https://b.other.com/w2", None,
+ {"snapshot": {"html": ""}, "frameUrl": "https://b.other.com/w2"},
+ [],
]
driver.current_url = "http://main.example.com/"
- frame1 = Mock()
- frame1.get_attribute = lambda attr: (
- "https://a.other.com/w1" if attr == 'src' else "pid-1"
- )
- frame2 = Mock()
- frame2.get_attribute = lambda attr: (
- "https://b.other.com/w2" if attr == 'src' else "pid-2"
- )
- same_origin = Mock()
- same_origin.get_attribute = lambda attr: (
- "http://main.example.com/inner" if attr == 'src' else "pid-same"
- )
- driver.find_elements.return_value = [frame1, same_origin, frame2]
-
dom = local.get_serialized_dom(driver, [], percy_dom_script="script")
self.assertIn("corsIframes", dom)
@@ -1231,13 +1237,15 @@ def test_get_serialized_dom_multiple_cross_origin_frames(self):
self.assertIn("pid-2", pids)
self.assertNotIn("pid-same", pids)
- def test_get_serialized_dom_handles_find_elements_exception(self):
- """If find_elements raises, the error is swallowed, cookies are still attached,
- and DOM stitching is skipped."""
+ def test_get_serialized_dom_handles_enumerate_exception(self):
+ """If iframe enumeration raises, the error is swallowed, cookies are still
+ attached, and CORS iframe stitching is skipped."""
driver = Mock()
- driver.execute_script.return_value = {"html": "