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/.cursor/rules/blazor.mdc b/.cursor/rules/blazor.mdc
new file mode 100644
index 000000000..eac65f209
--- /dev/null
+++ b/.cursor/rules/blazor.mdc
@@ -0,0 +1,78 @@
+---
+description:
+globs:
+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.
+
+ ## 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.
+ - You're using Anglesharp for querying the DOM - act like it.
+
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/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/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs
new file mode 100644
index 000000000..b7be12cb2
--- /dev/null
+++ b/src/bunit.web.query/Roles/AriaRole.cs
@@ -0,0 +1,98 @@
+namespace Bunit.Roles;
+
+///
+/// Represents ARIA roles as defined in the WAI-ARIA specification.
+///
+public enum AriaRole
+{
+ // Document Structure
+ Article,
+ 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
new file mode 100644
index 000000000..f0b9c40ad
--- /dev/null
+++ b/src/bunit.web.query/Roles/FindByRoleOptions.cs
@@ -0,0 +1,54 @@
+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
new file mode 100644
index 000000000..193baed8e
--- /dev/null
+++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs
@@ -0,0 +1,45 @@
+using AngleSharp.Dom;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Bunit.Roles;
+
+///
+/// Exception thrown when an element with the specified role is not found.
+///
+public class RoleNotFoundException : System.Exception
+{
+ ///
+ /// 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))
+ {
+ }
+
+ private static string BuildMessage(AriaRole role, string? name, IEnumerable availableRoles)
+ {
+ var roleStr = role.ToString().ToLowerInvariant();
+ var message = $"Unable to find an element with role '{roleStr}'";
+
+ if (!string.IsNullOrEmpty(name))
+ {
+ message += $" and name '{name}'";
+ }
+
+ if (availableRoles.Any())
+ {
+ var availableRolesStr = string.Join(", ", availableRoles);
+ message += $". Available roles are: {availableRolesStr}";
+ }
+ else
+ {
+ message += ". No roles are available in the document.";
+ }
+
+ 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
new file mode 100644
index 000000000..bc2bd10ed
--- /dev/null
+++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs
@@ -0,0 +1,331 @@
+using AngleSharp.Dom;
+using Bunit.Web.AngleSharp;
+using Bunit.Roles.Strategies;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+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 IRoleStrategy[] RoleStrategies = new IRoleStrategy[]
+ {
+ new ExplicitRoleStrategy(),
+ new ImplicitRoleStrategy()
+ };
+
+ ///
+ /// 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();
+
+ // Filter by level for headings
+ if (options.Level.HasValue && role == AriaRole.Heading)
+ {
+ 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;
+ });
+ }
+
+ // 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();
+ }
+
+ 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 string GetAccessibleName(IElement element)
+ {
+ // Check for aria-label
+ if (element.HasAttribute("aria-label"))
+ return element.GetAttribute("aria-label") ?? string.Empty;
+
+ // Check for aria-labelledby (simplified implementation)
+ if (element.HasAttribute("aria-labelledby"))
+ {
+ var labelledById = element.GetAttribute("aria-labelledby");
+ if (!string.IsNullOrEmpty(labelledById))
+ {
+ var doc = element.Owner;
+ var labelElement = doc.GetElementById(labelledById);
+ if (labelElement != null)
+ return labelElement.TextContent.Trim();
+ }
+ }
+
+ // 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"))
+ {
+ 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))
+ {
+ var doc = element.Owner;
+ var ids = describedById.Split(' ');
+ var descriptions = new List();
+
+ foreach (var id in ids)
+ {
+ var descElement = doc.GetElementById(id);
+ if (descElement != null)
+ {
+ descriptions.Add(descElement.TextContent.Trim());
+ }
+ }
+
+ return string.Join(" ", descriptions);
+ }
+ }
+
+ 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
new file mode 100644
index 000000000..a7ad8433d
--- /dev/null
+++ b/src/bunit.web.query/Roles/Strategies/ExplicitRoleStrategy.cs
@@ -0,0 +1,54 @@
+using AngleSharp.Dom;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Bunit.Roles.Strategies
+{
+ ///
+ /// Strategy for finding elements with explicitly declared ARIA roles.
+ ///
+ public class ExplicitRoleStrategy : IRoleStrategy
+ {
+ ///
+ /// 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/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/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
new file mode 100644
index 000000000..e4dc7b9e9
--- /dev/null
+++ b/src/bunit.web.query/Roles/Strategies/ImplicitRoleStrategy.cs
@@ -0,0 +1,90 @@
+using AngleSharp.Dom;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Bunit.Roles.Strategies
+{
+ ///
+ /// Strategy for finding elements with implicit ARIA roles.
+ ///
+ public class ImplicitRoleStrategy : IRoleStrategy
+ {
+ ///
+ /// 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" } },
+ };
+
+ ///
+ /// 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)
+ {
+ if (!ImplicitRoles.TryGetValue(role, out var selectors))
+ return Array.Empty();
+
+ var result = new List();
+ foreach (var selector in selectors)
+ {
+ try
+ {
+ var elements = context.QuerySelectorAll(selector);
+ result.AddRange(elements);
+ }
+ catch
+ {
+ // If a selector fails, continue with the next one
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// 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
new file mode 100644
index 000000000..1522f4d39
--- /dev/null
+++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test001Async.verified.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 000000000..14ab64019
--- /dev/null
+++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test002Async.verified.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 000000000..1522f4d39
--- /dev/null
+++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test003Async.verified.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 000000000..1522f4d39
--- /dev/null
+++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.Test004Async.verified.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 000000000..ff667e98d
--- /dev/null
+++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs
@@ -0,0 +1,260 @@
+namespace Bunit.Roles;
+
+public class RoleQueryExtensionsTest : BunitContext
+{
+ [Fact(DisplayName = "by default logs accessible roles when it fails")]
+ public async Task Test001Async()
+ {
+ var cut = Render(ps => ps.AddChildContent(
+ """
+
Hi
+ """));
+
+ var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article));
+ await Verify(exception.Message);
+ }
+
+ [Fact(DisplayName = "when hidden: true logs available roles when it fails")]
+ public async Task Test002Async()
+ {
+ var cut = Render(ps => ps.AddChildContent(
+ """
+
+
Hi
+
+ """));
+
+ var exception = Should.Throw(() => cut.FindByRole(AriaRole.Article, new() { Hidden = true }));
+ await Verify(exception.Message);
+ }
+
+ [Fact(DisplayName = "logs error when there are no accessible roles")]
+ public async Task Test003Async()
+ {
+ var cut = Render(ps => ps.AddChildContent(
+ """
+
+ """));
+
+ 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);
+ }
+
+ [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(
+ """
+
+ """));
+
+ Should.Throw(() => cut.FindByRole(AriaRole.Button));
+ }
+
+ [Fact(DisplayName = "should find the input using type property instead of attribute")]
+ public void Test016()
+ {
+ var cut = Render(ps => ps.AddChildContent(
+ """
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.TextBox);
+ element.ShouldNotBeNull();
+ }
+
+ [Fact(DisplayName = "can be filtered by accessible description")]
+ public void Test017()
+ {
+ var targetedNotificationMessage = "Your session is about to expire!";
+ 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");
+ }
+}
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();
+}
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