From 83d0a353a06bbf2e0555ebd3aad689f47967c4fa Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 30 Nov 2025 17:22:15 +0100 Subject: [PATCH] feat: Add generic overloads for Find and WaitFor methods to support specific element types --- CHANGELOG.md | 2 + .../Extensions/RenderedComponentExtensions.cs | 38 ++- ...tWaitForHelperExtensions.WaitForElement.cs | 231 +++++++++++++++++- .../WaitForHelpers/WaitForElementHelper.cs | 17 +- .../WaitForHelpers/WaitForElementsHelper.cs | 17 +- ...nentWaitForElementsHelperExtensionsTest.cs | 52 ++++ .../Rendering/RenderedComponentTest.cs | 43 +++- 7 files changed, 386 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b3c5bb5..f9d26df20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## Added - Added `FindByAllByLabel` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet). +- Added generic overloads `Find` and `FindAll` to query for specific element types (e.g., `IHtmlInputElement`). By [@linkdotnet](https://github.com/linkdotnet). +- Added generic overloads `WaitForElement` and `WaitForElements` to wait for specific element types. By [@linkdotnet](https://github.com/linkdotnet). ## [2.1.1] - 2025-11-21 diff --git a/src/bunit/Extensions/RenderedComponentExtensions.cs b/src/bunit/Extensions/RenderedComponentExtensions.cs index 199ae3316..55a85d27b 100644 --- a/src/bunit/Extensions/RenderedComponentExtensions.cs +++ b/src/bunit/Extensions/RenderedComponentExtensions.cs @@ -18,6 +18,21 @@ public static class RenderedComponentExtensions /// The group of selectors to use. public static IElement Find(this IRenderedComponent renderedComponent, string cssSelector) where TComponent : IComponent + => Find(renderedComponent, cssSelector); + + /// + /// Returns the first element of type from the rendered fragment or component under test, + /// using the provided , in a depth-first pre-order traversal + /// of the rendered nodes. + /// + /// The type of the component under test. + /// The type of element to find (e.g., IHtmlInputElement). + /// The rendered fragment to search. + /// The group of selectors to use. + /// Thrown if no element matches the . + public static TElement Find(this IRenderedComponent renderedComponent, string cssSelector) + where TComponent : IComponent + where TElement : class, IElement { ArgumentNullException.ThrowIfNull(renderedComponent); @@ -26,7 +41,11 @@ public static IElement Find(this IRenderedComponent rend if (result is null) throw new ElementNotFoundException(cssSelector); - return result.WrapUsing(new CssSelectorElementFactory((IRenderedComponent)renderedComponent, cssSelector)); + if (result is not TElement) + throw new ElementNotFoundException( + $"The element matching '{cssSelector}' is of type '{result.GetType().Name}', not '{typeof(TElement).Name}'."); + + return (TElement)result.WrapUsing(new CssSelectorElementFactory((IRenderedComponent)renderedComponent, cssSelector)); } /// @@ -39,10 +58,25 @@ public static IElement Find(this IRenderedComponent rend /// An , that can be refreshed to execute the search again. public static IReadOnlyList FindAll(this IRenderedComponent renderedComponent, string cssSelector) where TComponent : IComponent + => FindAll(renderedComponent, cssSelector); + + /// + /// Returns a collection of elements of type from the rendered fragment or component under test, + /// using the provided , in a depth-first pre-order traversal + /// of the rendered nodes. Only elements matching the type are returned. + /// + /// The type of the component under test. + /// The type of elements to find (e.g., IHtmlInputElement). + /// The rendered fragment to search. + /// The group of selectors to use. + /// An containing only elements matching the specified type. + public static IReadOnlyList FindAll(this IRenderedComponent renderedComponent, string cssSelector) + where TComponent : IComponent + where TElement : class, IElement { ArgumentNullException.ThrowIfNull(renderedComponent); - return renderedComponent.Nodes.QuerySelectorAll(cssSelector).ToArray(); + return renderedComponent.Nodes.QuerySelectorAll(cssSelector).OfType().ToArray(); } /// diff --git a/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForElement.cs b/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForElement.cs index 9e1e56a14..454778f74 100644 --- a/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForElement.cs +++ b/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForElement.cs @@ -33,6 +33,37 @@ public static IElement WaitForElement(this IRenderedComponent WaitForElementCore(renderedComponent, cssSelector, timeout: timeout); + /// + /// Wait until an element of type matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The type of the component under test. + /// The type of element to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for the element. + /// Thrown if no elements is found matching the within the default timeout. See the inner exception for details. + /// The . + public static TElement WaitForElement(this IRenderedComponent renderedComponent, string cssSelector) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementCore(renderedComponent, cssSelector, timeout: null); + + /// + /// Wait until an element of type matching the exists in the , + /// or the is reached. + /// + /// The type of the component under test. + /// The type of element to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for the element. + /// The maximum time to wait for the element to appear. + /// Thrown if no elements is found matching the within the default timeout. See the inner exception for details. + /// The . + public static TElement WaitForElement(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan timeout) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementCore(renderedComponent, cssSelector, timeout: timeout); + /// /// Wait until at least one element matching the exists in the , /// or the timeout is reached (default is one second). @@ -83,6 +114,70 @@ public static IReadOnlyList WaitForElements(this IRendered where TComponent : IComponent => WaitForElementsCore(renderedComponent, cssSelector, matchElementCount: matchElementCount, timeout: timeout); + /// + /// Wait until at least one element of type matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static IReadOnlyList WaitForElements(this IRenderedComponent renderedComponent, string cssSelector) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCore(renderedComponent, cssSelector, matchElementCount: null, timeout: null); + + /// + /// Wait until exactly element(s) of type matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The exact number of elements to that the should match. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static IReadOnlyList WaitForElements(this IRenderedComponent renderedComponent, string cssSelector, int matchElementCount) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCore(renderedComponent, cssSelector, matchElementCount: matchElementCount, timeout: null); + + /// + /// Wait until at least one element of type matching the exists in the , + /// or the is reached. + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The maximum time to wait for elements to appear. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static IReadOnlyList WaitForElements(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan timeout) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCore(renderedComponent, cssSelector, matchElementCount: null, timeout: timeout); + + /// + /// Wait until exactly element(s) of type matching the exists in the , + /// or the is reached. + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The exact number of elements to that the should match. + /// The maximum time to wait for elements to appear. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static IReadOnlyList WaitForElements(this IRenderedComponent renderedComponent, string cssSelector, int matchElementCount, TimeSpan timeout) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCore(renderedComponent, cssSelector, matchElementCount: matchElementCount, timeout: timeout); + /// /// Wait until an element matching the exists in the , /// or the timeout is reached (default is one second). @@ -107,6 +202,37 @@ public static Task WaitForElementAsync(this IRenderedCompo where TComponent : IComponent => WaitForElementCoreAsync(renderedComponent, cssSelector, timeout: timeout); + /// + /// Wait until an element of type matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The type of the component under test. + /// The type of element to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for the element. + /// Thrown if no elements is found matching the within the default timeout. See the inner exception for details. + /// The . + public static Task WaitForElementAsync(this IRenderedComponent renderedComponent, string cssSelector) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementCoreAsync(renderedComponent, cssSelector, timeout: null); + + /// + /// Wait until an element of type matching the exists in the , + /// or the is reached. + /// + /// The type of the component under test. + /// The type of element to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for the element. + /// The maximum time to wait for the element to appear. + /// Thrown if no elements is found matching the within the default timeout. See the inner exception for details. + /// The . + public static Task WaitForElementAsync(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan timeout) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementCoreAsync(renderedComponent, cssSelector, timeout: timeout); + /// /// Wait until exactly element(s) matching the exists in the , /// or the timeout is reached (default is one second). @@ -157,12 +283,80 @@ public static Task> WaitForElementsAsync(thi /// The . public static Task> WaitForElementsAsync(this IRenderedComponent renderedComponent, string cssSelector) where TComponent : IComponent - => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount: null, timeout: null); + => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount: null, timeout: null); + /// + /// Wait until at least one element of type matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static Task> WaitForElementsAsync(this IRenderedComponent renderedComponent, string cssSelector) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount: null, timeout: null); + + /// + /// Wait until exactly element(s) of type matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The exact number of elements to that the should match. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static Task> WaitForElementsAsync(this IRenderedComponent renderedComponent, string cssSelector, int matchElementCount) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount: matchElementCount, timeout: null); + + /// + /// Wait until at least one element of type matching the exists in the , + /// or the is reached. + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The maximum time to wait for elements to appear. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static Task> WaitForElementsAsync(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan timeout) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount: null, timeout: timeout); + + /// + /// Wait until exactly element(s) of type matching the exists in the , + /// or the is reached. + /// + /// The type of the component under test. + /// The type of elements to wait for (e.g., IHtmlInputElement). + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The exact number of elements to that the should match. + /// The maximum time to wait for elements to appear. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + public static Task> WaitForElementsAsync(this IRenderedComponent renderedComponent, string cssSelector, int matchElementCount, TimeSpan timeout) + where TComponent : IComponent + where TElement : class, IElement + => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount: matchElementCount, timeout: timeout); private static IElement WaitForElementCore(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan? timeout) where TComponent : IComponent + => WaitForElementCore(renderedComponent, cssSelector, timeout); + + private static TElement WaitForElementCore(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan? timeout) + where TComponent : IComponent + where TElement : class, IElement { - using var waiter = new WaitForElementHelper(renderedComponent, cssSelector, timeout); + using var waiter = new WaitForElementHelper(renderedComponent, cssSelector, timeout); try { @@ -177,10 +371,15 @@ private static IElement WaitForElementCore(this IRenderedComponent WaitForElementCoreAsync(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan? timeout) + private static Task WaitForElementCoreAsync(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan? timeout) + where TComponent : IComponent + => WaitForElementCoreAsync(renderedComponent, cssSelector, timeout); + + private static async Task WaitForElementCoreAsync(this IRenderedComponent renderedComponent, string cssSelector, TimeSpan? timeout) where TComponent : IComponent + where TElement : class, IElement { - using var waiter = new WaitForElementHelper(renderedComponent, cssSelector, timeout); + using var waiter = new WaitForElementHelper(renderedComponent, cssSelector, timeout); return await waiter.WaitTask; } @@ -191,8 +390,17 @@ private static IReadOnlyList WaitForElementsCore( int? matchElementCount, TimeSpan? timeout) where TComponent : IComponent + => WaitForElementsCore(renderedComponent, cssSelector, matchElementCount, timeout); + + private static IReadOnlyList WaitForElementsCore( + this IRenderedComponent renderedComponent, + string cssSelector, + int? matchElementCount, + TimeSpan? timeout) + where TComponent : IComponent + where TElement : class, IElement { - using var waiter = new WaitForElementsHelper(renderedComponent, cssSelector, matchElementCount, timeout); + using var waiter = new WaitForElementsHelper(renderedComponent, cssSelector, matchElementCount, timeout); try { @@ -207,14 +415,23 @@ private static IReadOnlyList WaitForElementsCore( } } - private static async Task> WaitForElementsCoreAsync( + private static Task> WaitForElementsCoreAsync( + this IRenderedComponent renderedComponent, + string cssSelector, + int? matchElementCount, + TimeSpan? timeout) + where TComponent : IComponent + => WaitForElementsCoreAsync(renderedComponent, cssSelector, matchElementCount, timeout); + + private static async Task> WaitForElementsCoreAsync( this IRenderedComponent renderedComponent, string cssSelector, int? matchElementCount, TimeSpan? timeout) where TComponent : IComponent + where TElement : class, IElement { - using var waiter = new WaitForElementsHelper(renderedComponent, cssSelector, matchElementCount, timeout); + using var waiter = new WaitForElementsHelper(renderedComponent, cssSelector, matchElementCount, timeout); return await waiter.WaitTask; } diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForElementHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForElementHelper.cs index 316ead9d1..70fd9cf35 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForElementHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForElementHelper.cs @@ -5,8 +5,21 @@ namespace Bunit.Extensions.WaitForHelpers; /// /// Represents an async wait helper, that will wait for a specified time for an element to become available in the DOM. /// -internal class WaitForElementHelper : WaitForHelper +internal class WaitForElementHelper : WaitForElementHelper where TComponent : IComponent +{ + public WaitForElementHelper(IRenderedComponent renderedComponent, string cssSelector, TimeSpan? timeout = null) + : base(renderedComponent, cssSelector, timeout) + { + } +} + +/// +/// Represents an async wait helper, that will wait for a specified time for an element of type to become available in the DOM. +/// +internal class WaitForElementHelper : WaitForHelper + where TComponent : IComponent + where TElement : class, IElement { internal const string TimeoutBeforeFoundMessage = "The CSS selector and/or predicate did not result in a matching element before the timeout period passed."; @@ -19,7 +32,7 @@ internal class WaitForElementHelper : WaitForHelper renderedComponent, string cssSelector, TimeSpan? timeout = null) : base(renderedComponent, () => { - var element = renderedComponent.Find(cssSelector); + var element = renderedComponent.Find(cssSelector); return (true, element); }, timeout) { diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForElementsHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForElementsHelper.cs index e53ab8dfb..ec50c9fd4 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForElementsHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForElementsHelper.cs @@ -7,8 +7,21 @@ namespace Bunit.Extensions.WaitForHelpers; /// /// Represents an async wait helper, that will wait for a specified time for element(s) to become available in the DOM. /// -internal class WaitForElementsHelper : WaitForHelper, TComponent> +internal class WaitForElementsHelper : WaitForElementsHelper where TComponent : IComponent +{ + public WaitForElementsHelper(IRenderedComponent renderedComponent, string cssSelector, int? matchElementCount, TimeSpan? timeout = null) + : base(renderedComponent, cssSelector, matchElementCount, timeout) + { + } +} + +/// +/// Represents an async wait helper, that will wait for a specified time for element(s) of type to become available in the DOM. +/// +internal class WaitForElementsHelper : WaitForHelper, TComponent> + where TComponent : IComponent + where TElement : class, IElement { internal const string TimeoutBeforeFoundMessage = "The CSS selector did not result in any matching element(s) before the timeout period passed."; internal static readonly CompositeFormat TimeoutBeforeFoundWithCountMessage = CompositeFormat.Parse("The CSS selector did not result in exactly {0} matching element(s) before the timeout period passed."); @@ -25,7 +38,7 @@ internal class WaitForElementsHelper : WaitForHelper renderedComponent, string cssSelector, int? matchElementCount, TimeSpan? timeout = null) : base(renderedComponent, () => { - var elements = renderedComponent.FindAll(cssSelector); + var elements = renderedComponent.FindAll(cssSelector); var checkPassed = matchElementCount is null ? elements.Count > 0 diff --git a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForElementsHelperExtensionsTest.cs b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForElementsHelperExtensionsTest.cs index e7f4b844b..295fdf844 100644 --- a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForElementsHelperExtensionsTest.cs +++ b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForElementsHelperExtensionsTest.cs @@ -1,4 +1,5 @@ using System.Globalization; +using AngleSharp.Html.Dom; namespace Bunit.Extensions.WaitForHelpers; @@ -97,4 +98,55 @@ public void Test025() elms.ShouldBeEmpty(); } + + [Fact(DisplayName = "WaitForElement waits until element of specified type matching cssSelector appears")] + [Trait("Category", "sync")] + public void Test026() + { + var expectedMarkup = ""; + var cut = Render(ps => ps.AddChildContent(expectedMarkup)); + + var elm = cut.WaitForElement("#myInput"); + + elm.ShouldNotBeNull(); + elm.Type.ShouldBe("text"); + } + + [Fact(DisplayName = "WaitForElement throws exception when element type does not match")] + [Trait("Category", "sync")] + public void Test027() + { + var cut = Render(ps => ps.AddChildContent("
")); + + var expected = Should.Throw(() => + cut.WaitForElement("#myDiv", WaitForTestTimeout)); + + expected.InnerException.ShouldBeOfType(); + } + + [Fact(DisplayName = "WaitForElements waits until elements of specified type matching cssSelector appear")] + [Trait("Category", "sync")] + public void Test028() + { + var expectedMarkup = "
"; + var cut = Render(ps => ps.AddChildContent(expectedMarkup)); + + var elms = cut.WaitForElements("main input"); + + elms.Count.ShouldBe(2); + elms[0].ShouldBeAssignableTo(); + elms[1].ShouldBeAssignableTo(); + } + + [Fact(DisplayName = "WaitForElements with count waits until exactly N elements of specified type appear")] + [Trait("Category", "sync")] + public void Test029() + { + var expectedMarkup = ""; + var cut = Render(ps => ps.AddChildContent(expectedMarkup)); + + var elms = cut.WaitForElements("main input", matchElementCount: 3); + + elms.Count.ShouldBe(3); + } } diff --git a/tests/bunit.tests/Rendering/RenderedComponentTest.cs b/tests/bunit.tests/Rendering/RenderedComponentTest.cs index 2c1866495..bdf04d193 100644 --- a/tests/bunit.tests/Rendering/RenderedComponentTest.cs +++ b/tests/bunit.tests/Rendering/RenderedComponentTest.cs @@ -1,4 +1,5 @@ using AngleSharp.Dom; +using AngleSharp.Html.Dom; using Bunit.Rendering; namespace Bunit; @@ -255,7 +256,47 @@ public void Test025() cut.Instance.Invoked.ShouldBeTrue(); } - + + [Fact(DisplayName = "Find returns element of specified type when it matches")] + public void Test026() + { + var cut = Render(x => x.AddChildContent("")); + + var result = cut.Find("#myInput"); + + result.ShouldNotBeNull(); + result.Type.ShouldBe("text"); + } + + [Fact(DisplayName = "Find throws ElementNotFoundException when no element matches selector")] + public void Test027() + { + var cut = Render(x => x.AddChildContent("
")); + + Should.Throw(() => cut.Find("#nonexistent")); + } + + [Fact(DisplayName = "FindAll returns only elements of specified type")] + public void Test028() + { + var cut = Render(x => x.AddChildContent("
")); + + var results = cut.FindAll("*"); + + results.Count.ShouldBe(2); + results.ShouldAllBe(e => e is IHtmlInputElement); + } + + [Fact(DisplayName = "FindAll returns empty list when no elements match the type")] + public void Test029() + { + var cut = Render(x => x.AddChildContent("
")); + + var results = cut.FindAll("*"); + + results.ShouldBeEmpty(); + } + private class BaseComponent : ComponentBase { protected override void BuildRenderTree(RenderTreeBuilder builder)