From f1c742d7ea870dfb8b84804a7916743d6c0186e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 03:16:54 -0300 Subject: [PATCH] Use checkVisibility for element visibility and add Locator.Visible() Port upstream isElementVisible: rely on Element.checkVisibility on every browser (it handles nested
and unslotted shadow content), with the WebKit list box option workaround. The visible= selector engine now shares the same function, and the role engine uses checkVisibility with upstream's display:contents handling. Co-Authored-By: Claude Opus 5.5 --- .../LocatorIsVisibleTests.cs | 74 +++++++++++++++++++ .../LocatorMisc2Tests.cs | 23 ++++++ src/PlaywrightNative/Helpers/DomVisibility.cs | 33 ++++++--- .../Helpers/RoleSelectorEngine.cs | 22 +++++- src/PlaywrightNative/Helpers/SelectorQuery.cs | 22 +----- .../LocatorCompatExtensions.cs | 8 ++ 6 files changed, 148 insertions(+), 34 deletions(-) diff --git a/src/PlaywrightNative.Tests/LocatorIsVisibleTests.cs b/src/PlaywrightNative.Tests/LocatorIsVisibleTests.cs index 318980b7..2f27bd38 100644 --- a/src/PlaywrightNative.Tests/LocatorIsVisibleTests.cs +++ b/src/PlaywrightNative.Tests/LocatorIsVisibleTests.cs @@ -145,6 +145,80 @@ await page.SetContentAsync(@"
await Assertions.Expect(page.Locator("ul")).ToBeHiddenAsync().ConfigureAwait(false); } + [PlaywrightTest("locator-is-visible.spec.ts", "isVisible and isHidden should work with nested details")] + [Test] + [Timeout(30_000)] + public async Task IsVisibleAndIsHiddenShouldWorkWithNestedDetails() + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + await page.SetContentAsync(@"
+ outer +
+ inner + +
+
").ConfigureAwait(false); + + await Assertions.Expect(page.Locator("summary", new() { HasText = "outer" })).ToBeVisibleAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("summary", new() { HasText = "inner" })).ToBeHiddenAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("button")).ToBeHiddenAsync().ConfigureAwait(false); + await Assertions.Expect(page.GetByRole(AriaRole.Button)).ToHaveCountAsync(0).ConfigureAwait(false); + } + + [PlaywrightTest("locator-is-visible.spec.ts", "isVisible and isHidden should work with unslotted content")] + [Test] + [Timeout(30_000)] + public async Task IsVisibleAndIsHiddenShouldWorkWithUnslottedContent() + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + await page.SetContentAsync(@" +
hidden 1
hidden 2
+
visible
+ + ").ConfigureAwait(false); + + await Assertions.Expect(page.Locator("span", new() { HasText = "hidden 1" })).ToBeHiddenAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("span", new() { HasText = "hidden 2" })).ToBeHiddenAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("span", new() { HasText = "visible" })).ToBeVisibleAsync().ConfigureAwait(false); + } + + [PlaywrightTest("locator-is-visible.spec.ts", "isVisible and isHidden should work with list box options")] + [Test] + [Timeout(30_000)] + public async Task IsVisibleAndIsHiddenShouldWorkWithListBoxOptions() + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + await page.SetContentAsync(@" + + + + ").ConfigureAwait(false); + + foreach (string name in new[] { "visible 1", "visible 2", "visible 3" }) + { + await Assertions.Expect(page.GetByRole(AriaRole.Option, new() { Name = name, Exact = true })).ToBeVisibleAsync().ConfigureAwait(false); + } + + foreach (string text in new[] { "hidden 1", "hidden 2", "hidden 3" }) + { + await Assertions.Expect(page.Locator("option", new() { HasText = text })).ToBeHiddenAsync().ConfigureAwait(false); + } + } + [PlaywrightTest("locator-is-visible.spec.ts", "isVisible inside a button")] [Test] [Timeout(30_000)] diff --git a/src/PlaywrightNative.Tests/LocatorMisc2Tests.cs b/src/PlaywrightNative.Tests/LocatorMisc2Tests.cs index 8ad4f717..684d81d1 100644 --- a/src/PlaywrightNative.Tests/LocatorMisc2Tests.cs +++ b/src/PlaywrightNative.Tests/LocatorMisc2Tests.cs @@ -332,6 +332,29 @@ await page.SetContentAsync(@"
await Assertions.Expect(page.Locator(".item").Filter(visible: false).GetByText("data1")).ToHaveTextAsync("Hidden data1").ConfigureAwait(false); } + [PlaywrightTest("locator-misc-2.spec.ts", "should support visible()")] + [Test] + [Timeout(30_000)] + public async Task ShouldSupportVisible() + { + await using IBrowser browser = await BrowserLauncher.LaunchAsync().ConfigureAwait(false); + await using IBrowserContext context = await browser.NewContextAsync().ConfigureAwait(false); + IPage page = await context.NewPageAsync().ConfigureAwait(false); + await page.SetContentAsync(@"
+
Hidden data0
+
visible data1
+
Hidden data1
+
visible data2
+
Hidden data2
+
visible data3
+
+ ").ConfigureAwait(false); + ILocator locator = page.Locator(".item").Visible().Nth(1); + await Assertions.Expect(locator).ToHaveTextAsync("visible data2").ConfigureAwait(false); + await Assertions.Expect(page.Locator(".item").Visible().GetByText("data3")).ToHaveTextAsync("visible data3").ConfigureAwait(false); + await Assertions.Expect(page.Locator(".item").Visible()).ToHaveCountAsync(3).ConfigureAwait(false); + } + [PlaywrightTest("locator-misc-2.spec.ts", "locator.count should work with deleted Map in main world")] [Test] [Timeout(30_000)] diff --git a/src/PlaywrightNative/Helpers/DomVisibility.cs b/src/PlaywrightNative/Helpers/DomVisibility.cs index cd91f0a8..fe7c31d6 100644 --- a/src/PlaywrightNative/Helpers/DomVisibility.cs +++ b/src/PlaywrightNative/Helpers/DomVisibility.cs @@ -23,8 +23,8 @@ namespace PlaywrightNative.Helpers { /// - /// Official injected isElementVisible: style visibility, closed - /// <details>, display:contents, and a non-empty box. + /// Official injected isElementVisible: checkVisibility(), style + /// visibility, display:contents, and a non-empty box. /// Opacity 0 and off-screen boxes stay visible. Used by element-handle and /// page-level isVisible / isHidden. /// @@ -41,15 +41,29 @@ function isVisibleTextNode(node) { const rect = range.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } - function isStyleVisible(element, style) { + function getStyle(element) { + const view = element.ownerDocument && element.ownerDocument.defaultView; + return view ? view.getComputedStyle(element) : null; + } + // WebKit does not create renderers for list box options, so checkVisibility() returns false for them. + function isWebKitListBoxOptionVisible(element, style) { const ua = navigator.userAgent || ''; const isWebKit = /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg\//.test(ua); - if (typeof element.checkVisibility === 'function' && !isWebKit) { - if (!element.checkVisibility()) return false; + if (!isWebKit || element.nodeName !== 'OPTION' || style.display === 'none') return false; + const select = element.closest('select'); + if (!select || !(select.multiple || select.size > 1)) return false; + for (let e = element.parentElement; e && e !== select; e = e.parentElement) { + const parentStyle = getStyle(e); + if (parentStyle && parentStyle.display === 'none') return false; } - const detailsOrSummary = element.closest && element.closest('details,summary'); - if (detailsOrSummary && detailsOrSummary !== element && detailsOrSummary.nodeName === 'DETAILS' && !detailsOrSummary.open) - return false; + const selectStyle = getStyle(select); + return !selectStyle || isStyleVisible(select, selectStyle); + } + function isStyleVisible(element, style) { + // Element.checkVisibility checks for content-visibility and also looks at + // styles up the flat tree including user-agent ShadowRoots, such as the + // details element for example. + if (!element.checkVisibility() && !isWebKitListBoxOptionVisible(element, style)) return false; if (style.visibility !== 'visible') return false; return true; } @@ -63,8 +77,7 @@ function isVisible(element) { String(element.getAttribute('loading') || '').toLowerCase() === 'lazy') { return !!(element.isConnected); } - const view = element.ownerDocument && element.ownerDocument.defaultView; - const style = view ? view.getComputedStyle(element) : null; + const style = getStyle(element); if (!style) return true; if (style.display === 'contents') { for (let child = element.firstChild; child; child = child.nextSibling) { diff --git a/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs b/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs index 3832e005..f5acf8ed 100644 --- a/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs +++ b/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs @@ -97,11 +97,12 @@ internal static class RoleSelectorEngine }; const isElementStyleVisibilityVisible = (element, style) => { - const detailsOrSummary = element.closest && element.closest('details,summary'); - if (detailsOrSummary && detailsOrSummary !== element && detailsOrSummary.nodeName === 'DETAILS' && !detailsOrSummary.open) - return false; style = style || getComputedStyleSafe(element); if (!style) return true; + // Element.checkVisibility checks for content-visibility and also looks at + // styles up the flat tree including user-agent ShadowRoots, such as the + // details element for example. + if (!element.checkVisibility()) return false; return style.visibility === 'visible'; }; @@ -115,10 +116,25 @@ internal static class RoleSelectorEngine return parent ? belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent) : false; }; + const isVisibleTextNode = (node) => { + const range = node.ownerDocument.createRange(); + range.selectNode(node); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + const isElementHiddenForAria = (element) => { if (isElementIgnoredForAria(element)) return true; const style = getComputedStyleSafe(element); const isSlot = element.nodeName === 'SLOT'; + if (style && style.display === 'contents' && !isSlot) { + // display:contents is not rendered itself, but its child nodes are. + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && !isElementHiddenForAria(child)) return false; + if (child.nodeType === 3 && isVisibleTextNode(child)) return false; + } + return true; + } const isOptionInsideSelect = element.nodeName === 'OPTION' && !!element.closest('select'); if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) return true; diff --git a/src/PlaywrightNative/Helpers/SelectorQuery.cs b/src/PlaywrightNative/Helpers/SelectorQuery.cs index dd85f5e8..90a8f02c 100644 --- a/src/PlaywrightNative/Helpers/SelectorQuery.cs +++ b/src/PlaywrightNative/Helpers/SelectorQuery.cs @@ -832,27 +832,7 @@ function queryCssLight(root, sel) { catch (e) { return []; } } - function isElementVisible(element) { - if (!element) return false; - const view = element.ownerDocument && element.ownerDocument.defaultView; - const style = view ? view.getComputedStyle(element) : null; - if (!style) return true; - if (style.display === 'contents') { - for (let child = element.firstChild; child; child = child.nextSibling) { - if (child.nodeType === 1 && isElementVisible(child)) return true; - if (child.nodeType === 3) { - const range = element.ownerDocument.createRange(); - range.selectNode(child); - const rect = range.getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0) return true; - } - } - return false; - } - if (style.visibility !== 'visible') return false; - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - } + const isElementVisible = " + DomVisibility.IsVisibleFunction + @"; function sortInDomOrder(elements) { const list = elements.slice(); diff --git a/src/PlaywrightNative/LocatorCompatExtensions.cs b/src/PlaywrightNative/LocatorCompatExtensions.cs index 35fb4da3..8044daa9 100644 --- a/src/PlaywrightNative/LocatorCompatExtensions.cs +++ b/src/PlaywrightNative/LocatorCompatExtensions.cs @@ -145,6 +145,14 @@ public static ILocator Filter(this ILocator locator, Regex hasText) public static ILocator Filter(this ILocator locator, bool visible) => RequireLocator(locator).Filter(visible); + /// + /// Narrows the locator to visible elements only. Same as Filter(visible: true). + /// + /// The locator to narrow. + /// A locator that matches only visible elements. + public static ILocator Visible(this ILocator locator) + => RequireLocator(locator).Filter(visible: true); + /// Legacy has filter. public static ILocator Filter(this ILocator locator, ILocator has) => RequireLocator(locator).Has(has);