From 51ce138890290ddeb0b7d716b611d2ba7ea06f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 14:50:00 -0300 Subject: [PATCH] Port locator.within, frame-relative highlight, and regex-flag tests Add Locator.Within as a compat extension (upstream locator.within). Port upstream implicit ARIA role mapping so getByRole finds table cells, rows, lists and other elements. Resolve hideHighlight relative to the locator's frame and clear highlights in every frame on page.hideHighlight. Allow '-' in custom selector engine names, as upstream does. Co-Authored-By: Claude Opus 5.5 --- .../LibraryLocatorHighlightParityTests.cs | 60 +++++ .../LocatorQueryTests.cs | 25 +++ .../SelectorsGetByParityTests.cs | 32 +++ src/PlaywrightNative/Firefox/FirefoxPage.cs | 6 +- .../Helpers/PageHighlights.cs | 41 +++- .../Helpers/RoleSelectorEngine.cs | 212 +++++++++++++++--- src/PlaywrightNative/Helpers/Selectors.cs | 3 +- src/PlaywrightNative/Locator.cs | 6 +- .../LocatorCompatExtensions.cs | 14 ++ src/PlaywrightNative/Page.cs | 6 +- src/PlaywrightNative/WebKit/WKPage.cs | 6 +- 11 files changed, 354 insertions(+), 57 deletions(-) diff --git a/src/PlaywrightNative.Tests/LibraryLocatorHighlightParityTests.cs b/src/PlaywrightNative.Tests/LibraryLocatorHighlightParityTests.cs index 92b49225..1f93fdcd 100644 --- a/src/PlaywrightNative.Tests/LibraryLocatorHighlightParityTests.cs +++ b/src/PlaywrightNative.Tests/LibraryLocatorHighlightParityTests.cs @@ -148,5 +148,65 @@ public async Task PageHideHighlightClearsAllLocatorHighlights() await page.HideHighlightAsync().ConfigureAwait(false); await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(0).ConfigureAwait(false); } + + [PlaywrightTest("locator-highlight.spec.ts", "highlight should work with a custom selector engine that runs in the main world")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task HighlightShouldWorkWithACustomSelectorEngineThatRunsInTheMainWorld() + { + // Engines registered without "contentScript" run in the main world and can see page globals. + const string createEngine = @"() => ({ + query(root, selector) { + return window['__engineEnabled'] ? root.querySelector(selector) : null; + }, + queryAll(root, selector) { + return window['__engineEnabled'] ? Array.from(root.querySelectorAll(selector)) : []; + }, + })"; + await Playwright.Selectors.RegisterAsync("highlight-tag", createEngine).ConfigureAwait(false); + + 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); + await page.EvaluateAsync("() => window['__engineEnabled'] = true").ConfigureAwait(false); + + ILocator button = page.Locator("highlight-tag=button"); + await button.HighlightAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(1).ConfigureAwait(false); + + await button.HideHighlightAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(0).ConfigureAwait(false); + + await button.HighlightAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(1).ConfigureAwait(false); + + await page.HideHighlightAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(0).ConfigureAwait(false); + } + + [PlaywrightTest("locator-highlight.spec.ts", "highlight should resolve relative to the frame of the locator")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task HighlightShouldResolveRelativeToTheFrameOfTheLocator() + { + 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); + IFrame frame = page.Frame("frame"); + + await page.Locator("button").HighlightAsync().ConfigureAwait(false); + await frame.Locator("button").HighlightAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(1).ConfigureAwait(false); + await Assertions.Expect(frame.Locator("x-pw-highlight")).ToHaveCountAsync(1).ConfigureAwait(false); + + await page.Locator("button").HideHighlightAsync().ConfigureAwait(false); + await Assertions.Expect(page.Locator("x-pw-highlight")).ToHaveCountAsync(0).ConfigureAwait(false); + await Assertions.Expect(frame.Locator("x-pw-highlight")).ToHaveCountAsync(1).ConfigureAwait(false); + + await frame.Locator("button").HideHighlightAsync().ConfigureAwait(false); + await Assertions.Expect(frame.Locator("x-pw-highlight")).ToHaveCountAsync(0).ConfigureAwait(false); + } } } diff --git a/src/PlaywrightNative.Tests/LocatorQueryTests.cs b/src/PlaywrightNative.Tests/LocatorQueryTests.cs index 327cda63..c6cb7dab 100644 --- a/src/PlaywrightNative.Tests/LocatorQueryTests.cs +++ b/src/PlaywrightNative.Tests/LocatorQueryTests.cs @@ -500,5 +500,30 @@ public void CountShouldNotThrowDuringNavigation() { Assert.Ignore("Node-only: playwright.selectors.register with a query-time navigation helper."); } + + [PlaywrightTest("locator-query.spec.ts", "should support locator.within")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldSupportLocatorWithin() + { + 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(@" + + + + +
a1a2a3
b1b2b3
c1c2c3
+ outside + ").ConfigureAwait(false); + + await Assertions.Expect(page.GetByRole("cell").Within(page.GetByRole("row"))).ToHaveTextAsync(new[] { "a1", "a2", "a3", "b1", "b2", "b3", "c1", "c2", "c3" }).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("cell").Nth(1).Within(page.GetByRole("row"))).ToHaveTextAsync(new[] { "a2", "b2", "c2" }).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("cell").Last.Within(page.GetByRole("row"))).ToHaveTextAsync(new[] { "a3", "b3", "c3" }).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("cell").Nth(1).Within(page.GetByRole("row").Nth(2))).ToHaveTextAsync(new[] { "c2" }).ConfigureAwait(false); + await Assertions.Expect(page.Locator("span").Within(page.GetByRole("row"))).ToHaveCountAsync(0).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("cell").Nth(1).Within(page.GetByRole("row")).Nth(1)).ToHaveTextAsync("b2").ConfigureAwait(false); + } } } diff --git a/src/PlaywrightNative.Tests/SelectorsGetByParityTests.cs b/src/PlaywrightNative.Tests/SelectorsGetByParityTests.cs index 91770aa6..f403ac6a 100644 --- a/src/PlaywrightNative.Tests/SelectorsGetByParityTests.cs +++ b/src/PlaywrightNative.Tests/SelectorsGetByParityTests.cs @@ -521,5 +521,37 @@ await page.SetContentAsync(@" ").ConfigureAwait(false); Assert.That(await page.GetByRole("alert", description: " doc-2025.pdf \n was uploaded ").EvaluateAllAsync("els => els.map(e => e.textContent)").ConfigureAwait(false), Is.EqualTo(new[] { "Alert" })); } + + [PlaywrightTest("selectors-get-by.spec.ts", "locators should not be affected by global and sticky regex flags")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task LocatorsShouldNotBeAffectedByGlobalAndStickyRegexFlags() + { + 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(@" +
foo
foo
foo
+ + + + ").ConfigureAwait(false); + + // .NET Regex has no global (g) or sticky (y) flag, so the upstream + // /foo/g and /foo/y lines use the same pattern as /foo/. + await Assertions.Expect(page.GetByText(new Regex("foo"))).ToHaveCountAsync(9).ConfigureAwait(false); + await Assertions.Expect(page.GetByText(new Regex("foo"))).ToHaveCountAsync(9).ConfigureAwait(false); + await Assertions.Expect(page.GetByText(new Regex("foo"))).ToHaveCountAsync(9).ConfigureAwait(false); + await Assertions.Expect(page.GetByLabel(new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByLabel(new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByLabel(new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("button", nameRegex: new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("button", nameRegex: new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByRole("button", nameRegex: new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByPlaceholder(new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByPlaceholder(new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + await Assertions.Expect(page.GetByPlaceholder(new Regex("foo"))).ToHaveCountAsync(3).ConfigureAwait(false); + } } } diff --git a/src/PlaywrightNative/Firefox/FirefoxPage.cs b/src/PlaywrightNative/Firefox/FirefoxPage.cs index 73110382..6fe22e0b 100644 --- a/src/PlaywrightNative/Firefox/FirefoxPage.cs +++ b/src/PlaywrightNative/Firefox/FirefoxPage.cs @@ -1289,11 +1289,7 @@ Task IPage.GoForwardAsync(PageGoForwardOptions options) Task IPage.GotoAsync(string url, PageGotoOptions options) => GoToAsync(url, options?.WaitUntil ?? default, options?.Timeout, options?.Referer); - async Task IPage.HideHighlightAsync() - { - PageHighlights.Clear(this); - await EvaluateAsync(ElementStateScript.HideAllHighlightsFunction).ConfigureAwait(false); - } + Task IPage.HideHighlightAsync() => PageHighlights.HideAllAsync(this); Task IPage.HoverAsync(string selector, PageHoverOptions options) => HoverAsync(selector, options?.Position, options?.Modifiers, options?.Force, options?.Timeout, options?.Trial, ActionScrollBridge.FromScrollOption(options?.Scroll), options?.Strict); diff --git a/src/PlaywrightNative/Helpers/PageHighlights.cs b/src/PlaywrightNative/Helpers/PageHighlights.cs index bb836bf6..ba449370 100644 --- a/src/PlaywrightNative/Helpers/PageHighlights.cs +++ b/src/PlaywrightNative/Helpers/PageHighlights.cs @@ -36,10 +36,11 @@ internal static class PageHighlights /// after a later navigation. /// /// The page that owns the overlay. + /// The frame the locator resolves relative to. /// The highlighted locator. /// Optional extra inline CSS. /// Stable overlay id for this locator. - internal static void Remember(IPage page, ILocator locator, string style, string id) + internal static void Remember(IPage page, IFrame frame, ILocator locator, string style, string id) { if (page == null || locator == null || string.IsNullOrEmpty(id)) { @@ -49,9 +50,10 @@ internal static void Remember(IPage page, ILocator locator, string style, string Registry registry = _pages.GetOrCreateValue(page); lock (registry.Sync) { - RemoveId(registry, id); + RemoveId(registry, frame, id); registry.Entries.Add(new Entry { + Frame = frame, Locator = locator, Style = style, Id = id, @@ -69,8 +71,9 @@ internal static void Remember(IPage page, ILocator locator, string style, string /// Forgets one locator overlay after locator.hideHighlight(). /// /// The page that owns the overlay. + /// The frame the locator resolves relative to. /// Stable overlay id for this locator. - internal static void Forget(IPage page, string id) + internal static void Forget(IPage page, IFrame frame, string id) { if (page == null || string.IsNullOrEmpty(id) || !_pages.TryGetValue(page, out Registry registry)) { @@ -79,24 +82,36 @@ internal static void Forget(IPage page, string id) lock (registry.Sync) { - RemoveId(registry, id); + RemoveId(registry, frame, id); } } /// - /// Forgets every overlay after page.hideHighlight(). + /// page.hideHighlight(): forgets every overlay and removes it + /// from every frame, since locators may highlight in any frame. /// /// The page that owns the overlays. - internal static void Clear(IPage page) + /// A task that completes when every frame is cleared. + internal static async Task HideAllAsync(IPage page) { - if (page == null || !_pages.TryGetValue(page, out Registry registry)) + if (_pages.TryGetValue(page, out Registry registry)) { - return; + lock (registry.Sync) + { + registry.Entries.Clear(); + } } - lock (registry.Sync) + foreach (IFrame frame in page.Frames) { - registry.Entries.Clear(); + try + { + await frame.EvaluateAsync(ElementStateScript.HideAllHighlightsFunction).ConfigureAwait(false); + } + catch (PlaywrightException) when (frame != page.MainFrame) + { + // A child frame may detach or navigate while we clear it. + } } } @@ -132,11 +147,11 @@ private static async Task ReapplyAsync(IPage page) } } - private static void RemoveId(Registry registry, string id) + private static void RemoveId(Registry registry, IFrame frame, string id) { for (int i = registry.Entries.Count - 1; i >= 0; i--) { - if (string.Equals(registry.Entries[i].Id, id, StringComparison.Ordinal)) + if (ReferenceEquals(registry.Entries[i].Frame, frame) && string.Equals(registry.Entries[i].Id, id, StringComparison.Ordinal)) { registry.Entries.RemoveAt(i); } @@ -154,6 +169,8 @@ private sealed class Registry private sealed class Entry { + internal IFrame Frame { get; set; } + internal ILocator Locator { get; set; } internal string Style { get; set; } diff --git a/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs b/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs index 3832e005..cacd45cb 100644 --- a/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs +++ b/src/PlaywrightNative/Helpers/RoleSelectorEngine.cs @@ -41,7 +41,7 @@ internal static class RoleSelectorEngine return null; }; - const elementSafeTagName = (el) => el && el.tagName ? el.tagName : ''; + const elementSafeTagName = (el) => el && typeof el.tagName === 'string' ? el.tagName.toUpperCase() : ''; const getExplicitAriaRole = (element) => { const roles = String(element.getAttribute('role') || '').split(' '); @@ -52,34 +52,194 @@ internal static class RoleSelectorEngine return null; }; - const getImplicitAriaRole = (element) => { - const tag = elementSafeTagName(element); - if (tag === 'BUTTON') return 'button'; - if (tag === 'DETAILS') return 'group'; - if (tag === 'OPTION') return 'option'; - if (tag === 'OUTPUT') return 'status'; - if (tag === 'LI') return 'listitem'; - if (tag === 'TEXTAREA') return 'textbox'; - if (tag === 'A' && element.hasAttribute('href')) return 'link'; - if (tag === 'IMG') return 'img'; - if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') return 'heading'; - if (tag === 'SELECT') { - const size = Number(element.size); - if (element.hasAttribute('multiple') || size > 1) return 'listbox'; - return 'combobox'; + const closestCrossShadow = (element, css) => { + while (element) { + const closest = element.closest(css); + if (closest) return closest; + while (element.parentElement) element = element.parentElement; + element = parentElementOrShadowHost(element); } - if (tag === 'INPUT') { - const type = String(element.type || '').toLowerCase(); + return null; + }; + + const hasExplicitAccessibleName = (e) => e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby'); + + const kAncestorPreventingLandmark = 'article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]'; + + const kNameProhibitedRoles = ['caption', 'code', 'deletion', 'emphasis', 'generic', 'insertion', 'paragraph', 'presentation', 'strong', 'subscript', 'superscript']; + const kGlobalAriaAttributes = [ + ['aria-atomic', undefined], + ['aria-busy', undefined], + ['aria-controls', undefined], + ['aria-current', undefined], + ['aria-describedby', undefined], + ['aria-details', undefined], + ['aria-dropeffect', undefined], + ['aria-flowto', undefined], + ['aria-grabbed', undefined], + ['aria-hidden', undefined], + ['aria-keyshortcuts', undefined], + ['aria-label', kNameProhibitedRoles], + ['aria-labelledby', kNameProhibitedRoles], + ['aria-live', undefined], + ['aria-owns', undefined], + ['aria-relevant', undefined], + ['aria-roledescription', ['generic']], + ]; + + const hasGlobalAriaAttribute = (element, forRole) => kGlobalAriaAttributes.some(([attr, prohibited]) => + !(prohibited && prohibited.includes(forRole || '')) && element.hasAttribute(attr)); + + const hasTabIndex = (element) => !Number.isNaN(Number(String(element.getAttribute('tabindex')))); + + const isNativelyFocusable = (element) => { + const tag = elementSafeTagName(element); + if (['BUTTON', 'DETAILS', 'SELECT', 'TEXTAREA'].includes(tag)) return true; + if (tag === 'A' || tag === 'AREA') return element.hasAttribute('href'); + if (tag === 'INPUT') return !element.hidden; + return false; + }; + + const isFocusable = (element) => !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element)); + + const inputTypeToRole = { button: 'button', checkbox: 'checkbox', image: 'button', number: 'spinbutton', radio: 'radio', range: 'slider', reset: 'button', submit: 'button' }; + + const isHeaderCell = (element) => !!element && elementSafeTagName(element) === 'TH'; + + const isNonEmptyDataCell = (element) => { + if (!element || elementSafeTagName(element) !== 'TD') return false; + return !!((element.textContent && element.textContent.trim()) || element.children.length > 0); + }; + + // https://w3c.github.io/html-aam/#html-element-role-mappings + const kImplicitRoleByTagName = { + A: (e) => e.hasAttribute('href') ? 'link' : null, + AREA: (e) => e.hasAttribute('href') ? 'link' : null, + ARTICLE: () => 'article', + ASIDE: () => 'complementary', + BLOCKQUOTE: () => 'blockquote', + BUTTON: () => 'button', + CAPTION: () => 'caption', + CODE: () => 'code', + DATALIST: () => 'listbox', + DD: () => 'definition', + DEL: () => 'deletion', + DETAILS: () => 'group', + DFN: () => 'term', + DIALOG: () => 'dialog', + DT: () => 'term', + EM: () => 'emphasis', + FIELDSET: () => 'group', + FIGURE: () => 'figure', + FOOTER: (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'contentinfo', + FORM: (e) => hasExplicitAccessibleName(e) ? 'form' : null, + H1: () => 'heading', + H2: () => 'heading', + H3: () => 'heading', + H4: () => 'heading', + H5: () => 'heading', + H6: () => 'heading', + HEADER: (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : 'banner', + HR: () => 'separator', + HTML: () => 'document', + IMG: (e) => (e.getAttribute('alt') === '') && !e.getAttribute('title') && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? 'presentation' : 'img', + INPUT: (e) => { + const type = String(e.type || '').toLowerCase(); + if (['email', 'search', 'tel', 'text', 'url', ''].includes(type)) { + const list = getIdRefs(e, e.getAttribute('list'))[0]; + if (list && elementSafeTagName(list) === 'DATALIST') return 'combobox'; + return type === 'search' ? 'searchbox' : 'textbox'; + } if (type === 'hidden') return null; - if (type === 'checkbox') return 'checkbox'; - if (type === 'radio') return 'radio'; - if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image' || type === 'file') return 'button'; - if (type === 'number') return 'spinbutton'; - if (type === 'range') return 'slider'; - if (type === 'search') return 'searchbox'; - return 'textbox'; + if (type === 'file') return 'button'; + return inputTypeToRole[type] || 'textbox'; + }, + INS: () => 'insertion', + LI: () => 'listitem', + MAIN: () => 'main', + MARK: () => 'mark', + MATH: () => 'math', + MENU: () => 'list', + METER: () => 'meter', + NAV: () => 'navigation', + OL: () => 'list', + OPTGROUP: () => 'group', + OPTION: () => 'option', + OUTPUT: () => 'status', + P: () => 'paragraph', + PROGRESS: () => 'progressbar', + SEARCH: () => 'search', + SECTION: (e) => hasExplicitAccessibleName(e) ? 'region' : null, + SELECT: (e) => (e.multiple || e.size > 1) ? 'listbox' : 'combobox', + STRONG: () => 'strong', + SUB: () => 'subscript', + SUP: () => 'superscript', + SVG: () => 'img', + TABLE: () => 'table', + TBODY: () => 'rowgroup', + TD: (e) => { + const table = closestCrossShadow(e, 'table'); + const role = table ? getExplicitAriaRole(table) : ''; + return (role === 'grid' || role === 'treegrid') ? 'gridcell' : 'cell'; + }, + TEXTAREA: () => 'textbox', + TFOOT: () => 'rowgroup', + TH: (e) => { + const scope = e.getAttribute('scope'); + if (scope === 'col' || scope === 'colgroup') return 'columnheader'; + if (scope === 'row' || scope === 'rowgroup') return 'rowheader'; + const nextSibling = e.nextElementSibling; + const prevSibling = e.previousElementSibling; + const row = !!e.parentElement && elementSafeTagName(e.parentElement) === 'TR' ? e.parentElement : undefined; + if (!nextSibling && !prevSibling) { + if (row) { + const table = closestCrossShadow(row, 'table'); + if (table && table.rows.length <= 1) return null; + } + return 'columnheader'; + } + if (isHeaderCell(nextSibling) && isHeaderCell(prevSibling)) return 'columnheader'; + if (isNonEmptyDataCell(nextSibling) || isNonEmptyDataCell(prevSibling)) return 'rowheader'; + return 'columnheader'; + }, + THEAD: () => 'rowgroup', + TIME: () => 'time', + TR: () => 'row', + UL: () => 'list', + }; + + const kPresentationInheritanceParents = { + DD: ['DL', 'DIV'], + DIV: ['DL'], + DT: ['DL', 'DIV'], + LI: ['OL', 'UL'], + TBODY: ['TABLE'], + TD: ['TR'], + TFOOT: ['TABLE'], + TH: ['TR'], + THEAD: ['TABLE'], + TR: ['THEAD', 'TBODY', 'TFOOT', 'TABLE'], + }; + + const hasPresentationConflictResolution = (element, role) => hasGlobalAriaAttribute(element, role) || isFocusable(element); + + const getImplicitAriaRole = (element) => { + const factory = kImplicitRoleByTagName[elementSafeTagName(element)]; + const implicitRole = (factory && factory(element)) || ''; + if (!implicitRole) return null; + // Inherit presentation role when required. + // https://www.w3.org/TR/wai-aria-1.2/#conflict_resolution_presentation_none + let ancestor = element; + while (ancestor) { + const parent = parentElementOrShadowHost(ancestor); + const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)]; + if (!parents || !parent || !parents.includes(elementSafeTagName(parent))) break; + const parentExplicitRole = getExplicitAriaRole(parent); + if ((parentExplicitRole === 'none' || parentExplicitRole === 'presentation') && !hasPresentationConflictResolution(parent, parentExplicitRole)) + return parentExplicitRole; + ancestor = parent; } - return null; + return implicitRole; }; const getAriaRole = (element) => getExplicitAriaRole(element) || getImplicitAriaRole(element); diff --git a/src/PlaywrightNative/Helpers/Selectors.cs b/src/PlaywrightNative/Helpers/Selectors.cs index 2f6a0aba..dc3ae047 100644 --- a/src/PlaywrightNative/Helpers/Selectors.cs +++ b/src/PlaywrightNative/Helpers/Selectors.cs @@ -67,7 +67,8 @@ static bool NameIsValid(string value) bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') - || c == '_'; + || c == '_' + || c == '-'; if (!ok) { return false; diff --git a/src/PlaywrightNative/Locator.cs b/src/PlaywrightNative/Locator.cs index 0c319fff..b3e9ec14 100644 --- a/src/PlaywrightNative/Locator.cs +++ b/src/PlaywrightNative/Locator.cs @@ -1076,8 +1076,8 @@ public async Task HideHighlightAsync(float? timeout = default) { _ = timeout; string id = ToString(); - PageHighlights.Forget(Page, id); - await Page.EvaluateAsync(ElementStateScript.HideHighlightByIdFunction, id).ConfigureAwait(false); + PageHighlights.Forget(Page, _frame, id); + await _frame.EvaluateAsync(ElementStateScript.HideHighlightByIdFunction, id).ConfigureAwait(false); } /// @@ -1363,7 +1363,7 @@ internal async Task HighlightInternalAsync(float? timeout, string style) string tooltip = ToString(); string payload = "{\"tooltip\":" + JsonSerializer.Serialize(tooltip) + ",\"style\":" + JsonSerializer.Serialize(style ?? string.Empty) + ",\"id\":" + JsonSerializer.Serialize(tooltip) + "}"; await handle.EvaluateAsync(ElementStateScript.HighlightFunction, payload).ConfigureAwait(false); - PageHighlights.Remember(Page, this, style, tooltip); + PageHighlights.Remember(Page, _frame, this, style, tooltip); } internal Locator WithAnyFrame() diff --git a/src/PlaywrightNative/LocatorCompatExtensions.cs b/src/PlaywrightNative/LocatorCompatExtensions.cs index 35fb4da3..6a626997 100644 --- a/src/PlaywrightNative/LocatorCompatExtensions.cs +++ b/src/PlaywrightNative/LocatorCompatExtensions.cs @@ -90,6 +90,20 @@ public static Task HighlightAsync(this ILocator locator, string style = default) public static ILocator Has(this ILocator locator, ILocator has) => RequireLocator(locator).Has(has); + /// + /// Returns a locator that matches this locator's elements inside each element matched by + /// . Same as parent.Locator(locator), but reads in natural order. + /// Relative locators such as Nth or First resolve separately inside each parent. + /// + /// The locator to scope. + /// Locator matching the parent elements to search within. + /// The scoped locator. + public static ILocator Within(this ILocator locator, ILocator parent) + { + ArgumentNullException.ThrowIfNull(parent); + return parent.Locator(locator); + } + /// Legacy hasNot filter. public static ILocator HasNot(this ILocator locator, ILocator hasNot) => RequireLocator(locator).HasNot(hasNot); diff --git a/src/PlaywrightNative/Page.cs b/src/PlaywrightNative/Page.cs index 5b018218..33f2e3f7 100644 --- a/src/PlaywrightNative/Page.cs +++ b/src/PlaywrightNative/Page.cs @@ -2634,11 +2634,7 @@ Task IPage.GoForwardAsync(PageGoForwardOptions options) Task IPage.GotoAsync(string url, PageGotoOptions options) => GoToAsync(url, options?.WaitUntil ?? default, options?.Timeout, options?.Referer); - async Task IPage.HideHighlightAsync() - { - PageHighlights.Clear(this); - await EvaluateAsync(ElementStateScript.HideAllHighlightsFunction).ConfigureAwait(false); - } + Task IPage.HideHighlightAsync() => PageHighlights.HideAllAsync(this); Task IPage.HoverAsync(string selector, PageHoverOptions options) => HoverAsync(selector, options?.Position, options?.Modifiers, options?.Force, options?.Timeout, options?.Trial, ActionScrollBridge.FromScrollOption(options?.Scroll), options?.Strict); diff --git a/src/PlaywrightNative/WebKit/WKPage.cs b/src/PlaywrightNative/WebKit/WKPage.cs index b9853b40..84a38455 100644 --- a/src/PlaywrightNative/WebKit/WKPage.cs +++ b/src/PlaywrightNative/WebKit/WKPage.cs @@ -12184,11 +12184,7 @@ Task IPage.GoForwardAsync(PageGoForwardOptions options) Task IPage.GotoAsync(string url, PageGotoOptions options) => GoToAsync(url, options?.WaitUntil ?? default, options?.Timeout, options?.Referer); - async Task IPage.HideHighlightAsync() - { - PageHighlights.Clear(this); - await EvaluateAsync(ElementStateScript.HideAllHighlightsFunction).ConfigureAwait(false); - } + Task IPage.HideHighlightAsync() => PageHighlights.HideAllAsync(this); Task IPage.HoverAsync(string selector, PageHoverOptions options) => HoverAsync(selector, options?.Position, options?.Modifiers, options?.Force, options?.Timeout, options?.Trial, ActionScrollBridge.FromScrollOption(options?.Scroll), options?.Strict);