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;