diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aad902f8..0f4d2ed8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Added + +- `FindByRole` and `FindAllByRole` to `bunit.web.query` to find elements by their WAI-ARIA role, mirroring Playwright's `GetByRole` API. Supports implicit roles from semantic HTML (e.g. button, anchor with href, heading elements), explicit `role` attributes, and filtering by accessible name, level, checked, pressed, selected, expanded, and disabled states. + ## [2.6.2] - 2026-02-27 ### Added diff --git a/docs/site/docs/verification/verify-markup.md b/docs/site/docs/verification/verify-markup.md index 5628fa2b3..67ced1a1f 100644 --- a/docs/site/docs/verification/verify-markup.md +++ b/docs/site/docs/verification/verify-markup.md @@ -9,7 +9,7 @@ Generally, the strategy for verifying markup produced by components depends on w With a **reusable component library**, the markup produced may be considered part of the externally observable behavior of the component, and that should thus be verified, since users of the component may depend on the markup having a specific structure. Consider using `MarkupMatches` and semantic comparison described below to get the best protection against regressions and good maintainability. -When **building components for a Blazor app**, the externally observable behavior of components are how they visibly look and behave from an end-users point of view, e.g. what the user sees and interact with in a browser. In this scenario, consider use `FindByLabelText` and related methods described below to inspect and assert against individual elements look and feel, for a good balance between protection against regressions and maintainability. Learn more about this testing approach at https://testing-library.com. +When **building components for a Blazor app**, the externally observable behavior of components are how they visibly look and behave from an end-users point of view, e.g. what the user sees and interact with in a browser. In this scenario, consider using `FindByRole`, `FindByLabelText`, and related methods described below to inspect and assert against individual elements look and feel, for a good balance between protection against regressions and maintainability. Learn more about this testing approach at https://testing-library.com. This page covers the following **verification approaches:** @@ -35,7 +35,8 @@ The rendered markup from a component is available as a DOM node through the ` has role "button") or explicit (via the `role` attribute). Supports filtering by accessible name, heading level, and states like checked, pressed, selected, expanded, and disabled. Throws an exception if none are found. `FindAllByRole` returns all matches. This is the recommended query method for accessing non-form fields, following the [testing-library.com](https://testing-library.com/docs/queries/byrole/) approach. (Included in the library [bunit.web.query](https://www.nuget.org/packages/bunit.web.query).) +- `FindByLabelText(string labelText)` that takes a text string to retrieve an element by its label and returns an `IElement` as output, or throws an exception if none are found which is an accessibility violation (this is included in the library [bunit.web.query](https://www.nuget.org/packages/bunit.web.query)). This is recommended for accessing form elements. - [`Find(string cssSelector)`](xref:Bunit.RenderedComponentExtensions.Find``1(Bunit.IRenderedComponent{``0},System.String)) takes a "CSS selector" as input and returns an `IElement` as output, or throws an exception if none are found. - [`FindAll(string cssSelector)`](xref:Bunit.RenderedComponentExtensions.FindAll``1(Bunit.IRenderedComponent{``0},System.String)) takes a "CSS selector" as input and returns a list of `IElement` elements. diff --git a/src/bunit.web.query/ByRoleElementFactory.cs b/src/bunit.web.query/ByRoleElementFactory.cs new file mode 100644 index 000000000..153efd344 --- /dev/null +++ b/src/bunit.web.query/ByRoleElementFactory.cs @@ -0,0 +1,31 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit; + +internal sealed class ByRoleElementFactory : IElementWrapperFactory +{ + private readonly IRenderedComponent testTarget; + private readonly AriaRole role; + private readonly ByRoleOptions options; + + public Action? OnElementReplaced { get; set; } + + public ByRoleElementFactory(IRenderedComponent testTarget, AriaRole role, ByRoleOptions options) + { + this.testTarget = testTarget; + this.role = role; + this.options = options; + testTarget.OnMarkupUpdated += FragmentsMarkupUpdated; + } + + private void FragmentsMarkupUpdated(object? sender, EventArgs args) + => OnElementReplaced?.Invoke(); + + public TElement GetElement() where TElement : class, IElement + { + var element = testTarget.FindByRoleInternal(role, options) as TElement; + + return element ?? throw new ElementRemovedFromDomException(role.ToRoleString()); + } +} diff --git a/src/bunit.web.query/Roles/AccessibleNameCalculator.cs b/src/bunit.web.query/Roles/AccessibleNameCalculator.cs new file mode 100644 index 000000000..33f8dce83 --- /dev/null +++ b/src/bunit.web.query/Roles/AccessibleNameCalculator.cs @@ -0,0 +1,130 @@ +using AngleSharp.Dom; + +namespace Bunit.Roles; + +internal static class AccessibleNameCalculator +{ + internal static string? GetAccessibleName(IElement element, INodeList rootNodes) + { + // 1. aria-labelledby — resolve each referenced ID, join with spaces + var labelledBy = element.GetAttribute("aria-labelledby"); + if (!string.IsNullOrEmpty(labelledBy)) + { + var ids = labelledBy.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var parts = new List(); + foreach (var id in ids) + { + var referenced = rootNodes.TryQuerySelector($"#{id}"); + if (referenced is not null) + { + var text = referenced.TextContent.Trim(); + if (!string.IsNullOrEmpty(text)) + parts.Add(text); + } + } + + if (parts.Count > 0) + return string.Join(" ", parts); + } + + // 2. aria-label attribute + var ariaLabel = element.GetAttribute("aria-label"); + if (!string.IsNullOrEmpty(ariaLabel)) + return ariaLabel.Trim(); + + // 3. For IMG, AREA, INPUT[type=image]: alt attribute + var isImageInput = element.NodeName == "INPUT" && + string.Equals(element.GetAttribute("type"), "image", StringComparison.OrdinalIgnoreCase); + if (element.NodeName is "IMG" or "AREA" || isImageInput) + { + var alt = element.GetAttribute("alt"); + if (!string.IsNullOrEmpty(alt)) + return alt.Trim(); + } + + // 4. For form controls: associated