From c69277788d6ef1e88406fb005cdd0746d19fda81 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:15:25 +0530 Subject: [PATCH 1/3] feat(snapshot): add nested CORS iframe capture with depth, cycle, ignore, and recovery controls Replace the single-level processFrame helper with a recursive ProcessFrameTree pipeline that mirrors the canonical Percy CORS iframe spec from percy/percy-nightwatch#869. Adds: - DEFAULT_MAX_FRAME_DEPTH/MAX_ALLOWED_FRAME_DEPTH, ClampFrameDepth, NormalizeIgnoreSelectors, ResolveMaxFrameDepth, ResolveIgnoreSelectors inlined since .NET has no @percy/sdk-utils equivalent. - ENUMERATE_IFRAMES_SCRIPT + IframeInfo to collect iframe metadata in one round-trip per frame context. - ShouldSkipIframe centralizes filtering: data-percy-ignore attribute, ignoreIframeSelectors matches, unsupported / srcdoc / same-origin frames, and frames without data-percy-element-id are dropped early. - ProcessFrameTree recurses cross-origin descendants up to MaxFrameDepth, with a HashSet ancestor-URL chain to break cyclic A->B->A graphs. - Post-switch URL re-check: after SwitchTo().Frame, the loaded document.URL is rechecked against IsUnsupportedIframeSrc to handle late navigations. - PercyContextLostException carries the partial capture so a failed SwitchTo().ParentFrame() unwind still surfaces every frame serialized up to the failure. CaptureCorsIframes wires the helpers into getSerializedDom, replacing the prior ad-hoc top-level iframe loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- Percy/Percy.cs | 537 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 452 insertions(+), 85 deletions(-) diff --git a/Percy/Percy.cs b/Percy/Percy.cs index 98f1731..2e016ff 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,428 @@ 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; + } + + // 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; + } - Dictionary? iframeSnapshot = null; + // 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) { - driver.SwitchTo().DefaultContent(); + 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; + } + } } - catch (Exception 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) { - Log($"Fatal: could not exit iframe context after processing \"{frameUrl}\". Driver may be unstable. {err.Message}", "error"); + 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; + } } - } - return new Dictionary + Log($"Captured {corsIframes.Count} cross-origin iframe(s)", "debug"); + } + catch (Exception e) { - { "iframeData", new Dictionary { { "percyElementId", percyElementId } } }, - { "iframeSnapshot", iframeSnapshot }, - { "frameUrl", frameUrl } - }; + Log($"Error capturing CORS iframes: {e.Message}", "warn"); + } + return corsIframes; } private static dynamic getSerializedDom( @@ -403,60 +799,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; } } From e94c4b5d6819c9f094fd60cd78d9e0e82beff09c Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:16:23 +0530 Subject: [PATCH 2/3] feat(snapshot): expose closed shadow DOM via CDP for Chrome drivers Mirrors percy/percy-playwright#609. Walks the CDP DOM tree (DOM.getDocument with depth=-1 and pierce=true), collects backendNodeId pairs for every closed shadow root, resolves both sides via DOM.resolveNode, then uses Runtime.callFunctionOn to register each shadow root in a window-bound WeakMap (window.__percyClosedShadowRoots) that PercyDOM.serialize() reads during cloning. Nodes inside child frame documents are skipped because their execution contexts don't share the WeakMap. Wired into Snapshot() before serialization and into the responsive capture reload path so the WeakMap survives page.reload(). No-op on non-Chrome drivers and when ExecuteCdpCommand isn't available, so existing Firefox / non-Chrome test paths are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- Percy/Percy.cs | 144 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/Percy/Percy.cs b/Percy/Percy.cs index 2e016ff..32fc29c 100644 --- a/Percy/Percy.cs +++ b/Percy/Percy.cs @@ -788,6 +788,142 @@ private static List> CaptureCorsIframes( 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) + { + 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 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) + { + 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); + } + } + + if (node.TryGetValue("children", out object? childrenRaw) && childrenRaw is System.Collections.IEnumerable children) + { + 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( WebDriver driver, object cookies, @@ -1059,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; } @@ -1130,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; From eb87a77a6bce6778f5f04e323f51a1a2bdb1ab11 Mon Sep 17 00:00:00 2001 From: Aryan Kumar Date: Mon, 11 May 2026 15:16:31 +0530 Subject: [PATCH 3/3] chore(test): add unit tests for CORS iframe helpers Covers the inlined helpers (GetOrigin, IsUnsupportedIframeSrc, ClampFrameDepth, NormalizeIgnoreSelectors), the ShouldSkipIframe skip matrix (data-percy-ignore, ignoreIframeSelectors, unsupported src, srcdoc, same-origin, missing percyElementId, and immediate-parent origin comparison), and the PercyContextLostException carrier. Tests rely on the existing InternalsVisibleTo("Percy.Test") in AssemblyInfo.cs and use reflection for the private IframeInfo / ShouldSkipIframe symbols so the production API stays sealed. Co-Authored-By: Claude Opus 4.7 (1M context) --- Percy.Test/CorsIframesTest.cs | 178 ++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 Percy.Test/CorsIframesTest.cs 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"]); + } + } +}