Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/PlaywrightNative.Tests/LocatorIsVisibleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,80 @@ await page.SetContentAsync(@"<details>
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(@"<details>
<summary>outer</summary>
<details open>
<summary>inner</summary>
<button>hidden button</button>
</details>
</details>").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(@"
<div id=unslotted><span>hidden 1</span><div><span>hidden 2</span></div></div>
<div id=slotted><span>visible</span></div>
<script>
unslotted.attachShadow({ mode: 'open' }).innerHTML = 'nothing to see here';
slotted.attachShadow({ mode: 'open' }).innerHTML = '<slot></slot>';
</script>
").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(@"
<select multiple>
<option>visible 1</option>
<optgroup label=""group""><option>visible 2</option></optgroup>
<option hidden>hidden 1</option>
<optgroup label=""hidden group"" style=""display:none""><option>hidden 2</option></optgroup>
</select>
<select size=3><option>visible 3</option></select>
<select multiple style=""display:none""><option>hidden 3</option></select>
").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)]
Expand Down
23 changes: 23 additions & 0 deletions src/PlaywrightNative.Tests/LocatorMisc2Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,29 @@ await page.SetContentAsync(@"<div>
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(@"<div>
<div class=""item"" style=""display: none"">Hidden data0</div>
<div class=""item"">visible data1</div>
<div class=""item"" style=""display: none"">Hidden data1</div>
<div class=""item"">visible data2</div>
<div class=""item"" style=""display: none"">Hidden data2</div>
<div class=""item"">visible data3</div>
</div>
").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)]
Expand Down
33 changes: 23 additions & 10 deletions src/PlaywrightNative/Helpers/DomVisibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
namespace PlaywrightNative.Helpers
{
/// <summary>
/// Official injected <c>isElementVisible</c>: style visibility, closed
/// <c>&lt;details&gt;</c>, <c>display:contents</c>, and a non-empty box.
/// Official injected <c>isElementVisible</c>: <c>checkVisibility()</c>, style
/// visibility, <c>display:contents</c>, and a non-empty box.
/// Opacity 0 and off-screen boxes stay visible. Used by element-handle and
/// page-level <c>isVisible</c> / <c>isHidden</c>.
/// </summary>
Expand All @@ -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;
}
Expand All @@ -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) {
Expand Down
22 changes: 19 additions & 3 deletions src/PlaywrightNative/Helpers/RoleSelectorEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
};

Expand All @@ -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;
Expand Down
22 changes: 1 addition & 21 deletions src/PlaywrightNative/Helpers/SelectorQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions src/PlaywrightNative/LocatorCompatExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Narrows the locator to visible elements only. Same as <c>Filter(visible: true)</c>.
/// </summary>
/// <param name="locator">The locator to narrow.</param>
/// <returns>A locator that matches only visible elements.</returns>
public static ILocator Visible(this ILocator locator)
=> RequireLocator(locator).Filter(visible: true);

/// <summary>Legacy has filter.</summary>
public static ILocator Filter(this ILocator locator, ILocator has)
=> RequireLocator(locator).Has(has);
Expand Down
Loading