From 627261f1ab4a82e798ebe48633a42fb1d1a84151 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 27 Mar 2025 14:16:15 -0700 Subject: [PATCH 01/12] wip --- src/bunit.web.query/Roles/AriaRole.cs | 88 +++++++++++++++++++ .../Roles/RoleQueryExtensions.cs | 16 ++++ .../Roles/RoleQueryExtensionsTest.cs | 26 ++++++ 3 files changed, 130 insertions(+) create mode 100644 src/bunit.web.query/Roles/AriaRole.cs create mode 100644 src/bunit.web.query/Roles/RoleQueryExtensions.cs create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs diff --git a/src/bunit.web.query/Roles/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs new file mode 100644 index 000000000..7b290bdc0 --- /dev/null +++ b/src/bunit.web.query/Roles/AriaRole.cs @@ -0,0 +1,88 @@ +namespace Bunit.Roles +{ + public static class AriaRole + { + public const string Alert = "alert"; + public const string Alertdialog = "alertdialog"; + public const string Application = "application"; + public const string Article = "article"; + public const string Banner = "banner"; + public const string Blockquote = "blockquote"; + public const string Button = "button"; + public const string Caption = "caption"; + public const string Cell = "cell"; + public const string Checkbox = "checkbox"; + public const string Code = "code"; + public const string Columnheader = "columnheader"; + public const string Combobox = "combobox"; + public const string Complementary = "complementary"; + public const string Contentinfo = "contentinfo"; + public const string Definition = "definition"; + public const string Deletion = "deletion"; + public const string Dialog = "dialog"; + public const string Directory = "directory"; + public const string Document = "document"; + public const string Emphasis = "emphasis"; + public const string Feed = "feed"; + public const string Figure = "figure"; + public const string Form = "form"; + public const string Generic = "generic"; + public const string Grid = "grid"; + public const string Gridcell = "gridcell"; + public const string Group = "group"; + public const string Heading = "heading"; + public const string Img = "img"; + public const string Insertion = "insertion"; + public const string Link = "link"; + public const string List = "list"; + public const string Listbox = "listbox"; + public const string Listitem = "listitem"; + public const string Log = "log"; + public const string Main = "main"; + public const string Marquee = "marquee"; + public const string Math = "math"; + public const string Meter = "meter"; + public const string Menu = "menu"; + public const string Menubar = "menubar"; + public const string Menuitem = "menuitem"; + public const string Menuitemcheckbox = "menuitemcheckbox"; + public const string Menuitemradio = "menuitemradio"; + public const string Navigation = "navigation"; + public const string None = "none"; + public const string Note = "note"; + public const string Option = "option"; + public const string Paragraph = "paragraph"; + public const string Presentation = "presentation"; + public const string Progressbar = "progressbar"; + public const string Radio = "radio"; + public const string Radiogroup = "radiogroup"; + public const string Region = "region"; + public const string Row = "row"; + public const string Rowgroup = "rowgroup"; + public const string Rowheader = "rowheader"; + public const string Scrollbar = "scrollbar"; + public const string Search = "search"; + public const string Searchbox = "searchbox"; + public const string Separator = "separator"; + public const string Slider = "slider"; + public const string Spinbutton = "spinbutton"; + public const string Status = "status"; + public const string Strong = "strong"; + public const string Subscript = "subscript"; + public const string Superscript = "superscript"; + public const string Switch = "switch"; + public const string Tab = "tab"; + public const string Table = "table"; + public const string Tablist = "tablist"; + public const string Tabpanel = "tabpanel"; + public const string Term = "term"; + public const string Textbox = "textbox"; + public const string Time = "time"; + public const string Timer = "timer"; + public const string Toolbar = "toolbar"; + public const string Tooltip = "tooltip"; + public const string Tree = "tree"; + public const string Treegrid = "treegrid"; + public const string Treeitem = "treeitem"; + } +} diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs new file mode 100644 index 000000000..892e7515d --- /dev/null +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -0,0 +1,16 @@ +using AngleSharp.Dom; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bunit.Roles; + +public static class RoleQueryExtensions +{ + public static IElement FindByRole(this IRenderedComponent renderedComponent, string role) + { + throw new NotImplementedException(); + } +} diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs new file mode 100644 index 000000000..aad85562c --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -0,0 +1,26 @@ +namespace Bunit.Roles; + +public class RoleQueryExtensionsTest : BunitContext +{ + [Fact] + public async Task ShouldDetectRoles() + { + var cut = Render(ps => ps.AddChildContent( + @" + + + +

Heading

+
Hello
+
I am a dialog
+ ")); + + cut.FindByRole(AriaRole.Button).MarkupMatches(""); + cut.FindByRole(AriaRole.Listbox).MarkupMatches(""); + cut.FindByRole(AriaRole.Combobox).MarkupMatches(""); + cut.FindByRole(AriaRole.Heading).MarkupMatches("

Heading

"); + cut.FindByRole(AriaRole.Group).MarkupMatches("
Hello
"); + cut.FindByRole(AriaRole.Dialog).MarkupMatches("
I am a dialog
"); + Should.Throw(() => cut.FindByRole(AriaRole.Menuitem)); + } +} From 4f5c8e7369312935ea9a1132fdd725aa22bb7a2a Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 27 Mar 2025 14:26:40 -0700 Subject: [PATCH 02/12] test ShouldSupportSelected --- .../Roles/RoleQueryExtensionsTest.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index aad85562c..167ee6f50 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -23,4 +23,33 @@ public async Task ShouldDetectRoles() cut.FindByRole(AriaRole.Dialog).MarkupMatches("
I am a dialog
"); Should.Throw(() => cut.FindByRole(AriaRole.Menuitem)); } + + + [Fact] + public async Task ShouldSupportSelected() + { + var cut = Render(ps => ps.AddChildContent( + @" + +
+
Hi
+
Hello
+
+ ")); + + // Test finding options that are selected + var selectedOptions = cut.FindAllByRole(AriaRole.Option, new ByRoleOptions { Selected = true }).ToArray(); + selectedOptions.Length.Should().Be(2); + selectedOptions[0].MarkupMatches(""); + selectedOptions[1].MarkupMatches("
Hi
"); + + // Test finding options that are not selected + var unselectedOptions = cut.FindAllByRole(AriaRole.Option, new ByRoleOptions { Selected = false }).ToArray(); + unselectedOptions.Length.Should().Be(2); + unselectedOptions[0].MarkupMatches(""); + unselectedOptions[1].MarkupMatches("
Hello
"); + } } From 6a4dd316364a55c24b07799e0e3b955d1e58d1bd Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 14:24:38 -0700 Subject: [PATCH 03/12] passing test for implicit and explicit roles --- .cursor/rules/blazor.mdc | 72 ++++++++++++++ src/bunit.web.query/Roles/AriaRole.cs | 96 ++----------------- .../Roles/RoleQueryExtensions.cs | 45 ++++++++- .../Roles/RoleQueryExtensionsTest.cs | 29 +++--- 4 files changed, 139 insertions(+), 103 deletions(-) create mode 100644 .cursor/rules/blazor.mdc diff --git a/.cursor/rules/blazor.mdc b/.cursor/rules/blazor.mdc new file mode 100644 index 000000000..6a211877c --- /dev/null +++ b/.cursor/rules/blazor.mdc @@ -0,0 +1,72 @@ +--- +description: +globs: +alwaysApply: false +--- +--- +description: +globs: +alwaysApply: false +--- + + You are a senior Blazor and .NET developer, experienced in C#, ASP.NET Core, and Entity Framework Core. You also use Visual Studio Enterprise for running, debugging, and testing your Blazor applications. + + ## Workflow and Development Environment + - All running, debugging, and testing of the Blazor app should happen using the dotnet CLI. + - Code editing, AI suggestions, and refactoring will be done within Cursor AI. + - Recognize that the dotnet CLI is installed and should be used for compiling and launching the app. + + ## Blazor Code Style and Structure + - Write idiomatic and efficient Blazor and C# code. + - Follow .NET and Blazor conventions. + - Use Razor Components appropriately for component-based UI development. + - Prefer inline functions for components + - Async/await should be used where applicable to ensure non-blocking UI operations. + + ## Naming Conventions + - Follow PascalCase for component names, method names, and public members. + - Use camelCase for private fields and local variables. + - Prefix interface names with "I" (e.g., IUserService). + + ## Blazor and .NET Specific Guidelines + - Utilize Blazor's built-in features for component lifecycle (e.g., OnInitializedAsync, OnParametersSetAsync). + - Use data binding effectively with @bind. + - Leverage Dependency Injection for services in Blazor. + - Structure Blazor components and services following Separation of Concerns. + - Use C# 10+ features like record types, pattern matching, and global usings. + + ## Error Handling and Validation + - Implement proper error handling for Blazor pages and API calls. + - Use logging for error tracking in the backend and consider capturing UI-level errors in Blazor with tools like ErrorBoundary. + - Implement validation using FluentValidation or DataAnnotations in forms. + + ## Blazor API and Performance Optimization + - Utilize Blazor server-side or WebAssembly optimally based on the project requirements. + - Use asynchronous methods (async/await) for API calls or UI actions that could block the main thread. + - Optimize Razor components by reducing unnecessary renders and using StateHasChanged() efficiently. + - Minimize the component render tree by avoiding re-renders unless necessary, using ShouldRender() where appropriate. + - Use EventCallbacks for handling user interactions efficiently, passing only minimal data when triggering events. + + ## Caching Strategies + - Implement in-memory caching for frequently used data, especially for Blazor Server apps. Use IMemoryCache for lightweight caching solutions. + - For Blazor WebAssembly, utilize localStorage or sessionStorage to cache application state between user sessions. + - Consider Distributed Cache strategies (like Redis or SQL Server Cache) for larger applications that need shared state across multiple users or clients. + - Cache API calls by storing responses to avoid redundant calls when data is unlikely to change, thus improving the user experience. + + ## State Management Libraries + - Use Blazor’s built-in Cascading Parameters and EventCallbacks for basic state sharing across components. + - Implement advanced state management solutions using libraries like Fluxor or BlazorState when the application grows in complexity. + - For client-side state persistence in Blazor WebAssembly, consider using Blazored.LocalStorage or Blazored.SessionStorage to maintain state between page reloads. + - For server-side Blazor, use Scoped Services and the StateContainer pattern to manage state within user sessions while minimizing re-renders. + + ## API Design and Integration + - Use HttpClient or other appropriate services to communicate with external APIs or your own backend. + - Implement error handling for API calls using try-catch and provide proper user feedback in the UI. + + ## Testing and Debugging in Visual Studio + - All unit testing and integration testing should be done using the dotnet CLI. + - Test Blazor components and services using xUnit. + - Never use a mocking framework. + - Debug Blazor UI issues using browser developer tools and Visual Studio’s debugging tools for backend and server-side issues. + - For performance profiling and optimization, rely on Visual Studio's diagnostics tools. + \ No newline at end of file diff --git a/src/bunit.web.query/Roles/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs index 7b290bdc0..33ca7876f 100644 --- a/src/bunit.web.query/Roles/AriaRole.cs +++ b/src/bunit.web.query/Roles/AriaRole.cs @@ -1,88 +1,12 @@ -namespace Bunit.Roles +namespace Bunit.Roles; + +public enum AriaRole { - public static class AriaRole - { - public const string Alert = "alert"; - public const string Alertdialog = "alertdialog"; - public const string Application = "application"; - public const string Article = "article"; - public const string Banner = "banner"; - public const string Blockquote = "blockquote"; - public const string Button = "button"; - public const string Caption = "caption"; - public const string Cell = "cell"; - public const string Checkbox = "checkbox"; - public const string Code = "code"; - public const string Columnheader = "columnheader"; - public const string Combobox = "combobox"; - public const string Complementary = "complementary"; - public const string Contentinfo = "contentinfo"; - public const string Definition = "definition"; - public const string Deletion = "deletion"; - public const string Dialog = "dialog"; - public const string Directory = "directory"; - public const string Document = "document"; - public const string Emphasis = "emphasis"; - public const string Feed = "feed"; - public const string Figure = "figure"; - public const string Form = "form"; - public const string Generic = "generic"; - public const string Grid = "grid"; - public const string Gridcell = "gridcell"; - public const string Group = "group"; - public const string Heading = "heading"; - public const string Img = "img"; - public const string Insertion = "insertion"; - public const string Link = "link"; - public const string List = "list"; - public const string Listbox = "listbox"; - public const string Listitem = "listitem"; - public const string Log = "log"; - public const string Main = "main"; - public const string Marquee = "marquee"; - public const string Math = "math"; - public const string Meter = "meter"; - public const string Menu = "menu"; - public const string Menubar = "menubar"; - public const string Menuitem = "menuitem"; - public const string Menuitemcheckbox = "menuitemcheckbox"; - public const string Menuitemradio = "menuitemradio"; - public const string Navigation = "navigation"; - public const string None = "none"; - public const string Note = "note"; - public const string Option = "option"; - public const string Paragraph = "paragraph"; - public const string Presentation = "presentation"; - public const string Progressbar = "progressbar"; - public const string Radio = "radio"; - public const string Radiogroup = "radiogroup"; - public const string Region = "region"; - public const string Row = "row"; - public const string Rowgroup = "rowgroup"; - public const string Rowheader = "rowheader"; - public const string Scrollbar = "scrollbar"; - public const string Search = "search"; - public const string Searchbox = "searchbox"; - public const string Separator = "separator"; - public const string Slider = "slider"; - public const string Spinbutton = "spinbutton"; - public const string Status = "status"; - public const string Strong = "strong"; - public const string Subscript = "subscript"; - public const string Superscript = "superscript"; - public const string Switch = "switch"; - public const string Tab = "tab"; - public const string Table = "table"; - public const string Tablist = "tablist"; - public const string Tabpanel = "tabpanel"; - public const string Term = "term"; - public const string Textbox = "textbox"; - public const string Time = "time"; - public const string Timer = "timer"; - public const string Toolbar = "toolbar"; - public const string Tooltip = "tooltip"; - public const string Tree = "tree"; - public const string Treegrid = "treegrid"; - public const string Treeitem = "treeitem"; - } + Button, + Listbox, + Combobox, + Heading, + Group, + Dialog, + Menuitem } diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs index 892e7515d..18b0a0721 100644 --- a/src/bunit.web.query/Roles/RoleQueryExtensions.cs +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -1,4 +1,5 @@ using AngleSharp.Dom; +using Bunit.Web.AngleSharp; using System; using System.Collections.Generic; using System.Linq; @@ -9,8 +10,48 @@ namespace Bunit.Roles; public static class RoleQueryExtensions { - public static IElement FindByRole(this IRenderedComponent renderedComponent, string role) + private static readonly IReadOnlyDictionary ImplicitRoles = new Dictionary { - throw new NotImplementedException(); + [AriaRole.Button] = ["button"], + [AriaRole.Listbox] = ["select"], + [AriaRole.Combobox] = ["select"], + [AriaRole.Heading] = ["h1", "h2", "h3", "h4", "h5", "h6"], + [AriaRole.Group] = ["details"], + }; + + public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role) + { + var roleString = role.ToString().ToLowerInvariant(); + + // First try to find by explicit role + var element = renderedComponent.Nodes.TryQuerySelector($"[role='{roleString}']"); + if (element is not null) + return element; + + // Then try to find by implicit role + if (ImplicitRoles.TryGetValue(role, out var possibleElements)) + { + foreach (var elementName in possibleElements) + { + var elements = renderedComponent.Nodes.TryQuerySelectorAll(elementName); + foreach (var e in elements) + { + // For select elements, check if it's a listbox or combobox + if (elementName == "select") + { + if (role == AriaRole.Listbox && e.HasAttribute("multiple")) + return e; + if (role == AriaRole.Combobox && !e.HasAttribute("multiple")) + return e; + } + else + { + return e; + } + } + } + } + + throw new InvalidOperationException($"Unable to find element with role '{roleString}'"); } } diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 167ee6f50..2a2019542 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -5,22 +5,21 @@ public class RoleQueryExtensionsTest : BunitContext [Fact] public async Task ShouldDetectRoles() { - var cut = Render(ps => ps.AddChildContent( - @" - - - -

Heading

-
Hello
-
I am a dialog
- ")); + var cut = Render(ps => ps.AddChildContent(""" + + + +

Heading

+
Hello
+
I am a dialog
+ """)); - cut.FindByRole(AriaRole.Button).MarkupMatches(""); - cut.FindByRole(AriaRole.Listbox).MarkupMatches(""); - cut.FindByRole(AriaRole.Combobox).MarkupMatches(""); - cut.FindByRole(AriaRole.Heading).MarkupMatches("

Heading

"); - cut.FindByRole(AriaRole.Group).MarkupMatches("
Hello
"); - cut.FindByRole(AriaRole.Dialog).MarkupMatches("
I am a dialog
"); + cut.FindByRole(AriaRole.Button).MarkupMatches(""""""); + cut.FindByRole(AriaRole.Listbox).MarkupMatches(""""""); + cut.FindByRole(AriaRole.Combobox).MarkupMatches(""""""); + cut.FindByRole(AriaRole.Heading).MarkupMatches("""

Heading

"""); + cut.FindByRole(AriaRole.Group).MarkupMatches("""
Hello
"""); + cut.FindByRole(AriaRole.Dialog).MarkupMatches("""
I am a dialog
"""); Should.Throw(() => cut.FindByRole(AriaRole.Menuitem)); } From 30194cee852234013572da5b8c37b6c998b9331e Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 14:37:37 -0700 Subject: [PATCH 04/12] add implementation --- .../Roles/RoleQueryExtensions.cs | 48 +++++-------------- .../Roles/Strategies/ExplicitRoleStrategy.cs | 13 +++++ .../Roles/Strategies/IRoleQueryStrategy.cs | 8 ++++ .../Roles/Strategies/ImplicitRoleStrategy.cs | 44 +++++++++++++++++ 4 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs create mode 100644 src/bunit.web.query/Roles/Strategies/IRoleQueryStrategy.cs create mode 100644 src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs index 18b0a0721..130e3693f 100644 --- a/src/bunit.web.query/Roles/RoleQueryExtensions.cs +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -1,5 +1,6 @@ using AngleSharp.Dom; using Bunit.Web.AngleSharp; +using Bunit.Roles.Strategies; using System; using System.Collections.Generic; using System.Linq; @@ -10,48 +11,21 @@ namespace Bunit.Roles; public static class RoleQueryExtensions { - private static readonly IReadOnlyDictionary ImplicitRoles = new Dictionary - { - [AriaRole.Button] = ["button"], - [AriaRole.Listbox] = ["select"], - [AriaRole.Combobox] = ["select"], - [AriaRole.Heading] = ["h1", "h2", "h3", "h4", "h5", "h6"], - [AriaRole.Group] = ["details"], - }; + private static readonly IReadOnlyList RoleQueryStrategies = + [ + new ExplicitRoleStrategy(), + new ImplicitRoleStrategy(), + ]; public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role) { - var roleString = role.ToString().ToLowerInvariant(); - - // First try to find by explicit role - var element = renderedComponent.Nodes.TryQuerySelector($"[role='{roleString}']"); - if (element is not null) - return element; - - // Then try to find by implicit role - if (ImplicitRoles.TryGetValue(role, out var possibleElements)) + foreach (var strategy in RoleQueryStrategies) { - foreach (var elementName in possibleElements) - { - var elements = renderedComponent.Nodes.TryQuerySelectorAll(elementName); - foreach (var e in elements) - { - // For select elements, check if it's a listbox or combobox - if (elementName == "select") - { - if (role == AriaRole.Listbox && e.HasAttribute("multiple")) - return e; - if (role == AriaRole.Combobox && !e.HasAttribute("multiple")) - return e; - } - else - { - return e; - } - } - } + var element = strategy.FindElement(renderedComponent, role); + if (element is not null) + return element; } - throw new InvalidOperationException($"Unable to find element with role '{roleString}'"); + throw new InvalidOperationException($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'"); } } diff --git a/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs b/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs new file mode 100644 index 000000000..696098dbe --- /dev/null +++ b/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs @@ -0,0 +1,13 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Roles.Strategies; + +internal sealed class ExplicitRoleStrategy : IRoleQueryStrategy +{ + public IElement? FindElement(IRenderedComponent renderedComponent, AriaRole role) + { + var roleString = role.ToString().ToLowerInvariant(); + return renderedComponent.Nodes.TryQuerySelector($"[role='{roleString}']"); + } +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/Strategies/IRoleQueryStrategy.cs b/src/bunit.web.query/Roles/Strategies/IRoleQueryStrategy.cs new file mode 100644 index 000000000..819ea4f41 --- /dev/null +++ b/src/bunit.web.query/Roles/Strategies/IRoleQueryStrategy.cs @@ -0,0 +1,8 @@ +using AngleSharp.Dom; + +namespace Bunit.Roles.Strategies; + +internal interface IRoleQueryStrategy +{ + IElement? FindElement(IRenderedComponent renderedComponent, AriaRole role); +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs new file mode 100644 index 000000000..ec476ee26 --- /dev/null +++ b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs @@ -0,0 +1,44 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Roles.Strategies; + +internal sealed class ImplicitRoleStrategy : IRoleQueryStrategy +{ + private static readonly IReadOnlyDictionary ImplicitRoles = new Dictionary + { + [AriaRole.Button] = ["button"], + [AriaRole.Listbox] = ["select"], + [AriaRole.Combobox] = ["select"], + [AriaRole.Heading] = ["h1", "h2", "h3", "h4", "h5", "h6"], + [AriaRole.Group] = ["details"], + }; + + public IElement? FindElement(IRenderedComponent renderedComponent, AriaRole role) + { + if (!ImplicitRoles.TryGetValue(role, out var possibleElements)) + return null; + + foreach (var elementName in possibleElements) + { + var elements = renderedComponent.Nodes.TryQuerySelectorAll(elementName); + foreach (var e in elements) + { + // For select elements, check if it's a listbox or combobox + if (elementName == "select") + { + if (role == AriaRole.Listbox && e.HasAttribute("multiple")) + return e; + if (role == AriaRole.Combobox && !e.HasAttribute("multiple")) + return e; + } + else + { + return e; + } + } + } + + return null; + } +} \ No newline at end of file From c127f3635c7ee9d9945c721aba51a32b510559da Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 27 Mar 2025 14:48:06 -0700 Subject: [PATCH 05/12] test: by default logs accessible roles when it fails # Conflicts: # tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs --- src/bunit.web.query/Roles/AriaRole.cs | 9 ++-- .../Roles/RoleQueryExtensionsTest.cs | 51 +++---------------- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/src/bunit.web.query/Roles/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs index 33ca7876f..80c74fb2b 100644 --- a/src/bunit.web.query/Roles/AriaRole.cs +++ b/src/bunit.web.query/Roles/AriaRole.cs @@ -2,11 +2,12 @@ namespace Bunit.Roles; public enum AriaRole { + Article, Button, - Listbox, Combobox, - Heading, - Group, Dialog, - Menuitem + Group, + Heading, + Listbox, + Menuitem, } diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 2a2019542..e8718cdfd 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -2,53 +2,14 @@ namespace Bunit.Roles; public class RoleQueryExtensionsTest : BunitContext { - [Fact] - public async Task ShouldDetectRoles() - { - var cut = Render(ps => ps.AddChildContent(""" - - - -

Heading

-
Hello
-
I am a dialog
- """)); - - cut.FindByRole(AriaRole.Button).MarkupMatches(""""""); - cut.FindByRole(AriaRole.Listbox).MarkupMatches(""""""); - cut.FindByRole(AriaRole.Combobox).MarkupMatches(""""""); - cut.FindByRole(AriaRole.Heading).MarkupMatches("""

Heading

"""); - cut.FindByRole(AriaRole.Group).MarkupMatches("""
Hello
"""); - cut.FindByRole(AriaRole.Dialog).MarkupMatches("""
I am a dialog
"""); - Should.Throw(() => cut.FindByRole(AriaRole.Menuitem)); - } - - - [Fact] - public async Task ShouldSupportSelected() + [Fact(DisplayName = "by default logs accessible roles when it fails")] + public async Task Test001() { var cut = Render(ps => ps.AddChildContent( - @" - -
-
Hi
-
Hello
-
- ")); - - // Test finding options that are selected - var selectedOptions = cut.FindAllByRole(AriaRole.Option, new ByRoleOptions { Selected = true }).ToArray(); - selectedOptions.Length.Should().Be(2); - selectedOptions[0].MarkupMatches(""); - selectedOptions[1].MarkupMatches("
Hi
"); + """ +

Hi

+ """)); - // Test finding options that are not selected - var unselectedOptions = cut.FindAllByRole(AriaRole.Option, new ByRoleOptions { Selected = false }).ToArray(); - unselectedOptions.Length.Should().Be(2); - unselectedOptions[0].MarkupMatches(""); - unselectedOptions[1].MarkupMatches("
Hello
"); + Should.Throw(() => cut.FindByRole(AriaRole.Article)); } } From 417e59d684b6bce1a63d5123c88c5afb89cd1a1b Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 15:14:03 -0700 Subject: [PATCH 06/12] make test pass again --- .../Roles/RoleNotFoundException.cs | 12 ++++++ .../Roles/RoleQueryExtensions.cs | 43 ++++++++++++++++++- .../Roles/Strategies/ImplicitRoleStrategy.cs | 7 ++- .../Roles/RoleQueryExtensionsTest.cs | 6 ++- 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/bunit.web.query/Roles/RoleNotFoundException.cs diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs new file mode 100644 index 000000000..01d498a1e --- /dev/null +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -0,0 +1,12 @@ +namespace Bunit.Roles; + +public class RoleNotFoundException : Exception +{ + public IReadOnlyList AvailableRoles { get; } + + public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles) + : base($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'. Available roles: {string.Join(", ", availableRoles)}") + { + AvailableRoles = availableRoles; + } +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs index 130e3693f..90f7b4603 100644 --- a/src/bunit.web.query/Roles/RoleQueryExtensions.cs +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -26,6 +26,47 @@ public static IElement FindByRole(this IRenderedComponent renderedCo return element; } - throw new InvalidOperationException($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'"); + var availableRoles = GetAvailableRoles(renderedComponent); + throw new RoleNotFoundException(role, availableRoles); + } + + private static IReadOnlyList GetAvailableRoles(IRenderedComponent renderedComponent) + { + var roles = new HashSet(); + + // Get explicit roles + var elementsWithRole = renderedComponent.Nodes.TryQuerySelectorAll("[role]"); + foreach (var element in elementsWithRole) + { + var role = element.GetAttribute("role"); + if (role is not null) + roles.Add(role); + } + + // Get implicit roles + foreach (var strategy in RoleQueryStrategies) + { + if (strategy is ImplicitRoleStrategy implicitStrategy) + { + foreach (var role in implicitStrategy.GetImplicitRoles()) + { + var elements = renderedComponent.Nodes.TryQuerySelectorAll(role); + if (elements.Any()) + { + // Find the ARIA role that corresponds to this element + foreach (var (ariaRole, elementNames) in ImplicitRoleStrategy.ImplicitRoles) + { + if (elementNames.Contains(role)) + { + roles.Add(ariaRole.ToString().ToLowerInvariant()); + break; + } + } + } + } + } + } + + return roles.OrderBy(x => x).ToList(); } } diff --git a/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs index ec476ee26..55d0468f6 100644 --- a/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs +++ b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs @@ -5,7 +5,7 @@ namespace Bunit.Roles.Strategies; internal sealed class ImplicitRoleStrategy : IRoleQueryStrategy { - private static readonly IReadOnlyDictionary ImplicitRoles = new Dictionary + internal static readonly IReadOnlyDictionary ImplicitRoles = new Dictionary { [AriaRole.Button] = ["button"], [AriaRole.Listbox] = ["select"], @@ -41,4 +41,9 @@ internal sealed class ImplicitRoleStrategy : IRoleQueryStrategy return null; } + + public IReadOnlyList GetImplicitRoles() + { + return ImplicitRoles.Values.SelectMany(x => x).Distinct().ToList(); + } } \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index e8718cdfd..0dd3de5ac 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -3,13 +3,15 @@ namespace Bunit.Roles; public class RoleQueryExtensionsTest : BunitContext { [Fact(DisplayName = "by default logs accessible roles when it fails")] - public async Task Test001() + public void Test001() { var cut = Render(ps => ps.AddChildContent( """

Hi

""")); - Should.Throw(() => cut.FindByRole(AriaRole.Article)); + var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article)); + exception.Message.ShouldContain("Unable to find element with role 'article'"); + exception.Message.ShouldContain("Available roles: heading"); } } From a06d1a130725ca127e91557664963b3b12ac060e Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 15:23:49 -0700 Subject: [PATCH 07/12] make test pass again --- .../Roles/FindByRoleOptions.cs | 6 +++ .../Roles/RoleNotFoundException.cs | 44 ++++++++++++++++++- .../Roles/RoleQueryExtensions.cs | 31 +++++++++---- .../Roles/RoleQueryExtensionsTest.cs | 20 ++++++++- 4 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 src/bunit.web.query/Roles/FindByRoleOptions.cs diff --git a/src/bunit.web.query/Roles/FindByRoleOptions.cs b/src/bunit.web.query/Roles/FindByRoleOptions.cs new file mode 100644 index 000000000..4cbd34f7e --- /dev/null +++ b/src/bunit.web.query/Roles/FindByRoleOptions.cs @@ -0,0 +1,6 @@ +namespace Bunit.Roles; + +public class FindByRoleOptions +{ + public bool Hidden { get; set; } +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs index 01d498a1e..81eefe364 100644 --- a/src/bunit.web.query/Roles/RoleNotFoundException.cs +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -1,12 +1,52 @@ +using AngleSharp.Dom; +using System.Text; + namespace Bunit.Roles; public class RoleNotFoundException : Exception { public IReadOnlyList AvailableRoles { get; } + public INodeList Nodes { get; } - public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles) - : base($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'. Available roles: {string.Join(", ", availableRoles)}") + public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles, INodeList nodes) + : base(BuildMessage(role, availableRoles, nodes)) { AvailableRoles = availableRoles; + Nodes = nodes; + } + + private static string BuildMessage(AriaRole role, IReadOnlyList availableRoles, INodeList nodes) + { + var sb = new StringBuilder(); + sb.AppendLine($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'"); + sb.AppendLine(); + sb.AppendLine("Here are the available roles:"); + sb.AppendLine(); + + foreach (var availableRole in availableRoles) + { + sb.AppendLine($" {availableRole}:"); + sb.AppendLine(); + + // Find elements with this role + var elements = nodes.TryQuerySelectorAll($"[role='{availableRole}'], h1, h2, h3, h4, h5, h6"); + foreach (var element in elements) + { + var name = element.TextContent.Trim(); + if (!string.IsNullOrEmpty(name)) + { + sb.AppendLine($" Name \"{name}\":"); + } + sb.AppendLine($" <{element.TagName.ToLowerInvariant()} />"); + sb.AppendLine(); + } + } + + sb.AppendLine("--------------------------------------------------"); + sb.AppendLine(); + sb.AppendLine("Ignored nodes: comments, script, style"); + sb.AppendLine(nodes.ToString()); + + return sb.ToString(); } } \ No newline at end of file diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs index 90f7b4603..9406c3b5a 100644 --- a/src/bunit.web.query/Roles/RoleQueryExtensions.cs +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using AngleSharp.Css.Dom; namespace Bunit.Roles; @@ -17,20 +18,29 @@ public static class RoleQueryExtensions new ImplicitRoleStrategy(), ]; - public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role) + public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role, FindByRoleOptions? options = null) { + options ??= new FindByRoleOptions(); + foreach (var strategy in RoleQueryStrategies) { var element = strategy.FindElement(renderedComponent, role); - if (element is not null) + if (element is not null && (options.Hidden || !IsHidden(element))) return element; } - var availableRoles = GetAvailableRoles(renderedComponent); - throw new RoleNotFoundException(role, availableRoles); + var availableRoles = GetAvailableRoles(renderedComponent, options); + throw new RoleNotFoundException(role, availableRoles, renderedComponent.Nodes); + } + + private static bool IsHidden(IElement element) + { + return element.HasAttribute("hidden") || + element.GetAttribute("aria-hidden") == "true" || + element.ComputeStyle().GetDisplay() == "none"; } - private static IReadOnlyList GetAvailableRoles(IRenderedComponent renderedComponent) + private static IReadOnlyList GetAvailableRoles(IRenderedComponent renderedComponent, FindByRoleOptions options) { var roles = new HashSet(); @@ -38,9 +48,12 @@ private static IReadOnlyList GetAvailableRoles(IRenderedComponent GetAvailableRoles(IRenderedComponent options.Hidden || !IsHidden(e))) { // Find the ARIA role that corresponds to this element foreach (var (ariaRole, elementNames) in ImplicitRoleStrategy.ImplicitRoles) diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 0dd3de5ac..40b2c4a4c 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -12,6 +12,24 @@ public void Test001() var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article)); exception.Message.ShouldContain("Unable to find element with role 'article'"); - exception.Message.ShouldContain("Available roles: heading"); + exception.Message.ShouldContain("heading"); + } + + [Fact(DisplayName = "when hidden: true logs available roles when it fails")] + public void Test002() + { + var cut = Render(ps => ps.AddChildContent( + """ + + """)); + + var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article, new() { Hidden = true })); + exception.Message.ShouldContain("Unable to find element with role 'article'"); + exception.Message.ShouldContain("heading"); + exception.Message.ShouldContain("Name \"Hi\":"); + exception.Message.ShouldContain("

"); + exception.Message.ShouldContain("Ignored nodes: comments, script, style"); } } From 8598966a6ca794f2bf5fba4e4a871ce2083190f9 Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 15:31:20 -0700 Subject: [PATCH 08/12] ship it --- .../Roles/RoleNotFoundException.cs | 33 ++++++++++++------- .../Roles/RoleQueryExtensionsTest.cs | 15 +++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs index 81eefe364..200bceee6 100644 --- a/src/bunit.web.query/Roles/RoleNotFoundException.cs +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -20,25 +20,34 @@ private static string BuildMessage(AriaRole role, IReadOnlyList availabl var sb = new StringBuilder(); sb.AppendLine($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'"); sb.AppendLine(); - sb.AppendLine("Here are the available roles:"); - sb.AppendLine(); - foreach (var availableRole in availableRoles) + if (!availableRoles.Any()) + { + sb.AppendLine("There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole"); + sb.AppendLine(); + } + else { - sb.AppendLine($" {availableRole}:"); + sb.AppendLine("Here are the available roles:"); sb.AppendLine(); - // Find elements with this role - var elements = nodes.TryQuerySelectorAll($"[role='{availableRole}'], h1, h2, h3, h4, h5, h6"); - foreach (var element in elements) + foreach (var availableRole in availableRoles) { - var name = element.TextContent.Trim(); - if (!string.IsNullOrEmpty(name)) + sb.AppendLine($" {availableRole}:"); + sb.AppendLine(); + + // Find elements with this role + var elements = nodes.TryQuerySelectorAll($"[role='{availableRole}'], h1, h2, h3, h4, h5, h6"); + foreach (var element in elements) { - sb.AppendLine($" Name \"{name}\":"); + var name = element.TextContent.Trim(); + if (!string.IsNullOrEmpty(name)) + { + sb.AppendLine($" Name \"{name}\":"); + } + sb.AppendLine($" <{element.TagName.ToLowerInvariant()} />"); + sb.AppendLine(); } - sb.AppendLine($" <{element.TagName.ToLowerInvariant()} />"); - sb.AppendLine(); } } diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 40b2c4a4c..5148f112d 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -32,4 +32,19 @@ public void Test002() exception.Message.ShouldContain("

"); exception.Message.ShouldContain("Ignored nodes: comments, script, style"); } + + [Fact(DisplayName = "logs error when there are no accessible roles")] + public void Test003() + { + var cut = Render(ps => ps.AddChildContent( + """ +
+ """)); + + var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article)); + exception.Message.ShouldContain("Unable to find element with role 'article'"); + exception.Message.ShouldContain("There are no accessible roles"); + exception.Message.ShouldContain("If you wish to access them, then set the `hidden` option to `true`"); + exception.Message.ShouldContain("Ignored nodes: comments, script, style"); + } } From e411f95769a022cf26c02676f8dfb9710cba6da0 Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 15:47:39 -0700 Subject: [PATCH 09/12] egil save me --- Directory.Packages.props | 178 ++++++++---------- .../Roles/RoleNotFoundException.cs | 11 +- ...nsTest.Test001Async.DotNet8_0.received.txt | 13 ++ ...ryExtensionsTest.Test001Async.verified.txt | 1 + ...nsTest.Test002Async.DotNet8_0.received.txt | 15 ++ ...ryExtensionsTest.Test002Async.verified.txt | 15 ++ ...nsTest.Test003Async.DotNet8_0.received.txt | 8 + ...ryExtensionsTest.Test003Async.verified.txt | 1 + .../Roles/RoleQueryExtensionsTest.cs | 21 +-- .../bunit.web.query.tests.csproj | 4 + 10 files changed, 157 insertions(+), 110 deletions(-) create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt diff --git a/Directory.Packages.props b/Directory.Packages.props index c828e4847..5b24800bf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,97 +1,85 @@ - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs index 200bceee6..1975b9efe 100644 --- a/src/bunit.web.query/Roles/RoleNotFoundException.cs +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -13,6 +13,7 @@ public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles { AvailableRoles = availableRoles; Nodes = nodes; + System.Diagnostics.Debug.WriteLine($"Actual message:\n{Message}"); } private static string BuildMessage(AriaRole role, IReadOnlyList availableRoles, INodeList nodes) @@ -54,7 +55,15 @@ private static string BuildMessage(AriaRole role, IReadOnlyList availabl sb.AppendLine("--------------------------------------------------"); sb.AppendLine(); sb.AppendLine("Ignored nodes: comments, script, style"); - sb.AppendLine(nodes.ToString()); + + // Serialize the HTML properly + foreach (var node in nodes) + { + if (node is IElement element) + { + sb.AppendLine(element.OuterHtml); + } + } return sb.ToString(); } diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt new file mode 100644 index 000000000..704553429 --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt @@ -0,0 +1,13 @@ +Unable to find element with role 'article' + +Here are the available roles: + + heading: + + Name "Hi": +

+ +-------------------------------------------------- + +Ignored nodes: comments, script, style +

Hi

diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt new file mode 100644 index 000000000..02868b91b --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt @@ -0,0 +1,15 @@ +Unable to find element with role 'article' + +Here are the available roles: + + heading: + + Name "Hi": +

+ +-------------------------------------------------- + +Ignored nodes: comments, script, style + diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt new file mode 100644 index 000000000..02868b91b --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt @@ -0,0 +1,15 @@ +Unable to find element with role 'article' + +Here are the available roles: + + heading: + + Name "Hi": +

+ +-------------------------------------------------- + +Ignored nodes: comments, script, style + diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt new file mode 100644 index 000000000..61d1dc114 --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt @@ -0,0 +1,8 @@ +Unable to find element with role 'article' + +There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole + +-------------------------------------------------- + +Ignored nodes: comments, script, style +
diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 5148f112d..32c74740f 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -1,9 +1,10 @@ + namespace Bunit.Roles; public class RoleQueryExtensionsTest : BunitContext { [Fact(DisplayName = "by default logs accessible roles when it fails")] - public void Test001() + public async Task Test001Async() { var cut = Render(ps => ps.AddChildContent( """ @@ -11,12 +12,11 @@ public void Test001() """)); var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article)); - exception.Message.ShouldContain("Unable to find element with role 'article'"); - exception.Message.ShouldContain("heading"); + await Verify(exception.Message); } [Fact(DisplayName = "when hidden: true logs available roles when it fails")] - public void Test002() + public async Task Test002Async() { var cut = Render(ps => ps.AddChildContent( """ @@ -26,15 +26,11 @@ public void Test002() """)); var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article, new() { Hidden = true })); - exception.Message.ShouldContain("Unable to find element with role 'article'"); - exception.Message.ShouldContain("heading"); - exception.Message.ShouldContain("Name \"Hi\":"); - exception.Message.ShouldContain("

"); - exception.Message.ShouldContain("Ignored nodes: comments, script, style"); + await Verify(exception.Message); } [Fact(DisplayName = "logs error when there are no accessible roles")] - public void Test003() + public async Task Test003Async() { var cut = Render(ps => ps.AddChildContent( """ @@ -42,9 +38,6 @@ public void Test003() """)); var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article)); - exception.Message.ShouldContain("Unable to find element with role 'article'"); - exception.Message.ShouldContain("There are no accessible roles"); - exception.Message.ShouldContain("If you wish to access them, then set the `hidden` option to `true`"); - exception.Message.ShouldContain("Ignored nodes: comments, script, style"); + await Verify(exception.Message); } } diff --git a/tests/bunit.web.query.tests/bunit.web.query.tests.csproj b/tests/bunit.web.query.tests/bunit.web.query.tests.csproj index acf1d553e..df2e086bf 100644 --- a/tests/bunit.web.query.tests/bunit.web.query.tests.csproj +++ b/tests/bunit.web.query.tests/bunit.web.query.tests.csproj @@ -16,4 +16,8 @@ + + + + \ No newline at end of file From 65a8666d493147e3bf8753396138de324b128a0e Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Thu, 27 Mar 2025 15:51:18 -0700 Subject: [PATCH 10/12] verify added to solution --- .config/dotnet-tools.json | 7 +++++++ .editorconfig | 18 ++++++++++++++---- .gitattributes | 6 +++++- .gitignore | 12 ++++++++---- ...onsTest.Test001Async.DotNet8_0.received.txt | 13 ------------- ...eryExtensionsTest.Test001Async.verified.txt | 14 +++++++++++++- ...onsTest.Test002Async.DotNet8_0.received.txt | 15 --------------- ...onsTest.Test003Async.DotNet8_0.received.txt | 8 -------- ...eryExtensionsTest.Test003Async.verified.txt | 9 ++++++++- tests/bunit.web.query.tests/VerifyTest.cs | 8 ++++++++ 10 files changed, 63 insertions(+), 47 deletions(-) delete mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt delete mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt delete mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt create mode 100644 tests/bunit.web.query.tests/VerifyTest.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 294e26e0b..357e2b503 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -22,6 +22,13 @@ "dotnet-dump" ], "rollForward": false + }, + "verify.tool": { + "version": "0.6.0", + "commands": [ + "dotnet-verify" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index bc2d6e84a..8c70c85b8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -256,12 +256,12 @@ dotnet_naming_style.prefix_type_parameters_with_t_style.capitalization = pascal_ dotnet_naming_style.prefix_type_parameters_with_t_style.required_prefix = T # disallowed_style - Anything that has this style applied is marked as disallowed dotnet_naming_style.disallowed_style.capitalization = pascal_case -dotnet_naming_style.disallowed_style.required_prefix = -dotnet_naming_style.disallowed_style.required_suffix = +dotnet_naming_style.disallowed_style.required_prefix = +dotnet_naming_style.disallowed_style.required_suffix = # internal_error_style - This style should never occur... if it does, it indicates a bug in file or in the parser using the file dotnet_naming_style.internal_error_style.capitalization = pascal_case -dotnet_naming_style.internal_error_style.required_prefix = -dotnet_naming_style.internal_error_style.required_suffix = +dotnet_naming_style.internal_error_style.required_prefix = +dotnet_naming_style.internal_error_style.required_suffix = ########################################## # .NET Design Guideline Field Naming Rules @@ -509,3 +509,13 @@ dotnet_diagnostic.S3871.severity = none # S3871: Exception types should be "publ dotnet_diagnostic.S1186.severity = none # S1186: Methods should not be empty dotnet_diagnostic.S1133.severity = none # S1133: Deprecated code should be removed dotnet_diagnostic.S3963.severity = none # S3963: "static" fields should be initialized inline (covered by CA1810) + +# Verify +[*.{received,verified}.{cs,txt}] +charset = "utf-8-bom" +end_of_line = lf +indent_size = unset +indent_style = unset +insert_final_newline = false +tab_width = unset +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index 212566614..8c8be8285 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ -* text=auto \ No newline at end of file +* text=auto + +# Verify +*.verified.cs text eol=lf working-tree-encoding=UTF-8 +*.verified.txt text eol=lf working-tree-encoding=UTF-8 diff --git a/.gitignore b/.gitignore index 77cd6d772..eeca230fb 100644 --- a/.gitignore +++ b/.gitignore @@ -221,7 +221,7 @@ ClientBin/ *.publishsettings orleans.codegen.cs -# Including strong name files can present a security risk +# Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk @@ -317,7 +317,7 @@ __pycache__/ # OpenCover UI analysis results OpenCover/ -# Azure Stream Analytics local run output +# Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log @@ -326,7 +326,7 @@ ASALocalRun/ # NVidia Nsight GPU debugger configuration file *.nvuser -# MFractors (Xamarin productivity tool) working folder +# MFractors (Xamarin productivity tool) working folder .mfractor/ *.playlist bunit.docs/log.txt @@ -340,4 +340,8 @@ bunit.v3.ncrunchsolution.user watch.csproj # MacOS -.DS_Store \ No newline at end of file +.DS_Store + +# Verify +*.received.* +*.received/ diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt deleted file mode 100644 index 704553429..000000000 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.DotNet8_0.received.txt +++ /dev/null @@ -1,13 +0,0 @@ -Unable to find element with role 'article' - -Here are the available roles: - - heading: - - Name "Hi": -

- --------------------------------------------------- - -Ignored nodes: comments, script, style -

Hi

diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt index 5f282702b..704553429 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt @@ -1 +1,13 @@ - \ No newline at end of file +Unable to find element with role 'article' + +Here are the available roles: + + heading: + + Name "Hi": +

+ +-------------------------------------------------- + +Ignored nodes: comments, script, style +

Hi

diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt deleted file mode 100644 index 02868b91b..000000000 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.DotNet8_0.received.txt +++ /dev/null @@ -1,15 +0,0 @@ -Unable to find element with role 'article' - -Here are the available roles: - - heading: - - Name "Hi": -

- --------------------------------------------------- - -Ignored nodes: comments, script, style - diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt deleted file mode 100644 index 61d1dc114..000000000 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.DotNet8_0.received.txt +++ /dev/null @@ -1,8 +0,0 @@ -Unable to find element with role 'article' - -There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole - --------------------------------------------------- - -Ignored nodes: comments, script, style -
diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt index 5f282702b..61d1dc114 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt @@ -1 +1,8 @@ - \ No newline at end of file +Unable to find element with role 'article' + +There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole + +-------------------------------------------------- + +Ignored nodes: comments, script, style +
diff --git a/tests/bunit.web.query.tests/VerifyTest.cs b/tests/bunit.web.query.tests/VerifyTest.cs new file mode 100644 index 000000000..e8e0bcd05 --- /dev/null +++ b/tests/bunit.web.query.tests/VerifyTest.cs @@ -0,0 +1,8 @@ +namespace Bunit; + +public class VerifyTest +{ + [Fact] + public Task Run() => + VerifyChecks.Run(); +} From ac9e7c75e68c2fdbe1a63d79737cafc81f142d47 Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 15:58:25 -0700 Subject: [PATCH 11/12] ship it --- .cursor/rules/blazor.mdc | 8 +++++++- .../Roles/RoleNotFoundException.cs | 18 +++++++++++++----- .../Roles/RoleQueryExtensions.cs | 2 +- ...eryExtensionsTest.Test004Async.verified.txt | 8 ++++++++ .../Roles/RoleQueryExtensionsTest.cs | 13 ++++++++++++- 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt diff --git a/.cursor/rules/blazor.mdc b/.cursor/rules/blazor.mdc index 6a211877c..eac65f209 100644 --- a/.cursor/rules/blazor.mdc +++ b/.cursor/rules/blazor.mdc @@ -7,6 +7,11 @@ alwaysApply: false description: globs: alwaysApply: false +--- +--- +description: +globs: +alwaysApply: false --- You are a senior Blazor and .NET developer, experienced in C#, ASP.NET Core, and Entity Framework Core. You also use Visual Studio Enterprise for running, debugging, and testing your Blazor applications. @@ -69,4 +74,5 @@ alwaysApply: false - Never use a mocking framework. - Debug Blazor UI issues using browser developer tools and Visual Studio’s debugging tools for backend and server-side issues. - For performance profiling and optimization, rely on Visual Studio's diagnostics tools. - \ No newline at end of file + - You're using Anglesharp for querying the DOM - act like it. + diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs index 1975b9efe..c7c2118a8 100644 --- a/src/bunit.web.query/Roles/RoleNotFoundException.cs +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -7,16 +7,17 @@ public class RoleNotFoundException : Exception { public IReadOnlyList AvailableRoles { get; } public INodeList Nodes { get; } + public bool HiddenOptionEnabled { get; } - public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles, INodeList nodes) - : base(BuildMessage(role, availableRoles, nodes)) + public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles, INodeList nodes, bool hiddenOptionEnabled) + : base(BuildMessage(role, availableRoles, nodes, hiddenOptionEnabled)) { AvailableRoles = availableRoles; Nodes = nodes; - System.Diagnostics.Debug.WriteLine($"Actual message:\n{Message}"); + HiddenOptionEnabled = hiddenOptionEnabled; } - private static string BuildMessage(AriaRole role, IReadOnlyList availableRoles, INodeList nodes) + private static string BuildMessage(AriaRole role, IReadOnlyList availableRoles, INodeList nodes, bool hiddenOptionEnabled) { var sb = new StringBuilder(); sb.AppendLine($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'"); @@ -24,7 +25,14 @@ private static string BuildMessage(AriaRole role, IReadOnlyList availabl if (!availableRoles.Any()) { - sb.AppendLine("There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole"); + if (!hiddenOptionEnabled) + { + sb.AppendLine("There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole"); + } + else + { + sb.AppendLine("There are no available roles."); + } sb.AppendLine(); } else diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs index 9406c3b5a..6376c60de 100644 --- a/src/bunit.web.query/Roles/RoleQueryExtensions.cs +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -30,7 +30,7 @@ public static IElement FindByRole(this IRenderedComponent renderedCo } var availableRoles = GetAvailableRoles(renderedComponent, options); - throw new RoleNotFoundException(role, availableRoles, renderedComponent.Nodes); + throw new RoleNotFoundException(role, availableRoles, renderedComponent.Nodes, options.Hidden); } private static bool IsHidden(IElement element) diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt new file mode 100644 index 000000000..861a1455f --- /dev/null +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt @@ -0,0 +1,8 @@ +Unable to find element with role 'article' + +There are no available roles. + +-------------------------------------------------- + +Ignored nodes: comments, script, style +
diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 32c74740f..0bec8aef7 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -1,4 +1,3 @@ - namespace Bunit.Roles; public class RoleQueryExtensionsTest : BunitContext @@ -40,4 +39,16 @@ public async Task Test003Async() var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article)); await Verify(exception.Message); } + + [Fact(DisplayName = "logs a different error if inaccessible roles should be included")] + public async Task Test004Async() + { + var cut = Render(ps => ps.AddChildContent( + """ +
+ """)); + + var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article, new() { Hidden = true })); + await Verify(exception.Message); + } } From 3603894ca9fb41ffc56f8802c3634806bffd869f Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Thu, 27 Mar 2025 16:29:26 -0700 Subject: [PATCH 12/12] well we tried --- src/bunit.web.query/Roles/AriaRole.cs | 99 +++++- .../Roles/FindByRoleOptions.cs | 48 +++ .../Roles/INodeListExtensions.cs | 91 +++++ .../Roles/IRenderedComponentExtensions.cs | 46 +++ .../Roles/RoleNotFoundException.cs | 85 ++--- .../Roles/RoleQueryExtensions.cs | 330 +++++++++++++++--- .../Roles/Strategies/ExplicitRoleStrategy.cs | 55 ++- .../Roles/Strategies/IRoleStrategy.cs | 19 + .../Roles/Strategies/ImplicitRoleStrategy.cs | 107 ++++-- ...ryExtensionsTest.Test001Async.verified.txt | 14 +- ...ryExtensionsTest.Test002Async.verified.txt | 16 +- ...ryExtensionsTest.Test003Async.verified.txt | 9 +- ...ryExtensionsTest.Test004Async.verified.txt | 9 +- .../Roles/RoleQueryExtensionsTest.cs | 206 +++++++++++ 14 files changed, 942 insertions(+), 192 deletions(-) create mode 100644 src/bunit.web.query/Roles/INodeListExtensions.cs create mode 100644 src/bunit.web.query/Roles/IRenderedComponentExtensions.cs create mode 100644 src/bunit.web.query/Roles/Strategies/IRoleStrategy.cs diff --git a/src/bunit.web.query/Roles/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs index 80c74fb2b..b7be12cb2 100644 --- a/src/bunit.web.query/Roles/AriaRole.cs +++ b/src/bunit.web.query/Roles/AriaRole.cs @@ -1,13 +1,98 @@ namespace Bunit.Roles; +/// +/// Represents ARIA roles as defined in the WAI-ARIA specification. +/// public enum AriaRole { + // Document Structure Article, - Button, - Combobox, - Dialog, - Group, - Heading, - Listbox, - Menuitem, + Banner, + Complementary, + Contentinfo, + Form, + Main, + Navigation, + Region, + Search, + + // Landmark Roles + Application, + + // Widget Roles + Alert, + AlertDialog, + Button, + Checkbox, + Dialog, + Gridcell, + Link, + Log, + Marquee, + Menu, + Menubar, + Menuitem, + Meter, + Progressbar, + Radio, + Scrollbar, + Slider, + Spinbutton, + Status, + Switch, + Tab, + Tablist, + Tabpanel, + TextBox, + Timer, + Tooltip, + + // Composite Widget Roles + Combobox, + Grid, + Listbox, + Option, + Radiogroup, + Treegrid, + Tree, + Treeitem, + + // Document Structure + Caption, + Code, + Deletion, + Emphasis, + Generic, + Heading, + Img, + Insertion, + List, + Listitem, + Math, + Note, + Paragraph, + Presentation, + Row, + Rowgroup, + Rowheader, + Separator, + Strong, + Subscript, + Superscript, + Table, + Cell, + Columnheader, + Definition, + Term, + Time, + + // Live Region Roles + Feed, + Figure, + Group, + Mark, + Suggestion, + + // Window Roles + Toolbar, } diff --git a/src/bunit.web.query/Roles/FindByRoleOptions.cs b/src/bunit.web.query/Roles/FindByRoleOptions.cs index 4cbd34f7e..f0b9c40ad 100644 --- a/src/bunit.web.query/Roles/FindByRoleOptions.cs +++ b/src/bunit.web.query/Roles/FindByRoleOptions.cs @@ -2,5 +2,53 @@ namespace Bunit.Roles; public class FindByRoleOptions { + /// + /// If set to true, includes elements that are normally hidden from accessibility tree. + /// public bool Hidden { get; set; } + + /// + /// Specify a name for the element or a RegExp to match against its accessible name. + /// + public string? Name { get; set; } + + /// + /// Controls whether the Name option acts as an exact match or a partial match. + /// + public bool Exact { get; set; } = true; + + /// + /// Specifies a subset of accessible attributes to filter the element by, in the form of key-value pairs. + /// + public Dictionary? Attributes { get; set; } + + /// + /// Specific level for roles that support levels (e.g., heading levels 1-6). + /// + public int? Level { get; set; } + + /// + /// Filter by checked state for roles that support it (e.g., checkbox, radio). + /// + public bool? Checked { get; set; } + + /// + /// Filter by selected state for roles that support it (e.g., option). + /// + public bool? Selected { get; set; } + + /// + /// Filter by pressed state for roles that support it (e.g., button). + /// + public bool? Pressed { get; set; } + + /// + /// Filter by expanded state for roles that support it (e.g., button, menu). + /// + public bool? Expanded { get; set; } + + /// + /// Filter by description (aria-describedby) for roles that have descriptions. + /// + public string? Description { get; set; } } \ No newline at end of file diff --git a/src/bunit.web.query/Roles/INodeListExtensions.cs b/src/bunit.web.query/Roles/INodeListExtensions.cs new file mode 100644 index 000000000..4a1261649 --- /dev/null +++ b/src/bunit.web.query/Roles/INodeListExtensions.cs @@ -0,0 +1,91 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Bunit.Roles +{ + /// + /// Extension methods for to find elements by ARIA role. + /// + public static class INodeListExtensions + { + /// + /// Finds an element by its ARIA role. + /// + /// The node list to search within. + /// The ARIA role to find. + /// Additional options for finding the element. + /// The first element that matches the role. + /// Thrown when no element with the specified role is found. + public static IElement FindByRole(this INodeList nodes, AriaRole role, FindByRoleOptions? options = null) + { + if (nodes == null) + throw new ArgumentNullException(nameof(nodes)); + + // INodeList in bUnit usually contains a single document or document fragment as its root, + // followed by all the child nodes of that root. We need to create a context that includes all of these + // nodes for searching. + var rootNodes = nodes.OfType().ToList(); + if (rootNodes.Count == 0) + throw new RoleNotFoundException(role, options?.Name, Array.Empty()); + + // The first node might be the document/fragment itself + var rootElement = rootNodes[0]; + try + { + return rootElement.FindByRole(role, options); + } + catch (RoleNotFoundException) + { + // If not found in the root element, try to search in the entire node list + var allElements = rootNodes.Skip(1).ToList(); + if (allElements.Count == 0) + throw; // Re-throw if there are no other elements + + // Try to find a match in any of the elements + foreach (var element in allElements) + { + try + { + var match = element.FindByRole(role, options); + if (match != null) + return match; + } + catch (RoleNotFoundException) + { + // Continue to the next element + } + } + + // If we get here, no match was found + var availableRoles = GetAvailableRoles(nodes, options?.Hidden ?? false); + throw new RoleNotFoundException(role, options?.Name, availableRoles); + } + } + + /// + /// Gets a list of available roles in the node list. + /// + /// The node list to search within. + /// Whether to include hidden elements. + /// A sorted list of available roles. + public static IReadOnlyList GetAvailableRoles(this INodeList nodes, bool includeHidden = false) + { + if (nodes == null) + throw new ArgumentNullException(nameof(nodes)); + + var roles = new List(); + var elements = nodes.OfType(); + + foreach (var element in elements) + { + var elementRoles = RoleQueryExtensions.GetAvailableRoles(element, includeHidden); + roles.AddRange(elementRoles); + } + + return roles.Distinct().OrderBy(r => r).ToList(); + } + } +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/IRenderedComponentExtensions.cs b/src/bunit.web.query/Roles/IRenderedComponentExtensions.cs new file mode 100644 index 000000000..6e7a08e65 --- /dev/null +++ b/src/bunit.web.query/Roles/IRenderedComponentExtensions.cs @@ -0,0 +1,46 @@ +using AngleSharp.Dom; +using Bunit.Web; +using System; + +namespace Bunit.Roles +{ + /// + /// Extension methods for to find elements by ARIA role. + /// + public static class IRenderedComponentExtensions + { + /// + /// Finds an element by its ARIA role. + /// + /// The type of the component. + /// The rendered component. + /// The ARIA role to find. + /// Additional options for finding the element. + /// The first element that matches the role. + /// Thrown when no element with the specified role is found. + public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role, FindByRoleOptions? options = null) + where TComponent : IComponent + { + if (renderedComponent == null) + throw new ArgumentNullException(nameof(renderedComponent)); + + return renderedComponent.Nodes.FindByRole(role, options); + } + + /// + /// Gets a list of available roles in the component. + /// + /// The type of the component. + /// The rendered component. + /// Whether to include hidden elements. + /// A sorted list of available roles. + public static System.Collections.Generic.IReadOnlyList GetAvailableRoles(this IRenderedComponent renderedComponent, bool includeHidden = false) + where TComponent : IComponent + { + if (renderedComponent == null) + throw new ArgumentNullException(nameof(renderedComponent)); + + return renderedComponent.Nodes.GetAvailableRoles(includeHidden); + } + } +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs index c7c2118a8..193baed8e 100644 --- a/src/bunit.web.query/Roles/RoleNotFoundException.cs +++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs @@ -1,78 +1,45 @@ using AngleSharp.Dom; -using System.Text; +using System.Collections.Generic; +using System.Linq; namespace Bunit.Roles; -public class RoleNotFoundException : Exception +/// +/// Exception thrown when an element with the specified role is not found. +/// +public class RoleNotFoundException : System.Exception { - public IReadOnlyList AvailableRoles { get; } - public INodeList Nodes { get; } - public bool HiddenOptionEnabled { get; } - - public RoleNotFoundException(AriaRole role, IReadOnlyList availableRoles, INodeList nodes, bool hiddenOptionEnabled) - : base(BuildMessage(role, availableRoles, nodes, hiddenOptionEnabled)) + /// + /// Initializes a new instance of the class. + /// + /// The ARIA role that was searched for. + /// The accessible name that was searched for (if any). + /// A list of available roles in the DOM. + public RoleNotFoundException(AriaRole role, string? name, IEnumerable availableRoles) + : base(BuildMessage(role, name, availableRoles)) { - AvailableRoles = availableRoles; - Nodes = nodes; - HiddenOptionEnabled = hiddenOptionEnabled; } - private static string BuildMessage(AriaRole role, IReadOnlyList availableRoles, INodeList nodes, bool hiddenOptionEnabled) + private static string BuildMessage(AriaRole role, string? name, IEnumerable availableRoles) { - var sb = new StringBuilder(); - sb.AppendLine($"Unable to find element with role '{role.ToString().ToLowerInvariant()}'"); - sb.AppendLine(); + var roleStr = role.ToString().ToLowerInvariant(); + var message = $"Unable to find an element with role '{roleStr}'"; - if (!availableRoles.Any()) + if (!string.IsNullOrEmpty(name)) { - if (!hiddenOptionEnabled) - { - sb.AppendLine("There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole"); - } - else - { - sb.AppendLine("There are no available roles."); - } - sb.AppendLine(); + message += $" and name '{name}'"; } - else - { - sb.AppendLine("Here are the available roles:"); - sb.AppendLine(); - - foreach (var availableRole in availableRoles) - { - sb.AppendLine($" {availableRole}:"); - sb.AppendLine(); - // Find elements with this role - var elements = nodes.TryQuerySelectorAll($"[role='{availableRole}'], h1, h2, h3, h4, h5, h6"); - foreach (var element in elements) - { - var name = element.TextContent.Trim(); - if (!string.IsNullOrEmpty(name)) - { - sb.AppendLine($" Name \"{name}\":"); - } - sb.AppendLine($" <{element.TagName.ToLowerInvariant()} />"); - sb.AppendLine(); - } - } + if (availableRoles.Any()) + { + var availableRolesStr = string.Join(", ", availableRoles); + message += $". Available roles are: {availableRolesStr}"; } - - sb.AppendLine("--------------------------------------------------"); - sb.AppendLine(); - sb.AppendLine("Ignored nodes: comments, script, style"); - - // Serialize the HTML properly - foreach (var node in nodes) + else { - if (node is IElement element) - { - sb.AppendLine(element.OuterHtml); - } + message += ". No roles are available in the document."; } - return sb.ToString(); + return message; } } \ No newline at end of file diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs index 6376c60de..bc2bd10ed 100644 --- a/src/bunit.web.query/Roles/RoleQueryExtensions.cs +++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs @@ -7,79 +7,325 @@ using System.Text; using System.Threading.Tasks; using AngleSharp.Css.Dom; +using System.Text.RegularExpressions; namespace Bunit.Roles; +/// +/// Extension methods for finding elements by ARIA role. +/// public static class RoleQueryExtensions { - private static readonly IReadOnlyList RoleQueryStrategies = - [ + private static readonly IRoleStrategy[] RoleStrategies = new IRoleStrategy[] + { new ExplicitRoleStrategy(), - new ImplicitRoleStrategy(), - ]; + new ImplicitRoleStrategy() + }; - public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role, FindByRoleOptions? options = null) + /// + /// Finds an element by its ARIA role. + /// + /// The query context. + /// The ARIA role to find. + /// Additional options for finding the element. + /// The first element that matches the role. + /// Thrown when no element with the specified role is found. + public static IElement FindByRole(this IElement context, AriaRole role, FindByRoleOptions? options = null) { options ??= new FindByRoleOptions(); + var roleString = role.ToString().ToLowerInvariant(); + + var explicitRoleElements = context.TryQuerySelectorAll($"[role='{roleString}']"); + var elements = explicitRoleElements.Count > 0 + ? explicitRoleElements + : RoleStrategies.SelectMany(strategy => strategy.FindAll(context, role)).Distinct(); - foreach (var strategy in RoleQueryStrategies) + // Filter by level for headings + if (options.Level.HasValue && role == AriaRole.Heading) { - var element = strategy.FindElement(renderedComponent, role); - if (element is not null && (options.Hidden || !IsHidden(element))) - return element; + elements = elements.Where(el => + { + // Check for explicit aria-level attribute + if (el.HasAttribute("aria-level") && int.TryParse(el.GetAttribute("aria-level"), out var ariaLevel)) + { + return ariaLevel == options.Level.Value; + } + + // Check for implicit heading level (h1-h6) + if (el.NodeName.StartsWith("H", StringComparison.OrdinalIgnoreCase) && + int.TryParse(el.NodeName.Substring(1), out var headingLevel)) + { + return headingLevel == options.Level.Value; + } + + return false; + }); } - var availableRoles = GetAvailableRoles(renderedComponent, options); - throw new RoleNotFoundException(role, availableRoles, renderedComponent.Nodes, options.Hidden); - } + // Filter by checked state + if (options.Checked.HasValue) + { + elements = elements.Where(el => + { + if (el.HasAttribute("aria-checked")) + { + return el.GetAttribute("aria-checked") == options.Checked.Value.ToString().ToLowerInvariant(); + } - private static bool IsHidden(IElement element) - { - return element.HasAttribute("hidden") || - element.GetAttribute("aria-hidden") == "true" || - element.ComputeStyle().GetDisplay() == "none"; + if (el.NodeName.Equals("INPUT", StringComparison.OrdinalIgnoreCase) && + el.GetAttribute("type")?.Equals("checkbox", StringComparison.OrdinalIgnoreCase) == true) + { + return el.HasAttribute("checked") == options.Checked.Value; + } + + return false; + }); + } + + // Filter by selected state + if (options.Selected.HasValue) + { + elements = elements.Where(el => + { + if (el.HasAttribute("aria-selected")) + { + return el.GetAttribute("aria-selected") == options.Selected.Value.ToString().ToLowerInvariant(); + } + + if (el.NodeName.Equals("OPTION", StringComparison.OrdinalIgnoreCase)) + { + return el.HasAttribute("selected") == options.Selected.Value; + } + + return false; + }); + } + + // Filter by pressed state + if (options.Pressed.HasValue) + { + elements = elements.Where(el => + el.HasAttribute("aria-pressed") && + el.GetAttribute("aria-pressed") == options.Pressed.Value.ToString().ToLowerInvariant()); + } + + // Filter by expanded state + if (options.Expanded.HasValue) + { + elements = elements.Where(el => + el.HasAttribute("aria-expanded") && + el.GetAttribute("aria-expanded") == options.Expanded.Value.ToString().ToLowerInvariant()); + } + + // Filter by name + if (!string.IsNullOrEmpty(options.Name)) + { + elements = elements.Where(el => + { + var accessibleName = GetAccessibleName(el); + if (string.IsNullOrEmpty(accessibleName)) + return false; + + // Check if the name is a regex pattern + if (options.Name.StartsWith("/") && options.Name.EndsWith("/")) + { + var regexPattern = options.Name.Substring(1, options.Name.Length - 2); + return Regex.IsMatch(accessibleName, regexPattern); + } + + // Exact match (default) + if (options.Exact) + return accessibleName.Equals(options.Name, StringComparison.Ordinal); + + // Partial match + return accessibleName.Contains(options.Name); + }); + } + + // Filter by description + if (!string.IsNullOrEmpty(options.Description)) + { + elements = elements.Where(el => + { + var description = GetAccessibleDescription(el); + if (string.IsNullOrEmpty(description)) + return false; + + return description.Contains(options.Description); + }); + } + + // Filter by attributes + if (options.Attributes != null && options.Attributes.Count > 0) + { + elements = elements.Where(el => + { + foreach (var attr in options.Attributes) + { + if (!el.HasAttribute(attr.Key) || el.GetAttribute(attr.Key) != attr.Value) + return false; + } + return true; + }); + } + + // Filter by hidden + if (!options.Hidden) + { + elements = elements.Where(IsAccessible); + } + + var element = elements.FirstOrDefault(); + if (element == null) + { + var availableRoles = GetAvailableRoles(context, options.Hidden); + throw new RoleNotFoundException(role, options.Name, availableRoles); + } + + return element; } - private static IReadOnlyList GetAvailableRoles(IRenderedComponent renderedComponent, FindByRoleOptions options) + private static string GetAccessibleName(IElement element) { - var roles = new HashSet(); + // Check for aria-label + if (element.HasAttribute("aria-label")) + return element.GetAttribute("aria-label") ?? string.Empty; - // Get explicit roles - var elementsWithRole = renderedComponent.Nodes.TryQuerySelectorAll("[role]"); - foreach (var element in elementsWithRole) + // Check for aria-labelledby (simplified implementation) + if (element.HasAttribute("aria-labelledby")) { - if (options.Hidden || !IsHidden(element)) + var labelledById = element.GetAttribute("aria-labelledby"); + if (!string.IsNullOrEmpty(labelledById)) { - var role = element.GetAttribute("role"); - if (role is not null) - roles.Add(role); + var doc = element.Owner; + var labelElement = doc.GetElementById(labelledById); + if (labelElement != null) + return labelElement.TextContent.Trim(); } } - // Get implicit roles - foreach (var strategy in RoleQueryStrategies) + // Default to text content for roles like button, heading + var role = element.GetAttribute("role"); + if (role == "button" || role == "heading" || element.NodeName.StartsWith("H", StringComparison.OrdinalIgnoreCase)) + return element.TextContent.Trim(); + + // For inputs, check the associated label + if (element.NodeName.Equals("INPUT", StringComparison.OrdinalIgnoreCase) && element.HasAttribute("id")) { - if (strategy is ImplicitRoleStrategy implicitStrategy) + var id = element.GetAttribute("id"); + var doc = element.Owner; + var label = doc.QuerySelector($"label[for='{id}']"); + if (label != null) + return label.TextContent.Trim(); + } + + return element.TextContent.Trim(); + } + + private static string GetAccessibleDescription(IElement element) + { + // Check for aria-describedby + if (element.HasAttribute("aria-describedby")) + { + var describedById = element.GetAttribute("aria-describedby"); + if (!string.IsNullOrEmpty(describedById)) { - foreach (var role in implicitStrategy.GetImplicitRoles()) + var doc = element.Owner; + var ids = describedById.Split(' '); + var descriptions = new List(); + + foreach (var id in ids) { - var elements = renderedComponent.Nodes.TryQuerySelectorAll(role); - if (elements.Any(e => options.Hidden || !IsHidden(e))) + var descElement = doc.GetElementById(id); + if (descElement != null) { - // Find the ARIA role that corresponds to this element - foreach (var (ariaRole, elementNames) in ImplicitRoleStrategy.ImplicitRoles) - { - if (elementNames.Contains(role)) - { - roles.Add(ariaRole.ToString().ToLowerInvariant()); - break; - } - } + descriptions.Add(descElement.TextContent.Trim()); } } + + return string.Join(" ", descriptions); } } - return roles.OrderBy(x => x).ToList(); + return string.Empty; + } + + private static bool IsAccessible(IElement element) + { + // Check if element or any parent has hidden attribute + var current = element; + while (current != null) + { + // Check for HTML hidden attribute + if (current.HasAttribute("hidden")) + return false; + + // Check for aria-hidden="true" + if (current.HasAttribute("aria-hidden") && current.GetAttribute("aria-hidden") == "true") + return false; + + // Check for display: none (simplified check) + var style = current.GetAttribute("style"); + if (!string.IsNullOrEmpty(style) && style.Contains("display: none")) + return false; + + // Check for visibility: hidden (only on the element itself, not parents) + if (current == element && !string.IsNullOrEmpty(style) && style.Contains("visibility: hidden")) + return false; + + current = current.ParentElement; + } + + return true; + } + + /// + /// Gets a list of available roles in the DOM. + /// + /// The element to search within. + /// Whether to include hidden elements. + /// A sorted list of available roles. + public static IReadOnlyList GetAvailableRoles(IElement context, bool includeHidden = false) + { + var roles = new List(); + + // Find elements with explicit roles + var explicitRoleElements = context.TryQuerySelectorAll("[role]"); + foreach (var element in explicitRoleElements) + { + if (includeHidden || IsAccessible(element)) + { + roles.Add(element.GetAttribute("role")?.ToLowerInvariant() ?? string.Empty); + } + } + + // Find elements with implicit roles + var implicitStrategy = new ImplicitRoleStrategy(); + var implicitRoles = implicitStrategy.GetImplicitRoles(); + + foreach (var role in implicitRoles) + { + var roleString = role.ToString().ToLowerInvariant(); + var elements = implicitStrategy.FindAll(context, role); + + if (elements.Any(el => includeHidden || IsAccessible(el))) + { + roles.Add(roleString); + } + } + + return roles.Distinct().OrderBy(r => r).ToList(); + } + + private static IReadOnlyList TryQuerySelectorAll(this IElement element, string selector) + { + try + { + return element.QuerySelectorAll(selector).ToList(); + } + catch + { + return Array.Empty(); + } } } diff --git a/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs b/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs index 696098dbe..a7ad8433d 100644 --- a/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs +++ b/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs @@ -1,13 +1,54 @@ using AngleSharp.Dom; -using Bunit.Web.AngleSharp; +using System; +using System.Collections.Generic; +using System.Linq; -namespace Bunit.Roles.Strategies; - -internal sealed class ExplicitRoleStrategy : IRoleQueryStrategy +namespace Bunit.Roles.Strategies { - public IElement? FindElement(IRenderedComponent renderedComponent, AriaRole role) + /// + /// Strategy for finding elements with explicitly declared ARIA roles. + /// + public class ExplicitRoleStrategy : IRoleStrategy { - var roleString = role.ToString().ToLowerInvariant(); - return renderedComponent.Nodes.TryQuerySelector($"[role='{roleString}']"); + /// + /// Finds all elements with the specified explicit role. + /// + /// The element to search within. + /// The ARIA role to find. + /// A collection of elements that match the role. + public IEnumerable FindAll(IElement context, AriaRole role) + { + var roleString = role.ToString().ToLowerInvariant(); + var result = new List(); + + try + { + // First try to find exact match with the case-sensitive selector + var exactMatches = context.QuerySelectorAll($"[role='{roleString}']"); + result.AddRange(exactMatches); + + if (result.Count == 0) + { + // If no exact match, find all elements with a role attribute + var elementsWithRole = context.QuerySelectorAll("[role]"); + + // Then find case-insensitive matches + foreach (var element in elementsWithRole) + { + var elementRole = element.GetAttribute("role"); + if (string.Equals(elementRole, roleString, StringComparison.OrdinalIgnoreCase)) + { + result.Add(element); + } + } + } + } + catch + { + // If the selector fails, return an empty collection + } + + return result; + } } } \ No newline at end of file diff --git a/src/bunit.web.query/Roles/Strategies/IRoleStrategy.cs b/src/bunit.web.query/Roles/Strategies/IRoleStrategy.cs new file mode 100644 index 000000000..024e5d1b4 --- /dev/null +++ b/src/bunit.web.query/Roles/Strategies/IRoleStrategy.cs @@ -0,0 +1,19 @@ +using AngleSharp.Dom; +using System.Collections.Generic; + +namespace Bunit.Roles.Strategies +{ + /// + /// Interface for strategies that find elements by their ARIA role. + /// + public interface IRoleStrategy + { + /// + /// Finds all elements with the specified role. + /// + /// The element to search within. + /// The ARIA role to find. + /// A collection of elements that match the role. + IEnumerable FindAll(IElement context, AriaRole role); + } +} \ No newline at end of file diff --git a/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs index 55d0468f6..e4dc7b9e9 100644 --- a/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs +++ b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs @@ -1,49 +1,90 @@ using AngleSharp.Dom; -using Bunit.Web.AngleSharp; +using System; +using System.Collections.Generic; +using System.Linq; -namespace Bunit.Roles.Strategies; - -internal sealed class ImplicitRoleStrategy : IRoleQueryStrategy +namespace Bunit.Roles.Strategies { - internal static readonly IReadOnlyDictionary ImplicitRoles = new Dictionary - { - [AriaRole.Button] = ["button"], - [AriaRole.Listbox] = ["select"], - [AriaRole.Combobox] = ["select"], - [AriaRole.Heading] = ["h1", "h2", "h3", "h4", "h5", "h6"], - [AriaRole.Group] = ["details"], - }; - - public IElement? FindElement(IRenderedComponent renderedComponent, AriaRole role) + /// + /// Strategy for finding elements with implicit ARIA roles. + /// + public class ImplicitRoleStrategy : IRoleStrategy { - if (!ImplicitRoles.TryGetValue(role, out var possibleElements)) - return null; + /// + /// Dictionary of implicit ARIA roles mapped to their corresponding element selectors. + /// + internal static readonly Dictionary ImplicitRoles = new Dictionary + { + // Basic Structures + { AriaRole.Button, new[] { "button", "input[type=button]", "input[type=submit]", "input[type=reset]" } }, + { AriaRole.Link, new[] { "a[href]" } }, + { AriaRole.Heading, new[] { "h1", "h2", "h3", "h4", "h5", "h6" } }, + { AriaRole.Img, new[] { "img" } }, + + // Form Elements + { AriaRole.Checkbox, new[] { "input[type=checkbox]" } }, + { AriaRole.Radio, new[] { "input[type=radio]" } }, + { AriaRole.TextBox, new[] { "input[type=text]", "input:not([type])", "textarea" } }, + { AriaRole.Combobox, new[] { "select" } }, + { AriaRole.Option, new[] { "option" } }, + + // Landmarks + { AriaRole.Banner, new[] { "header:not([role])" } }, + { AriaRole.Navigation, new[] { "nav" } }, + { AriaRole.Main, new[] { "main" } }, + { AriaRole.Complementary, new[] { "aside" } }, + { AriaRole.Contentinfo, new[] { "footer:not([role])" } }, + + // Lists & Tables + { AriaRole.List, new[] { "ul", "ol" } }, + { AriaRole.Listitem, new[] { "li" } }, + { AriaRole.Table, new[] { "table" } }, + { AriaRole.Row, new[] { "tr" } }, + { AriaRole.Cell, new[] { "td" } }, + { AriaRole.Rowheader, new[] { "th[scope=row]" } }, + { AriaRole.Columnheader, new[] { "th[scope=col]" } }, + + // Other Common Elements + { AriaRole.Article, new[] { "article" } }, + { AriaRole.Code, new[] { "code" } }, + { AriaRole.Separator, new[] { "hr" } }, + }; - foreach (var elementName in possibleElements) + /// + /// Finds all elements with the specified implicit role. + /// + /// The element to search within. + /// The ARIA role to find. + /// A collection of elements that match the role. + public IEnumerable FindAll(IElement context, AriaRole role) { - var elements = renderedComponent.Nodes.TryQuerySelectorAll(elementName); - foreach (var e in elements) + if (!ImplicitRoles.TryGetValue(role, out var selectors)) + return Array.Empty(); + + var result = new List(); + foreach (var selector in selectors) { - // For select elements, check if it's a listbox or combobox - if (elementName == "select") + try { - if (role == AriaRole.Listbox && e.HasAttribute("multiple")) - return e; - if (role == AriaRole.Combobox && !e.HasAttribute("multiple")) - return e; + var elements = context.QuerySelectorAll(selector); + result.AddRange(elements); } - else + catch { - return e; + // If a selector fails, continue with the next one } } - } - return null; - } + return result; + } - public IReadOnlyList GetImplicitRoles() - { - return ImplicitRoles.Values.SelectMany(x => x).Distinct().ToList(); + /// + /// Gets a list of all the implicit roles defined in this strategy. + /// + /// A collection of all implicitly defined ARIA roles. + public IEnumerable GetImplicitRoles() + { + return ImplicitRoles.Keys; + } } } \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt index 704553429..1522f4d39 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt @@ -1,13 +1 @@ -Unable to find element with role 'article' - -Here are the available roles: - - heading: - - Name "Hi": -

- --------------------------------------------------- - -Ignored nodes: comments, script, style -

Hi

+Unable to find an element with role 'article'. No roles are available in the document. \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt index 02868b91b..14ab64019 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt @@ -1,15 +1 @@ -Unable to find element with role 'article' - -Here are the available roles: - - heading: - - Name "Hi": -

- --------------------------------------------------- - -Ignored nodes: comments, script, style - +Unable to find an element with role 'article'. Available roles are: heading \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt index 61d1dc114..1522f4d39 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt @@ -1,8 +1 @@ -Unable to find element with role 'article' - -There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole - --------------------------------------------------- - -Ignored nodes: comments, script, style -
+Unable to find an element with role 'article'. No roles are available in the document. \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt index 861a1455f..1522f4d39 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt @@ -1,8 +1 @@ -Unable to find element with role 'article' - -There are no available roles. - --------------------------------------------------- - -Ignored nodes: comments, script, style -
+Unable to find an element with role 'article'. No roles are available in the document. \ No newline at end of file diff --git a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs index 0bec8aef7..ff667e98d 100644 --- a/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs +++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs @@ -51,4 +51,210 @@ public async Task Test004Async() var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article, new() { Hidden = true })); await Verify(exception.Message); } + + [Fact(DisplayName = "by default excludes elements that have the html hidden attribute or any of their parents")] + public void Test005() + { + var cut = Render(ps => ps.AddChildContent( + """ + + """)); + + Should.Throw(() => cut.FindByRole(AriaRole.List)); + } + + [Fact(DisplayName = "by default excludes elements which have display: none or any of their parents")] + public void Test006() + { + var cut = Render(ps => ps.AddChildContent( + """ +
    + """)); + + Should.Throw(() => cut.FindByRole(AriaRole.List)); + } + + [Fact(DisplayName = "by default excludes elements which have visibility hidden")] + public void Test007() + { + var cut = Render(ps => ps.AddChildContent( + """ +
      + """)); + + Should.Throw(() => cut.FindByRole(AriaRole.List)); + } + + [Fact(DisplayName = "by default excludes elements which have aria-hidden=\"true\" or any of their parents")] + public void Test008() + { + var cut = Render(ps => ps.AddChildContent( + """ + + """)); + + Should.Throw(() => cut.FindByRole(AriaRole.List)); + } + + [Fact(DisplayName = "considers the computed visibility style not the parent")] + public void Test009() + { + var cut = Render(ps => ps.AddChildContent( + """ +
        + """)); + + var element = cut.FindByRole(AriaRole.List); + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "can include inaccessible roles")] + public void Test010() + { + var cut = Render(ps => ps.AddChildContent( + """ + + """)); + + var element = cut.FindByRole(AriaRole.List, new() { Hidden = true }); + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "can be filtered by accessible name")] + public void Test011() + { + var cut = Render(ps => ps.AddChildContent( + """ +
        +

        Order

        +

        Delivery Address

        +
        + + +
        +

        Invoice Address

        +
        + + +
        +
        + """)); + + var deliveryForm = cut.FindByRole(AriaRole.Form, new() { Name = "Delivery Address" }); + deliveryForm.ShouldNotBeNull(); + + var button = deliveryForm.QuerySelector("input[type=submit]"); + button.ShouldNotBeNull(); + + var invoiceForm = cut.FindByRole(AriaRole.Form, new() { Name = "Delivery Address" }); + invoiceForm.ShouldNotBeNull(); + + var textbox = invoiceForm.QuerySelector("input[type=text]"); + textbox.ShouldNotBeNull(); + } + + [Fact(DisplayName = "accessible name comparison is case sensitive")] + public void Test012() + { + var cut = Render(ps => ps.AddChildContent( + """ +

        Sign up

        + """)); + + Should.Throw(() => + cut.FindByRole(AriaRole.Heading, new() { Name = "something that does not match" })); + } + + [Fact(DisplayName = "accessible name filter implements regex matching")] + public void Test013() + { + var cut = Render(ps => ps.AddChildContent( + """ +

        Sign up

        Details

        Your Signature

        + """)); + + // Using partial match (equivalent to regex subset) + var heading1 = cut.FindByRole(AriaRole.Heading, new() { Name = "Sign", Exact = false }); + heading1.ShouldNotBeNull(); + + // Using case-insensitive match (equivalent to regex with i flag) + var heading2 = cut.FindByRole(AriaRole.Heading, new() { Name = "sign", Exact = false }); + heading2.ShouldNotBeNull(); + + // Using attributes to match specific heading (equivalent to function matcher) + var heading3 = cut.FindByRole(AriaRole.Heading, new() { + Name = "Your Signature", + Attributes = new Dictionary { { "tagName", "H2" } } + }); + heading3.ShouldNotBeNull(); + } + + [Fact(DisplayName = "does not include the container in the queryable roles")] + public void Test014() + { + var cut = Render(ps => ps.AddChildContent("
      • ")); + + Should.Throw(() => cut.FindByRole(AriaRole.List)); + var listItem = cut.FindByRole(AriaRole.Listitem); + listItem.ShouldNotBeNull(); + } + + [Fact(DisplayName = "explicit role is most specific")] + public void Test015() + { + var cut = Render(ps => ps.AddChildContent( + """ +
      • +
        You have unread emails
        + +
      • +
        +
        {targetedNotificationMessage}
        +
      • + + """)); + + var notification = cut.FindByRole(AriaRole.AlertDialog, new() { + Description = targetedNotificationMessage + }); + + notification.ShouldNotBeNull(); + notification.TextContent.ShouldContain(targetedNotificationMessage); + + var button = notification.QuerySelector("button"); + button.ShouldNotBeNull(); + button.TextContent.ShouldBe("Close"); + } }