From 4c686c0fc604c6ec8b09245068362d72ed9dc296 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Mon, 8 Dec 2025 13:49:40 +0000 Subject: [PATCH 01/11] Set version to '2.3-preview' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index b814203cf..1307d2a4b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.2-preview", + "version": "2.3-preview", "assemblyVersion": { "precision": "revision" }, From 45a1a7cb250ead1cb8ec82afddeb644f4746f875 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 30 Nov 2025 17:22:15 +0100 Subject: [PATCH 02/11] 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 29d8a7e24..21c29280e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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{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). ### Fixed 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) From 0ca8d95e8ba2da29a50575400124c4cf2b625906 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Tue, 9 Dec 2025 21:02:44 +0100 Subject: [PATCH 03/11] chore: Upgrade packages --- Directory.Packages.props | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d99488398..bf8561613 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,29 +29,29 @@ - + - - + + - - - + + + - - - + + + - - - - - - + + + + + + From b149e7f79afded3ceaa960379249a012a3f378b4 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Tue, 9 Dec 2025 21:07:19 +0100 Subject: [PATCH 04/11] fix: Correct position for new entry --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c29280e..d5da26aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,16 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Added + +- 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). + ## [2.2.2] - 2025-12-08 ### Added - Added `FindByAllByLabel` to `bunit.web.query` package. By [@linkdotnet](https://github.com/linkdotnet). -- 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). ### Fixed From b6f59fd8ad43d9344f9da57f563ac269237ee207 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 12 Dec 2025 09:12:30 +0100 Subject: [PATCH 05/11] feat: Update version increment options to include "build" --- .github/workflows/prepare-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 7fc66628c..bfdb1f216 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: inputs: versionIncrement: - description: 'The version increment. Allowed values are "major" and "minor".' + description: 'The version increment. Allowed values are "major", "minor" and "build".' required: true default: 'minor' @@ -13,7 +13,7 @@ jobs: prepare-release: name: ๐Ÿšš Prepare new release runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && contains(fromJson('["major","minor"]'), github.event.inputs.versionIncrement) + if: github.ref == 'refs/heads/main' && contains(fromJson('["major","minor","build"]'), github.event.inputs.versionIncrement) steps: - name: ๐Ÿ›’ Checkout repository uses: actions/checkout@v6 From 9ca6e6b6495dc1c2c42dd474f69b1d09883431b4 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 12 Dec 2025 20:53:51 +0100 Subject: [PATCH 06/11] refactor: Use preselect --- .github/workflows/prepare-release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index bfdb1f216..0b325a942 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -6,7 +6,12 @@ on: inputs: versionIncrement: description: 'The version increment. Allowed values are "major", "minor" and "build".' + type: choice required: true + options: + - major + - minor + -build default: 'minor' jobs: From 17472e0b88f5c65569c9ac26ce84e0fa7bab1119 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 12 Dec 2025 20:54:24 +0100 Subject: [PATCH 07/11] fix: Spacing --- .github/workflows/prepare-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 0b325a942..55c2b4309 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -11,7 +11,7 @@ on: options: - major - minor - -build + - build default: 'minor' jobs: From cd04f5ddf36c3586b36a4f4165e8bff85192661f Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 14 Dec 2025 19:08:16 +0100 Subject: [PATCH 08/11] chore: Update PackageValidationBaselineVersion to v2 --- src/Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 89a13faaa..ed6fe5bd1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -26,7 +26,7 @@ true true - 1.25.3 + 2.0.66 From 720d419cdf1f7a1f8fc944942ad836fbe429741d Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Tue, 16 Dec 2025 12:54:29 +0100 Subject: [PATCH 09/11] fix: Add async overloads for Input and Change methods (fixes #1795) --- CHANGELOG.md | 4 ++++ .../InputEventDispatchExtensions.cs | 22 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5da26aea..89d442c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ 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). +### Fixed + +- Adding convenient overloads for `InputAsync` and `ChangeAsync` to have feature parity with the sync version. Reported by [@ScarletKuro](https://github.com/ScarletKuro). Fixed by [@linkdotnet](https://github.com/linkdotnet). + ## [2.2.2] - 2025-12-08 ### Added diff --git a/src/bunit/EventDispatchExtensions/InputEventDispatchExtensions.cs b/src/bunit/EventDispatchExtensions/InputEventDispatchExtensions.cs index bae8697fc..bfa7d9f70 100644 --- a/src/bunit/EventDispatchExtensions/InputEventDispatchExtensions.cs +++ b/src/bunit/EventDispatchExtensions/InputEventDispatchExtensions.cs @@ -9,7 +9,7 @@ namespace Bunit; public static partial class EventHandlerDispatchExtensions { /// - /// Raises the @onchange event on , passing the provided + /// Raises the @onchange event on , passing the provided /// properties to the event handler via a object. /// /// The element to raise the event on. @@ -18,7 +18,16 @@ public static void Change(this IElement element, T value) => _ = ChangeAsync(element, CreateFrom(value)); /// - /// Raises the @oninput event on , passing the provided + /// Raises the @onchange event on , passing the provided + /// properties to the event handler via a object. + /// + /// The element to raise the event on. + /// The new value. + public static void ChangeAsync(this IElement element, T value) + => _ = ChangeAsync(element, CreateFrom(value)); + + /// + /// Raises the @oninput event on , passing the provided /// properties to the event handler via a object. /// /// The element to raise the event on. @@ -26,6 +35,15 @@ public static void Change(this IElement element, T value) public static void Input(this IElement element, T value) => _ = InputAsync(element, CreateFrom(value)); + /// + /// Raises the @oninput event on , passing the provided + /// properties to the event handler via a object. + /// + /// The element to raise the event on. + /// The new value. + public static void InputAsync(this IElement element, T value) + => _ = InputAsync(element, CreateFrom(value)); + private static ChangeEventArgs CreateFrom(T value) => new() { Value = FormatValue(value) }; private static object? FormatValue(T value) From c6a0b95e12474c495ea79e9cd72678fd3039baf2 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Tue, 16 Dec 2025 13:04:09 +0100 Subject: [PATCH 10/11] docs: Update migration and remove MIGRATION.md --- MIGRATION.md | 107 ------------------------------ bunit.slnx | 1 - docs/site/docs/migrations/1to2.md | 36 +++++++++- 3 files changed, 35 insertions(+), 109 deletions(-) delete mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index 1c1c3a358..000000000 --- a/MIGRATION.md +++ /dev/null @@ -1,107 +0,0 @@ -# Migration Guide `v1` to `v2` -This document describes the changes that need to be made to migrate from bUnit 1.x to 2.x. - -## Removal of `GetChangesSinceFirstRender` and `GetChangesSinceLastRender` methods -The `GetChangesSinceFirstRender` and `GetChangesSinceLastRender` methods have been removed from `RenderedComponent`. There is no one-to-one replacement for these methods, but the general idea is to select the HTML in question via `Find` and assert against that. - -Alternatively, the `RenderedFragment` still offers the `OnMarkupUpdated` event, which can be used to assert against the markup after a render. - -## Removal of `IsNullOrEmpty` extension method on `IEnumerable` and `CreateLogger` on `IServiceProvider` -The `IsNullOrEmpty` extension method on `IEnumerable` has been removed, as well as the `CreateLogger` extension method on `IServiceProvider`. These extension methods are pretty common and conflict with other libraries. These methods can be recreated like this: - -```csharp -public static class Extensions -{ - public static bool IsNullOrEmpty(this IEnumerable enumerable) - => enumerable == null || !enumerable.Any(); - - public static ILogger CreateLogger(this IServiceProvider serviceProvider) - { - var loggerFactory = serviceProvider.GetRequiredService() ?? NullLoggerFactory.Instance; - return loggerFactory.CreateLogger(); - } -} -``` - -## Merge of `bunit.core` and `bunit.web` -The `bunit.core` and `bunit.web` packages have been merged into a single `bunit` package. If you used either of these packages, you should remove them and install the `bunit` package instead. - -## Removal of unneeded abstraction - -### `IRenderedComponentBase` and `RenderedFragmentBase` -`IRenderedComponentBase`, `IRenderedComponent`, `RenderedFragmentBase`, `RenderedFragment` and `RenderedFragmentBase` have been removed. -If you used either of these types, you should replace them with `RenderedComponent` or `RenderedFragment` respectively. - -### `WebTestRender` merged into `BunitTestRender` -The `WebTestRender` class has been merged into the `TestRender` class. If you used `WebTestRender`, you should replace it with `BunitTestRender`. - -## Renamed `Fake` to `Bunit` in many test doubles -The `Fake` prefix has been replaced with `Bunit` in many test doubles. For example, `FakeNavigationManager` is now `BunitNavigationManager`. If you reference any of these types explicitly, you need to update your code. - -### Renamed `AddTestAuthorization` to `AddAuthorization` -The `AddTestAuthorization` method on `BunitContext` has been renamed to `AddAuthorization`. If you used `AddTestAuthorization`, you should replace it with `AddAuthorization`. - -## Merged `BunitContext` and `BunitContextBase` -The `BunitContext` and `BunitContextBase` classes have been merged into a single `BunitContext` class. All references to `BunitContextBase` should replace them with `BunitContext` to migrate. - -## Renamed all `RenderComponent` and `SetParametersAndRender` to `Render` -To make the API more consistent, `RenderComponent` and `SetParametersAndRender` methods have been renamed to `Render`. - -## Removal of `ComponentParameter` and method using them -Using `ComponentParameter` and factory methods to create them is not recommend in V1 and have now been removed in V2. Instead, use the strongly typed builder pattern that enables you to pass parameters to components you render. - -## `BunitContext` implements `IDisposable` and `IAsyncDisposable` -The `BunitContext` now implements `IDisposable` and `IAsyncDisposable`. In version 1.x, `BunitContext` only implemented `IDisposable` and cleaned up asynchronous objects in the synchronous `Dispose` method. This is no longer the case, and asynchronous objects are now cleaned up in the `DisposeAsync` method. -If you register services into the container that implement `IAsyncDisposable` make sure that the test framework calls the right method. - -## `TestContext` was renamed to `BunitContext` -The `TestContext` class has been renamed to `BunitContext`. If you used `TestContext`, you should replace it with `BunitContext`. - -## `TestContextWrapper` was removed -The `TestContextWrapper` class has been removed. Either use lifecycle events of the testing framework (like `LifeCycle.InstancePerTestCase` in NUnit). -```csharp -[FixtureLifeCycle(LifeCycle.InstancePerTestCase)] -public class HelloWorldInstancePerTestCase : Bunit.TestContext -{ - [Test] - public void HelloWorldComponentRendersCorrectly() - { - // Act - var cut = RenderComponent(); - - // Assert - cut.MarkupMatches("

Hello world from Blazor

"); - } -} -``` - -Or use the `BunitContext` directly and manage the lifecycle yourself. - -## `TestServiceProvider` renamed to `BunitTestServiceProvider` -The `TestServiceProvider` class has been renamed to `BunitTestServiceProvider`. If you used `TestServiceProvider`, you should replace it with `BunitTestServiceProvider`. - -## `DisposeComponents` is now asynchronous and called `DisposeComponentsAsync` -`DisposeComponentsAsync` allows to await `DisposeAsync` of components under test. If you used `DisposeComponents`, you should replace it with `DisposeComponentsAsync`. - -## `IRefreshableElementCollection` was removed - -The `IRefreshableElementCollection` interface has been removed. With this the `FindAll` method does not accept a `bool enableRefresh` parameter anymore. Code like this: - -```csharp -var items = cut.FindAll("li", enableRefresh: true); - -cut.Find("button").Click(); // Some action that causes items to change - -Assert.Equal(3, items.Count); -``` - -Should be changed to this: - -```csharp -var items = cut.FindAll("li"); - -cut.Find("button").Click(); // Some action that causes items to change - -items = cut.FindAll("li"); // Re-query the items -Assert.Equal(3, items.Count); -``` \ No newline at end of file diff --git a/bunit.slnx b/bunit.slnx index 7294d3ede..8c453b444 100644 --- a/bunit.slnx +++ b/bunit.slnx @@ -11,7 +11,6 @@ - diff --git a/docs/site/docs/migrations/1to2.md b/docs/site/docs/migrations/1to2.md index 16bb88e9d..0555f58a2 100644 --- a/docs/site/docs/migrations/1to2.md +++ b/docs/site/docs/migrations/1to2.md @@ -78,4 +78,38 @@ cut.Find("button").Click(detail: 2, ctrlKey: true); The last one was a method with all parameters of `MouseEventArgs` as optional parameters. This method has been removed in favor of using the `MouseEventArgs` directly. -Also `ClickAsync` - to align with its synchronous counterpart - doesn't take `MouseEventArgs` as mandatory parameter anymore. If not set, a default instance will be created. \ No newline at end of file +Also `ClickAsync` - to align with its synchronous counterpart - doesn't take `MouseEventArgs` as mandatory parameter anymore. If not set, a default instance will be created. + +## `DisposeComponents` is now async and called `DisposeComponentsAsync` + +The `DisposeComponents` method has been renamed to `DisposeComponentsAsync` and is now asynchronous. To migrate, simply rename the method and add `await`: + +```diff +- DisposeComponents(); ++ await DisposeComponentsAsync(); +``` + +##ย The `ComponentParameterFactory` and `ComponentParameter` has been removed +The `ComponentParameterFactory` class has been removed (and therefore the usage of `ComponentParameter`). +Instead, use the `Render` method (and its overloads) to pass parameters to components. + +## `IRefreshableElementCollection` was removed +The `IRefreshableElementCollection` interface has been removed. With that, the overload in `FindAll` doesn't accept a `bool refresh` parameter anymore. Instead, simply call `FindAll` again to get a refreshed collection. + +```csharp +var items = cut.FindAll("li", refresh: true); +items.Count.ShouldBe(3); +cut.Find("button").Click(); // This changes the list items + +items.Count.ShouldBe(4); +``` + +Should be changed to: + +```csharp +var items = cut.FindAll("li"); +items.Count.ShouldBe(3); +cut.Find("button").Click(); // This changes the list items +items = cut.FindAll("li"); // Call FindAll again to refresh +items.Count.ShouldBe(4); +``` \ No newline at end of file From b1628eb41c07ea59b2021db69887c6d5e3bb45ad Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Thu, 18 Dec 2025 08:03:51 +0000 Subject: [PATCH 11/11] Set version to '2.3' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 1307d2a4b..1c599a00b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.3-preview", + "version": "2.3", "assemblyVersion": { "precision": "revision" },