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);