diff --git a/Percy.Test/CorsIframesTest.cs b/Percy.Test/CorsIframesTest.cs
new file mode 100644
index 0000000..bcbae8b
--- /dev/null
+++ b/Percy.Test/CorsIframesTest.cs
@@ -0,0 +1,178 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using Xunit;
+
+namespace PercyIO.Selenium.Tests
+{
+ // Unit tests for the CORS iframe + closed shadow DOM helpers added to
+ // Percy.cs. These don't require the Percy CLI or a real browser; they
+ // exercise the pure-C# helpers via reflection where they are internal.
+ public class CorsIframesTest
+ {
+ // -- GetOrigin -----------------------------------------------------------
+
+ [Fact]
+ public void GetOrigin_ExtractsSchemeAndAuthority()
+ {
+ Assert.Equal("https://example.com", Percy.GetOrigin("https://example.com/some/path?q=1"));
+ Assert.Equal("http://localhost:8000", Percy.GetOrigin("http://localhost:8000/page"));
+ }
+
+ [Fact]
+ public void GetOrigin_ReturnsEmptyForInvalidOrEmptyUrl()
+ {
+ Assert.Equal("", Percy.GetOrigin(""));
+ Assert.Equal("", Percy.GetOrigin(null));
+ Assert.Equal("", Percy.GetOrigin("not a url"));
+ }
+
+ // -- IsUnsupportedIframeSrc ---------------------------------------------
+
+ [Theory]
+ [InlineData("javascript:void(0)", true)]
+ [InlineData("JAVASCRIPT:alert(1)", true)]
+ [InlineData("data:text/html,
", true)]
+ [InlineData("vbscript:foo()", true)]
+ [InlineData("", true)]
+ [InlineData(null, true)]
+ [InlineData("https://example.com/x", false)]
+ [InlineData("http://localhost/page", false)]
+ public void IsUnsupportedIframeSrc_RecognizesUnsupportedSchemes(string? src, bool expected)
+ {
+ Assert.Equal(expected, Percy.IsUnsupportedIframeSrc(src));
+ }
+
+ // -- ClampFrameDepth -----------------------------------------------------
+
+ [Theory]
+ [InlineData(0, Percy.DEFAULT_MAX_FRAME_DEPTH)]
+ [InlineData(-5, Percy.DEFAULT_MAX_FRAME_DEPTH)]
+ [InlineData(3, 3)]
+ [InlineData(100, Percy.MAX_ALLOWED_FRAME_DEPTH)]
+ public void ClampFrameDepth_AppliesBounds(int input, int expected)
+ {
+ Assert.Equal(expected, Percy.ClampFrameDepth(input));
+ }
+
+ // -- NormalizeIgnoreSelectors -------------------------------------------
+
+ [Fact]
+ public void NormalizeIgnoreSelectors_AcceptsSingleString()
+ {
+ var result = Percy.NormalizeIgnoreSelectors(".ad");
+ Assert.Equal(new List { ".ad" }, result);
+ }
+
+ [Fact]
+ public void NormalizeIgnoreSelectors_AcceptsArrayAndDropsEmpties()
+ {
+ var result = Percy.NormalizeIgnoreSelectors(new List { ".ad", "", null!, "iframe[data-ad]" });
+ Assert.Equal(new List { ".ad", "iframe[data-ad]" }, result);
+ }
+
+ [Fact]
+ public void NormalizeIgnoreSelectors_ReturnsEmptyOnNull()
+ {
+ Assert.Empty(Percy.NormalizeIgnoreSelectors(null));
+ }
+
+ // -- ShouldSkipIframe ----------------------------------------------------
+ //
+ // The skip helper is internal — call via reflection so tests live in the
+ // same project without changing visibility on production code.
+ private static bool InvokeShouldSkipIframe(object iframeInfo, string parentOrigin)
+ {
+ MethodInfo method = typeof(Percy).GetMethod(
+ "ShouldSkipIframe", BindingFlags.Static | BindingFlags.NonPublic)!;
+ return (bool)method.Invoke(null, new[] { iframeInfo, parentOrigin })!;
+ }
+
+ private static object MakeIframeInfo(string src, string? percyElementId,
+ bool dataPercyIgnore = false, bool matchesIgnoreSelector = false, string? srcdoc = null)
+ {
+ Type t = typeof(Percy).GetNestedType("IframeInfo", BindingFlags.NonPublic)!;
+ object info = Activator.CreateInstance(t)!;
+ t.GetField("Src")!.SetValue(info, src);
+ t.GetField("PercyElementId")!.SetValue(info, percyElementId);
+ t.GetField("DataPercyIgnore")!.SetValue(info, dataPercyIgnore);
+ t.GetField("MatchesIgnoreSelector")!.SetValue(info, matchesIgnoreSelector);
+ t.GetField("Srcdoc")!.SetValue(info, srcdoc);
+ return info;
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_SkipsDataPercyIgnore()
+ {
+ var info = MakeIframeInfo("https://cross.example.com", "p-1", dataPercyIgnore: true);
+ Assert.True(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_SkipsMatchesIgnoreSelector()
+ {
+ var info = MakeIframeInfo("https://ads.example.com", "p-2", matchesIgnoreSelector: true);
+ Assert.True(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_SkipsUnsupportedSrc()
+ {
+ var info = MakeIframeInfo("javascript:void(0)", "p-3");
+ Assert.True(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_SkipsSrcdoc()
+ {
+ var info = MakeIframeInfo("https://cross.example.com", "p-4", srcdoc: "x
");
+ Assert.True(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_SkipsSameOrigin()
+ {
+ var info = MakeIframeInfo("https://parent.example.com/iframe", "p-5");
+ Assert.True(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_SkipsMissingPercyElementId()
+ {
+ var info = MakeIframeInfo("https://cross.example.com", percyElementId: null);
+ Assert.True(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ [Fact]
+ public void ShouldSkipIframe_AllowsCrossOriginWithPercyElementId()
+ {
+ var info = MakeIframeInfo("https://cross.example.com/x", "p-6");
+ Assert.False(InvokeShouldSkipIframe(info, "https://parent.example.com"));
+ }
+
+ // Origin is compared to the IMMEDIATE parent, not the top-level page —
+ // a frame whose origin matches an ancestor higher up the chain should
+ // still be considered cross-origin from its parent.
+ [Fact]
+ public void ShouldSkipIframe_ComparesAgainstImmediateParentOrigin()
+ {
+ // Parent = http://b, child src points back to http://a (the top page).
+ // From the parent's perspective the child is cross-origin and should
+ // be captured.
+ var info = MakeIframeInfo("http://a.example.com/page", "p-7");
+ Assert.False(InvokeShouldSkipIframe(info, "http://b.example.com"));
+ }
+
+ // -- PercyContextLostException -------------------------------------------
+
+ [Fact]
+ public void PercyContextLostException_CarriesPartialCapture()
+ {
+ var ex = new Percy.PercyContextLostException("ctx lost");
+ ex.PartialCapture.Add(new Dictionary { ["frameUrl"] = "http://a/" });
+ Assert.Single(ex.PartialCapture);
+ Assert.Equal("http://a/", ex.PartialCapture[0]["frameUrl"]);
+ }
+ }
+}
diff --git a/Percy/Percy.cs b/Percy/Percy.cs
index 98f1731..32fc29c 100644
--- a/Percy/Percy.cs
+++ b/Percy/Percy.cs
@@ -312,7 +312,37 @@ public static Region CreateRegion(
return region;
}
- private static bool IsUnsupportedIframeSrc(string? src)
+ // Default cap on nested CORS iframe recursion. Inlined from @percy/sdk-utils
+ // (DEFAULT_MAX_FRAME_DEPTH) because .NET has no equivalent package.
+ internal const int DEFAULT_MAX_FRAME_DEPTH = 5;
+ internal const int MAX_ALLOWED_FRAME_DEPTH = 10;
+
+ // In-browser script that enumerates iframes and reports each frame's src,
+ // srcdoc, percyElementId, data-percy-ignore presence, and whether it matches
+ // any ignore selector. Returned as a JSON array (List of Dictionaries
+ // when read back through Selenium's executor).
+ internal const string ENUMERATE_IFRAMES_SCRIPT =
+ "var selectors = arguments[0] || [];" +
+ "var iframes = document.querySelectorAll('iframe');" +
+ "var result = [];" +
+ "for (var i = 0; i < iframes.length; i++) {" +
+ " var frame = iframes[i];" +
+ " var matchesIgnore = false;" +
+ " for (var j = 0; j < selectors.length; j++) {" +
+ " try { if (frame.matches(selectors[j])) { matchesIgnore = true; break; } } catch (e) {}" +
+ " }" +
+ " result.push({" +
+ " src: frame.src || ''," +
+ " srcdoc: frame.getAttribute('srcdoc')," +
+ " percyElementId: frame.getAttribute('data-percy-element-id')," +
+ " dataPercyIgnore: frame.hasAttribute('data-percy-ignore')," +
+ " matchesIgnoreSelector: matchesIgnore," +
+ " index: i" +
+ " });" +
+ "}" +
+ "return result;";
+
+ internal static bool IsUnsupportedIframeSrc(string? src)
{
return string.IsNullOrEmpty(src) ||
src.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase) ||
@@ -320,8 +350,9 @@ private static bool IsUnsupportedIframeSrc(string? src)
src.StartsWith("vbscript:", StringComparison.OrdinalIgnoreCase);
}
- private static string GetOrigin(string url)
+ internal static string GetOrigin(string? url)
{
+ if (string.IsNullOrEmpty(url)) return "";
try
{
Uri uri = new Uri(url);
@@ -333,63 +364,564 @@ private static string GetOrigin(string url)
}
}
- private static Dictionary? ProcessFrame(
- WebDriver driver,
- IWebElement frameElement,
- Dictionary? options,
- string domJs)
+ // Clamp user-supplied iframe nesting depth to a safe range. Mirrors
+ // @percy/sdk-utils clampFrameDepth — anything non-positive falls back to
+ // the default, anything above MAX_ALLOWED_FRAME_DEPTH is capped.
+ internal static int ClampFrameDepth(int depth, int defaultMax = DEFAULT_MAX_FRAME_DEPTH)
{
- // Read attributes while still in parent context — these calls will
- // fail if made after switchTo().frame().
- string? frameUrl = frameElement.GetAttribute("src") ?? "unknown-src";
- Log($"processFrame: checking iframe src=\"{frameUrl}\"", "debug");
+ if (depth <= 0) return defaultMax;
+ if (depth > MAX_ALLOWED_FRAME_DEPTH) return MAX_ALLOWED_FRAME_DEPTH;
+ return depth;
+ }
- string? percyElementId = frameElement.GetAttribute("data-percy-element-id");
- Log($"processFrame: data-percy-element-id=\"{percyElementId}\" for src=\"{frameUrl}\"", "debug");
- if (string.IsNullOrEmpty(percyElementId))
+ // Accept either a single string or a list of strings as the
+ // ignoreIframeSelectors input, normalize to a flat List, and
+ // strip any non-string / empty entries.
+ internal static List NormalizeIgnoreSelectors(object? input)
+ {
+ var result = new List();
+ if (input == null) return result;
+ if (input is string s)
{
- Log($"Skipping frame {frameUrl}: no matching percyElementId found", "debug");
- return null;
+ if (!string.IsNullOrWhiteSpace(s)) result.Add(s);
+ return result;
+ }
+ if (input is System.Collections.IEnumerable enumerable)
+ {
+ foreach (var item in enumerable)
+ {
+ if (item is string str && !string.IsNullOrWhiteSpace(str))
+ result.Add(str);
+ }
+ }
+ return result;
+ }
+
+ private static int ResolveMaxFrameDepth(Dictionary? options)
+ {
+ int? candidate = null;
+ if (options != null && options.TryGetValue("maxIframeDepth", out object? v) && v != null)
+ {
+ if (v is int iv) candidate = iv;
+ else if (v is long lv) candidate = (int)lv;
+ else if (int.TryParse(v.ToString(), out int parsed)) candidate = parsed;
+ }
+ if (candidate == null && cliConfig != null)
+ {
+ try
+ {
+ JsonElement config = (JsonElement)cliConfig;
+ if (config.TryGetProperty("snapshot", out JsonElement snap) &&
+ snap.TryGetProperty("maxIframeDepth", out JsonElement md) &&
+ md.ValueKind == JsonValueKind.Number)
+ {
+ candidate = md.GetInt32();
+ }
+ }
+ catch { /* fall through */ }
+ }
+ return ClampFrameDepth(candidate ?? DEFAULT_MAX_FRAME_DEPTH);
+ }
+
+ private static List ResolveIgnoreSelectors(Dictionary? options)
+ {
+ object? raw = null;
+ if (options != null && options.TryGetValue("ignoreIframeSelectors", out object? optVal))
+ {
+ raw = optVal;
+ }
+ else if (cliConfig != null)
+ {
+ try
+ {
+ JsonElement config = (JsonElement)cliConfig;
+ if (config.TryGetProperty("snapshot", out JsonElement snap) &&
+ snap.TryGetProperty("ignoreIframeSelectors", out JsonElement sel))
+ {
+ if (sel.ValueKind == JsonValueKind.String)
+ {
+ raw = sel.GetString();
+ }
+ else if (sel.ValueKind == JsonValueKind.Array)
+ {
+ var list = new List();
+ foreach (var item in sel.EnumerateArray())
+ if (item.ValueKind == JsonValueKind.String) list.Add(item.GetString() ?? "");
+ raw = list;
+ }
+ }
+ }
+ catch { /* fall through */ }
+ }
+ return NormalizeIgnoreSelectors(raw);
+ }
+
+ // Raised inside ProcessFrameTree when SwitchTo().ParentFrame() (or its
+ // fallback) cannot reliably return us to the parent context. Carries
+ // whatever frames the recursion managed to capture before the failure
+ // so callers can still ship a partial result.
+ public class PercyContextLostException : Exception
+ {
+ public List> PartialCapture { get; set; }
+ public PercyContextLostException(string message, Exception? inner = null)
+ : base(message, inner)
+ {
+ PartialCapture = new List>();
+ }
+ }
+
+ // Metadata for an iframe discovered via ENUMERATE_IFRAMES_SCRIPT. Built from
+ // the raw Dictionary returned by the in-browser script so the rest of the
+ // C# code can deal with typed fields instead of dynamic property access.
+ internal class IframeInfo
+ {
+ public string Src = "";
+ public string? Srcdoc;
+ public string? PercyElementId;
+ public bool DataPercyIgnore;
+ public bool MatchesIgnoreSelector;
+ public int Index;
+
+ public static IframeInfo FromDictionary(IDictionary d)
+ {
+ var info = new IframeInfo();
+ if (d.TryGetValue("src", out object? src) && src != null) info.Src = src.ToString() ?? "";
+ if (d.TryGetValue("srcdoc", out object? sd)) info.Srcdoc = sd?.ToString();
+ if (d.TryGetValue("percyElementId", out object? pid)) info.PercyElementId = pid?.ToString();
+ if (d.TryGetValue("dataPercyIgnore", out object? dpi)) info.DataPercyIgnore = Convert.ToBoolean(dpi);
+ if (d.TryGetValue("matchesIgnoreSelector", out object? mis)) info.MatchesIgnoreSelector = Convert.ToBoolean(mis);
+ if (d.TryGetValue("index", out object? idx) && idx != null && int.TryParse(idx.ToString(), out int parsed))
+ info.Index = parsed;
+ return info;
+ }
+ }
+
+ // Enumerate iframes in the current frame context. Returns a list of
+ // IframeInfo. Each entry carries enough metadata for ShouldSkipIframe to
+ // decide whether to recurse without re-querying the DOM.
+ private static List EnumerateIframes(WebDriver driver, List ignoreSelectors)
+ {
+ var raw = driver.ExecuteScript(ENUMERATE_IFRAMES_SCRIPT, ignoreSelectors);
+ var result = new List();
+ var items = raw as System.Collections.IEnumerable;
+ if (items == null) return result;
+ foreach (var item in items)
+ {
+ if (item is IDictionary dict)
+ {
+ result.Add(IframeInfo.FromDictionary(dict));
+ }
+ else if (item is Dictionary d2)
+ {
+ var coerced = new Dictionary();
+ foreach (var kv in d2) coerced[kv.Key] = kv.Value;
+ result.Add(IframeInfo.FromDictionary(coerced));
+ }
}
+ return result;
+ }
- Dictionary? iframeSnapshot = null;
+ // Decide whether to skip a discovered iframe, comparing its origin to
+ // the IMMEDIATE parent's origin (passed in by the caller) rather than
+ // the top-level page origin — this is how nested CORS iframes get
+ // detected when the parent itself is cross-origin.
+ internal static bool ShouldSkipIframe(IframeInfo iframe, string parentOrigin)
+ {
+ if (iframe.DataPercyIgnore)
+ {
+ Log($"Skipping iframe marked with data-percy-ignore: {iframe.Src}", "debug");
+ return true;
+ }
+ if (iframe.MatchesIgnoreSelector)
+ {
+ Log($"Skipping iframe matching ignoreIframeSelectors: {iframe.Src}", "debug");
+ return true;
+ }
+ if (string.IsNullOrEmpty(iframe.Src) || IsUnsupportedIframeSrc(iframe.Src))
+ {
+ if (!string.IsNullOrEmpty(iframe.Src))
+ Log($"Skipping unsupported iframe src: {iframe.Src}", "debug");
+ return true;
+ }
+ if (!string.IsNullOrEmpty(iframe.Srcdoc))
+ {
+ Log($"Skipping srcdoc iframe at index {iframe.Index}", "debug");
+ return true;
+ }
+ string frameOrigin = GetOrigin(iframe.Src);
+ if (string.IsNullOrEmpty(frameOrigin))
+ {
+ Log($"Skipping iframe with invalid URL: {iframe.Src}", "debug");
+ return true;
+ }
+ if (frameOrigin == parentOrigin)
+ {
+ Log($"Skipping same-origin iframe: {iframe.Src}", "debug");
+ return true;
+ }
+ if (string.IsNullOrEmpty(iframe.PercyElementId))
+ {
+ Log($"Skipping cross-origin iframe without data-percy-element-id: {iframe.Src}", "debug");
+ return true;
+ }
+ return false;
+ }
+
+ // Context threaded through recursive ProcessFrameTree calls so we don't
+ // re-resolve options/depth on every level.
+ internal class FrameTreeContext
+ {
+ public int MaxFrameDepth;
+ public List IgnoreSelectors = new List();
+ public Dictionary SerializeOptions = new Dictionary();
+ public string DomJs = "";
+ }
+
+ // Switch into the iframe described by `info`, serialize its DOM, then
+ // recurse into any nested cross-origin iframes. Restores the parent
+ // context on exit via SwitchTo().ParentFrame(). Bounded by
+ // ctx.MaxFrameDepth. `ancestorUrls` carries the chain of frame URLs
+ // above us so cyclic A->B->A graphs terminate after one capture per
+ // unique URL instead of running to MAX depth.
+ private static List> ProcessFrameTree(
+ WebDriver driver,
+ IframeInfo info,
+ int depth,
+ HashSet ancestorUrls,
+ FrameTreeContext ctx)
+ {
+ var collected = new List>();
+ if (depth > ctx.MaxFrameDepth)
+ {
+ Log($"Reached max iframe nesting depth ({ctx.MaxFrameDepth}); stopping at {info.Src}", "debug");
+ return collected;
+ }
+ if (ancestorUrls.Contains(info.Src))
+ {
+ Log($"Skipping cyclic iframe ({info.Src} appears in ancestor chain)", "debug");
+ return collected;
+ }
+
+ bool switchedIn = false;
+ Exception? capturedError = null;
try
{
- driver.SwitchTo().Frame(frameElement);
- // Inject Percy DOM into the cross-origin frame context
- driver.ExecuteScript(domJs);
- // Serialize inside the frame; enableJavaScript=true is required for CORS iframes
- var iframeOptions = options != null
- ? new Dictionary(options)
- : new Dictionary();
- iframeOptions["enableJavaScript"] = true;
- var iframeOpts = JsonSerializer.Serialize(iframeOptions);
- iframeSnapshot = (Dictionary)driver.ExecuteScript(
- $"return PercyDOM.serialize({iframeOpts})");
+ Log($"Processing cross-origin iframe (depth {depth}): {info.Src}", "debug");
+
+ // Look up the iframe element by data-percy-element-id rather than
+ // index so we tolerate DOM reorders between enumeration and switch.
+ IWebElement? element = null;
+ try
+ {
+ element = (IWebElement)driver.ExecuteScript(
+ "return document.querySelector('iframe[data-percy-element-id=\"' + arguments[0] + '\"]');",
+ info.PercyElementId);
+ }
+ catch (Exception e)
+ {
+ Log($"Could not resolve iframe element for percyElementId {info.PercyElementId}: {e.Message}", "warn");
+ }
+ if (element == null)
+ {
+ Log($"Could not find iframe element with data-percy-element-id: {info.PercyElementId}", "warn");
+ return collected;
+ }
+
+ driver.SwitchTo().Frame(element);
+ switchedIn = true;
+
+ // Post-switch URL re-check: the frame may have navigated to an
+ // unsupported URL (about:blank, javascript:, data:, ...) after the
+ // src attribute was first read. Skip those before serializing.
+ string? frameUrl = null;
+ try
+ {
+ frameUrl = driver.ExecuteScript("return document.URL")?.ToString();
+ }
+ catch (Exception e)
+ {
+ Log($"Could not read document.URL inside frame {info.Src}: {e.Message}", "debug");
+ }
+ if (!string.IsNullOrEmpty(frameUrl) && IsUnsupportedIframeSrc(frameUrl))
+ {
+ Log($"Skipping iframe whose document loaded an unsupported URL: {frameUrl}", "debug");
+ return collected;
+ }
+ if (string.IsNullOrEmpty(frameUrl)) frameUrl = info.Src;
+
+ // Inject PercyDOM into the cross-origin frame and serialize.
+ // enableJavaScript=true is mandatory: it disables the in-DOM
+ // iframe serializer (we recurse manually instead).
+ driver.ExecuteScript(ctx.DomJs);
+ var serializeOptions = new Dictionary(ctx.SerializeOptions)
+ {
+ ["enableJavaScript"] = true
+ };
+ string optsJson = JsonSerializer.Serialize(serializeOptions);
+ Dictionary? snapshot = null;
+ try
+ {
+ snapshot = (Dictionary)driver.ExecuteScript(
+ $"return PercyDOM.serialize({optsJson})");
+ }
+ catch (Exception e)
+ {
+ Log($"Serialization failed for frame {info.Src}: {e.Message}", "warn");
+ return collected;
+ }
+
+ if (snapshot == null)
+ {
+ Log($"Serialization returned empty result for frame: {info.Src}", "warn");
+ return collected;
+ }
+
+ Log($"Captured cross-origin iframe (depth {depth}): {frameUrl}", "debug");
+
+ collected.Add(new Dictionary
+ {
+ ["frameUrl"] = frameUrl,
+ ["iframeData"] = new Dictionary { ["percyElementId"] = info.PercyElementId! },
+ ["iframeSnapshot"] = snapshot
+ });
+
+ // Recurse into nested cross-origin iframes if we haven't hit the cap.
+ if (depth < ctx.MaxFrameDepth)
+ {
+ string currentOrigin = GetOrigin(frameUrl);
+ var children = EnumerateIframes(driver, ctx.IgnoreSelectors);
+ var nextAncestors = new HashSet(ancestorUrls);
+ nextAncestors.Add(frameUrl);
+ nextAncestors.Add(info.Src);
+ foreach (var child in children)
+ {
+ if (ShouldSkipIframe(child, currentOrigin)) continue;
+ var nested = ProcessFrameTree(driver, child, depth + 1, nextAncestors, ctx);
+ if (nested.Count > 0) collected.AddRange(nested);
+ }
+ }
+
+ return collected;
+ }
+ catch (PercyContextLostException ctxLost)
+ {
+ // Merge any partial capture into the inner exception's payload
+ // before propagating, so the top-level caller gets every frame
+ // serialized before the context was lost.
+ if (ctxLost.PartialCapture != null && ctxLost.PartialCapture.Count > 0)
+ {
+ collected.AddRange(ctxLost.PartialCapture);
+ }
+ ctxLost.PartialCapture = collected;
+ throw;
}
catch (Exception e)
{
- Log($"Failed to process cross-origin frame {frameUrl}: {e.Message}", "error");
- throw new Exception($"Failed to process cross-origin frame {frameUrl}", e);
+ Log($"Failed to process cross-origin iframe {info.Src}: {e.Message}", "warn");
+ capturedError = e;
+ return collected;
}
finally
{
- try
+ if (switchedIn)
+ {
+ try
+ {
+ driver.SwitchTo().ParentFrame();
+ }
+ catch (Exception e)
+ {
+ Log($"Failed to switch back to parent frame: {e.Message}", "warn");
+ try { driver.SwitchTo().DefaultContent(); } catch { /* ignore */ }
+ if (depth > 1)
+ {
+ var err = new PercyContextLostException(
+ $"Lost parent frame context: {e.Message}", capturedError);
+ err.PartialCapture = collected;
+ throw err;
+ }
+ }
+ }
+ }
+ }
+
+ // Walk top-level iframes, recurse via ProcessFrameTree, and roll up the
+ // results. Mirrors @percy/sdk-utils captureCorsIframes.
+ private static List> CaptureCorsIframes(
+ WebDriver driver,
+ string pageUrl,
+ FrameTreeContext ctx)
+ {
+ var corsIframes = new List>();
+ try
+ {
+ var topLevel = EnumerateIframes(driver, ctx.IgnoreSelectors);
+ if (topLevel.Count == 0) return corsIframes;
+
+ Log($"Found {topLevel.Count} top-level iframe(s)", "debug");
+ string pageOrigin = GetOrigin(pageUrl);
+
+ foreach (var iframe in topLevel)
+ {
+ if (ShouldSkipIframe(iframe, pageOrigin)) continue;
+ try
+ {
+ var entries = ProcessFrameTree(driver, iframe, 1,
+ new HashSet { pageUrl }, ctx);
+ if (entries.Count > 0) corsIframes.AddRange(entries);
+ }
+ catch (PercyContextLostException ctxLost)
+ {
+ Log("Aborting further nested CORS capture due to lost frame context", "warn");
+ if (ctxLost.PartialCapture != null && ctxLost.PartialCapture.Count > 0)
+ corsIframes.AddRange(ctxLost.PartialCapture);
+ break;
+ }
+ }
+
+ Log($"Captured {corsIframes.Count} cross-origin iframe(s)", "debug");
+ }
+ catch (Exception e)
+ {
+ Log($"Error capturing CORS iframes: {e.Message}", "warn");
+ }
+ return corsIframes;
+ }
+
+ // Use Chrome DevTools Protocol to discover closed shadow roots (which are
+ // invisible to JS — `el.shadowRoot` is null) and expose each one to the
+ // page via a window-bound WeakMap. PercyDOM.serialize() looks up the map
+ // when cloning shadow hosts so the closed content is included in the
+ // snapshot. No-op on non-Chrome drivers / when CDP isn't reachable.
+ // Re-prime after page reloads — the WeakMap lives on `window` and is
+ // wiped along with the rest of the document.
+ internal static void ExposeClosedShadowRoots(WebDriver driver)
+ {
+ if (!IsChromeBrowser(driver)) return;
+ MethodInfo? executeCdp = driver.GetType().GetMethod(
+ "ExecuteCdpCommand", new[] { typeof(string), typeof(Dictionary) });
+ if (executeCdp == null)
+ {
+ Log("ExecuteCdpCommand unavailable on driver; skipping closed shadow root exposure", "debug");
+ return;
+ }
+
+ try
+ {
+ executeCdp.Invoke(driver, new object[] { "DOM.enable", new Dictionary() });
+
+ var getDocResult = executeCdp.Invoke(driver, new object[] {
+ "DOM.getDocument",
+ new Dictionary { ["depth"] = -1, ["pierce"] = true }
+ });
+ if (getDocResult == null) return;
+
+ // The .NET ExecuteCdpCommand returns a Dictionary
+ // whose values are JSON-decoded. Pull out the root node and walk it.
+ var docDict = getDocResult as IDictionary;
+ if (docDict == null || !docDict.TryGetValue("root", out object? rootObj) || rootObj == null)
+ return;
+
+ var closedPairs = new List<(long host, long shadow)>();
+ CollectClosedShadowRoots(rootObj, closedPairs);
+
+ if (closedPairs.Count == 0) return;
+ Log($"Found {closedPairs.Count} closed shadow root(s), exposing via CDP", "debug");
+
+ // Prime the WeakMap on the page first (same key as PercyDOM uses).
+ driver.ExecuteScript(
+ "window.__percyClosedShadowRoots = window.__percyClosedShadowRoots || new WeakMap();");
+
+ foreach (var pair in closedPairs)
{
- driver.SwitchTo().DefaultContent();
+ try
+ {
+ var hostResolve = executeCdp.Invoke(driver, new object[] {
+ "DOM.resolveNode",
+ new Dictionary { ["backendNodeId"] = pair.host }
+ }) as IDictionary;
+ var shadowResolve = executeCdp.Invoke(driver, new object[] {
+ "DOM.resolveNode",
+ new Dictionary { ["backendNodeId"] = pair.shadow }
+ }) as IDictionary;
+ string? hostObjectId = ExtractObjectId(hostResolve);
+ string? shadowObjectId = ExtractObjectId(shadowResolve);
+ if (hostObjectId == null || shadowObjectId == null) continue;
+
+ var args = new List {
+ new Dictionary { ["objectId"] = shadowObjectId }
+ };
+ executeCdp.Invoke(driver, new object[] {
+ "Runtime.callFunctionOn",
+ new Dictionary {
+ ["functionDeclaration"] = "function(shadowRoot) { window.__percyClosedShadowRoots.set(this, shadowRoot); }",
+ ["objectId"] = hostObjectId,
+ ["arguments"] = args
+ }
+ });
+ }
+ catch (Exception e)
+ {
+ Log($"Failed to expose one closed shadow root: {e.Message}", "debug");
+ }
}
- catch (Exception err)
+ }
+ catch (Exception e)
+ {
+ Log($"Could not expose closed shadow roots via CDP: {e.Message}", "debug");
+ }
+ }
+
+ // Walk a CDP DOM node tree collecting (hostBackendNodeId,
+ // shadowBackendNodeId) pairs for every closed shadow root. Skips nodes
+ // inside child frame documents — closed shadow DOM inside cross-frame
+ // documents lives in a different execution context whose window has no
+ // WeakMap to write into.
+ private static void CollectClosedShadowRoots(object nodeObj, List<(long, long)> pairs)
+ {
+ var node = nodeObj as IDictionary;
+ if (node == null) return;
+ if (node.ContainsKey("contentDocument")) return; // child frame document
+
+ if (node.TryGetValue("shadowRoots", out object? srRaw) && srRaw is System.Collections.IEnumerable shadowRoots)
+ {
+ long? hostId = TryGetLong(node, "backendNodeId");
+ foreach (var srItem in shadowRoots)
{
- Log($"Fatal: could not exit iframe context after processing \"{frameUrl}\". Driver may be unstable. {err.Message}", "error");
+ var sr = srItem as IDictionary;
+ if (sr == null) continue;
+ string? type = sr.TryGetValue("shadowRootType", out object? t) ? t?.ToString() : null;
+ long? shadowId = TryGetLong(sr, "backendNodeId");
+ if (type == "closed" && hostId.HasValue && shadowId.HasValue)
+ {
+ pairs.Add((hostId.Value, shadowId.Value));
+ }
+ CollectClosedShadowRoots(sr, pairs);
}
}
- return new Dictionary
+ if (node.TryGetValue("children", out object? childrenRaw) && childrenRaw is System.Collections.IEnumerable children)
{
- { "iframeData", new Dictionary { { "percyElementId", percyElementId } } },
- { "iframeSnapshot", iframeSnapshot },
- { "frameUrl", frameUrl }
- };
+ foreach (var child in children) CollectClosedShadowRoots(child, pairs);
+ }
+ }
+
+ private static long? TryGetLong(IDictionary dict, string key)
+ {
+ if (!dict.TryGetValue(key, out object? raw) || raw == null) return null;
+ if (raw is long l) return l;
+ if (raw is int i) return i;
+ if (long.TryParse(raw.ToString(), out long parsed)) return parsed;
+ return null;
+ }
+
+ private static string? ExtractObjectId(IDictionary? resolveResult)
+ {
+ if (resolveResult == null) return null;
+ if (!resolveResult.TryGetValue("object", out object? obj)) return null;
+ var objDict = obj as IDictionary;
+ if (objDict == null) return null;
+ return objDict.TryGetValue("objectId", out object? id) ? id?.ToString() : null;
}
private static dynamic getSerializedDom(
@@ -403,60 +935,31 @@ private static dynamic getSerializedDom(
var domSnapshot = (Dictionary)driver.ExecuteScript(script);
domSnapshot["cookies"] = cookies;
- // Process CORS iframes when DOM script is available
+ // Process CORS iframes when DOM script is available. Uses the
+ // nested ProcessFrameTree pipeline so we capture multi-level
+ // cross-origin nesting, with cycle and depth guards, plus
+ // data-percy-ignore / ignoreIframeSelectors filtering.
if (!string.IsNullOrEmpty(domJs))
{
try
{
- string pageOrigin = GetOrigin(driver.Url);
- var iframes = driver.FindElements(By.TagName("iframe"));
- if (iframes.Count > 0)
+ var ctx = new FrameTreeContext
{
- var processedFrames = new List>();
- foreach (IWebElement frame in iframes)
- {
- string? frameSrc = frame.GetAttribute("src");
- if (IsUnsupportedIframeSrc(frameSrc))
- continue;
-
- string frameOrigin;
- try
- {
- Uri baseUri = new Uri(driver.Url);
- Uri resolvedUri = new Uri(baseUri, frameSrc);
- frameOrigin = GetOrigin(resolvedUri.ToString());
- }
- catch (Exception e)
- {
- Log($"Skipping iframe \"{frameSrc}\": {e.Message}", "debug");
- continue;
- }
-
- if (frameOrigin == pageOrigin)
- continue;
-
- try
- {
- var result = ProcessFrame(driver, frame, options, domJs);
- if (result != null)
- processedFrames.Add(result);
- }
- catch (Exception e)
- {
- Log($"Skipping frame \"{frameSrc}\" due to error: {e.Message}", "debug");
- if (e.Message.Contains("Fatal"))
- throw;
- }
- }
- if (processedFrames.Count > 0)
- domSnapshot["corsIframes"] = processedFrames;
- }
+ MaxFrameDepth = ResolveMaxFrameDepth(options),
+ IgnoreSelectors = ResolveIgnoreSelectors(options),
+ SerializeOptions = options != null
+ ? new Dictionary(options)
+ : new Dictionary(),
+ DomJs = domJs!
+ };
+ string pageUrl = driver.Url ?? "";
+ var corsIframes = CaptureCorsIframes(driver, pageUrl, ctx);
+ if (corsIframes.Count > 0)
+ domSnapshot["corsIframes"] = corsIframes;
}
catch (Exception e)
{
Log($"Failed to process cross-origin iframes: {e.Message}", "debug");
- if (e.Message.Contains("Fatal"))
- throw;
}
}
@@ -692,6 +1195,9 @@ public static List> CaptureResponsiveDom(WebDriver dr
{
driver.ExecuteScript(GetPercyDOM());
}
+ // Re-prime the closed shadow root WeakMap — page reload
+ // creates a fresh document and the previous map is gone.
+ ExposeClosedShadowRoots(driver);
driver.ExecuteScript("PercyDOM.waitForResize()");
resizeCount = 0;
}
@@ -763,6 +1269,11 @@ public class Options : Dictionary {}
if (_dom == null)
_dom = GetPercyDOM();
+ // Expose closed shadow roots via CDP before serializing so
+ // PercyDOM.serialize() can pick them up through the WeakMap.
+ // Non-Chrome browsers and missing ExecuteCdpCommand are no-ops.
+ ExposeClosedShadowRoots(driver);
+
var cookies = driver.Manage().Cookies.AllCookies;
string opts = JsonSerializer.Serialize(options);
dynamic domSnapshot = null;