diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5da26aea..86ee0a317 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,12 +10,14 @@ All notable changes to **bUnit** will be documented in this file. The project ad
- Added generic overloads `Find{TComponent, TElement}` and `FindAll{TComponent, TElement}` to query for specific element types (e.g., `IHtmlInputElement`). By [@linkdotnet](https://github.com/linkdotnet).
- Added generic overloads `WaitForElement{TComponent, TElement}` and `WaitForElements{TComponent, TElement}` to wait for specific element types. By [@linkdotnet](https://github.com/linkdotnet).
+- Added `FindByRole` and `FindAllByRole` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet).
## [2.2.2] - 2025-12-08
### Added
- Added `FindByAllByLabel` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet).
+- Added `FindByRole` and `FindAllByRole` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet).
### Fixed
diff --git a/src/bunit.web.query/Roles/AccessibleNameComputation.cs b/src/bunit.web.query/Roles/AccessibleNameComputation.cs
new file mode 100644
index 000000000..7e7a8e68b
--- /dev/null
+++ b/src/bunit.web.query/Roles/AccessibleNameComputation.cs
@@ -0,0 +1,193 @@
+using AngleSharp.Dom;
+
+namespace Bunit;
+
+///
+/// Provides methods to compute the accessible name of an element.
+///
+///
+/// This is a simplified implementation of the Accessible Name and Description Computation algorithm.
+/// See https://www.w3.org/TR/accname-1.1/ for the full specification.
+///
+[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Using lowercase for comparison with lowercase constant values.")]
+internal static class AccessibleNameComputation
+{
+ ///
+ /// Computes the accessible name of an element.
+ ///
+ public static string? GetAccessibleName(IElement element)
+ {
+ return GetNameFromAriaLabelledBy(element)
+ ?? GetNameFromAriaLabel(element)
+ ?? GetNameFromAssociatedLabelOrNativeContent(element)
+ ?? GetNameFromTitleAttribute(element)
+ ?? GetNameFromPlaceholder(element);
+ }
+
+ private static string? GetNameFromAriaLabelledBy(IElement element)
+ {
+ var labelledBy = element.GetAttribute("aria-labelledby");
+ if (string.IsNullOrWhiteSpace(labelledBy))
+ return null;
+
+ return GetTextFromReferencedElements(element, labelledBy);
+ }
+
+ private static string? GetNameFromAriaLabel(IElement element)
+ {
+ var ariaLabel = element.GetAttribute("aria-label");
+ return string.IsNullOrWhiteSpace(ariaLabel) ? null : ariaLabel;
+ }
+
+ private static string? GetNameFromAssociatedLabelOrNativeContent(IElement element)
+ {
+ var tagName = element.TagName.ToUpperInvariant();
+
+ if (tagName is "INPUT" or "SELECT" or "TEXTAREA")
+ {
+ return GetNameFromLinkedLabel(element)
+ ?? GetNameFromInputButtonValue(element);
+ }
+
+ if (tagName == "IMG")
+ {
+ return GetNameFromAltAttribute(element);
+ }
+
+ if (tagName is "BUTTON" or "A" or "H1" or "H2" or "H3" or "H4" or "H5" or "H6")
+ {
+ return GetNonEmptyTextContent(element);
+ }
+
+ return null;
+ }
+
+ private static string? GetNameFromTitleAttribute(IElement element)
+ {
+ var title = element.GetAttribute("title");
+ return string.IsNullOrWhiteSpace(title) ? null : title;
+ }
+
+ private static string? GetNameFromPlaceholder(IElement element)
+ {
+ var tagName = element.TagName.ToUpperInvariant();
+ if (tagName is not ("INPUT" or "TEXTAREA"))
+ return null;
+
+ var placeholder = element.GetAttribute("placeholder");
+ return string.IsNullOrWhiteSpace(placeholder) ? null : placeholder;
+ }
+
+ private static string? GetTextFromReferencedElements(IElement element, string spaceDelimitedIds)
+ {
+ var ids = spaceDelimitedIds.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var texts = new List();
+ var root = GetRootElement(element);
+
+ foreach (var id in ids)
+ {
+ var referencedElement = element.Owner?.GetElementById(id) ?? root?.QuerySelector($"#{id}");
+ if (referencedElement == null)
+ continue;
+
+ var text = referencedElement.TextContent.Trim();
+ if (!string.IsNullOrWhiteSpace(text))
+ texts.Add(text);
+ }
+
+ return texts.Count > 0 ? string.Join(" ", texts) : null;
+ }
+
+ private static IElement? GetRootElement(IElement element)
+ {
+ var current = element;
+ while (current.ParentElement != null)
+ {
+ current = current.ParentElement;
+ }
+ return current;
+ }
+
+ private static string? GetNameFromLinkedLabel(IElement element)
+ {
+ var id = element.GetAttribute("id");
+ if (!string.IsNullOrWhiteSpace(id))
+ {
+ var linkedLabel = FindLabelWithForAttribute(element, id);
+ if (linkedLabel != null)
+ {
+ return linkedLabel.TextContent.Trim();
+ }
+ }
+
+ var wrappingLabel = element.Closest("label");
+ if (wrappingLabel != null)
+ {
+ return GetTextContentExcludingElement(wrappingLabel, element);
+ }
+
+ return null;
+ }
+
+ private static IElement? FindLabelWithForAttribute(IElement element, string id)
+ {
+ var label = element.Owner?.QuerySelector($"label[for='{id}']");
+ if (label != null)
+ return label;
+
+ var root = GetRootElement(element);
+ return root?.QuerySelector($"label[for='{id}']");
+ }
+
+ private static string? GetNameFromInputButtonValue(IElement element)
+ {
+ if (!element.TagName.Equals("INPUT", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var inputType = element.GetAttribute("type")?.ToLowerInvariant();
+ if (inputType is not ("button" or "submit" or "reset"))
+ return null;
+
+ var value = element.GetAttribute("value");
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ private static string? GetNameFromAltAttribute(IElement element)
+ {
+ var alt = element.GetAttribute("alt");
+ return string.IsNullOrWhiteSpace(alt) ? null : alt;
+ }
+
+ private static string? GetNonEmptyTextContent(IElement element)
+ {
+ var textContent = element.TextContent.Trim();
+ return string.IsNullOrWhiteSpace(textContent) ? null : textContent;
+ }
+
+ private static string GetTextContentExcludingElement(IElement container, IElement excludeElement)
+ {
+ var texts = new List();
+ CollectTextNodesExcluding(container, excludeElement, texts);
+ return string.Join(" ", texts).Trim();
+ }
+
+ private static void CollectTextNodesExcluding(INode node, IElement excludeElement, List texts)
+ {
+ foreach (var child in node.ChildNodes)
+ {
+ if (child == excludeElement)
+ continue;
+
+ if (child.NodeType == NodeType.Text)
+ {
+ var text = child.TextContent.Trim();
+ if (!string.IsNullOrWhiteSpace(text))
+ texts.Add(text);
+ }
+ else if (child.NodeType == NodeType.Element)
+ {
+ CollectTextNodesExcluding(child, excludeElement, texts);
+ }
+ }
+ }
+}
diff --git a/src/bunit.web.query/Roles/AriaRole.cs b/src/bunit.web.query/Roles/AriaRole.cs
new file mode 100644
index 000000000..f158de9c7
--- /dev/null
+++ b/src/bunit.web.query/Roles/AriaRole.cs
@@ -0,0 +1,475 @@
+namespace Bunit;
+
+///
+/// Represents ARIA roles that can be used to query elements.
+///
+///
+/// See https://www.w3.org/TR/wai-aria-1.2/#role_definitions for the full specification.
+///
+public enum AriaRole
+{
+ ///
+ /// A message with important, and usually time-sensitive, information.
+ ///
+ Alert,
+
+ ///
+ /// A type of dialog that contains an alert message.
+ ///
+ AlertDialog,
+
+ ///
+ /// A structure containing one or more focusable elements requiring user input.
+ ///
+ Application,
+
+ ///
+ /// An independent section of the document, page, or site.
+ ///
+ Article,
+
+ ///
+ /// A section of content that is quoted from another source.
+ ///
+ Blockquote,
+
+ ///
+ /// An input that allows for user-triggered actions when clicked or pressed.
+ ///
+ Button,
+
+ ///
+ /// Visible content that names, and may also describe, a figure, table, grid, or treegrid.
+ ///
+ Caption,
+
+ ///
+ /// A checkable input that has three possible values: true, false, or mixed.
+ ///
+ Checkbox,
+
+ ///
+ /// A cell in a tabular container.
+ ///
+ Cell,
+
+ ///
+ /// A section whose content represents a fragment of computer code.
+ ///
+ Code,
+
+ ///
+ /// A cell containing header information for a column.
+ ///
+ ColumnHeader,
+
+ ///
+ /// A composite widget containing a single-line textbox and another element.
+ ///
+ Combobox,
+
+ ///
+ /// A form of widget that performs an action but does not receive input data. (Abstract)
+ ///
+ Command,
+
+ ///
+ /// A supporting section of the document, designed to be complementary to the main content.
+ ///
+ Complementary,
+
+ ///
+ /// A large perceivable region that contains information about the parent document.
+ ///
+ ContentInfo,
+
+ ///
+ /// A widget that may contain navigable descendants or owned children. (Abstract)
+ ///
+ Composite,
+
+ ///
+ /// A definition of a term or concept.
+ ///
+ Definition,
+
+ ///
+ /// A deletion contains content that is marked as removed or suggested for removal.
+ ///
+ Deletion,
+
+ ///
+ /// A dialog is a descendant window of the primary window of a web application.
+ ///
+ Dialog,
+
+ ///
+ /// A structure containing one or more focusable elements.
+ ///
+ Document,
+
+ ///
+ /// [Deprecated in ARIA 1.2] A list of references to members of a group.
+ ///
+ Directory,
+
+ ///
+ /// A scrollable list of articles.
+ ///
+ Feed,
+
+ ///
+ /// A figure is a perceivable section of content.
+ ///
+ Figure,
+
+ ///
+ /// A landmark region that contains a collection of items and objects.
+ ///
+ Form,
+
+ ///
+ /// A set of user interface objects that are not intended to be included in a page summary.
+ ///
+ Generic,
+
+ ///
+ /// A grid is an interactive control that contains cells of tabular data arranged in rows and columns.
+ ///
+ Grid,
+
+ ///
+ /// A cell in a grid or treegrid.
+ ///
+ GridCell,
+
+ ///
+ /// A set of user interface objects that are not intended to be included in a page summary or table of contents.
+ ///
+ Group,
+
+ ///
+ /// A heading for a section of the page.
+ ///
+ Heading,
+
+ ///
+ /// A container for a collection of elements that form an image.
+ ///
+ Img,
+
+ ///
+ /// A generic type of widget that allows user input. (Abstract)
+ ///
+ Input,
+
+ ///
+ /// An insertion contains content that is marked as added or suggested for addition.
+ ///
+ Insertion,
+
+ ///
+ /// A perceivable section for a specific purpose that users can navigate to. (Abstract)
+ ///
+ Landmark,
+
+ ///
+ /// An interactive reference to an internal or external resource.
+ ///
+ Link,
+
+ ///
+ /// A section containing listitem elements.
+ ///
+ List,
+
+ ///
+ /// A widget that allows the user to select one or more items from a list of choices.
+ ///
+ Listbox,
+
+ ///
+ /// A single item in a list or directory.
+ ///
+ Listitem,
+
+ ///
+ /// A type of live region where new information is added in meaningful order.
+ ///
+ Log,
+
+ ///
+ /// The main content of a document.
+ ///
+ Main,
+
+ ///
+ /// A type of live region where non-essential information changes frequently.
+ ///
+ Marquee,
+
+ ///
+ /// Content that represents a mathematical expression.
+ ///
+ Math,
+
+ ///
+ /// A type of widget that offers a list of choices to the user.
+ ///
+ Menu,
+
+ ///
+ /// A presentation of menu that usually remains visible and is usually presented horizontally.
+ ///
+ Menubar,
+
+ ///
+ /// An option in a set of choices contained by a menu or menubar.
+ ///
+ Menuitem,
+
+ ///
+ /// A menuitem with a checkable state.
+ ///
+ MenuitemCheckbox,
+
+ ///
+ /// A checkable menuitem in a set of elements with the same role.
+ ///
+ MenuitemRadio,
+
+ ///
+ /// A graphical display of values.
+ ///
+ Meter,
+
+ ///
+ /// A section of a page that represents navigation links.
+ ///
+ Navigation,
+
+ ///
+ /// A section whose content represents additional information.
+ ///
+ Note,
+
+ ///
+ /// An element whose implicit native role semantics will not be mapped to the accessibility API. Synonym of Presentation.
+ ///
+ None,
+
+ ///
+ /// A selectable item in a select list.
+ ///
+ Option,
+
+ ///
+ /// A paragraph of content.
+ ///
+ Paragraph,
+
+ ///
+ /// An element whose implicit native role semantics will not be mapped to the accessibility API.
+ ///
+ Presentation,
+
+ ///
+ /// An element that displays the progress status for tasks that take a long time.
+ ///
+ Progressbar,
+
+ ///
+ /// A checkable input in a group of elements with the same role.
+ ///
+ Radio,
+
+ ///
+ /// A group of radio buttons.
+ ///
+ RadioGroup,
+
+ ///
+ /// An element representing a range of values. (Abstract)
+ ///
+ Range,
+
+ ///
+ /// A perceivable section containing content that is relevant to a specific, author-specified purpose.
+ ///
+ Region,
+
+ ///
+ /// The base role from which all other roles inherit. (Abstract)
+ ///
+ RoleType,
+
+ ///
+ /// A row of cells in a tabular container.
+ ///
+ Row,
+
+ ///
+ /// A group containing one or more row elements in a grid.
+ ///
+ RowGroup,
+
+ ///
+ /// A cell containing header information for a row in a grid.
+ ///
+ RowHeader,
+
+ ///
+ /// A graphical object that controls the scrolling of content within a viewing area.
+ ///
+ Scrollbar,
+
+ ///
+ /// A landmark region that contains a collection of items and objects that, as a whole, combine to create a search facility.
+ ///
+ Search,
+
+ ///
+ /// A type of textbox intended for specifying search criteria.
+ ///
+ Searchbox,
+
+ ///
+ /// A divider that separates and distinguishes sections of content or groups of menuitems.
+ ///
+ Separator,
+
+ ///
+ /// An input where the user selects a value from within a given range.
+ ///
+ Slider,
+
+ ///
+ /// A form of range that expects the user to select from among discrete choices.
+ ///
+ Spinbutton,
+
+ ///
+ /// A type of live region whose content is advisory information for the user.
+ ///
+ Status,
+
+ ///
+ /// A type of checkbox that represents on/off values.
+ ///
+ Switch,
+
+ ///
+ /// A grouping label providing a mechanism for selecting the tab content that is to be rendered to the user.
+ ///
+ Tab,
+
+ ///
+ /// A section containing data arranged in rows and columns.
+ ///
+ Table,
+
+ ///
+ /// A list of tab elements, which are references to tabpanel elements.
+ ///
+ Tablist,
+
+ ///
+ /// A container for the resources associated with a tab.
+ ///
+ Tabpanel,
+
+ ///
+ /// An element that represents a specific value within a range.
+ ///
+ Term,
+
+ ///
+ /// A type of input that allows free-form text as its value.
+ ///
+ Textbox,
+
+ ///
+ /// An element that represents a specific point in time.
+ ///
+ Time,
+
+ ///
+ /// A type of live region containing a numerical counter.
+ ///
+ Timer,
+
+ ///
+ /// A collection of commonly used function buttons or controls represented in compact visual form.
+ ///
+ Toolbar,
+
+ ///
+ /// A contextual popup that displays a description for an element.
+ ///
+ Tooltip,
+
+ ///
+ /// A widget that allows the user to select one or more items from a hierarchically organized collection.
+ ///
+ Tree,
+
+ ///
+ /// A grid whose rows can be expanded and collapsed in the same manner as for a tree.
+ ///
+ Treegrid,
+
+ ///
+ /// An item in a tree.
+ ///
+ Treeitem,
+
+ ///
+ /// A document structural element. (Abstract)
+ ///
+ Structure,
+
+ ///
+ /// One or more subscripted characters.
+ ///
+ Subscript,
+
+ ///
+ /// One or more superscripted characters.
+ ///
+ Superscript,
+
+ ///
+ /// Content that is important, serious, or urgent.
+ ///
+ Strong,
+
+ ///
+ /// A renderable structural containment unit in a document or application. (Abstract)
+ ///
+ Section,
+
+ ///
+ /// A structure that labels or summarizes the topic of its related section. (Abstract)
+ ///
+ Sectionhead,
+
+ ///
+ /// A form widget that allows the user to make selections from a set of choices. (Abstract)
+ ///
+ Select,
+
+ ///
+ /// An interactive component of a graphical user interface (GUI). (Abstract)
+ ///
+ Widget,
+
+ ///
+ /// A browser or application window.
+ ///
+ Window,
+
+ ///
+ /// A landmark that contains mostly site-oriented content, rather than page-specific content.
+ ///
+ Banner,
+}
diff --git a/src/bunit.web.query/Roles/ByRoleOptions.cs b/src/bunit.web.query/Roles/ByRoleOptions.cs
new file mode 100644
index 000000000..aea7a637b
--- /dev/null
+++ b/src/bunit.web.query/Roles/ByRoleOptions.cs
@@ -0,0 +1,71 @@
+namespace Bunit;
+
+///
+/// Allows overrides of behavior for FindByRole method.
+///
+public record class ByRoleOptions
+{
+ ///
+ /// The default behavior used by FindByRole if no overrides are specified.
+ ///
+ internal static readonly ByRoleOptions Default = new();
+
+ ///
+ /// The StringComparison used for comparing the accessible name. Defaults to Ordinal (case sensitive).
+ ///
+ public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal;
+
+ ///
+ /// If true, includes elements that are normally excluded from the accessibility tree.
+ /// Elements can be excluded via aria-hidden="true" or the hidden attribute.
+ /// Defaults to false.
+ ///
+ public bool Hidden { get; set; }
+
+ ///
+ /// Filters elements by their accessible name (e.g., aria-label, button text, label text).
+ /// When null, no filtering by name is applied.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// Filters elements by their selected state (aria-selected).
+ /// Use for elements like tabs or options.
+ ///
+ public bool? Selected { get; set; }
+
+ ///
+ /// Filters elements by their checked state (aria-checked or native checked).
+ /// Use for checkboxes and radio buttons.
+ ///
+ public bool? Checked { get; set; }
+
+ ///
+ /// Filters elements by their pressed state (aria-pressed).
+ /// Use for toggle buttons.
+ ///
+ public bool? Pressed { get; set; }
+
+ ///
+ /// Filters elements by their current state (aria-current).
+ /// Can be true/false or a specific token like "page", "step", "location", "date", "time".
+ ///
+ public object? Current { get; set; }
+
+ ///
+ /// Filters elements by their expanded state (aria-expanded).
+ /// Use for elements like accordions, dropdowns, or tree items.
+ ///
+ public bool? Expanded { get; set; }
+
+ ///
+ /// Filters elements by their busy state (aria-busy).
+ ///
+ public bool? Busy { get; set; }
+
+ ///
+ /// Filters headings by their level (1-6).
+ /// Only applicable when searching for the "heading" role.
+ ///
+ public int? Level { get; set; }
+}
diff --git a/src/bunit.web.query/Roles/ImplicitRoleMapper.cs b/src/bunit.web.query/Roles/ImplicitRoleMapper.cs
new file mode 100644
index 000000000..bddadf806
--- /dev/null
+++ b/src/bunit.web.query/Roles/ImplicitRoleMapper.cs
@@ -0,0 +1,178 @@
+using AngleSharp.Dom;
+
+namespace Bunit;
+
+///
+/// Maps HTML elements to their implicit ARIA roles.
+///
+///
+/// See https://www.w3.org/TR/html-aria/ for the full specification.
+///
+internal static class ImplicitRoleMapper
+{
+ private static readonly string[] SectioningElements = ["ARTICLE", "ASIDE", "MAIN", "NAV", "SECTION"];
+
+ ///
+ /// Gets the implicit ARIA role for an HTML element.
+ ///
+ public static AriaRole? GetImplicitRole(IElement element)
+ {
+ var tagName = element.TagName.ToUpperInvariant();
+
+ return tagName switch
+ {
+ "A" when element.HasAttribute("href") => AriaRole.Link,
+ "AREA" when element.HasAttribute("href") => AriaRole.Link,
+ "ARTICLE" => AriaRole.Article,
+ "ASIDE" => AriaRole.Complementary,
+ "BUTTON" => AriaRole.Button,
+ "DATALIST" => AriaRole.Listbox,
+ "DETAILS" => AriaRole.Group,
+ "DIALOG" => AriaRole.Dialog,
+ "FIGURE" => AriaRole.Figure,
+ "FORM" => GetFormRoleWhenHasAccessibleName(element),
+ "FOOTER" => GetFooterRoleWhenNotInSectioningContent(element),
+ "H1" or "H2" or "H3" or "H4" or "H5" or "H6" => AriaRole.Heading,
+ "HEADER" => GetHeaderRoleWhenNotInSectioningContent(element),
+ "HR" => AriaRole.Separator,
+ "IMG" => GetImgRoleBasedOnAltAttribute(element),
+ "INPUT" => GetInputRole(element),
+ "LI" => AriaRole.Listitem,
+ "MAIN" => AriaRole.Main,
+ "MATH" => AriaRole.Math,
+ "MENU" => AriaRole.List,
+ "METER" => AriaRole.Meter,
+ "NAV" => AriaRole.Navigation,
+ "OL" => AriaRole.List,
+ "OPTGROUP" => AriaRole.Group,
+ "OPTION" => AriaRole.Option,
+ "OUTPUT" => AriaRole.Status,
+ "PROGRESS" => AriaRole.Progressbar,
+ "SECTION" => GetSectionRoleWhenHasAccessibleName(element),
+ "SELECT" => GetSelectRoleBasedOnMultipleOrSize(element),
+ "SUMMARY" => AriaRole.Button,
+ "TABLE" => AriaRole.Table,
+ "TBODY" or "TFOOT" or "THEAD" => AriaRole.RowGroup,
+ "TD" => AriaRole.Cell,
+ "TEXTAREA" => AriaRole.Textbox,
+ "TH" => GetThRoleBasedOnScopeAndContext(element),
+ "TR" => AriaRole.Row,
+ "UL" => AriaRole.List,
+ _ => null
+ };
+ }
+
+ ///
+ /// Gets the heading level for heading elements.
+ ///
+ public static int? GetHeadingLevel(IElement element)
+ {
+ var explicitAriaLevel = element.GetAttribute("aria-level");
+ if (!string.IsNullOrWhiteSpace(explicitAriaLevel) && int.TryParse(explicitAriaLevel, out var level))
+ {
+ return level;
+ }
+
+ return element.TagName.ToUpperInvariant() switch
+ {
+ "H1" => 1,
+ "H2" => 2,
+ "H3" => 3,
+ "H4" => 4,
+ "H5" => 5,
+ "H6" => 6,
+ _ => null
+ };
+ }
+
+ private static AriaRole? GetFormRoleWhenHasAccessibleName(IElement element) =>
+ HasAccessibleName(element) ? AriaRole.Form : null;
+
+ private static AriaRole? GetFooterRoleWhenNotInSectioningContent(IElement element) =>
+ IsDescendantOfSectioningContent(element) ? null : AriaRole.ContentInfo;
+
+ private static AriaRole? GetHeaderRoleWhenNotInSectioningContent(IElement element) =>
+ IsDescendantOfSectioningContent(element) ? null : AriaRole.Banner;
+
+ private static AriaRole? GetImgRoleBasedOnAltAttribute(IElement element)
+ {
+ var alt = element.GetAttribute("alt");
+ var hasEmptyAltForDecorativeImage = alt is { Length: 0 };
+ return hasEmptyAltForDecorativeImage ? AriaRole.Presentation : AriaRole.Img;
+ }
+
+ private static AriaRole? GetInputRole(IElement element)
+ {
+ var type = element.GetAttribute("type")?.ToUpperInvariant() ?? "TEXT";
+
+ return type switch
+ {
+ "BUTTON" or "IMAGE" or "RESET" or "SUBMIT" => AriaRole.Button,
+ "CHECKBOX" => AriaRole.Checkbox,
+ "EMAIL" or "TEL" or "TEXT" or "URL" => GetTextInputRoleBasedOnDatalist(element, AriaRole.Textbox),
+ "NUMBER" => AriaRole.Spinbutton,
+ "RADIO" => AriaRole.Radio,
+ "RANGE" => AriaRole.Slider,
+ "SEARCH" => GetTextInputRoleBasedOnDatalist(element, AriaRole.Searchbox),
+ _ => null
+ };
+ }
+
+ private static AriaRole GetTextInputRoleBasedOnDatalist(IElement element, AriaRole defaultRole)
+ {
+ var listAttributeId = element.GetAttribute("list");
+ if (string.IsNullOrWhiteSpace(listAttributeId))
+ return defaultRole;
+
+ var hasAssociatedDatalist = element.Owner?.GetElementById(listAttributeId) != null;
+ return hasAssociatedDatalist ? AriaRole.Combobox : defaultRole;
+ }
+
+ private static AriaRole? GetSectionRoleWhenHasAccessibleName(IElement element) =>
+ HasAccessibleName(element) ? AriaRole.Region : null;
+
+ private static AriaRole GetSelectRoleBasedOnMultipleOrSize(IElement element)
+ {
+ if (element.HasAttribute("multiple"))
+ return AriaRole.Listbox;
+
+ var size = element.GetAttribute("size");
+ var hasMultipleVisibleOptions = !string.IsNullOrWhiteSpace(size)
+ && int.TryParse(size, out var sizeValue)
+ && sizeValue > 1;
+
+ return hasMultipleVisibleOptions ? AriaRole.Listbox : AriaRole.Combobox;
+ }
+
+ private static AriaRole GetThRoleBasedOnScopeAndContext(IElement element)
+ {
+ var scope = element.GetAttribute("scope")?.ToUpperInvariant();
+ if (scope == "ROW")
+ {
+ return AriaRole.RowHeader;
+ }
+
+ var isInTableHeader = element.Closest("thead") != null;
+ return isInTableHeader ? AriaRole.ColumnHeader : AriaRole.Cell;
+ }
+
+ private static bool HasAccessibleName(IElement element)
+ {
+ return !string.IsNullOrWhiteSpace(element.GetAttribute("aria-label"))
+ || !string.IsNullOrWhiteSpace(element.GetAttribute("aria-labelledby"))
+ || !string.IsNullOrWhiteSpace(element.GetAttribute("title"));
+ }
+
+ private static bool IsDescendantOfSectioningContent(IElement element)
+ {
+ var parent = element.ParentElement;
+ while (parent != null)
+ {
+ if (SectioningElements.Contains(parent.TagName.ToUpperInvariant()))
+ return true;
+ parent = parent.ParentElement;
+ }
+
+ return false;
+ }
+}
diff --git a/src/bunit.web.query/Roles/RoleElementExtensions.cs b/src/bunit.web.query/Roles/RoleElementExtensions.cs
new file mode 100644
index 000000000..a31489332
--- /dev/null
+++ b/src/bunit.web.query/Roles/RoleElementExtensions.cs
@@ -0,0 +1,193 @@
+using AngleSharp.Dom;
+
+namespace Bunit;
+
+///
+/// Provides helper methods for checking element state related to ARIA roles.
+///
+internal static class RoleElementExtensions
+{
+ private const string AriaHiddenTrue = "true";
+
+ ///
+ /// Determines if an element is hidden from the accessibility tree.
+ ///
+ public static bool IsHiddenFromAccessibilityTree(this IElement element)
+ {
+ return HasAriaHiddenTrue(element)
+ || element.HasAttribute("hidden")
+ || HasHiddenAncestor(element);
+ }
+
+ ///
+ /// Gets the checked state of an element.
+ ///
+ public static bool? GetCheckedState(this IElement element)
+ {
+ var ariaChecked = element.GetAttribute("aria-checked");
+ return !string.IsNullOrWhiteSpace(ariaChecked)
+ ? ParseTriStateBoolean(ariaChecked)
+ : GetNativeCheckedStateForCheckboxOrRadio(element);
+ }
+
+ ///
+ /// Gets the selected state of an element.
+ ///
+ public static bool? GetSelectedState(this IElement element)
+ {
+ var ariaSelected = element.GetAttribute("aria-selected");
+ return !string.IsNullOrWhiteSpace(ariaSelected)
+ ? ParseBooleanAttribute(ariaSelected)
+ : GetNativeSelectedStateForOption(element);
+ }
+
+ ///
+ /// Gets the pressed state of an element.
+ ///
+ public static bool? GetPressedState(this IElement element)
+ {
+ var ariaPressed = element.GetAttribute("aria-pressed");
+ return string.IsNullOrWhiteSpace(ariaPressed)
+ ? null
+ : ParseTriStateBoolean(ariaPressed);
+ }
+
+ ///
+ /// Gets the expanded state of an element.
+ ///
+ public static bool? GetExpandedState(this IElement element)
+ {
+ var ariaExpanded = element.GetAttribute("aria-expanded");
+ return !string.IsNullOrWhiteSpace(ariaExpanded)
+ ? ParseBooleanAttribute(ariaExpanded)
+ : GetNativeExpandedStateForDetails(element);
+ }
+
+ ///
+ /// Gets the busy state of an element.
+ ///
+ public static bool? GetBusyState(this IElement element)
+ {
+ var ariaBusy = element.GetAttribute("aria-busy");
+ return string.IsNullOrWhiteSpace(ariaBusy) ? null : ParseBooleanAttribute(ariaBusy);
+ }
+
+ ///
+ /// Gets the current state of an element.
+ ///
+ public static object? GetCurrentState(this IElement element)
+ {
+ var ariaCurrent = element.GetAttribute("aria-current");
+ if (string.IsNullOrWhiteSpace(ariaCurrent))
+ {
+ return null;
+ }
+
+ return ariaCurrent.ToUpperInvariant() switch
+ {
+ "TRUE" => true,
+ "FALSE" => false,
+ _ => ariaCurrent
+ };
+ }
+
+ ///
+ /// Gets the explicit role of an element from the role attribute.
+ ///
+ public static AriaRole? GetExplicitRole(this IElement element)
+ {
+ var role = element.GetAttribute("role");
+ if (string.IsNullOrWhiteSpace(role))
+ {
+ return null;
+ }
+
+ var firstRole = role.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
+ if (string.IsNullOrWhiteSpace(firstRole))
+ {
+ return null;
+ }
+
+ return Enum.TryParse(firstRole, ignoreCase: true, out var ariaRole)
+ ? ariaRole
+ : null;
+ }
+
+ ///
+ /// Gets the role of an element, checking explicit role first, then implicit role.
+ ///
+ public static AriaRole? GetRole(this IElement element)
+ {
+ return element.GetExplicitRole() ?? ImplicitRoleMapper.GetImplicitRole(element);
+ }
+
+ private static bool HasAriaHiddenTrue(IElement element) =>
+ element.GetAttribute("aria-hidden") == AriaHiddenTrue;
+
+ private static bool HasHiddenAncestor(IElement element)
+ {
+ var parent = element.ParentElement;
+ while (parent != null)
+ {
+ if (parent.GetAttribute("aria-hidden") == AriaHiddenTrue || parent.HasAttribute("hidden"))
+ {
+ return true;
+ }
+
+ parent = parent.ParentElement;
+ }
+
+ return false;
+ }
+
+ private static bool? ParseTriStateBoolean(string value)
+ {
+ return value.ToUpperInvariant() switch
+ {
+ "TRUE" => true,
+ "FALSE" => false,
+ "MIXED" => null,
+ _ => null
+ };
+ }
+
+ private static bool? ParseBooleanAttribute(string value)
+ {
+ return value.ToUpperInvariant() switch
+ {
+ "TRUE" => true,
+ "FALSE" => false,
+ _ => null
+ };
+ }
+
+ private static bool? GetNativeCheckedStateForCheckboxOrRadio(IElement element)
+ {
+ if (!element.TagName.Equals("INPUT", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var type = element.GetAttribute("type")?.ToUpperInvariant();
+ if (type is not ("CHECKBOX" or "RADIO"))
+ {
+ return null;
+ }
+
+ return element.HasAttribute("checked");
+ }
+
+ private static bool? GetNativeSelectedStateForOption(IElement element)
+ {
+ return !element.TagName.Equals("OPTION", StringComparison.OrdinalIgnoreCase)
+ ? null
+ : element.HasAttribute("selected");
+ }
+
+ private static bool? GetNativeExpandedStateForDetails(IElement element)
+ {
+ return !element.TagName.Equals("DETAILS", StringComparison.OrdinalIgnoreCase)
+ ? null
+ : element.HasAttribute("open");
+ }
+}
diff --git a/src/bunit.web.query/Roles/RoleNotFoundException.cs b/src/bunit.web.query/Roles/RoleNotFoundException.cs
new file mode 100644
index 000000000..efdb8cf72
--- /dev/null
+++ b/src/bunit.web.query/Roles/RoleNotFoundException.cs
@@ -0,0 +1,38 @@
+namespace Bunit;
+
+///
+/// Represents a failure to find an element in the searched target
+/// using the specified role.
+///
+public sealed class RoleNotFoundException : Exception
+{
+ ///
+ /// Gets the role used to search with.
+ ///
+ public AriaRole Role { get; }
+
+ ///
+ /// Gets the accessible name filter that was applied, if any.
+ ///
+ public string? AccessibleName { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The role that was searched for.
+ /// The accessible name filter that was applied, if any.
+ public RoleNotFoundException(AriaRole role, string? accessibleName = null)
+ : base(CreateMessage(role, accessibleName))
+ {
+ Role = role;
+ AccessibleName = accessibleName;
+ }
+
+ private static string CreateMessage(AriaRole role, string? accessibleName)
+ {
+ var roleString = role.ToString();
+ return accessibleName is not null
+ ? $"Unable to find an element with the role '{roleString}' and accessible name '{accessibleName}'."
+ : $"Unable to find an element with the role '{roleString}'.";
+ }
+}
diff --git a/src/bunit.web.query/Roles/RoleQueryExtensions.cs b/src/bunit.web.query/Roles/RoleQueryExtensions.cs
new file mode 100644
index 000000000..e8d80e199
--- /dev/null
+++ b/src/bunit.web.query/Roles/RoleQueryExtensions.cs
@@ -0,0 +1,154 @@
+using AngleSharp.Dom;
+using Bunit.Web.AngleSharp;
+
+namespace Bunit;
+
+///
+/// Extension methods for querying by ARIA role.
+///
+public static class RoleQueryExtensions
+{
+ ///
+ /// Returns the first element with the specified ARIA role.
+ ///
+ /// The rendered fragment to search.
+ /// The ARIA role to search for (e.g., AriaRole.Button, AriaRole.Link).
+ /// Method used to override the default behavior of FindByRole.
+ /// The first element matching the specified role and options.
+ /// Thrown when no element matching the role is found.
+ public static IElement FindByRole(this IRenderedComponent renderedComponent, AriaRole role, Action? configureOptions = null)
+ {
+ var options = ByRoleOptions.Default;
+ if (configureOptions is not null)
+ {
+ options = options with { };
+ configureOptions.Invoke(options);
+ }
+
+ return FindByRoleInternal(renderedComponent, role, options) ?? throw new RoleNotFoundException(role, options.Name);
+ }
+
+ ///
+ /// Returns all elements with the specified ARIA role.
+ ///
+ /// The rendered fragment to search.
+ /// The ARIA role to search for (e.g., AriaRole.Button, AriaRole.Link).
+ /// Method used to override the default behavior of FindAllByRole.
+ /// A read-only collection of elements matching the role. Returns an empty collection if no matches are found.
+ public static IReadOnlyList FindAllByRole(this IRenderedComponent renderedComponent, AriaRole role, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(renderedComponent);
+
+ var options = ByRoleOptions.Default;
+ if (configureOptions is not null)
+ {
+ options = options with { };
+ configureOptions.Invoke(options);
+ }
+
+ return FindAllByRoleInternal(renderedComponent, role, options);
+ }
+
+ private static IElement? FindByRoleInternal(IRenderedComponent renderedComponent, AriaRole role, ByRoleOptions options)
+ {
+ var results = FindAllByRoleInternal(renderedComponent, role, options);
+ return results.Count > 0 ? results[0] : null;
+ }
+
+ private static List FindAllByRoleInternal(IRenderedComponent renderedComponent, AriaRole role, ByRoleOptions options)
+ {
+ var allElements = renderedComponent.Nodes.TryQuerySelectorAll("*");
+ var results = new List();
+ var seen = new HashSet();
+
+ foreach (var element in allElements)
+ {
+ if (!MatchesRole(element, role, options))
+ continue;
+
+ // Deduplicate using the underlying element
+ var underlyingElement = element.Unwrap();
+ if (seen.Add(underlyingElement))
+ {
+ results.Add(element);
+ }
+ }
+
+ return results;
+ }
+
+ private static bool MatchesRole(IElement element, AriaRole role, ByRoleOptions options)
+ {
+ if (!options.Hidden && element.IsHiddenFromAccessibilityTree())
+ return false;
+
+ var elementRole = element.GetRole();
+ if (elementRole != role)
+ return false;
+
+ if (options.Name is not null)
+ {
+ var accessibleName = AccessibleNameComputation.GetAccessibleName(element);
+ if (accessibleName is null || !accessibleName.Equals(options.Name, options.ComparisonType))
+ return false;
+ }
+
+ if (options.Level.HasValue)
+ {
+ if (role != AriaRole.Heading)
+ return false;
+
+ var level = ImplicitRoleMapper.GetHeadingLevel(element);
+ if (level != options.Level.Value)
+ return false;
+ }
+
+ if (options.Checked.HasValue)
+ {
+ var checkedState = element.GetCheckedState();
+ if (checkedState != options.Checked.Value)
+ return false;
+ }
+
+ if (options.Selected.HasValue)
+ {
+ var selectedState = element.GetSelectedState();
+ if (selectedState != options.Selected.Value)
+ return false;
+ }
+
+ if (options.Pressed.HasValue)
+ {
+ var pressedState = element.GetPressedState();
+ if (pressedState != options.Pressed.Value)
+ return false;
+ }
+
+ if (options.Expanded.HasValue)
+ {
+ var expandedState = element.GetExpandedState();
+ if (expandedState != options.Expanded.Value)
+ return false;
+ }
+
+ if (options.Busy.HasValue)
+ {
+ var busyState = element.GetBusyState();
+ if (busyState != options.Busy.Value)
+ return false;
+ }
+
+ if (options.Current is not null)
+ {
+ var currentState = element.GetCurrentState();
+ switch (options.Current)
+ {
+ case bool currentBool when !Equals(currentState, currentBool):
+ case string currentString when !currentString.Equals(currentState as string, options.ComparisonType):
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/bunit/BunitContext.Obsoletes.cs b/src/bunit/BunitContext.Obsoletes.cs
index 121e95636..07ddb1caa 100644
--- a/src/bunit/BunitContext.Obsoletes.cs
+++ b/src/bunit/BunitContext.Obsoletes.cs
@@ -1,5 +1,6 @@
namespace Bunit;
+[RemovedInFutureVersion("Obsolete in v2, removed in future version")]
public partial class BunitContext
{
///
diff --git a/src/bunit/TestContext.cs b/src/bunit/TestContext.cs
index 2f3ad5022..615dc0599 100644
--- a/src/bunit/TestContext.cs
+++ b/src/bunit/TestContext.cs
@@ -2,6 +2,7 @@ namespace Bunit;
///
[Obsolete($"Use {nameof(BunitContext)} instead. TestContext will be removed in a future release.", false, UrlFormat = "https://bunit.dev/docs/migrations")]
+[RemovedInFutureVersion("Obsolete in v2, removed in future version")]
public class TestContext : BunitContext
{
}
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..68cacc214
--- /dev/null
+++ b/tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs
@@ -0,0 +1,825 @@
+namespace Bunit.Roles;
+
+public class RoleQueryExtensionsTest : BunitContext
+{
+ [Fact(DisplayName = "Should return element with explicit role attribute")]
+ public void Test001()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
Click me
"""));
+
+ var element = cut.FindByRole(AriaRole.Button);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Click me");
+ }
+
+ [Fact(DisplayName = "Should throw RoleNotFoundException when role does not exist")]
+ public void Test002()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
Just a div
"""));
+
+ var exception = Should.Throw(() => cut.FindByRole(AriaRole.Button));
+ exception.Role.ShouldBe(AriaRole.Button);
+ }
+
+ [Fact(DisplayName = "FindAllByRole should return empty collection when no matches")]
+ public void Test003()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
Just a div
"""));
+
+ var elements = cut.FindAllByRole(AriaRole.Button);
+
+ elements.ShouldBeEmpty();
+ }
+
+ [Fact(DisplayName = "FindAllByRole should return all matching elements")]
+ public void Test004()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Button);
+
+ elements.Count.ShouldBe(3);
+ } [Fact(DisplayName = "Should find button element by implicit button role")]
+ public void Test010()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Button);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("BUTTON", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find input type=button by implicit button role")]
+ public void Test011()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Button);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("button");
+ }
+
+ [Fact(DisplayName = "Should find input type=submit by implicit button role")]
+ public void Test012()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Button);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("submit");
+ }
+
+ [Fact(DisplayName = "Should find input type=reset by implicit button role")]
+ public void Test013()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Button);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("reset");
+ }
+
+ [Fact(DisplayName = "Should find summary element by implicit button role")]
+ public void Test014()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""DetailsContent"""));
+
+ var element = cut.FindByRole(AriaRole.Button);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("SUMMARY", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find anchor with href by implicit link role")]
+ public void Test020()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""Home"""));
+
+ var element = cut.FindByRole(AriaRole.Link);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("A", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should not find anchor without href by link role")]
+ public void Test021()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""Not a link"""));
+
+ var elements = cut.FindAllByRole(AriaRole.Link);
+
+ elements.ShouldBeEmpty();
+ }
+
+ [Theory(DisplayName = "Should find heading elements by implicit heading role")]
+ [InlineData("h1")]
+ [InlineData("h2")]
+ [InlineData("h3")]
+ [InlineData("h4")]
+ [InlineData("h5")]
+ [InlineData("h6")]
+ public void Test030(string headingTag)
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent($"<{headingTag}>Heading{headingTag}>"));
+
+ var element = cut.FindByRole(AriaRole.Heading);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe(headingTag, StringCompareShould.IgnoreCase);
+ }
+
+ [Theory(DisplayName = "Should filter headings by level option")]
+ [InlineData(1, "h1")]
+ [InlineData(2, "h2")]
+ [InlineData(3, "h3")]
+ [InlineData(4, "h4")]
+ [InlineData(5, "h5")]
+ [InlineData(6, "h6")]
+ public void Test031(int level, string expectedTag)
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
Level 1
+
Level 2
+
Level 3
+
Level 4
+
Level 5
+
Level 6
+ """));
+
+ var element = cut.FindByRole(AriaRole.Heading, o => o.Level = level);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe(expectedTag, StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find all headings when no level specified")]
+ public void Test032()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
Level 1
+
Level 2
+
Level 3
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Heading);
+
+ elements.Count.ShouldBe(3);
+ }
+
+ [Fact(DisplayName = "Should respect aria-level attribute for heading level")]
+ public void Test033()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
Custom Heading
"""));
+
+ var element = cut.FindByRole(AriaRole.Heading, o => o.Level = 2);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Custom Heading");
+ }
+
+ [Fact(DisplayName = "Should find checkbox input by implicit checkbox role")]
+ public void Test040()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Checkbox);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("checkbox");
+ }
+
+ [Fact(DisplayName = "Should filter checkbox by checked state")]
+ public void Test041()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Checkbox, o => o.Checked = true);
+
+ element.ShouldNotBeNull();
+ element.Id.ShouldBe("checked");
+ }
+
+ [Fact(DisplayName = "Should filter checkbox by unchecked state")]
+ public void Test042()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Checkbox, o => o.Checked = false);
+
+ element.ShouldNotBeNull();
+ element.Id.ShouldBe("unchecked");
+ }
+
+ [Fact(DisplayName = "Should find radio input by implicit radio role")]
+ public void Test050()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Radio);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("radio");
+ }
+
+ [Fact(DisplayName = "Should find text input by implicit textbox role")]
+ public void Test060()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Textbox);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("text");
+ }
+
+ [Fact(DisplayName = "Should find textarea by implicit textbox role")]
+ public void Test061()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Textbox);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("TEXTAREA", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find email input by implicit textbox role")]
+ public void Test062()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Textbox);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("email");
+ }
+
+ [Theory(DisplayName = "Should find list elements by implicit list role")]
+ [InlineData("ul")]
+ [InlineData("ol")]
+ public void Test070(string listTag)
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent($"<{listTag}>
Item
{listTag}>"));
+
+ var element = cut.FindByRole(AriaRole.List);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe(listTag, StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find li element by implicit listitem role")]
+ public void Test071()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
Item
"""));
+
+ var element = cut.FindByRole(AriaRole.Listitem);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("LI", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find nav element by implicit navigation role")]
+ public void Test080()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Navigation);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("NAV", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find main element by implicit main role")]
+ public void Test081()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""Content"""));
+
+ var element = cut.FindByRole(AriaRole.Main);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("MAIN", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find aside element by implicit complementary role")]
+ public void Test082()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Complementary);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("ASIDE", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should filter by accessible name from aria-label")]
+ public void Test090()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o => o.Name = "Submit form");
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Submit");
+ }
+
+ [Fact(DisplayName = "Should filter by accessible name from button text content")]
+ public void Test091()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o => o.Name = "Submit");
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Submit");
+ }
+
+ [Fact(DisplayName = "Should filter by accessible name from aria-labelledby")]
+ public void Test092()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+ First Label
+
+ Second Label
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o => o.Name = "First Label");
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Button 1");
+ }
+
+ [Fact(DisplayName = "Should throw RoleNotFoundException when name does not match")]
+ public void Test093()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var exception = Should.Throw(() =>
+ cut.FindByRole(AriaRole.Button, o => o.Name = "NonExistent"));
+ exception.AccessibleName.ShouldBe("NonExistent");
+ }
+
+ [Theory(DisplayName = "Should respect ComparisonType for name filter")]
+ [InlineData(StringComparison.OrdinalIgnoreCase, true)]
+ [InlineData(StringComparison.Ordinal, false)]
+ public void Test094(StringComparison comparison, bool shouldFind)
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ if (shouldFind)
+ {
+ var element = cut.FindByRole(AriaRole.Button, o =>
+ {
+ o.Name = "SUBMIT";
+ o.ComparisonType = comparison;
+ });
+ element.ShouldNotBeNull();
+ }
+ else
+ {
+ Should.Throw(() =>
+ cut.FindByRole(AriaRole.Button, o =>
+ {
+ o.Name = "SUBMIT";
+ o.ComparisonType = comparison;
+ }));
+ }
+ }
+
+ [Fact(DisplayName = "Should filter by accessible name from linked label")]
+ public void Test095()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Textbox, o => o.Name = "Email Address");
+
+ element.ShouldNotBeNull();
+ element.Id.ShouldBe("email");
+ }
+
+ [Fact(DisplayName = "Should exclude aria-hidden elements by default")]
+ public void Test100()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Button);
+
+ elements.Count.ShouldBe(1);
+ elements[0].TextContent.ShouldBe("Visible");
+ }
+
+ [Fact(DisplayName = "Should include aria-hidden elements when Hidden option is true")]
+ public void Test101()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Button, o => o.Hidden = true);
+
+ elements.Count.ShouldBe(2);
+ }
+
+ [Fact(DisplayName = "Should exclude elements with hidden attribute by default")]
+ public void Test102()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Button);
+
+ elements.Count.ShouldBe(1);
+ elements[0].TextContent.ShouldBe("Visible");
+ }
+
+ [Fact(DisplayName = "Should filter by pressed state")]
+ public void Test110()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o => o.Pressed = true);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Pressed");
+ }
+
+ [Fact(DisplayName = "Should filter by expanded state")]
+ public void Test120()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o => o.Expanded = true);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Expanded");
+ }
+
+ [Fact(DisplayName = "Should detect expanded state from details open attribute")]
+ public void Test121()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+ Open Details
+ Content
+
+
+ Closed Details
+ Content
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Group, o => o.Expanded = true);
+
+ element.ShouldNotBeNull();
+ element.HasAttribute("open").ShouldBeTrue();
+ }
+
+ [Fact(DisplayName = "Should filter by selected state")]
+ public void Test130()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Tab, o => o.Selected = true);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Tab 1");
+ }
+
+ [Fact(DisplayName = "Should filter by busy state")]
+ public void Test140()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o => o.Busy = true);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Loading");
+ }
+
+ [Fact(DisplayName = "Should filter by current state with boolean")]
+ public void Test150()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+ Current
+ About
+ """));
+
+ var element = cut.FindByRole(AriaRole.Link, o => o.Current = true);
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Current");
+ }
+
+ [Fact(DisplayName = "Should filter by current state with token value")]
+ public void Test151()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+ Home
+ About
+ """));
+
+ var element = cut.FindByRole(AriaRole.Link, o => o.Current = "page");
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Home");
+ }
+
+ [Fact(DisplayName = "Should combine multiple filters")]
+ public void Test160()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Button, o =>
+ {
+ o.Pressed = true;
+ o.Name = "Bold";
+ });
+
+ element.ShouldNotBeNull();
+ element.TextContent.ShouldBe("Bold");
+ }
+
+ [Fact(DisplayName = "Should find elements by combobox role for select")]
+ public void Test170()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Combobox);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("SELECT", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find elements by listbox role for select with multiple")]
+ public void Test171()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Listbox);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("SELECT", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find img element by img role")]
+ public void Test180()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Img);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("alt").ShouldBe("Test image");
+ }
+
+ [Fact(DisplayName = "Should find img with empty alt as presentation role")]
+ public void Test181()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Presentation);
+
+ element.ShouldNotBeNull();
+ }
+
+ [Fact(DisplayName = "Should filter img by accessible name from alt")]
+ public void Test182()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Img, o => o.Name = "Company Logo");
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("src").ShouldBe("logo.png");
+ }
+
+ [Fact(DisplayName = "Should find table element by table role")]
+ public void Test190()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
Header
+
Cell
+
+ """));
+
+ var element = cut.FindByRole(AriaRole.Table);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("TABLE", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find row elements by row role")]
+ public void Test191()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
Row 1
+
Row 2
+
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Row);
+
+ elements.Count.ShouldBe(2);
+ }
+
+ [Fact(DisplayName = "Should find cell elements by cell role")]
+ public void Test192()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""
+
+
Cell 1
Cell 2
+
+ """));
+
+ var elements = cut.FindAllByRole(AriaRole.Cell);
+
+ elements.Count.ShouldBe(2);
+ }
+
+ [Fact(DisplayName = "Should find progress element by progressbar role")]
+ public void Test200()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Progressbar);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("PROGRESS", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find meter element by meter role")]
+ public void Test201()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent("""60%"""));
+
+ var element = cut.FindByRole(AriaRole.Meter);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("METER", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find slider input by slider role")]
+ public void Test202()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Slider);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("range");
+ }
+
+ [Fact(DisplayName = "Should find spinbutton input by spinbutton role")]
+ public void Test203()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Spinbutton);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("number");
+ }
+
+ [Fact(DisplayName = "Should find dialog element by dialog role")]
+ public void Test210()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Dialog);
+
+ element.ShouldNotBeNull();
+ element.TagName.ShouldBe("DIALOG", StringCompareShould.IgnoreCase);
+ }
+
+ [Fact(DisplayName = "Should find search input by searchbox role")]
+ public void Test220()
+ {
+ var cut = Render(ps =>
+ ps.AddChildContent(""""""));
+
+ var element = cut.FindByRole(AriaRole.Searchbox);
+
+ element.ShouldNotBeNull();
+ element.GetAttribute("type").ShouldBe("search");
+ }
+}