Skip to content

Commit 3c6fedf

Browse files
committed
feat: Add async overloads
1 parent c6db5cc commit 3c6fedf

6 files changed

Lines changed: 315 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- 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).
12+
913
## [2.7.2] - 2026-03-31
1014

1115
### Fixed

src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,31 @@ public static async Task WaitForStateAsync<TComponent>(this IRenderedComponent<T
5656
await waiter.WaitTask;
5757
}
5858

59+
/// <summary>
60+
/// Wait until the provided asynchronous <paramref name="statePredicate"/> returns true,
61+
/// or the <paramref name="timeout"/> is reached (default is one second).
62+
///
63+
/// The <paramref name="statePredicate"/> is evaluated initially, and then each time
64+
/// the <paramref name="renderedComponent"/> renders.
65+
/// </summary>
66+
/// <param name="renderedComponent">The render fragment or component to attempt to verify state against.</param>
67+
/// <param name="statePredicate">The asynchronous predicate to invoke after each render, which must return <c>true</c> when the desired state has been reached.</param>
68+
/// <param name="timeout">The maximum time to wait for the desired state.</param>
69+
/// <exception cref="WaitForFailedException">Thrown if the <paramref name="statePredicate"/> throw an exception during invocation, or if the timeout has been reached. See the inner exception for details.</exception>
70+
/// <remarks>
71+
/// The predicate is awaited on the renderer's dispatcher. Because awaiting yields the dispatcher,
72+
/// a render may interleave at each <c>await</c> point, so the predicate is not guaranteed an atomic
73+
/// view of the DOM across awaits. Use this overload when the check itself must <c>await</c> (for
74+
/// example an async service or JavaScript interop call); prefer the synchronous <see cref="WaitForState{TComponent}(IRenderedComponent{TComponent}, Func{bool}, TimeSpan?)"/>
75+
/// overload for pure DOM or markup checks.
76+
/// </remarks>
77+
public static async Task WaitForStateAsync<TComponent>(this IRenderedComponent<TComponent> renderedComponent, Func<Task<bool>> statePredicate, TimeSpan? timeout = null)
78+
where TComponent : IComponent
79+
{
80+
using var waiter = new WaitForStateHelper<TComponent>(renderedComponent, statePredicate, timeout);
81+
await waiter.WaitTask;
82+
}
83+
5984
/// <summary>
6085
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an
6186
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
@@ -99,4 +124,29 @@ public static async Task WaitForAssertionAsync<TComponent>(this IRenderedCompone
99124
using var waiter = new WaitForAssertionHelper<TComponent>(renderedComponent, assertion, timeout);
100125
await waiter.WaitTask;
101126
}
127+
128+
/// <summary>
129+
/// Wait until the provided asynchronous <paramref name="assertion"/> passes (i.e. does not throw an
130+
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
131+
///
132+
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedComponent"/> renders.
133+
/// </summary>
134+
/// <param name="renderedComponent">The rendered fragment to wait for renders from and assert against.</param>
135+
/// <param name="assertion">The asynchronous verification or assertion to perform.</param>
136+
/// <param name="timeout">The maximum time to attempt the verification.</param>
137+
/// <exception cref="WaitForFailedException">Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception.</exception>
138+
/// <remarks>
139+
/// The assertion is awaited on the renderer's dispatcher. Because awaiting yields the dispatcher,
140+
/// a render may interleave at each <c>await</c> point, so the assertion is not guaranteed an atomic
141+
/// view of the DOM across awaits. Use this overload when the assertion itself must <c>await</c> (for
142+
/// example an async service or JavaScript interop call); prefer the synchronous <see cref="WaitForAssertion{TComponent}(IRenderedComponent{TComponent}, Action, TimeSpan?)"/>
143+
/// overload for pure DOM or markup assertions.
144+
/// </remarks>
145+
[AssertionMethod]
146+
public static async Task WaitForAssertionAsync<TComponent>(this IRenderedComponent<TComponent> renderedComponent, Func<Task> assertion, TimeSpan? timeout = null)
147+
where TComponent : IComponent
148+
{
149+
using var waiter = new WaitForAssertionHelper<TComponent>(renderedComponent, assertion, timeout);
150+
await waiter.WaitTask;
151+
}
102152
}

src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,28 @@ public WaitForAssertionHelper(IRenderedComponent<TComponent> renderedComponent,
3737
},
3838
timeout)
3939
{ }
40+
41+
/// <summary>
42+
/// Initializes a new instance of the <see cref="WaitForAssertionHelper{TComponent}"/> class,
43+
/// which will until the provided asynchronous <paramref name="assertion"/> passes (i.e. does not throw an
44+
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
45+
///
46+
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedComponent"/> renders.
47+
/// </summary>
48+
/// <param name="renderedComponent">The rendered fragment to wait for renders from and assert against.</param>
49+
/// <param name="assertion">The asynchronous verification or assertion to perform.</param>
50+
/// <param name="timeout">The maximum time to attempt the verification.</param>
51+
/// <remarks>
52+
/// If a debugger is attached the timeout is set to <see cref="Timeout.InfiniteTimeSpan" />, giving the possibility to debug without the timeout triggering.
53+
/// </remarks>
54+
public WaitForAssertionHelper(IRenderedComponent<TComponent> renderedComponent, Func<Task> assertion, TimeSpan? timeout = null)
55+
: base(
56+
renderedComponent,
57+
async () =>
58+
{
59+
await assertion();
60+
return (true, default);
61+
},
62+
timeout)
63+
{ }
4064
}

src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs

Lines changed: 108 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ internal abstract class WaitForHelper<T, TComponent> : IDisposable
1212
where TComponent : IComponent
1313
{
1414
private readonly TaskCompletionSource<T> checkPassedCompletionSource;
15-
private readonly Func<(bool CheckPassed, T Content)> completeChecker;
15+
private readonly Func<ValueTask<(bool CheckPassed, T Content)>> completeChecker;
1616
private readonly IRenderedComponent<TComponent> renderedComponent;
1717
private readonly ILogger<WaitForHelper<T, TComponent>> logger;
1818
private readonly BunitRenderer renderer;
1919
private readonly Timer? timer;
2020
private bool isDisposed;
21+
private bool isChecking;
22+
private bool isDirty;
2123
private int checkCount;
2224
private Exception? capturedException;
2325

@@ -44,12 +46,31 @@ internal abstract class WaitForHelper<T, TComponent> : IDisposable
4446
public Task<T> WaitTask { get; }
4547

4648
/// <summary>
47-
/// Initializes a new instance of the <see cref="WaitForHelper{T, TComponent}"/> class.
49+
/// Initializes a new instance of the <see cref="WaitForHelper{T, TComponent}"/> class
50+
/// with a synchronous check. The check is wrapped into a completed <see cref="ValueTask{TResult}"/>,
51+
/// so it is still evaluated synchronously (with no render interleaving) on the renderer's dispatcher.
4852
/// </summary>
4953
protected WaitForHelper(
5054
IRenderedComponent<TComponent> renderedComponent,
5155
Func<(bool CheckPassed, T Content)> completeChecker,
5256
TimeSpan? timeout = null)
57+
: this(renderedComponent, WrapSynchronousChecker(completeChecker), timeout)
58+
{
59+
}
60+
61+
/// <summary>
62+
/// Initializes a new instance of the <see cref="WaitForHelper{T, TComponent}"/> class.
63+
/// </summary>
64+
/// <remarks>
65+
/// The <paramref name="completeChecker"/> is awaited on the renderer's dispatcher after each render.
66+
/// A synchronous check (see the other constructor) wraps into an already-completed <see cref="ValueTask{TResult}"/>
67+
/// and therefore resumes synchronously without yielding the dispatcher; a genuinely asynchronous check yields the
68+
/// dispatcher at each <c>await</c>, so a render may interleave and the check is not guaranteed an atomic view of the DOM.
69+
/// </remarks>
70+
protected WaitForHelper(
71+
IRenderedComponent<TComponent> renderedComponent,
72+
Func<ValueTask<(bool CheckPassed, T Content)>> completeChecker,
73+
TimeSpan? timeout = null)
5374
{
5475
this.renderedComponent = renderedComponent ?? throw new ArgumentNullException(nameof(renderedComponent));
5576
this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker));
@@ -166,40 +187,87 @@ private void OnAfterRender(object? sender, EventArgs args)
166187
if (isDisposed || WaitTask.IsCompleted)
167188
return;
168189

169-
try
190+
// The check may be asynchronous, so it cannot be awaited inside this synchronous
191+
// event handler. Kick it off without blocking the dispatcher. A synchronous check
192+
// completes inline (see RunCheckAsync). If a check is already in flight, mark the
193+
// state dirty so the running check re-evaluates once it completes, ensuring the
194+
// final render is never missed.
195+
if (isChecking)
170196
{
171-
logger.LogCheckingWaitCondition(renderedComponent.ComponentId);
197+
isDirty = true;
198+
return;
199+
}
172200

173-
var checkResult = completeChecker();
174-
checkCount++;
175-
if (checkResult.CheckPassed)
176-
{
177-
checkPassedCompletionSource.TrySetResult(checkResult.Content);
178-
logger.LogCheckCompleted(renderedComponent.ComponentId);
179-
Dispose();
180-
}
181-
else
201+
isChecking = true;
202+
_ = RunCheckAsync();
203+
}
204+
205+
private async Task RunCheckAsync()
206+
{
207+
// Runs on the renderer's dispatcher. A synchronous check wraps into an already-completed
208+
// ValueTask, so the await below resumes inline without yielding - preserving the atomic,
209+
// no-render-interleaving behaviour synchronous checks rely on. A genuinely asynchronous
210+
// check yields the dispatcher; the default await captures and resumes on the dispatcher's
211+
// synchronization context, so isChecking/isDirty are only ever touched on the single
212+
// dispatcher thread and need no locking.
213+
try
214+
{
215+
do
182216
{
183-
logger.LogCheckFailed(renderedComponent.ComponentId);
217+
isDirty = false;
218+
219+
if (isDisposed || WaitTask.IsCompleted)
220+
return;
221+
222+
try
223+
{
224+
logger.LogCheckingWaitCondition(renderedComponent.ComponentId);
225+
226+
var checkResult = await completeChecker();
227+
checkCount++;
228+
229+
if (isDisposed || WaitTask.IsCompleted)
230+
return;
231+
232+
if (checkResult.CheckPassed)
233+
{
234+
checkPassedCompletionSource.TrySetResult(checkResult.Content);
235+
logger.LogCheckCompleted(renderedComponent.ComponentId);
236+
Dispose();
237+
return;
238+
}
239+
240+
logger.LogCheckFailed(renderedComponent.ComponentId);
241+
}
242+
catch (Exception ex)
243+
{
244+
checkCount++;
245+
capturedException = ex;
246+
logger.LogCheckThrow(renderedComponent.ComponentId, ex);
247+
248+
if (StopWaitingOnCheckException)
249+
{
250+
if (!isDisposed && !WaitTask.IsCompleted)
251+
{
252+
checkPassedCompletionSource.TrySetException(
253+
new WaitForFailedException(
254+
CheckThrowErrorMessage ?? string.Empty,
255+
checkCount,
256+
renderedComponent.RenderCount,
257+
renderer.RenderCount,
258+
capturedException));
259+
Dispose();
260+
}
261+
262+
return;
263+
}
264+
}
184265
}
266+
while (isDirty && !isDisposed && !WaitTask.IsCompleted);
185267
}
186-
catch (Exception ex)
268+
finally
187269
{
188-
checkCount++;
189-
capturedException = ex;
190-
logger.LogCheckThrow(renderedComponent.ComponentId, ex);
191-
192-
if (StopWaitingOnCheckException)
193-
{
194-
checkPassedCompletionSource.TrySetException(
195-
new WaitForFailedException(
196-
CheckThrowErrorMessage ?? string.Empty,
197-
checkCount,
198-
renderedComponent.RenderCount,
199-
renderer.RenderCount,
200-
capturedException));
201-
Dispose();
202-
}
270+
isChecking = false;
203271
}
204272
}
205273

@@ -218,4 +286,14 @@ private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout)
218286
? Timeout.InfiniteTimeSpan
219287
: timeout ?? BunitContext.DefaultWaitTimeout;
220288
}
289+
290+
private static Func<ValueTask<(bool CheckPassed, T Content)>> WrapSynchronousChecker(Func<(bool CheckPassed, T Content)> completeChecker)
291+
{
292+
ArgumentNullException.ThrowIfNull(completeChecker);
293+
294+
// Wrap the synchronous check in an already-completed ValueTask so the single check
295+
// pipeline can await it. Awaiting a completed ValueTask resumes inline, so the check
296+
// still runs synchronously on the dispatcher with no render interleaving.
297+
return () => new ValueTask<(bool CheckPassed, T Content)>(completeChecker());
298+
}
221299
}

src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,21 @@ public WaitForStateHelper(IRenderedComponent<TComponent> renderedComponent, Func
3434
: base(renderedComponent, () => (statePredicate(), default), timeout)
3535
{
3636
}
37+
38+
/// <summary>
39+
/// Initializes a new instance of the <see cref="WaitForStateHelper{TComponent}"/> class,
40+
/// which will wait until the provided asynchronous <paramref name="statePredicate"/> returns true,
41+
/// or the <paramref name="timeout"/> is reached (default is one second).
42+
/// </summary>
43+
/// <remarks>
44+
/// The <paramref name="statePredicate"/> is evaluated initially, and then each time the <paramref name="renderedComponent"/> renders.
45+
/// </remarks>
46+
/// <param name="renderedComponent">The render fragment or component to attempt to verify state against.</param>
47+
/// <param name="statePredicate">The asynchronous predicate to invoke after each render, which must return <c>true</c> when the desired state has been reached.</param>
48+
/// <param name="timeout">The maximum time to wait for the desired state.</param>
49+
/// <exception cref="WaitForFailedException">Thrown if the <paramref name="statePredicate"/> throw an exception during invocation, or if the timeout has been reached. See the inner exception for details.</exception>
50+
public WaitForStateHelper(IRenderedComponent<TComponent> renderedComponent, Func<Task<bool>> statePredicate, TimeSpan? timeout = null)
51+
: base(renderedComponent, async () => (await statePredicate(), default), timeout)
52+
{
53+
}
3754
}

0 commit comments

Comments
 (0)