From fbaebfb19c6926e6e2b6e4997f1f36a5cd0c9be5 Mon Sep 17 00:00:00 2001 From: Scott Sauber Date: Tue, 24 Mar 2026 15:33:44 -0700 Subject: [PATCH 1/3] feat: add findbyrole --- src/bunit.web.query/ByRoleElementFactory.cs | 31 ++ .../Roles/AccessibleNameCalculator.cs | 130 ++++++ src/bunit.web.query/Roles/AriaRole.cs | 183 ++++++++ src/bunit.web.query/Roles/ByRoleOptions.cs | 66 +++ .../Roles/ImplicitRoleMapping.cs | 94 ++++ .../Roles/RoleNotFoundException.cs | 53 +++ .../Roles/RoleQueryExtensions.cs | 350 +++++++++++++++ .../BlazorE2E/RoleQueryCounter.razor | 9 + .../Roles/RoleQueryExtensionsTest.cs | 405 ++++++++++++++++++ 9 files changed, 1321 insertions(+) create mode 100644 src/bunit.web.query/ByRoleElementFactory.cs create mode 100644 src/bunit.web.query/Roles/AccessibleNameCalculator.cs create mode 100644 src/bunit.web.query/Roles/AriaRole.cs create mode 100644 src/bunit.web.query/Roles/ByRoleOptions.cs create mode 100644 src/bunit.web.query/Roles/ImplicitRoleMapping.cs create mode 100644 src/bunit.web.query/Roles/RoleNotFoundException.cs create mode 100644 src/bunit.web.query/Roles/RoleQueryExtensions.cs create mode 100644 tests/bunit.testassets/BlazorE2E/RoleQueryCounter.razor create mode 100644 tests/bunit.web.query.tests/Roles/RoleQueryExtensionsTest.cs 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