diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 294e26e0b..a2ee9a494 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,21 +3,21 @@
"isRoot": true,
"tools": {
"dotnet-serve": {
- "version": "1.10.175",
+ "version": "1.10.194",
"commands": [
"dotnet-serve"
],
"rollForward": false
},
"docfx": {
- "version": "2.78.2",
+ "version": "2.78.5",
"commands": [
"docfx"
],
"rollForward": false
},
"dotnet-dump": {
- "version": "9.0.553101",
+ "version": "9.0.661903",
"commands": [
"dotnet-dump"
],
diff --git a/.editorconfig b/.editorconfig
index cfdab13ec..1fbb86aae 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -479,6 +479,7 @@ csharp_style_pattern_local_over_anonymous_function = false # IDE0039: Use local
# AsyncFixer
# http://www.asyncfixer.com
dotnet_diagnostic.AsyncFixer01.severity = none # AsyncFixer01: Unnecessary async/await usage
+dotnet_diagnostic.AsyncFixer04.severity = none # 'cts' is not awaited inside a using block - the resource may be disposed before the async operation completes. Add 'await' to ensure proper resource lifetime.
# Meziantou
# https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed97f5e09..aa579716e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,7 +35,7 @@ jobs:
create-nuget:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
fetch-depth: 0 # Get all history to allow automatic versioning using MinVer
@@ -50,7 +50,7 @@ jobs:
11.0.x
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
id: nbgv
with:
setAllVars: true
@@ -118,7 +118,7 @@ jobs:
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -153,7 +153,7 @@ jobs:
runs-on: ubuntu-latest
needs: [ create-nuget ]
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
fetch-depth: 0 # Get all history to allow automatic versioning using MinVer
@@ -169,7 +169,7 @@ jobs:
path: ${{ env.NUGET_DIRECTORY }}
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
with:
setAllVars: true
@@ -213,7 +213,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -227,7 +227,7 @@ jobs:
11.0.x
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
id: nbgv
with:
setAllVars: true
@@ -267,9 +267,9 @@ jobs:
if: github.event_name == 'pull_request' && github.repository_owner == 'bunit-dev'
steps:
- name: 'Checkout Repository'
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: 'Dependency Review'
- uses: actions/dependency-review-action@v4
+ uses: actions/dependency-review-action@v5
release-preview:
if: github.event_name == 'workflow_dispatch' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v2')
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 75a18d275..45e50a22c 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -30,7 +30,7 @@ jobs:
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.BUNIT_BOT_TOKEN }}
@@ -50,7 +50,7 @@ jobs:
git config --global commit.gpgsign true
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
id: nbgv
with:
setAllVars: true
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index ed15be861..490fa4934 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -21,7 +21,7 @@ jobs:
if: github.ref == 'refs/heads/main' && contains(fromJson('["major","minor","build"]'), github.event.inputs.versionIncrement)
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.BUNIT_BOT_TOKEN }}
@@ -34,7 +34,7 @@ jobs:
path: ./CHANGELOG.md
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
id: nbgv
with:
setAllVars: true
@@ -60,7 +60,7 @@ jobs:
git config --global commit.gpgsign true
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
with:
setAllVars: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4dd478931..6c5ece3d9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: 🛒 Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
token: ${{ secrets.BUNIT_BOT_TOKEN }}
@@ -54,7 +54,7 @@ jobs:
git config --global commit.gpgsign true
- name: ⚙️ Setup GIT versioning
- uses: dotnet/nbgv@v0.5.1
+ uses: dotnet/nbgv@v0.5.2
id: nbgv
with:
setAllVars: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 141be3761..3783df78d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@ All notable changes to **bUnit** will be documented in this file. The project ad
## [Unreleased]
+### Added
+
+- New overloads to WaitForHelpers to have async assertions and predicates. Reported by [@radmorecameron](https://github.com/radmorecameron) in #1833. Fixed by [@linkdotnet](https://github.com/linkdotnet).
+- `AddAsset` to `BunitContext` to seed the `ResourceAssetCollection` exposed via `ComponentBase.Assets`. Reported by [LasseHerget](https://github.com/LasseHerget) in #1846. Implemented by [@linkdotnet](https://github.com/linkdotnet).
+
## [2.7.2] - 2026-03-31
### Fixed
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9a478743b..9576fa66d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,25 +5,25 @@
-
-
+
+
-
+
-
+
-
-
+
+
-
+
@@ -72,18 +72,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -91,7 +91,7 @@
-
+
@@ -99,15 +99,15 @@
-
+
-
-
+
+
-
-
-
+
+
+
@@ -116,6 +116,6 @@
-
+
diff --git a/docs/site/docs/providing-input/seeding-assets.md b/docs/site/docs/providing-input/seeding-assets.md
new file mode 100644
index 000000000..af523698a
--- /dev/null
+++ b/docs/site/docs/providing-input/seeding-assets.md
@@ -0,0 +1,51 @@
+---
+uid: seeding-assets
+title: Seeding static assets (Assets)
+---
+
+# Seeding static assets (`Assets`)
+
+This article explains how to seed the `Assets` property of components under test in bUnit. This is supported for .NET 9 and later.
+
+Since .NET 9, components can access static assets mapped by [`MapStaticAssets`](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/map-static-files?view=aspnetcore-9.0) through the [`ComponentBase.Assets`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.componentbase.assets?view=aspnetcore-9.0) property, e.g. to resolve fingerprinted URLs:
+
+```razor
+
+```
+
+By default, bUnit's renderer returns an empty [`ResourceAssetCollection`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.resourceassetcollection?view=aspnetcore-9.0), which matches an app that does not use `MapStaticAssets`. The indexer then returns the passed-in key unchanged, i.e. `Assets["img.png"]` returns `"img.png"`, and iterating over `Assets` yields no items.
+
+## Adding assets
+
+Use the `AddAsset` method on `BunitContext` to add assets before rendering a component. Passing a `label` maps the stable asset key to its (fingerprinted) URL:
+
+```csharp
+[Fact]
+public void Image_uses_fingerprinted_url()
+{
+ AddAsset("img.abc123.png", label: "img.png");
+
+ var cut = Render();
+
+ cut.MarkupMatches(@"
");
+}
+```
+
+Assets can also carry additional properties, which components can read when iterating the collection:
+
+```csharp
+[Fact]
+public void Component_lists_subresources()
+{
+ AddAsset("css/app.abc123.css", label: "css/app.css");
+ AddAsset("js/app.def456.js", label: "js/app.js", new ResourceAssetProperty("integrity", "sha256-..."));
+
+ var cut = Render();
+
+ // component iterates Assets and reads each asset's "label" property
+ cut.MarkupMatches(@"css/app.cssjs/app.js");
+}
+```
+
+> [!NOTE]
+> The `"label"` property is the convention `ResourceAssetCollection` uses to build its key → URL mapping, just like `MapStaticAssets` does in production. An asset added without a label is part of the collection when iterating, but the indexer will not map any key to it.
diff --git a/docs/site/docs/toc.md b/docs/site/docs/toc.md
index 2f4bebfea..53fe7e811 100644
--- a/docs/site/docs/toc.md
+++ b/docs/site/docs/toc.md
@@ -8,6 +8,7 @@
## [Controlling the root render tree](xref:root-render-tree)
## [Substituting (mocking) component](xref:substituting-components)
## [Configure 3rd party libraries](xref:configure-3rd-party-libs)
+## [Seeding static assets](xref:seeding-assets)
# [Interaction](xref:interaction)
## [Trigger event handlers](xref:trigger-event-handlers)
diff --git a/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs b/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs
index 81d0039e7..4fc6eeb64 100644
--- a/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs
+++ b/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs
@@ -185,18 +185,16 @@ private static StringBuilder AppendCallParameters(this StringBuilder source, Imm
return source;
}
- private static StringBuilder AppendInheritDoc(this StringBuilder source)
+ private static void AppendInheritDoc(this StringBuilder source)
{
source.AppendLine("\t/// ");
- return source;
}
- private static StringBuilder AppendDefaultAttributes(this StringBuilder source, string tabs = "\t\t")
+ private static void AppendDefaultAttributes(this StringBuilder source, string tabs = "\t\t")
{
source.Append(tabs).AppendLine("[System.Diagnostics.DebuggerHidden]");
source.Append(tabs).AppendLine("[System.Diagnostics.DebuggerStepThrough]");
source.Append(tabs).AppendLine("[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]");
- return source;
}
private static bool IsSpecialMethod(string methodName)
diff --git a/src/bunit.template/template/Company.BlazorTests1.csproj b/src/bunit.template/template/Company.BlazorTests1.csproj
index fe62ba21c..a6870e2ad 100644
--- a/src/bunit.template/template/Company.BlazorTests1.csproj
+++ b/src/bunit.template/template/Company.BlazorTests1.csproj
@@ -18,8 +18,8 @@
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -27,28 +27,28 @@
-
+
all
runtime; build; native; contentfiles; analyzers
-
-
+
+
all
runtime; build; native; contentfiles; analyzers
-
-
+
+
-
-
+
+
diff --git a/src/bunit/BunitContext.cs b/src/bunit/BunitContext.cs
index cb3aea4d9..6783f63c8 100644
--- a/src/bunit/BunitContext.cs
+++ b/src/bunit/BunitContext.cs
@@ -184,6 +184,23 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
{
Renderer.SetRendererInfo(rendererInfo);
}
+
+ ///
+ /// Adds an asset to the that components
+ /// rendered with this can access through their property.
+ ///
+ ///
+ /// Pass a to map a stable asset key to its (fingerprinted) ,
+ /// i.e. AddAsset("img.abc123.png", label: "img.png") makes Assets["img.png"] return img.abc123.png.
+ /// Adding multiple assets with the same label results in an
+ /// when the property is first accessed.
+ ///
+ /// The url of the asset.
+ /// The label of the asset, used as the lookup key by the indexer. Pass to add the asset without a label.
+ /// Additional properties to associate with the asset.
+ [SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")]
+ public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties)
+ => Renderer.AddAsset(url, label, properties);
#endif
///
diff --git a/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs b/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs
index 17e6f2c57..8f66fd195 100644
--- a/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs
+++ b/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs
@@ -56,6 +56,31 @@ public static async Task WaitForStateAsync(this IRenderedComponent
+ /// Wait until the provided asynchronous returns true,
+ /// or the is reached (default is one second).
+ ///
+ /// The is evaluated initially, and then each time
+ /// the renders.
+ ///
+ /// The render fragment or component to attempt to verify state against.
+ /// The asynchronous predicate to invoke after each render, which must return true when the desired state has been reached.
+ /// The maximum time to wait for the desired state.
+ /// Throws if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details.
+ ///
+ /// The predicate is awaited on the renderer's dispatcher. Because awaiting yields the dispatcher,
+ /// a render may interleave at each await point, so the predicate is not guaranteed an atomic
+ /// view of the DOM across awaits. Use this overload when the check itself must await (for
+ /// example an async service or JavaScript interop call); prefer the synchronous
+ /// overload for pure DOM or markup checks.
+ ///
+ public static async Task WaitForStateAsync(this IRenderedComponent renderedComponent, Func> statePredicate, TimeSpan? timeout = null)
+ where TComponent : IComponent
+ {
+ using var waiter = new WaitForStateHelper(renderedComponent, statePredicate, timeout);
+ await waiter.WaitTask;
+ }
+
///
/// Wait until the provided passes (i.e. does not throw an
/// exception), or the is reached (default is one second).
@@ -99,4 +124,29 @@ public static async Task WaitForAssertionAsync(this IRenderedCompone
using var waiter = new WaitForAssertionHelper(renderedComponent, assertion, timeout);
await waiter.WaitTask;
}
+
+ ///
+ /// Wait until the provided asynchronous passes (i.e. does not throw an
+ /// exception), or the is reached (default is one second).
+ ///
+ /// The is attempted initially, and then each time the renders.
+ ///
+ /// The rendered fragment to wait for renders from and assert against.
+ /// The asynchronous verification or assertion to perform.
+ /// The maximum time to attempt the verification.
+ /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception.
+ ///
+ /// The assertion is awaited on the renderer's dispatcher. Because awaiting yields the dispatcher,
+ /// a render may interleave at each await point, so the assertion is not guaranteed an atomic
+ /// view of the DOM across awaits. Use this overload when the assertion itself must await (for
+ /// example an async service or JavaScript interop call); prefer the synchronous
+ /// overload for pure DOM or markup assertions.
+ ///
+ [AssertionMethod]
+ public static async Task WaitForAssertionAsync(this IRenderedComponent renderedComponent, Func assertion, TimeSpan? timeout = null)
+ where TComponent : IComponent
+ {
+ using var waiter = new WaitForAssertionHelper(renderedComponent, assertion, timeout);
+ await waiter.WaitTask;
+ }
}
diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs
index d93663e6b..b1e145862 100644
--- a/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs
+++ b/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs
@@ -37,4 +37,28 @@ public WaitForAssertionHelper(IRenderedComponent renderedComponent,
},
timeout)
{ }
+
+ ///
+ /// Initializes a new instance of the class,
+ /// which will wait until the provided asynchronous passes (i.e. does not throw an
+ /// exception), or the is reached (default is one second).
+ ///
+ /// The is attempted initially, and then each time the renders.
+ ///
+ /// The rendered fragment to wait for renders from and assert against.
+ /// The asynchronous verification or assertion to perform.
+ /// The maximum time to attempt the verification.
+ ///
+ /// If a debugger is attached the timeout is set to , giving the possibility to debug without the timeout triggering.
+ ///
+ public WaitForAssertionHelper(IRenderedComponent renderedComponent, Func assertion, TimeSpan? timeout = null)
+ : base(
+ renderedComponent,
+ async () =>
+ {
+ await assertion();
+ return (true, default);
+ },
+ timeout)
+ { }
}
diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs
index ec6e8e247..d8e5111e1 100644
--- a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs
+++ b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs
@@ -12,12 +12,14 @@ internal abstract class WaitForHelper : IDisposable
where TComponent : IComponent
{
private readonly TaskCompletionSource checkPassedCompletionSource;
- private readonly Func<(bool CheckPassed, T Content)> completeChecker;
+ private readonly Func> completeChecker;
private readonly IRenderedComponent renderedComponent;
private readonly ILogger> logger;
private readonly BunitRenderer renderer;
private readonly Timer? timer;
private bool isDisposed;
+ private bool isChecking;
+ private bool isDirty;
private int checkCount;
private Exception? capturedException;
@@ -44,12 +46,31 @@ internal abstract class WaitForHelper : IDisposable
public Task WaitTask { get; }
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class
+ /// with a synchronous check. The check is wrapped into a completed ,
+ /// so it is still evaluated synchronously (with no render interleaving) on the renderer's dispatcher.
///
protected WaitForHelper(
IRenderedComponent renderedComponent,
Func<(bool CheckPassed, T Content)> completeChecker,
TimeSpan? timeout = null)
+ : this(renderedComponent, WrapSynchronousChecker(completeChecker), timeout)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The is awaited on the renderer's dispatcher after each render.
+ /// A synchronous check (see the other constructor) wraps into an already-completed
+ /// and therefore resumes synchronously without yielding the dispatcher; a genuinely asynchronous check yields the
+ /// dispatcher at each await, so a render may interleave and the check is not guaranteed an atomic view of the DOM.
+ ///
+ protected WaitForHelper(
+ IRenderedComponent renderedComponent,
+ Func> completeChecker,
+ TimeSpan? timeout = null)
{
this.renderedComponent = renderedComponent ?? throw new ArgumentNullException(nameof(renderedComponent));
this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker));
@@ -166,40 +187,87 @@ private void OnAfterRender(object? sender, EventArgs args)
if (isDisposed || WaitTask.IsCompleted)
return;
- try
+ // The check may be asynchronous, so it cannot be awaited inside this synchronous
+ // event handler. Kick it off without blocking the dispatcher. A synchronous check
+ // completes inline (see RunCheckAsync). If a check is already in flight, mark the
+ // state dirty so the running check re-evaluates once it completes, ensuring the
+ // final render is never missed.
+ if (isChecking)
{
- logger.LogCheckingWaitCondition(renderedComponent.ComponentId);
+ isDirty = true;
+ return;
+ }
- var checkResult = completeChecker();
- checkCount++;
- if (checkResult.CheckPassed)
- {
- checkPassedCompletionSource.TrySetResult(checkResult.Content);
- logger.LogCheckCompleted(renderedComponent.ComponentId);
- Dispose();
- }
- else
+ isChecking = true;
+ _ = RunCheckAsync();
+ }
+
+ private async Task RunCheckAsync()
+ {
+ // Runs on the renderer's dispatcher. A synchronous check wraps into an already-completed
+ // ValueTask, so the await below resumes inline without yielding - preserving the atomic,
+ // no-render-interleaving behaviour synchronous checks rely on. A genuinely asynchronous
+ // check yields the dispatcher; the default await captures and resumes on the dispatcher's
+ // synchronization context, so isChecking/isDirty are only ever touched on the single
+ // dispatcher thread and need no locking.
+ try
+ {
+ do
{
- logger.LogCheckFailed(renderedComponent.ComponentId);
+ isDirty = false;
+
+ if (isDisposed || WaitTask.IsCompleted)
+ return;
+
+ try
+ {
+ logger.LogCheckingWaitCondition(renderedComponent.ComponentId);
+
+ var checkResult = await completeChecker();
+ checkCount++;
+
+ if (isDisposed || WaitTask.IsCompleted)
+ return;
+
+ if (checkResult.CheckPassed)
+ {
+ checkPassedCompletionSource.TrySetResult(checkResult.Content);
+ logger.LogCheckCompleted(renderedComponent.ComponentId);
+ Dispose();
+ return;
+ }
+
+ logger.LogCheckFailed(renderedComponent.ComponentId);
+ }
+ catch (Exception ex)
+ {
+ checkCount++;
+ capturedException = ex;
+ logger.LogCheckThrow(renderedComponent.ComponentId, ex);
+
+ if (StopWaitingOnCheckException)
+ {
+ if (!isDisposed && !WaitTask.IsCompleted)
+ {
+ checkPassedCompletionSource.TrySetException(
+ new WaitForFailedException(
+ CheckThrowErrorMessage ?? string.Empty,
+ checkCount,
+ renderedComponent.RenderCount,
+ renderer.RenderCount,
+ capturedException));
+ Dispose();
+ }
+
+ return;
+ }
+ }
}
+ while (isDirty && !isDisposed && !WaitTask.IsCompleted);
}
- catch (Exception ex)
+ finally
{
- checkCount++;
- capturedException = ex;
- logger.LogCheckThrow(renderedComponent.ComponentId, ex);
-
- if (StopWaitingOnCheckException)
- {
- checkPassedCompletionSource.TrySetException(
- new WaitForFailedException(
- CheckThrowErrorMessage ?? string.Empty,
- checkCount,
- renderedComponent.RenderCount,
- renderer.RenderCount,
- capturedException));
- Dispose();
- }
+ isChecking = false;
}
}
@@ -218,4 +286,14 @@ private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout)
? Timeout.InfiniteTimeSpan
: timeout ?? BunitContext.DefaultWaitTimeout;
}
+
+ private static Func> WrapSynchronousChecker(Func<(bool CheckPassed, T Content)> completeChecker)
+ {
+ ArgumentNullException.ThrowIfNull(completeChecker);
+
+ // Wrap the synchronous check in an already-completed ValueTask so the single check
+ // pipeline can await it. Awaiting a completed ValueTask resumes inline, so the check
+ // still runs synchronously on the dispatcher with no render interleaving.
+ return () => new ValueTask<(bool CheckPassed, T Content)>(completeChecker());
+ }
}
diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs
index ad603c0c0..4216cf7b1 100644
--- a/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs
+++ b/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs
@@ -34,4 +34,21 @@ public WaitForStateHelper(IRenderedComponent renderedComponent, Func
: base(renderedComponent, () => (statePredicate(), default), timeout)
{
}
+
+ ///
+ /// Initializes a new instance of the class,
+ /// which will wait until the provided asynchronous returns true,
+ /// or the is reached (default is one second).
+ ///
+ ///
+ /// The is evaluated initially, and then each time the renders.
+ ///
+ /// The render fragment or component to attempt to verify state against.
+ /// The asynchronous predicate to invoke after each render, which must return true when the desired state has been reached.
+ /// The maximum time to wait for the desired state.
+ /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details.
+ public WaitForStateHelper(IRenderedComponent renderedComponent, Func> statePredicate, TimeSpan? timeout = null)
+ : base(renderedComponent, async () => (await statePredicate(), default), timeout)
+ {
+ }
}
diff --git a/src/bunit/Rendering/BunitRenderer.cs b/src/bunit/Rendering/BunitRenderer.cs
index 5a9d47731..88740fcbc 100644
--- a/src/bunit/Rendering/BunitRenderer.cs
+++ b/src/bunit/Rendering/BunitRenderer.cs
@@ -77,6 +77,54 @@ public void SetRendererInfo(RendererInfo? rendererInfo)
{
this.rendererInfo = rendererInfo;
}
+
+ private readonly List resourceAssets = [];
+ private ResourceAssetCollection? resourceAssetCollection;
+
+ ///
+ protected override ResourceAssetCollection Assets
+ => resourceAssets.Count == 0
+ ? ResourceAssetCollection.Empty
+ : resourceAssetCollection ??= new ResourceAssetCollection(resourceAssets);
+
+ ///
+ /// Adds an asset to the returned by the renderers property,
+ /// which components rendered by this renderer can access through their property.
+ ///
+ ///
+ /// Pass a to map a stable asset key to its (fingerprinted) ,
+ /// i.e. AddAsset("img.abc123.png", label: "img.png") makes Assets["img.png"] return img.abc123.png.
+ /// Adding multiple assets with the same label results in an
+ /// when the property is first accessed.
+ ///
+ /// The url of the asset.
+ /// The label of the asset, used as the lookup key by the indexer. Pass to add the asset without a label.
+ /// Additional properties to associate with the asset.
+ [SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")]
+ public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(url);
+ ArgumentNullException.ThrowIfNull(properties);
+
+ var props = new List(properties.Length + 1);
+ if (label is not null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(label);
+ const string labelMarker = "label";
+
+ if (properties.Any(static property => string.Equals(property.Name, labelMarker, StringComparison.Ordinal)))
+ {
+ throw new ArgumentException("The label property is reserved when a label is provided.", nameof(properties));
+ }
+
+ props.Add(new ResourceAssetProperty(labelMarker, label));
+ }
+
+ props.AddRange(properties);
+
+ resourceAssets.Add(new ResourceAsset(url, props.Count > 0 ? props : null));
+ resourceAssetCollection = null;
+ }
#endif
///
diff --git a/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor b/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor
new file mode 100644
index 000000000..3b3a7cb3d
--- /dev/null
+++ b/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor
@@ -0,0 +1,7 @@
+@{
+#if NET9_0_OR_GREATER
+}
+
+@{
+#endif
+}
diff --git a/tests/bunit.testassets/Assets/AssetsIterationComponent.razor b/tests/bunit.testassets/Assets/AssetsIterationComponent.razor
new file mode 100644
index 000000000..f6dfbe340
--- /dev/null
+++ b/tests/bunit.testassets/Assets/AssetsIterationComponent.razor
@@ -0,0 +1,21 @@
+@{
+#if NET9_0_OR_GREATER
+}
+@foreach (var name in SubresourceNames)
+{
+ @name
+}
+@{
+#endif
+}
+@code {
+#if NET9_0_OR_GREATER
+ private IReadOnlyList SubresourceNames { get; set; } = Array.Empty();
+
+ protected override void OnInitialized()
+ => SubresourceNames = Assets
+ .Select(asset => asset.Properties?.SingleOrDefault(property => property.Name == "label")?.Value)
+ .OfType()
+ .ToList();
+#endif
+}
diff --git a/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor b/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor
new file mode 100644
index 000000000..bb4d4ffab
--- /dev/null
+++ b/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor
@@ -0,0 +1,15 @@
+@{
+#if NET9_0_OR_GREATER
+}
+@foreach (var asset in Assets)
+{
+
+ @foreach (var property in asset.Properties ?? Array.Empty())
+ {
+ - @property.Name=@property.Value
+ }
+
+}
+@{
+#endif
+}
diff --git a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs
index 9b5a96be6..0d0044ea4 100644
--- a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs
+++ b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs
@@ -128,6 +128,118 @@ public void Test301()
() => cut.WaitForState(() => false, TimeSpan.FromSeconds(5)));
}
+ [Fact(DisplayName = "WaitForStateAsync with an async predicate can wait for multiple renders and changes to occur")]
+ public async Task Test400()
+ {
+ var cut = Render();
+ cut.Instance.Counter.ShouldBe(0);
+
+ // Clicking 'tick' changes the counter, and starts a task
+ cut.Find("#tick").Click();
+ cut.Instance.Counter.ShouldBe(1);
+
+ // Clicking 'tock' completes the task, which updates the counter
+ // This click causes two renders, thus something is needed to await here.
+ cut.Find("#tock").Click();
+ await cut.WaitForStateAsync(async () =>
+ {
+ await Task.Yield();
+ return cut.Instance.Counter == 2;
+ });
+
+ cut.Instance.Counter.ShouldBe(2);
+ }
+
+ [Fact(DisplayName = "WaitForAssertionAsync with an async assertion can wait for multiple renders and changes to occur")]
+ public async Task Test401()
+ {
+ // Initial state is stopped
+ var cut = Render();
+ cut.Find("#state").TextContent.ShouldBe("Stopped");
+
+ // Clicking 'tick' changes the state, and starts a task
+ cut.Find("#tick").Click();
+ cut.Find("#state").TextContent.ShouldBe("Started");
+
+ // Clicking 'tock' completes the task, which updates the state
+ // This click causes two renders, thus something is needed to await here.
+ cut.Find("#tock").Click();
+ await cut.WaitForAssertionAsync(async () =>
+ {
+ await Task.Yield();
+ cut.Find("#state").TextContent.ShouldBe("Stopped");
+ });
+ }
+
+ [Fact(DisplayName = "WaitForStateAsync throws WaitForFailedException after timeout when the async predicate never passes")]
+ public async Task Test402()
+ {
+ var cut = Render();
+
+ var expected = await Should.ThrowAsync(async () =>
+ await cut.WaitForStateAsync(async () =>
+ {
+ await Task.Yield();
+ return false;
+ }, TimeSpan.FromMilliseconds(50)));
+
+ expected.Message.ShouldStartWith(WaitForStateHelper.TimeoutBeforePassMessage);
+ }
+
+ [Fact(DisplayName = "WaitForStateAsync throws WaitForFailedException if the async predicate throws")]
+ public async Task Test403()
+ {
+ const string expectedInnerMessage = "INNER ASYNC MESSAGE";
+ var cut = Render();
+
+ var expected = await Should.ThrowAsync(async () =>
+ await cut.WaitForStateAsync(async () =>
+ {
+ await Task.Yield();
+ throw new InvalidOperationException(expectedInnerMessage);
+ }));
+
+ expected.Message.ShouldStartWith(WaitForStateHelper.ExceptionInPredicateMessage);
+ expected.InnerException.ShouldBeOfType()
+ .Message.ShouldBe(expectedInnerMessage);
+ }
+
+ [Fact(DisplayName = "WaitForStateAsync does not deadlock when the async predicate awaits work on the renderer dispatcher")]
+ public async Task Test405()
+ {
+ var cut = Render();
+
+ // Clicking 'tick' then 'tock' causes the counter to reach 2 across two renders.
+ cut.Find("#tick").Click();
+ cut.Find("#tock").Click();
+
+ // The predicate awaits work routed through the renderer's dispatcher. A naive
+ // sync-over-async implementation (e.g. blocking the dispatcher with .GetResult())
+ // would deadlock here; awaiting on the dispatcher does not.
+ await cut.WaitForStateAsync(async () =>
+ {
+ var counter = await cut.InvokeAsync(() => cut.Instance.Counter);
+ return counter == 2;
+ });
+
+ cut.Instance.Counter.ShouldBe(2);
+ }
+
+ [Fact(DisplayName = "WaitForAssertionAsync throws WaitForFailedException after timeout when the async assertion never passes")]
+ public async Task Test404()
+ {
+ var cut = Render();
+
+ var expected = await Should.ThrowAsync(async () =>
+ await cut.WaitForAssertionAsync(async () =>
+ {
+ await Task.Yield();
+ cut.Markup.ShouldBeEmpty();
+ }, TimeSpan.FromMilliseconds(50)));
+
+ expected.Message.ShouldStartWith(WaitForAssertionHelper.TimeoutMessage);
+ }
+
private sealed class ThrowsAfterAsyncOperation : ComponentBase
{
protected override async Task OnInitializedAsync()
diff --git a/tests/bunit.tests/Rendering/AssetsTest.razor b/tests/bunit.tests/Rendering/AssetsTest.razor
new file mode 100644
index 000000000..3f2dd16aa
--- /dev/null
+++ b/tests/bunit.tests/Rendering/AssetsTest.razor
@@ -0,0 +1,106 @@
+@code{
+ #if NET9_0_OR_GREATER
+}
+@using Bunit.TestAssets.Assets;
+@inherits BunitContext
+@code {
+ [Fact(DisplayName = "Assets defaults to an empty collection where the indexer returns the key unchanged")]
+ public void Test001()
+ {
+ var cut = Render();
+
+ cut.MarkupMatches(@
);
+ }
+
+ [Fact(DisplayName = "Iterating Assets without any added assets renders nothing")]
+ public void Test002()
+ {
+ var cut = Render();
+
+ cut.MarkupMatches(string.Empty);
+ }
+
+ [Fact(DisplayName = "AddAsset with label maps the asset key to the fingerprinted url")]
+ public void Test003()
+ {
+ AddAsset("img.abc123.png", label: "img.png");
+
+ var cut = Render();
+
+ cut.MarkupMatches(@
);
+ }
+
+ [Fact(DisplayName = "Components can iterate added assets and read their label property")]
+ public void Test004()
+ {
+ AddAsset("css/app.abc123.css", label: "css/app.css");
+ AddAsset("js/app.def456.js", label: "js/app.js");
+
+ var cut = Render();
+
+ cut.MarkupMatches(
+ @
+ css/app.css
+ js/app.js
+ );
+ }
+
+ [Fact(DisplayName = "AddAsset without label does not map the asset key")]
+ public void Test005()
+ {
+ AddAsset("img.png");
+
+ var cut = Render();
+
+ cut.MarkupMatches(@
);
+ }
+
+ [Fact(DisplayName = "AddAsset exposes additional properties to components")]
+ public void Test006()
+ {
+ AddAsset("js/app.js", label: "app.js", new ResourceAssetProperty("integrity", "sha256-abc"));
+
+ var cut = Render();
+
+ cut.MarkupMatches(
+ @
+ - label=app.js
+ - integrity=sha256-abc
+
);
+ }
+
+ [Fact(DisplayName = "Assets added after the initial render are available to components rendered afterwards")]
+ public void Test007()
+ {
+ var first = Render();
+ first.MarkupMatches(string.Empty);
+
+ AddAsset("img.abc123.png", label: "img.png");
+
+ var second = Render();
+ second.MarkupMatches(@img.png);
+ }
+
+ [Theory(DisplayName = "AddAsset rejects empty or whitespace-only labels")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("\t")]
+ public void Test008(string label)
+ {
+ var ex = Assert.Throws(() => AddAsset("app.js", label: label));
+
+ ex.ParamName.ShouldBe("label");
+ }
+
+ [Fact(DisplayName = "AddAsset rejects explicit label properties when label is provided")]
+ public void Test009()
+ {
+ var ex = Assert.Throws(
+ () => AddAsset("app.js", label: "app.js", new ResourceAssetProperty("label", "other")));
+
+ ex.ParamName.ShouldBe("properties");
+ }
+}
+@code{
+ #endif
+}
diff --git a/version.json b/version.json
index d486ef3e8..035a06cb3 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.7",
+ "version": "2.8-preview",
"assemblyVersion": {
"precision": "revision"
},